Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **Split test suites merge into one workspace row.** Multiple
`coverage-<id>--<suite>.xml` (and `tests-<id>--<suite>.xml`) files with the
same base id are now combined automatically: coverage is unioned per source
line via `cobertura.Merge` (a line counts as hit if any suite executed it),
and JUnit test counts are summed. A workspace exercised by unit +
integration jobs — or a language-runtime matrix — reports as one row instead
of one row per suite. Single-file usage (`coverage-web.xml`) is unchanged;
the merge only kicks in on the `--` (double-dash) sentinel, so existing ids
with single dashes (`shared-widget`) or dots (`api.v1`) stay distinct
workspaces. Any `coverage.yaml` workspace entry keyed on the base id applies
to every same-id artifact. See
[Splitting a workspace across test suites](README.md#splitting-a-workspace-across-test-suites).
- **`cobertura.Merge` — union of parsed reports.** Combines multiple `*Report`
values into one by unioning classes that share a filename (first-seen order
preserved) and, within a class, unioning lines by number: `Hits` is the max
Expand Down
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,16 @@ Both formats are rendered from templates in

| Thing | Convention | Example |
|---|---|---|
| Coverage artifact | `coverage-<id>.xml` (Cobertura) | `coverage-thingy.xml` |
| Test-count artifact | `tests-<id>.xml` (JUnit) | `tests-thingy.xml` |
| Workspace id | the `<id>` in the filenames; may contain dashes | `shared-widget` |
| Coverage artifact | `coverage-<id>[--<suite>].xml` (Cobertura) | `coverage-thingy.xml`, `coverage-thingy--unit.xml` |
| Test-count artifact | `tests-<id>[--<suite>].xml` (JUnit) | `tests-thingy.xml`, `tests-thingy--unit.xml` |
| Workspace id | the `<id>` in the filenames; may contain single dashes or dots | `shared-widget`, `api.v1` |
| Input dir | all `coverage-*.xml` + `tests-*.xml` flattened together | `./coverage-artifacts` |

The optional `--<suite>` suffix (double dash) lets one workspace ship coverage
from multiple test jobs — see
[Splitting a workspace across test suites](#splitting-a-workspace-across-test-suites)
below. Double-dash is used so existing dotted or single-dashed ids stay intact.

## Add it to your workflow

Three moving parts:
Expand Down Expand Up @@ -197,6 +202,29 @@ for your detected languages — see [docs/INIT.md](./docs/INIT.md). The Action's
inputs mirror the [CLI flags](#usage); the full annotated workflow is
[`examples/coverage.yml`](./examples/coverage.yml).

## Splitting a workspace across test suites

When one workspace is exercised by multiple test jobs (e.g. unit + integration,
or a language runtime matrix), upload each job's artifacts with the same base id
and a distinct `--<suite>` suffix:

| Job | Coverage file | Tests file |
|---|---|---|
| unit tests | `coverage-web--unit.xml` | `tests-web--unit.xml` |
| integration tests | `coverage-web--integration.xml` | `tests-web--integration.xml` |

The tool groups them by base id (`web`) and produces one row: coverage is
**unioned per source line** (a line counts as hit if any suite executed it) and
test counts are **summed**. A line that's a branch in any suite is a branch in
the merged row; branch coverage is taken as the max across suites (a safe
approximation — always `>=` any single suite and `<=` the true union).

A single-file id like `coverage-web.xml` still works exactly as before — the
merge only kicks in on the `--` sentinel, so ids with single dashes
(`shared-widget`) or dots (`api.v1`, `api.v2`) remain distinct workspaces. Any
`coverage.yaml` workspace config keyed on `web` applies to every same-id
artifact.

## Optional config

- **`.coverageignore`** — gitignore syntax, matched against repo-root-relative
Expand Down
13 changes: 9 additions & 4 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ Each project produces two files in a shared directory:

| File | Format | Purpose |
|---|---|---|
| `coverage-<id>.xml` | Cobertura | line & branch coverage |
| `tests-<id>.xml` | JUnit | test count (renders `—` if absent) |

`<id>` is any workspace id you choose (may contain dashes).
| `coverage-<id>[--<suite>].xml` | Cobertura | line & branch coverage |
| `tests-<id>[--<suite>].xml` | JUnit | test count (renders `—` if absent) |

`<id>` is any workspace id you choose (may contain single dashes or dots). The
optional `--<suite>` suffix (double dash) lets a workspace split across
multiple test jobs (unit + integration, or a matrix) contribute one merged row
— coverage is unioned per line, test counts are summed. See
[Splitting a workspace across test suites](../README.md#splitting-a-workspace-across-test-suites)
in the main README.

## Per-language how-to guides

Expand Down
49 changes: 37 additions & 12 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,31 +204,44 @@ func aggregateCoverage(opts Options, cfg *config.Config, matcher ignore.Matcher)
}
sort.Strings(files)

var workspaces []*workspaceAgg
excluded := 0

// Group files by workspace id — multiple coverage-<id>--<suite>.xml files
// with the same id are unioned via cobertura.Merge so a workspace split
// across test suites renders as one row.
var order []string
byID := map[string][]*cobertura.Report{}
for _, file := range files {
id := artifactID(file, "coverage-")
if id == "" {
continue
}
wsCfg, configured := cfg.Workspaces[id]
if !configured && opts.Verbose {
fmt.Fprintf(opts.Stderr, "warning: workspace %q has no config entry; matching with raw filenames\n", id)
}

report, err := cobertura.ParseFile(file)
if err != nil {
fmt.Fprintf(opts.Stderr, "warning: skipping %q: %v\n", file, err)
continue
}
if _, seen := byID[id]; !seen {
order = append(order, id)
}
byID[id] = append(byID[id], report)
}

var workspaces []*workspaceAgg
excluded := 0

for _, id := range order {
wsCfg, configured := cfg.Workspaces[id]
if !configured && opts.Verbose {
fmt.Fprintf(opts.Stderr, "warning: workspace %q has no config entry; matching with raw filenames\n", id)
}

merged := cobertura.Merge(byID[id])

ws := &workspaceAgg{
id: id,
displayName: cfg.DisplayName(id),
folders: map[string]*folderAgg{},
}
for _, class := range report.Classes {
for _, class := range merged.Classes {
rel := stripPrefix(class.Filename, wsCfg.StripPrefix)
full := wsCfg.Prefix + rel
if matcher.Match(full) {
Expand Down Expand Up @@ -281,6 +294,8 @@ func attachTests(opts Options, workspaces []*workspaceAgg) {
byID[w.id] = w
}

// Multiple tests-<id>--<suite>.xml files with the same id sum their test
// counts, matching the coverage-side merge for split suites.
for _, file := range files {
id := artifactID(file, "tests-")
if id == "" {
Expand All @@ -292,7 +307,7 @@ func attachTests(opts Options, workspaces []*workspaceAgg) {
continue
}
if w, ok := byID[id]; ok {
w.tests = report.Tests
w.tests += report.Tests
w.hasTests = true
}
}
Expand Down Expand Up @@ -451,13 +466,23 @@ func resolveFormat(opts Options) (string, error) {
// --- helpers ---

// artifactID strips a leading prefix and a trailing ".xml" from a path's base
// name. The id itself may contain dashes.
// name. The id itself may contain single dashes and dots. A double-dash "--"
// in the stripped remainder separates the id from an optional suite suffix,
// so split test suites can upload sibling artifacts (coverage-web--unit.xml,
// coverage-web--integration.xml) that get merged into one workspace row. The
// suffix itself is discarded — it only exists so multiple files can share the
// same id in one input directory. Double-dash is chosen so existing dotted ids
// like "api.v1" and dashed ids like "shared-widget" remain intact.
func artifactID(path, prefix string) string {
base := filepath.Base(path)
if !strings.HasPrefix(base, prefix) || !strings.HasSuffix(base, ".xml") {
return ""
}
return base[len(prefix) : len(base)-len(".xml")]
rest := base[len(prefix) : len(base)-len(".xml")]
if sep := strings.Index(rest, "--"); sep >= 0 {
return rest[:sep]
Comment on lines +482 to +483

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Double-dash IDs still collapse

When an existing workspace ID contains --, artifactID discards that sequence and everything after it. An ID such as foo--bar therefore becomes foo, so sibling IDs can merge into one row while configuration and baseline entries keyed by the original IDs stop matching.

Fix in Claude Code

}
return rest
}

func stripPrefix(filename, prefix string) string {
Expand Down
104 changes: 104 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,107 @@ func TestRunUnknownFormatErrors(t *testing.T) {
t.Fatal("expected error for unknown format")
}
}

// Same workspace id split across two coverage artifacts (unit + integration
// suites) merges into one row with per-line hits unioned.
func TestRunMergesSplitSuitesUnderOneWorkspace(t *testing.T) {
dir := t.TempDir()
// Same file, three lines. Unit hits lines 1 and 2; integration hits lines 2
// and 3. Merged: 3/3 covered.
write(t, dir, "coverage-web--unit.xml", coverageDoc(map[string][]int{"src/a.ts": {1, 1, 0}}))
write(t, dir, "coverage-web--integration.xml", coverageDoc(map[string][]int{"src/a.ts": {0, 1, 1}}))
// And the corresponding split test artifacts — counts should sum.
write(t, dir, "tests-web--unit.xml", `<testsuites tests="3"/>`)
write(t, dir, "tests-web--integration.xml", `<testsuites tests="5"/>`)

out, err := run(t, Options{Input: dir})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !strings.Contains(out, "| web | 8 | 3 / 3 | 100.0% |") {
t.Errorf("expected merged web row (8 tests, 3/3 lines), got:\n%s", out)
}
if strings.Count(out, "| web ") != 1 {
t.Errorf("expected exactly one web row, got:\n%s", out)
}
}

// A dashed id (single dashes, no "--") is untouched — split-suite parsing only
// kicks in on the double-dash sentinel.
func TestRunPreservesDashedIDs(t *testing.T) {
dir := t.TempDir()
write(t, dir, "coverage-shared-widget.xml", coverageDoc(map[string][]int{"src/w.ts": {1, 1}}))
write(t, dir, "tests-shared-widget.xml", `<testsuites tests="4"/>`)

out, err := run(t, Options{Input: dir})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !strings.Contains(out, "| shared-widget | 4 | 2 / 2 | 100.0% |") {
t.Errorf("dashed id should not be split at the dash, got:\n%s", out)
}
}

// Dotted ids (e.g. versioned service names like "api.v1") must stay distinct
// after this feature landed — regression guard for
// https://github.com/aanantaco/coverage/pull/23#discussion_r3849572610.
func TestRunPreservesDottedIDsAsDistinctWorkspaces(t *testing.T) {
dir := t.TempDir()
write(t, dir, "coverage-api.v1.xml", coverageDoc(map[string][]int{"src/v1.go": {1, 1}}))
write(t, dir, "coverage-api.v2.xml", coverageDoc(map[string][]int{"src/v2.go": {1, 0, 0}}))
// Only v1 gets a display name in config — used to prove config lookup still
// resolves by the full dotted id, not a truncated prefix.
cfgPath := filepath.Join(dir, "coverage.yaml")
write(t, dir, "coverage.yaml", `
workspaces:
"api.v1":
display_name: API v1
`)

out, err := run(t, Options{
Input: dir, ConfigPath: cfgPath, ConfigSet: true,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !strings.Contains(out, "| API v1 | — | 2 / 2 | 100.0% |") {
t.Errorf("api.v1 row missing (config lookup should hit the full dotted id), got:\n%s", out)
}
if !strings.Contains(out, "| api.v2 | — | 1 / 3 | 33.3% |") {
t.Errorf("api.v2 row missing or merged into api.v1, got:\n%s", out)
}
}

// A workspaces config entry keyed on the base id applies to every same-id
// artifact regardless of suffix.
func TestRunSplitSuitesShareConfig(t *testing.T) {
dir := t.TempDir()
write(t, dir, "coverage-worker--unit.xml", coverageDoc(map[string][]int{
"github.com/acme/repo/services/worker/logic/a.go": {1, 1},
}))
write(t, dir, "coverage-worker--integration.xml", coverageDoc(map[string][]int{
"github.com/acme/repo/services/worker/logic/b.go": {1},
}))
cfgPath := filepath.Join(dir, "coverage.yaml")
write(t, dir, "coverage.yaml", `
workspaces:
worker:
strip_prefix: github.com/acme/repo/services/worker/
`)

out, err := run(t, Options{
Input: dir, ConfigPath: cfgPath, ConfigSet: true,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
// 2 lines from unit + 1 line from integration, all covered.
if !strings.Contains(out, "| worker | — | 3 / 3 | 100.0% |") {
t.Errorf("split-suite config bridging failed, got:\n%s", out)
}
// Folder grouping should reflect the stripped rel path, proving strip_prefix
// was applied to both merged files.
if !strings.Contains(out, "└ logic") {
t.Errorf("expected stripped folder grouping for merged files:\n%s", out)
}
}
17 changes: 11 additions & 6 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@

Each project/test job produces two files in one shared directory:

- `coverage-<id>.xml` — Cobertura coverage
- `tests-<id>.xml` — JUnit test results (optional; the Tests column shows `—` if absent)

`<id>` is any workspace id you choose (it may contain dashes). Then, in one
aggregation job, download every `coverage-*` / `tests-*` artifact into a single
directory (`merge-multiple: true`) and run the tool once:
- `coverage-<id>[--<suite>].xml` — Cobertura coverage
- `tests-<id>[--<suite>].xml` — JUnit test results (optional; the Tests column shows `—` if absent)

`<id>` is any workspace id you choose (it may contain single dashes or dots).
The optional `--<suite>` suffix (double dash) lets a workspace split across
multiple test jobs (unit + integration, matrix) contribute one merged row:
coverage is unioned per source line, test counts are summed. Single-file usage
(`coverage-web.xml`) is unchanged, and ids with single dashes (`shared-widget`)
or dots (`api.v1`) stay distinct. Then, in one aggregation job, download every
`coverage-*` / `tests-*` artifact into a single directory
(`merge-multiple: true`) and run the tool once:

coverage --input ./cov --output "$GITHUB_STEP_SUMMARY" # Markdown
coverage --input ./cov --output report.html # HTML (auto-detected)
Expand Down