diff --git a/internal/cli/spec.go b/internal/cli/spec.go index 2e7a3ee..dcc1d04 100644 --- a/internal/cli/spec.go +++ b/internal/cli/spec.go @@ -696,9 +696,6 @@ func specTestReport(args []string) int { // Fold tests: parsed summary first, else explicit counts. if ts != nil { rep.Tests = &session.SpecTestCounts{Total: ts.Total, Passed: ts.Passed, Failed: ts.Failed, Skipped: ts.Skipped} - if ts.Source != "" { - rep.TestPaths = appendUnique(rep.TestPaths, ts.Source) - } rep.Attentions = append(rep.Attentions, testAttentions(ts)...) } else if total >= 0 || passed >= 0 || failed >= 0 || skipped >= 0 { rep.Tests = &session.SpecTestCounts{Total: nz(total), Passed: nz(passed), Failed: nz(failed), Skipped: nz(skipped)} @@ -707,9 +704,6 @@ func specTestReport(args []string) int { // Fold coverage: parsed summary first, else explicit counts. if cov != nil { rep.Coverage = &session.SpecCovSummary{Pct: cov.Pct, Covered: cov.Covered, Lines: cov.Lines} - if cov.Source != "" { - rep.TestPaths = appendUnique(rep.TestPaths, cov.Source) - } } else if pct >= 0 || covered >= 0 || lines >= 0 { c := &session.SpecCovSummary{Covered: nz(covered), Lines: nz(lines)} switch { @@ -730,6 +724,14 @@ func specTestReport(args []string) int { return 1 } + // testPaths is the spec's own folder — deterministic over (root, feature) and + // independent of where the test/coverage artifacts were found. + specRel := filepath.ToSlash(workspace.Relative(r, sdir)) + if !strings.HasSuffix(specRel, "/") { + specRel += "/" + } + rep.TestPaths = []string{specRel} + b, err := json.MarshalIndent(rep, "", " ") if err != nil { render.Err(err.Error()) @@ -798,16 +800,6 @@ func testAttentions(ts *session.TestSummary) []string { return out } -// appendUnique appends s to xs unless already present. -func appendUnique(xs []string, s string) []string { - for _, x := range xs { - if x == s { - return xs - } - } - return append(xs, s) -} - // nz clamps an "unset" (-1) flag value to 0. func nz(n int) int { if n < 0 { diff --git a/internal/cli/test_report_test.go b/internal/cli/test_report_test.go index 586dd7c..ab742b8 100644 --- a/internal/cli/test_report_test.go +++ b/internal/cli/test_report_test.go @@ -150,9 +150,89 @@ func TestSpecTestReportRunPassing(t *testing.T) { if rep.Command != cmd { t.Errorf("command = %q", rep.Command) } - if len(rep.TestPaths) == 0 { - t.Errorf("expected testPaths evidence, got none") + if len(rep.TestPaths) != 1 || rep.TestPaths[0] != "specs/demo/" { + t.Errorf("testPaths = %v, want [specs/demo/]", rep.TestPaths) } + for _, p := range rep.TestPaths { + if strings.Contains(p, "coverage.out") || strings.Contains(p, "junit.xml") { + t.Errorf("testPaths leaked an artifact path: %q", p) + } + } +} + +// testPaths must deterministically point at the spec folder regardless of how the +// metrics were gathered, and must never carry a discovered artifact path. +func TestSpecTestReportTestPathsAnchoredToSpec(t *testing.T) { + readPaths := func(t *testing.T, specDir string) []string { + t.Helper() + data, err := os.ReadFile(filepath.Join(specDir, "test-report.json")) + if err != nil { + t.Fatal(err) + } + var rep struct { + TestPaths []string `json:"testPaths"` + } + if err := json.Unmarshal(data, &rep); err != nil { + t.Fatal(err) + } + return rep.TestPaths + } + + t.Run("explicit counts, no run", func(t *testing.T) { + root := t.TempDir() + specDir := writeSpec(t, root) + if code := specTestReport([]string{"demo", "--root", root, "--total", "3", "--passed", "3"}); code != 0 { + t.Fatalf("exit = %d", code) + } + got := readPaths(t, specDir) + if len(got) != 1 || got[0] != "specs/demo/" { + t.Errorf("testPaths = %v, want [specs/demo/]", got) + } + }) + + t.Run("discover by lang and path drops artifact paths", func(t *testing.T) { + root := t.TempDir() + specDir := writeSpec(t, root) + testsDir := filepath.Join(root, "tests") + if err := os.MkdirAll(testsDir, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(testsDir, "coverage.out"), []byte("mode: set\ngithub.com/a/b.go:1.1,2.2 3 1\n"), 0o644) + os.WriteFile(filepath.Join(testsDir, "junit.xml"), []byte(``), 0o644) + if code := specTestReport([]string{"demo", "--root", root, "--lang", "go", "--path", "tests"}); code != 0 { + t.Fatalf("exit = %d", code) + } + got := readPaths(t, specDir) + if len(got) != 1 || got[0] != "specs/demo/" { + t.Errorf("testPaths = %v, want [specs/demo/]", got) + } + for _, p := range got { + if strings.Contains(p, "coverage.out") || strings.Contains(p, "junit.xml") || strings.Contains(p, "tests/") { + t.Errorf("testPaths leaked an artifact path: %q", p) + } + } + }) + + t.Run("explicit junit and coverage files", func(t *testing.T) { + root := t.TempDir() + specDir := writeSpec(t, root) + junit := filepath.Join(root, "junit.xml") + os.WriteFile(junit, []byte(``), 0o644) + cov := filepath.Join(root, "cover.out") + os.WriteFile(cov, []byte("mode: set\ngithub.com/a/b.go:1.1,2.2 3 1\n"), 0o644) + if code := specTestReport([]string{"demo", "--root", root, "--junit", junit, "--coverage", cov}); code != 0 { + t.Fatalf("exit = %d", code) + } + got := readPaths(t, specDir) + if len(got) != 1 || got[0] != "specs/demo/" { + t.Errorf("testPaths = %v, want [specs/demo/]", got) + } + for _, p := range got { + if strings.Contains(p, "junit.xml") || strings.Contains(p, "cover.out") { + t.Errorf("testPaths leaked an artifact path: %q", p) + } + } + }) } func TestSpecTestReportRunFailing(t *testing.T) { diff --git a/internal/session/reports.go b/internal/session/reports.go index 220451c..6e49a2c 100644 --- a/internal/session/reports.go +++ b/internal/session/reports.go @@ -26,7 +26,7 @@ type SpecReport struct { Command string `json:"command,omitempty"` Tests *SpecTestCounts `json:"tests,omitempty"` Coverage *SpecCovSummary `json:"coverage,omitempty"` - TestPaths []string `json:"testPaths,omitempty"` // report artifacts proving the run (traceable evidence) + TestPaths []string `json:"testPaths,omitempty"` // the spec folder this evidence belongs to (deterministic: specs//) Attentions []string `json:"attentions,omitempty"` // key unit-test signals: failures, skips, command failure }