Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ 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 when a `.<suite>` suffix is present, and dashed ids
like `shared-widget` still resolve as before. 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
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,15 @@ 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` |
| 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 dashes | `shared-widget` |
| Input dir | all `coverage-*.xml` + `tests-*.xml` flattened together | `./coverage-artifacts` |

The optional `.<suite>` suffix lets one workspace ship coverage from multiple
test jobs — see [Splitting a workspace across test suites](#splitting-a-workspace-across-test-suites)
below.

## Add it to your workflow

Three moving parts:
Expand Down Expand Up @@ -197,6 +201,27 @@ 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 when a `.<suite>` suffix appears. 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 dashes). The optional
`.<suite>` suffix 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
47 changes: 35 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,21 @@ 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 dashes. A dot 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.
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 dot := strings.IndexByte(rest, '.'); dot >= 0 {
return rest[:dot]
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
return rest
}

func stripPrefix(filename, prefix string) string {
Expand Down
74 changes: 74 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,77 @@ 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 (no dot) is untouched — split-suite parsing only kicks in when a
// dot appears in the id remainder.
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)
}
}

// 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)
}
}
15 changes: 9 additions & 6 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@

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 dashes). The optional
`.<suite>` suffix 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. 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