Skip to content
Open
48 changes: 48 additions & 0 deletions internal/testdrive/command_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
131 changes: 131 additions & 0 deletions internal/testdrive/frameworks_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions internal/testdrive/intake/findings.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ type SlowSuite struct {
type Facts struct {
ConfigurationErrors []string
EmptyCoverageEntryCount int
MissingCoverage bool
TestCount int
TestEventCount int
CoveredTestCount int
Expand Down Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions internal/testdrive/intake/troubleshooting.go
Original file line number Diff line number Diff line change
@@ -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)
}
54 changes: 54 additions & 0 deletions internal/testdrive/intake/troubleshooting_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
39 changes: 39 additions & 0 deletions internal/testdrive/javascript.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package testdrive
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
Expand Down Expand Up @@ -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
}
Loading
Loading