diff --git a/internal/testdrive/command_test.go b/internal/testdrive/command_test.go new file mode 100644 index 00000000..7ff9f3c7 --- /dev/null +++ b/internal/testdrive/command_test.go @@ -0,0 +1,48 @@ +package testdrive + +import ( + "os" + "testing" + + "github.com/DataDog/ddtest/internal/framework" + "github.com/DataDog/ddtest/internal/platform" + "github.com/DataDog/ddtest/internal/settings" + "github.com/stretchr/testify/require" +) + +func TestTestdriveUsesFrameworkCommand(t *testing.T) { + old := settings.Get().Command + settings.Get().Command = "" + t.Cleanup(func() { settings.Get().Command = old }) + javascript := platform.NewJavaScript() + for _, tc := range []struct { + runner framework.Framework + command string + args []string + }{ + {framework.NewJest(javascript), "npx", []string{"jest"}}, + {framework.NewMocha(javascript), "npx", []string{"mocha"}}, + {framework.NewVitest(javascript), "node", nil}, + {framework.NewPlaywright(javascript), "npx", []string{"playwright", "test"}}, + {framework.NewCucumber(javascript), "npx", []string{"cucumber-js"}}, + } { + t.Run(tc.runner.Name(), func(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + // Neither a custom script nor a package manager lockfile overrides execution. + require.NoError(t, os.WriteFile("package.json", []byte(`{"scripts":{"test":"jest && echo side-effect","unit":"vitest --config custom.ts"}}`), 0644)) + require.NoError(t, os.WriteFile("yarn.lock", nil, 0644)) + command, args := tc.runner.Command() + require.Equal(t, tc.command, command) + require.Equal(t, tc.args, args) + }) + } +} + +func TestTestdrivePreservesExplicitCommandArguments(t *testing.T) { + t.Cleanup(func() { settings.Get().Command = "" }) + settings.Get().Command = `npm run smoke -- --config "config with spaces.js"` + command, args := framework.NewMocha(platform.NewJavaScript()).Command() + require.Equal(t, "npm", command) + require.Equal(t, []string{"run", "smoke", "--", "--config", "config with spaces.js"}, args) +} diff --git a/internal/testdrive/frameworks_integration_test.go b/internal/testdrive/frameworks_integration_test.go new file mode 100644 index 00000000..82ff1179 --- /dev/null +++ b/internal/testdrive/frameworks_integration_test.go @@ -0,0 +1,131 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026 Datadog, Inc. + +package testdrive_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestPublicFrameworkTestdrives exercises the shipped CLI, real tracers, and +// real test runners. Browser downloads are explicit test setup, never testdrive +// side effects. Run with DDTEST_RUN_FRAMEWORK_INTEGRATION_TEST=1. +func TestPublicFrameworkTestdrives(t *testing.T) { + if os.Getenv("DDTEST_RUN_FRAMEWORK_INTEGRATION_TEST") == "" { + t.Skip("set DDTEST_RUN_FRAMEWORK_INTEGRATION_TEST=1") + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Minute) + defer cancel() + binary := filepath.Join(t.TempDir(), "ddtest") + integrationCommand(t, ctx, "../..", nil, "go", "build", "-o", binary, ".") + fixtures := []struct { + name, manifest, command string + files map[string]string + }{ + {"jest", `{"scripts":{"test":"jest"},"devDependencies":{"jest":"30.5.1"}}`, "npm test", map[string]string{"one.test.js": `test('adds', () => expect(1+1).toBe(2));`}}, + {"mocha", `{"scripts":{"test":"mocha"},"devDependencies":{"mocha":"11.7.5"}}`, "npm test", map[string]string{"test/one.js": `const assert = require('node:assert'); it('adds', () => assert.equal(1+1,2));`}}, + {"vitest", `{"type":"module","scripts":{"test":"vitest run"},"devDependencies":{"vitest":"3.2.4"}}`, "npm test", map[string]string{"one.test.js": `import {test,expect} from 'vitest'; test('adds', () => expect(1+1).toBe(2));`}}, + {"playwright", `{"scripts":{"test":"playwright test"},"devDependencies":{"@playwright/test":"1.55.1"}}`, "npm test", map[string]string{"one.spec.js": `const {test,expect} = require('@playwright/test'); test('adds', () => expect(1+1).toBe(2));`}}, + {"cucumber", `{"scripts":{"test":"cucumber-js"},"devDependencies":{"@cucumber/cucumber":"12.2.0"}}`, "npm test", map[string]string{"features/one.feature": "Feature: Arithmetic\n Scenario: Add\n Given addition works\n", "features/step_definitions/one.js": `const {Given} = require('@cucumber/cucumber'); Given('addition works', () => require('node:assert').equal(1+1,2));`}}, + } + for _, fixture := range fixtures { + t.Run(fixture.name, func(t *testing.T) { + name := "project space" + if fixture.name == "rspec" || fixture.name == "minitest" { + name = "project" + } // Keep the recorded Ruby fixture path; build failures retain Bundler diagnostics. + root := filepath.Join(t.TempDir(), name) + require.NoError(t, os.MkdirAll(root, 0755)) + integrationCommand(t, ctx, root, nil, "git", "init", "-q") + for name, contents := range fixture.files { + integrationFile(t, root, name, contents) + } + if fixture.manifest != "" { + integrationFile(t, root, "package.json", fixture.manifest) + } + integrationFile(t, root, ".github/workflows/test.yml", "name: tests\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: "+fixture.command+"\n") + env := []string{} + if fixture.manifest != "" { + integrationCommand(t, ctx, root, env, "npm", "install", "--no-audit", "--no-fund") + } + if fixture.name == "pytest" { + venv := filepath.Join(t.TempDir(), "venv") + integrationCommand(t, ctx, root, nil, "python3", "-m", "venv", venv) + env = append(env, "PATH="+filepath.Join(venv, "bin")+string(os.PathListSeparator)+os.Getenv("PATH")) + integrationCommand(t, ctx, root, env, filepath.Join(venv, "bin", "python"), "-m", "pip", "install", "pytest==8.4.2") + } + if fixture.name == "rspec" || fixture.name == "minitest" { + integrationCommand(t, ctx, root, env, "bundle", "lock") + } + before := map[string]string{} + for _, name := range []string{"package.json", "package-lock.json", "Gemfile", "Gemfile.lock", "requirements.txt", "cypress.config.js"} { + contents, err := os.ReadFile(filepath.Join(root, name)) + if err == nil { + before[name] = string(contents) + } + } + output := integrationCommand(t, ctx, root, env, binary, "testdrive", "--yes") + require.Contains(t, output, "Test events received.") + require.Contains(t, output, "Open report:") + reports, err := filepath.Glob(filepath.Join(root, ".testoptimization", "testdrive", "*", "report.html")) + require.NoError(t, err) + require.Len(t, reports, 1) + contents, err := os.ReadFile(reports[0]) + require.NoError(t, err) + require.Contains(t, string(contents), "Test events received.") + traffic, err := filepath.Glob(filepath.Join(filepath.Dir(reports[0]), "intake", "*citestcycle.json")) + require.NoError(t, err) + require.NotEmpty(t, traffic) + for name, contents := range before { + if (fixture.name == "rspec" || fixture.name == "minitest") && (name == "Gemfile" || name == "Gemfile.lock") { + continue // bundle add updates Ruby dependency files. + } + after, err := os.ReadFile(filepath.Join(root, name)) + require.NoError(t, err) + require.Equal(t, contents, string(after), name) + } + if fixture.name == "rspec" || fixture.name == "minitest" { + gemfile, err := os.ReadFile(filepath.Join(root, "Gemfile")) + require.NoError(t, err) + require.Contains(t, string(gemfile), "datadog-ci") + require.FileExists(t, filepath.Join(root, "Gemfile.lock")) + } else if _, existed := before["Gemfile.lock"]; !existed { + _, err := os.Stat(filepath.Join(root, "Gemfile.lock")) + require.True(t, os.IsNotExist(err), "project lockfile must not be created") + } + if fixture.name == "cypress" { + hook, err := os.ReadFile(filepath.Join(root, "original-hook.txt")) + require.NoError(t, err) + require.Equal(t, "ran", string(hook)) + } + t.Log(strings.TrimSpace(output)) + }) + } +} + +func integrationFile(t *testing.T, root, name, contents string) { + t.Helper() + path := filepath.Join(root, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, []byte(contents), 0644)) +} + +func integrationCommand(t *testing.T, ctx context.Context, directory string, env []string, name string, args ...string) string { + t.Helper() + command := exec.CommandContext(ctx, name, args...) + command.Dir = directory + command.Env = append(os.Environ(), env...) + output, err := command.CombinedOutput() + require.NoError(t, err, "%s %v:\n%s", name, args, output) + return string(output) +} diff --git a/internal/testdrive/intake/findings.go b/internal/testdrive/intake/findings.go index 56256dd8..cebec56e 100644 --- a/internal/testdrive/intake/findings.go +++ b/internal/testdrive/intake/findings.go @@ -81,6 +81,7 @@ type SlowSuite struct { type Facts struct { ConfigurationErrors []string EmptyCoverageEntryCount int + MissingCoverage bool TestCount int TestEventCount int CoveredTestCount int @@ -116,6 +117,7 @@ func (s *Server) Facts() (Facts, error) { findings.SlowSuites, findings.SuiteDurationMedian = slowSuites(findings.Tests) findings.TestCount = len(findings.Tests) findings.CoveredTestCount = uniqueCoveredTestCount(tests, coverages) + findings.MissingCoverage = len(tests) > 0 && len(coverages) == 0 && emptyEntries == 0 findings.BroadCoverage, findings.CoveredFilesMedian = analyzeCoverage(tests, coverages, findings.CoverageLevel) findings.ConfigurationErrors, err = s.configurationErrors() return findings, err diff --git a/internal/testdrive/intake/troubleshooting.go b/internal/testdrive/intake/troubleshooting.go new file mode 100644 index 00000000..82b3609b --- /dev/null +++ b/internal/testdrive/intake/troubleshooting.go @@ -0,0 +1,62 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026 Datadog, Inc. + +package intake + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +const MissingCoverageFinding = "missing-coverage" + +// Advice explains a likely cause of a finding and a concrete next step. +type Advice struct { + Finding string + Title string + Text string + URL string +} + +// Troubleshoot returns advice only when the project provides evidence for it. +func Troubleshoot(repositoryRoot, framework string, facts Facts) []Advice { + if !facts.MissingCoverage || !strings.EqualFold(framework, "cucumber") || !nycAbsent(repositoryRoot) { + return nil + } + return []Advice{{ + Finding: MissingCoverageFinding, + Title: "Cucumber needs nyc for coverage", + Text: "This project does not declare or install nyc locally. Add it with npm install --save-dev nyc, then run cucumber-js through nyc (for example, nyc cucumber-js) and repeat the testdrive.", + URL: "https://docs.datadoghq.com/tests/test_impact_analysis/setup/javascript/", + }} +} + +func nycAbsent(repositoryRoot string) bool { + data, err := os.ReadFile(filepath.Join(repositoryRoot, "package.json")) + if err != nil { + return false + } + var manifest struct { + Dependencies map[string]json.RawMessage `json:"dependencies"` + DevDependencies map[string]json.RawMessage `json:"devDependencies"` + OptionalDependencies map[string]json.RawMessage `json:"optionalDependencies"` + PeerDependencies map[string]json.RawMessage `json:"peerDependencies"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + return false + } + for _, dependencies := range []map[string]json.RawMessage{ + manifest.Dependencies, manifest.DevDependencies, manifest.OptionalDependencies, manifest.PeerDependencies, + } { + if _, exists := dependencies["nyc"]; exists { + return false + } + } + _, err = os.Stat(filepath.Join(repositoryRoot, "node_modules", ".bin", "nyc")) + return errors.Is(err, os.ErrNotExist) +} diff --git a/internal/testdrive/intake/troubleshooting_test.go b/internal/testdrive/intake/troubleshooting_test.go new file mode 100644 index 00000000..ae8b17f3 --- /dev/null +++ b/internal/testdrive/intake/troubleshooting_test.go @@ -0,0 +1,54 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026 Datadog, Inc. + +package intake + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tinylib/msgp/msgp" +) + +func TestMissingCoverageFindingRequiresTestEventsAndNoCoveragePayload(t *testing.T) { + events := msgp.AppendMapHeader(nil, 1) + events = msgp.AppendString(events, "events") + events = msgp.AppendArrayHeader(events, 1) + events = appendEvent(events, "test", 10, 20, 100) + + server := serverWithCoverage(t, events) + server.requests = server.requests[:1] + facts, err := server.Facts() + require.NoError(t, err) + require.True(t, facts.MissingCoverage) + + server = serverWithCoverage(t, events, appendCoverage(nil, 10, 20, 100, "src/greet.js")) + facts, err = server.Facts() + require.NoError(t, err) + require.False(t, facts.MissingCoverage) + + server = serverWithCoverage(t, events, appendCoverage(nil, 10, 20, 100)) + facts, err = server.Facts() + require.NoError(t, err) + require.False(t, facts.MissingCoverage) // Empty coverage has its own finding. +} + +func TestTroubleshootCucumberWithoutNYC(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"devDependencies":{"@cucumber/cucumber":"13.2.1"}}`), 0644)) + facts := Facts{MissingCoverage: true} + + advice := Troubleshoot(root, "Cucumber", facts) + require.Len(t, advice, 1) + require.Equal(t, MissingCoverageFinding, advice[0].Finding) + require.Contains(t, advice[0].Text, "npm install --save-dev nyc") + require.Empty(t, Troubleshoot(root, "Cucumber", Facts{})) + require.Empty(t, Troubleshoot(root, "Jest", facts)) + + require.NoError(t, os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"devDependencies":{"@cucumber/cucumber":"13.2.1","nyc":"17.0.0"}}`), 0644)) + require.Empty(t, Troubleshoot(root, "Cucumber", facts)) +} diff --git a/internal/testdrive/javascript.go b/internal/testdrive/javascript.go index ccc2b159..db3939bb 100644 --- a/internal/testdrive/javascript.go +++ b/internal/testdrive/javascript.go @@ -3,6 +3,7 @@ package testdrive import ( "encoding/json" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -66,3 +67,41 @@ func javascriptTracerVersion(preload string) string { } return pkg.Version } + +func currentNodeVersion() string { + output, err := exec.Command("node", "--version").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +func supportsNodeImport(version string) bool { + version = strings.TrimPrefix(strings.TrimSpace(version), "v") + parts := strings.Split(version, ".") + if len(parts) < 2 { + return false + } + major, majorErr := strconv.Atoi(parts[0]) + minor, minorErr := strconv.Atoi(parts[1]) + if majorErr != nil || minorErr != nil { + return false + } + return major > 18 || major == 18 && minor >= 18 +} + +func (t *Testdrive) javascriptEnvironment(path string) map[string]string { + env := javascriptEnvironment(path) + // ESM instrumentation is needed by Vitest and by ESM test/config files. + version := "" + if t.nodeVersion != nil { + version = t.nodeVersion() + } + if supportsNodeImport(version) { + registerPath := filepath.Join(filepath.Dir(filepath.Dir(path)), "register.js") + if info, err := os.Stat(registerPath); err == nil && info.Mode().IsRegular() { + env["NODE_OPTIONS"] += " --import " + strconv.Quote(absoluteFileURL(registerPath)) + } + } + return env +} diff --git a/internal/testdrive/multiframework_test.go b/internal/testdrive/multiframework_test.go new file mode 100644 index 00000000..211fc42c --- /dev/null +++ b/internal/testdrive/multiframework_test.go @@ -0,0 +1,118 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026 Datadog, Inc. + +package testdrive + +import ( + "bytes" + "github.com/DataDog/ddtest/internal/settings" + "os" + "path/filepath" + "testing" + + "github.com/DataDog/ddtest/internal/testdrive/intake" + "github.com/stretchr/testify/require" +) + +func TestPrepareAllSupportedFrameworks(t *testing.T) { + for _, name := range []string{"jest", "mocha", "vitest", "playwright", "cucumber"} { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + switch name { + case "pytest": + requireWriteFile(t, filepath.Join(root, "pyproject.toml"), "[tool.pytest.ini_options]\n") + case "rspec", "minitest": + requireWriteFile(t, filepath.Join(root, "Gemfile"), "gem '"+name+"'\n") + default: + requireWriteFile(t, filepath.Join(root, "package.json"), `{"scripts":{"test":"`+name+`"}}`) + } + t.Chdir(root) + run, err := Prepare("latest") + require.NoError(t, err) + var preview bytes.Buffer + run.Preview(&preview) + require.Contains(t, preview.String(), displayName(name)) + _, err = os.Stat(filepath.Join(root, ".testoptimization")) + require.True(t, os.IsNotExist(err), "preview must be read-only") + if name == "cypress" { + return + } // Browser wrapper has its own real-run test. + run.nodeVersion = func() string { return "v20.0.0" } + installer := &fakeTracer{preloadPath: filepath.Join(root, "isolated")} + run.platform = installer + executor := &fakeTestdriveExecutor{} + run.executor = executor + run.startIntake = func(string) (localIntake, error) { + return &fakeIntake{url: "http://127.0.0.1:1234", findings: intake.Facts{TestEventCount: 1, TestCount: 1}}, nil + } + var output bytes.Buffer + require.NoError(t, run.Run(t.Context(), &output)) + require.Contains(t, output.String(), displayName(name)+": Passed") + require.Contains(t, output.String(), "Tests with coverage: 0 / 1") + require.Equal(t, "ddtest-testdrive", executor.env["DD_API_KEY"]) + if name == "cucumber" { + require.Equal(t, "true", executor.env["DD_CIVISIBILITY_IMPACTED_TESTS_DETECTION_ENABLED"]) + } + switch run.language { + case "javascript": + require.NotContains(t, executor.env["NODE_OPTIONS"], "--import") + case "python": + require.NotEmpty(t, executor.env["PYTHONPATH"]) + require.Contains(t, executor.env["PYTEST_ADDOPTS"], "--ddtrace") + require.NotContains(t, executor.env, "NODE_OPTIONS") + case "ruby": + require.NotContains(t, executor.env, "BUNDLE_GEMFILE") + require.Contains(t, preview.String(), "Bundler updates the project Gemfile and lockfile") + require.Contains(t, output.String(), "datadog-ci · installed in project") + require.Contains(t, executor.env["RUBYOPT"], "datadog/ci/auto_instrument") + require.NotContains(t, executor.env, "NODE_OPTIONS") + } + }) + } +} + +func TestJavaScriptEnvironmentImportsExistingRegister(t *testing.T) { + t.Setenv("NODE_OPTIONS", "") + packageDir := filepath.Join(t.TempDir(), "dd-trace") + require.NoError(t, os.MkdirAll(filepath.Join(packageDir, "ci"), 0755)) + register := filepath.Join(packageDir, "register.js") + require.NoError(t, os.WriteFile(register, nil, 0644)) + run := &Testdrive{nodeVersion: func() string { return "v22.0.0" }} + preload := filepath.Join(packageDir, "ci", "init.js") + require.Contains(t, run.javascriptEnvironment(preload)["NODE_OPTIONS"], " --import ") + require.NoError(t, os.Remove(register)) + require.NotContains(t, run.javascriptEnvironment(preload)["NODE_OPTIONS"], "--import") +} + +func TestSupportsNodeImport(t *testing.T) { + for version, want := range map[string]bool{ + "v18.17.1": false, + "v18.18.0": true, + "v20.0.0": true, + "invalid": false, + } { + if got := supportsNodeImport(version); got != want { + t.Errorf("supportsNodeImport(%q) = %v, want %v", version, got, want) + } + } +} + +func TestPrepareRequiresSelectionForMultipleFrameworks(t *testing.T) { + previous := settings.Get().Framework + t.Cleanup(func() { settings.Get().Framework = previous }) + settings.Get().Framework = "" + root := t.TempDir() + requireWriteFile(t, filepath.Join(root, "package.json"), `{"scripts":{"test":"vitest run","e2e":"playwright test"}}`) + t.Chdir(root) + _, err := Prepare("latest") + require.ErrorContains(t, err, "--framework") + settings.Get().Framework = "vitest" + run, err := Prepare("latest") + require.NoError(t, err) + require.Equal(t, "vitest", run.framework.Name()) + settings.Get().Framework = "unsupported" + _, err = Prepare("latest") + require.ErrorContains(t, err, "unsupported framework") +} diff --git a/internal/testdrive/report.go b/internal/testdrive/report.go index 48a418e8..c61e0b4a 100644 --- a/internal/testdrive/report.go +++ b/internal/testdrive/report.go @@ -28,6 +28,7 @@ type reportCard struct { Title string Count int Context string + Advices []intake.Advice Tests []reportTest Suites []reportSuite Coverages []reportCoverage @@ -220,6 +221,12 @@ func buildReport(repositoryRoot string, findings intake.Facts, commandFailed boo Context: fmt.Sprintf("%d coverage entries had an empty files list. Affected payloads were excluded from coverage counts. Inspect the captured traffic.", findings.EmptyCoverageEntryCount), }) } + if findings.MissingCoverage { + model.Cards = append(model.Cards, reportCard{ + Kind: intake.MissingCoverageFinding, Title: "Coverage not reported", Count: 1, + Context: "Test events arrived, but no code coverage was reported. Test Impact Analysis cannot map these tests to changed files.", + }) + } if len(findings.FailedTests) > 0 { model.Cards = append(model.Cards, reportCard{ Kind: "failed", Title: "Failed tests", Context: "Inspect the errors and source behind each failure.", Count: len(findings.FailedTests), @@ -260,6 +267,9 @@ func buildReport(repositoryRoot string, findings intake.Facts, commandFailed boo }) } count := len(findings.ConfigurationErrors) + findings.EmptyCoverageEntryCount + if findings.MissingCoverage { + count++ + } for _, size := range []int{ len(findings.FailedTests), len(findings.FlakyTests), len(findings.SlowTests), len(findings.SlowSuites), len(findings.BroadCoverage), } { @@ -271,6 +281,13 @@ func buildReport(repositoryRoot string, findings intake.Facts, commandFailed boo if len(findings.ConfigurationErrors) > 0 { model.Summary += " Tracer configuration errors: " + strings.Join(findings.ConfigurationErrors, ", ") + ". Inspect the captured traffic and test output." } + for _, advice := range intake.Troubleshoot(repositoryRoot, info.Framework, findings) { + for i := range model.Cards { + if model.Cards[i].Kind == advice.Finding { + model.Cards[i].Advices = append(model.Cards[i].Advices, advice) + } + } + } return model } diff --git a/internal/testdrive/report.html b/internal/testdrive/report.html index 3cb8a706..bfbe6624 100644 --- a/internal/testdrive/report.html +++ b/internal/testdrive/report.html @@ -546,7 +546,8 @@

--card-accent: var(--red); } .problem-card[data-kind="flaky"], - .problem-card[data-kind="slow"] { + .problem-card[data-kind="slow"], + .problem-card[data-kind="missing-coverage"] { --card-accent: var(--amber); } .card-button { @@ -616,6 +617,17 @@

justify-content: space-between; background: var(--surface); } + .finding-explanation, + .finding-advice { + padding: 16px 20px; + border-top: 1px solid var(--line); + } + .finding-advice { + background: var(--raised); + } + .finding-advice p { + margin: 8px 0; + } h2 { font-size: 16px; margin: 0; @@ -1208,6 +1220,8 @@

Test report

{{ template "icon-arrow-repeat" }} {{ else if eq .Kind "slow" }} {{ template "icon-stopwatch" }} + {{ else if eq .Kind "missing-coverage" }} + {{ template "icon-exclamation-circle" }} {{ else if eq .Kind "error" }} {{ template "icon-exclamation-circle" }} {{ else }} @@ -1234,6 +1248,16 @@

Test report

{{ .Title }} · {{ .Count }}

+ {{ if eq .Kind "missing-coverage" }} +

{{ .Context }}

+ {{ end }} + {{ range .Advices }} + + {{ end }} {{ range .Tests }}
diff --git a/internal/testdrive/report_test.go b/internal/testdrive/report_test.go index b9d88e0f..38440d0f 100644 --- a/internal/testdrive/report_test.go +++ b/internal/testdrive/report_test.go @@ -24,6 +24,7 @@ func TestReportCountsIndividualFindings(t *testing.T) { want string }{ {"single", intake.Facts{TestEventCount: 1, FailedTests: []intake.Test{{Name: "failed"}}}, "1 finding."}, + {"missing coverage", intake.Facts{TestEventCount: 1, TestCount: 1, MissingCoverage: true}, "1 finding."}, {"multiple in one card", intake.Facts{TestEventCount: 2, FailedTests: []intake.Test{{Name: "one"}, {Name: "two"}}}, "2 findings."}, {"empty coverage without events", intake.Facts{EmptyCoverageEntryCount: 2}, "2 findings."}, {"configuration error without events", intake.Facts{ConfigurationErrors: []string{"skippable_tests"}}, "1 finding."}, @@ -50,6 +51,31 @@ func TestReportCountsIndividualFindings(t *testing.T) { } } +func TestReportShowsCucumberCoverageTroubleshootingWithFinding(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"devDependencies":{"@cucumber/cucumber":"13.2.1"}}`), 0644); err != nil { + t.Fatal(err) + } + facts := intake.Facts{TestEventCount: 2, TestCount: 2, MissingCoverage: true} + model := buildReport(root, facts, false, reportRuntime{Framework: "Cucumber"}) + if model.Summary != "1 finding." || len(model.Cards) != 1 || len(model.Cards[0].Advices) != 1 { + t.Fatalf("missing coverage advice: summary=%q cards=%+v", model.Summary, model.Cards) + } + path, err := writeReport(root, t.TempDir(), facts, false, reportRuntime{Framework: "Cucumber"}) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"Coverage not reported", "Cucumber needs nyc for coverage", "npm install --save-dev nyc"} { + if !strings.Contains(string(data), expected) { + t.Fatalf("report does not show %q", expected) + } + } +} + func TestReportKeepsPythonAndRubySourceEscaped(t *testing.T) { for _, extension := range []string{".py", ".rb"} { t.Run(extension, func(t *testing.T) { diff --git a/internal/testdrive/testdrive.go b/internal/testdrive/testdrive.go index e97244f9..cee45471 100644 --- a/internal/testdrive/testdrive.go +++ b/internal/testdrive/testdrive.go @@ -54,6 +54,7 @@ type Testdrive struct { installArgs []string executor commandExecutor startIntake func(string) (localIntake, error) + nodeVersion func() string } // Prepare detects the repository and probes the project tracer without writing files. @@ -78,7 +79,7 @@ func Prepare(version string) (*Testdrive, error) { } language := detectedPlatform.Name() switch runner.Name() { - case "jest": + case "jest", "mocha", "vitest", "playwright", "cucumber": default: return nil, fmt.Errorf("testdrive does not yet support %s", runner.Name()) } @@ -103,7 +104,7 @@ func Prepare(version string) (*Testdrive, error) { } return &Testdrive{projectTracer: projectTracer, session: session, installCommand: installCommand, installArgs: installArgs, repositoryRoot: repositoryRoot, framework: runner, language: language, command: command, args: args, platform: detectedPlatform, tracerVersion: version, tracerLabel: label, - executor: &ext.DefaultCommandExecutor{}, startIntake: func(directory string) (localIntake, error) { return intake.Start(directory) }}, nil + executor: &ext.DefaultCommandExecutor{}, startIntake: func(directory string) (localIntake, error) { return intake.Start(directory) }, nodeVersion: currentNodeVersion}, nil } func displayName(name string) string { @@ -300,6 +301,10 @@ func writeFindings(output io.Writer, findings intake.Facts) { _, _ = fmt.Fprintf(output, "Tracer configuration errors: %s. Inspect the captured traffic and test output.\n", strings.Join(findings.ConfigurationErrors, ", ")) } count := len(findings.ConfigurationErrors) + if findings.MissingCoverage { + count++ + _, _ = fmt.Fprintln(output, "Coverage not reported: test events arrived, but no code coverage was reported. Test Impact Analysis cannot map these tests to changed files.") + } if findings.EmptyCoverageEntryCount > 0 { count += findings.EmptyCoverageEntryCount _, _ = fmt.Fprintf(output, "Tracer error: received %d coverage entries with an empty files list. Affected payloads were excluded from coverage counts. Inspect the captured traffic.\n", findings.EmptyCoverageEntryCount) @@ -403,8 +408,9 @@ func testEnvironment(intakeURL, sessionID string) map[string]string { func (t *Testdrive) environment(path, intakeURL, sessionID string) map[string]string { env := testEnvironment(intakeURL, sessionID) - if t.language == "javascript" { - maps.Copy(env, javascriptEnvironment(path)) + switch t.language { + case "javascript": + maps.Copy(env, t.javascriptEnvironment(path)) } return env }