From e4d9a2356d7f7a3a9d967b79b1008902954091f1 Mon Sep 17 00:00:00 2001 From: Sebastian Conde Lorenzo Date: Tue, 16 Jun 2026 16:40:02 +0200 Subject: [PATCH 01/12] Add JavaScript Jest basic capabilities --- internal/constants/constants.go | 1 + internal/framework/framework.go | 98 ++++++ internal/framework/jest.go | 116 +++++++ internal/framework/jest_test.go | 226 +++++++++++++ internal/framework/minitest.go | 4 + internal/framework/rspec.go | 4 + internal/planner/discovery_cache.go | 5 +- internal/planner/planner.go | 53 ++- internal/planner/planner_test.go | 106 +++++- internal/platform/javascript.go | 141 ++++++++ internal/platform/javascript_test.go | 357 ++++++++++++++++++++ internal/platform/platform.go | 2 + internal/platform/scripts/javascript_env.js | 13 + internal/runner/test_helpers_test.go | 4 + 14 files changed, 1102 insertions(+), 28 deletions(-) create mode 100644 internal/framework/jest.go create mode 100644 internal/framework/jest_test.go create mode 100644 internal/platform/javascript.go create mode 100644 internal/platform/javascript_test.go create mode 100644 internal/platform/scripts/javascript_env.js diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 179b072..e87a92c 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -24,6 +24,7 @@ var HTTPCacheDir = filepath.Join(PlanDirectory, "cache", "http") // Platform specific output file paths var RubyEnvOutputPath = filepath.Join(PlanDirectory, "ruby_env.json") +var JavaScriptEnvOutputPath = filepath.Join(PlanDirectory, "javascript_env.json") // Executor constants const ( diff --git a/internal/framework/framework.go b/internal/framework/framework.go index dc61984..e3132bf 100644 --- a/internal/framework/framework.go +++ b/internal/framework/framework.go @@ -2,16 +2,114 @@ package framework import ( "context" + "encoding/json" + "errors" + "log/slog" + "os" + "path/filepath" + "time" "github.com/DataDog/ddtest/internal/discovery" + "github.com/DataDog/ddtest/internal/ext" "github.com/DataDog/ddtest/internal/testoptimization" + "github.com/bmatcuk/doublestar/v4" ) type Framework interface { Name() string TestPattern() string + TestExcludePattern() string DiscoverTests(ctx context.Context, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error SetPlatformEnv(platformEnv map[string]string) GetPlatformEnv() map[string]string } + +var ErrFullTestDiscoveryUnsupported = errors.New("full test discovery is not supported") + +type FullTestDiscoverySupporter interface { + SupportsFullTestDiscovery() bool +} + +// Asumme, by default, that every framework supports full test discovery. +// If it implements the FullTestDiscoverySupporter interface, then +// delegate the choice to SupportFullTestDiscovery() +func SupportsFullTestDiscovery(f Framework) bool { + supporter, ok := f.(FullTestDiscoverySupporter) + return !ok || supporter.SupportsFullTestDiscovery() +} + +// cleanupDiscoveryFile removes the discovery file, ignoring "not exists" errors +func cleanupDiscoveryFile(filePath string) { + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + slog.Warn("Warning: Failed to delete existing discovery file", "filePath", filePath, "error", err) + } +} + +// executeDiscoveryCommand runs the discovery command and logs timing +func executeDiscoveryCommand(ctx context.Context, executor ext.CommandExecutor, name string, args []string, envMap map[string]string, frameworkName string) ([]byte, error) { + slog.Debug("Starting test discovery...", "framework", frameworkName) + startTime := time.Now() + + output, err := executor.CombinedOutput(ctx, name, args, envMap) + if err != nil { + slog.Warn("Failed to run test discovery", "framework", frameworkName, "output", string(output), "error", err) + return nil, err + } + + duration := time.Since(startTime) + slog.Debug("Finished test discovery", "framework", frameworkName, "duration", duration) + + return output, nil +} + +// parseDiscoveryFile reads and parses the test discovery JSON file +func parseDiscoveryFile(filePath string) ([]testoptimization.Test, error) { + file, err := os.Open(filePath) + if err != nil { + slog.Error("Error opening JSON file", "error", err) + return nil, err + } + defer func() { + _ = file.Close() + }() + + var tests []testoptimization.Test + decoder := json.NewDecoder(file) + for decoder.More() { + var test testoptimization.Test + if err := decoder.Decode(&test); err != nil { + slog.Error("Error parsing JSON", "error", err) + return nil, err + } + tests = append(tests, test) + } + + return tests, nil +} + +func defaultTestPattern(rootDir, filePattern string) string { + return filepath.Join(rootDir, "**", filePattern) +} + +func globTestFiles(pattern string) ([]string, error) { + matches, err := doublestar.FilepathGlob(pattern, doublestar.WithFilesOnly()) + if err != nil { + return nil, err + } + + return matches, nil +} + +// BaseDiscoveryEnv returns environment variables required for all test discovery processes. +// These env vars ensure the test framework runs in discovery mode without requiring +// actual Datadog credentials or agent connectivity. +func BaseDiscoveryEnv() map[string]string { + return map[string]string{ + "DD_CIVISIBILITY_ENABLED": "1", + "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", + "DD_API_KEY": "dummy_key", + "DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED": "1", + "DD_TEST_OPTIMIZATION_DISCOVERY_FILE": discovery.TestsFilePath, + } +} diff --git a/internal/framework/jest.go b/internal/framework/jest.go new file mode 100644 index 0000000..8b99e0a --- /dev/null +++ b/internal/framework/jest.go @@ -0,0 +1,116 @@ +package framework + +import ( + "context" + "log/slog" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/DataDog/ddtest/internal/discovery" + "github.com/DataDog/ddtest/internal/ext" + "github.com/DataDog/ddtest/internal/settings" + "github.com/DataDog/ddtest/internal/testoptimization" +) + +const binJestPath = "node_modules/.bin/jest" + +var ( + jestTestFileExtensions = []string{"js", "jsx", "ts", "tsx", "mjs", "cjs"} + jestExcludedDirs = []string{"node_modules", ".git", "dist", "build", "coverage", ".next"} +) + +type Jest struct { + executor ext.CommandExecutor + commandOverride []string + platformEnv map[string]string +} + +func NewJest() *Jest { + return &Jest{ + executor: &ext.DefaultCommandExecutor{}, + commandOverride: loadCommandOverride(), + platformEnv: make(map[string]string), + } +} + +func (j *Jest) SetPlatformEnv(platformEnv map[string]string) { + j.platformEnv = platformEnv +} + +func (j *Jest) GetPlatformEnv() map[string]string { + return j.platformEnv +} + +func (j *Jest) Name() string { + return "jest" +} + +// Makes it a FullTestDiscoverySupporter. +// We will not be discovering tests, but test suites. +// We'll be working outside of the Node.js process +func (j *Jest) SupportsFullTestDiscovery() bool { + return false +} + +func (j *Jest) TestPattern() string { + if custom := settings.GetTestsLocation(); custom != "" { + return custom + } + return "{" + + filepath.ToSlash(filepath.Join("**", "__tests__", "**", "*."+jestTestFileExtensionPattern())) + "," + + filepath.ToSlash(filepath.Join("**", "*.{spec,test}."+jestTestFileExtensionPattern())) + + "}" +} + +func (j *Jest) TestExcludePattern() string { + patterns := make([]string, 0, len(jestExcludedDirs)*4) + for _, dir := range jestExcludedDirs { + patterns = append(patterns, + filepath.ToSlash(dir), + filepath.ToSlash(filepath.Join(dir, "**")), + filepath.ToSlash(filepath.Join("**", dir)), + filepath.ToSlash(filepath.Join("**", dir, "**")), + ) + } + return "{" + strings.Join(patterns, ",") + "}" +} + +func (j *Jest) DiscoverTests(ctx context.Context, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) { + return nil, ErrFullTestDiscoveryUnsupported +} + +func (j *Jest) RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error { + command, baseArgs := j.getJestCommand() + args := slices.Clone(baseArgs) + args = append(args, "--runTestsByPath") + args = append(args, testFiles...) + + slog.Info("Running tests with command", "command", command, "args", args) + + mergedEnv := make(map[string]string) + maps.Copy(mergedEnv, j.platformEnv) + maps.Copy(mergedEnv, envMap) + return j.executor.Run(ctx, command, args, mergedEnv) +} + +// Decide between user custom command, local jest binary and npx jest +func (j *Jest) getJestCommand() (string, []string) { + if len(j.commandOverride) > 0 { + return j.commandOverride[0], j.commandOverride[1:] + } + + if info, err := os.Stat(binJestPath); err == nil && !info.IsDir() && info.Mode()&0111 != 0 { + slog.Debug("Using local Jest binary") + return binJestPath, []string{} + } + + slog.Debug("Using npx jest for Jest commands") + return "npx", []string{"jest"} +} + +func jestTestFileExtensionPattern() string { + return "{" + strings.Join(jestTestFileExtensions, ",") + "}" +} diff --git a/internal/framework/jest_test.go b/internal/framework/jest_test.go new file mode 100644 index 0000000..e3c5c47 --- /dev/null +++ b/internal/framework/jest_test.go @@ -0,0 +1,226 @@ +package framework + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/DataDog/ddtest/internal/discovery" +) + +func TestNewJest(t *testing.T) { + jest := NewJest() + if jest == nil { + t.Fatal("NewJest() returned nil") + } + if jest.executor == nil { + t.Error("NewJest() created Jest with nil executor") + } +} + +func TestJest_Name(t *testing.T) { + jest := NewJest() + if jest.Name() != "jest" { + t.Errorf("expected %q, got %q", "jest", jest.Name()) + } +} + +func TestJest_DiscoverTests_Unsupported(t *testing.T) { + jest := NewJest() + tests, err := jest.DiscoverTests(context.Background(), discovery.TestFileSet{}) + + if tests != nil { + t.Errorf("expected nil tests, got %v", tests) + } + if !errors.Is(err, ErrFullTestDiscoveryUnsupported) { + t.Fatalf("expected ErrFullTestDiscoveryUnsupported, got %v", err) + } + if SupportsFullTestDiscovery(jest) { + t.Error("expected Jest to report unsupported full test discovery") + } +} + +func TestJest_DiscoverTestFiles_DefaultPatterns(t *testing.T) { + tempDir := t.TempDir() + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + filesToCreate := []string{ + "src/foo.test.js", + "src/foo.spec.ts", + "src/__tests__/bar.jsx", + "src/not-test.js", + "node_modules/pkg/bad.test.js", + "dist/bad.spec.js", + "coverage/bad.test.ts", + } + for _, file := range filesToCreate { + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + t.Fatalf("failed to create dir for %s: %v", file, err) + } + if err := os.WriteFile(file, []byte("test"), 0644); err != nil { + t.Fatalf("failed to create %s: %v", file, err) + } + } + + jest := NewJest() + files, err := discovery.DiscoverTestFiles(jest.TestPattern(), jest.TestExcludePattern()) + if err != nil { + t.Fatalf("generic discovery failed: %v", err) + } + + expected := []string{ + "src/__tests__/bar.jsx", + "src/foo.spec.ts", + "src/foo.test.js", + } + if !slices.Equal(files, expected) { + t.Errorf("expected files %v, got %v", expected, files) + } +} + +func TestJest_DiscoverTestFiles_WithTestsLocation(t *testing.T) { + tempDir := t.TempDir() + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + for _, file := range []string{"custom/a.check.js", "custom/b.check.js", "src/c.test.js"} { + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + t.Fatalf("failed to create dir for %s: %v", file, err) + } + if err := os.WriteFile(file, []byte("test"), 0644); err != nil { + t.Fatalf("failed to create %s: %v", file, err) + } + } + + setTestsLocation(t, "custom/*.check.js") + + jest := NewJest() + files, err := discovery.DiscoverTestFiles(jest.TestPattern(), jest.TestExcludePattern()) + if err != nil { + t.Fatalf("generic discovery failed: %v", err) + } + + expected := []string{"custom/a.check.js", "custom/b.check.js"} + if !slices.Equal(files, expected) { + t.Errorf("expected files %v, got %v", expected, files) + } +} + +func TestJest_RunTests_UsesLocalJestBinary(t *testing.T) { + tempDir := t.TempDir() + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + if err := os.MkdirAll(filepath.Dir(binJestPath), 0755); err != nil { + t.Fatalf("failed to create jest bin dir: %v", err) + } + if err := os.WriteFile(binJestPath, []byte("#!/usr/bin/env node\n"), 0755); err != nil { + t.Fatalf("failed to create jest bin: %v", err) + } + + var capturedName string + var capturedArgs []string + mockExecutor := &mockCommandExecutor{ + onExecution: func(name string, args []string) { + capturedName = name + capturedArgs = slices.Clone(args) + }, + } + jest := &Jest{ + executor: mockExecutor, + platformEnv: map[string]string{"NODE_OPTIONS": "-r dd-trace/ci/init", "SHARED": "platform"}, + } + + err := jest.RunTests(context.Background(), []string{"src/a.test.js", "src/b.test.ts"}, map[string]string{"SHARED": "worker", "DD_ENV": "ci"}) + if err != nil { + t.Fatalf("RunTests failed: %v", err) + } + + if capturedName != binJestPath { + t.Errorf("expected command %q, got %q", binJestPath, capturedName) + } + expectedArgs := []string{"--runTestsByPath", "src/a.test.js", "src/b.test.ts"} + if !slices.Equal(capturedArgs, expectedArgs) { + t.Errorf("expected args %v, got %v", expectedArgs, capturedArgs) + } + if mockExecutor.capturedEnvMap["NODE_OPTIONS"] != "-r dd-trace/ci/init" { + t.Errorf("expected NODE_OPTIONS from platform env, got %q", mockExecutor.capturedEnvMap["NODE_OPTIONS"]) + } + if mockExecutor.capturedEnvMap["SHARED"] != "worker" { + t.Errorf("expected worker env to override platform env, got %q", mockExecutor.capturedEnvMap["SHARED"]) + } + if mockExecutor.capturedEnvMap["DD_ENV"] != "ci" { + t.Errorf("expected worker env DD_ENV, got %q", mockExecutor.capturedEnvMap["DD_ENV"]) + } +} + +func TestJest_RunTests_UsesNpxFallback(t *testing.T) { + tempDir := t.TempDir() + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + var capturedName string + var capturedArgs []string + mockExecutor := &mockCommandExecutor{ + onExecution: func(name string, args []string) { + capturedName = name + capturedArgs = slices.Clone(args) + }, + } + jest := &Jest{executor: mockExecutor, platformEnv: make(map[string]string)} + + if err := jest.RunTests(context.Background(), []string{"src/a.test.js"}, nil); err != nil { + t.Fatalf("RunTests failed: %v", err) + } + + if capturedName != "npx" { + t.Errorf("expected command %q, got %q", "npx", capturedName) + } + expectedArgs := []string{"jest", "--runTestsByPath", "src/a.test.js"} + if !slices.Equal(capturedArgs, expectedArgs) { + t.Errorf("expected args %v, got %v", expectedArgs, capturedArgs) + } +} + +func TestJest_RunTests_WithOverride(t *testing.T) { + var capturedName string + var capturedArgs []string + mockExecutor := &mockCommandExecutor{ + onExecution: func(name string, args []string) { + capturedName = name + capturedArgs = slices.Clone(args) + }, + } + jest := &Jest{ + executor: mockExecutor, + commandOverride: []string{"pnpm", "jest", "--runInBand"}, + platformEnv: make(map[string]string), + } + + if err := jest.RunTests(context.Background(), []string{"src/a.test.js"}, nil); err != nil { + t.Fatalf("RunTests failed: %v", err) + } + + if capturedName != "pnpm" { + t.Errorf("expected command %q, got %q", "pnpm", capturedName) + } + expectedArgs := []string{"jest", "--runInBand", "--runTestsByPath", "src/a.test.js"} + if !slices.Equal(capturedArgs, expectedArgs) { + t.Errorf("expected args %v, got %v", expectedArgs, capturedArgs) + } +} diff --git a/internal/framework/minitest.go b/internal/framework/minitest.go index 036f873..9b20a42 100644 --- a/internal/framework/minitest.go +++ b/internal/framework/minitest.go @@ -78,6 +78,10 @@ func (m *Minitest) TestPattern() string { return filepath.Join(minitestRootDir, "**", minitestTestFilePattern) } +func (m *Minitest) TestExcludePattern() string { + return "" +} + func (m *Minitest) RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error { command, args, isRails := m.getMinitestCommand() slog.Info("Running tests with command", "command", command, "args", args) diff --git a/internal/framework/rspec.go b/internal/framework/rspec.go index dde4bf5..8f03b02 100644 --- a/internal/framework/rspec.go +++ b/internal/framework/rspec.go @@ -70,6 +70,10 @@ func (r *RSpec) TestPattern() string { return filepath.Join(rspecRootDir, "**", rspecTestFilePattern) } +func (r *RSpec) TestExcludePattern() string { + return "" +} + func (r *RSpec) RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error { command, baseArgs := r.getRSpecCommand() args := append(baseArgs, "--format", "progress") diff --git a/internal/planner/discovery_cache.go b/internal/planner/discovery_cache.go index cc573fb..daedf89 100644 --- a/internal/planner/discovery_cache.go +++ b/internal/planner/discovery_cache.go @@ -255,7 +255,7 @@ func (c discoveryCache) store() { Platform: c.platformName, Framework: c.testFramework.Name(), TestsLocation: c.testFramework.TestPattern(), - TestsExcludePattern: settings.GetTestsExcludePattern(), + TestsExcludePattern: effectiveTestExcludePattern(c.testFramework), } if err := appendDiscoveryCacheMetadata(c.filePath, metadata); err != nil { slog.Warn("Failed to append test discovery cache metadata", "error", err) @@ -271,6 +271,7 @@ func (c discoveryCache) validate() error { return fmt.Errorf("schema version mismatch: %d", metadata.SchemaVersion) } testPattern := c.testFramework.TestPattern() + testExcludePattern := effectiveTestExcludePattern(c.testFramework) for _, check := range []struct { name string got string @@ -279,7 +280,7 @@ func (c discoveryCache) validate() error { {"platform", metadata.Platform, c.platformName}, {"framework", metadata.Framework, c.testFramework.Name()}, {"tests location", metadata.TestsLocation, testPattern}, - {"tests exclude pattern", metadata.TestsExcludePattern, settings.GetTestsExcludePattern()}, + {"tests exclude pattern", metadata.TestsExcludePattern, testExcludePattern}, } { if check.got != check.want { return fmt.Errorf("%s mismatch: %s", check.name, check.got) diff --git a/internal/planner/planner.go b/internal/planner/planner.go index fe091a1..04dbe90 100644 --- a/internal/planner/planner.go +++ b/internal/planner/planner.go @@ -10,6 +10,7 @@ import ( "os" "slices" "strconv" + "strings" "time" ciConstants "github.com/DataDog/ddtest/civisibility/constants" @@ -259,15 +260,17 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { } // Detect framework once to avoid duplicate work - framework, err := detectedPlatform.DetectFramework() + testFramework, err := detectedPlatform.DetectFramework() if err != nil { return fmt.Errorf("failed to detect framework: %w", err) } - slog.Info("Framework detected", "framework", framework.Name()) + slog.Info("Framework detected", "framework", testFramework.Name()) + fullTestDiscoverySupported := framework.SupportsFullTestDiscovery(testFramework) tp.runInfo = runmetadata.New(utils.GetCITags()) - tp.planInfo = NewPlanInfo(tags, detectedPlatform.Name(), framework.Name()) + tp.planInfo = NewPlanInfo(tags, detectedPlatform.Name(), testFramework.Name()) - resolvedTestFiles, err := discovery.ResolveTestFiles(framework.TestPattern(), settings.GetTestsExcludePattern()) + testExcludePattern := effectiveTestExcludePattern(testFramework) + resolvedTestFiles, err := discovery.ResolveTestFiles(testFramework.TestPattern(), testExcludePattern) if err != nil { return err } @@ -286,7 +289,7 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { slog.Info("Running from subdirectory, will normalize repo-root-relative paths", "subdirPrefix", cwdSubdirPrefix) } - discoveryCache := newDiscoveryCache(detectedPlatform.Name(), framework) + discoveryCache := newDiscoveryCache(detectedPlatform.Name(), testFramework) g, planningCtx := errgroup.WithContext(ctx) // planningCtx cancels discovery if a required planning goroutine fails; // cancelDiscovery lets backend settings stop only full discovery. @@ -308,6 +311,11 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { slog.Debug("Repository settings", "tia_enabled", repositorySettings.ItrEnabled, "tests_skipping", repositorySettings.TestsSkipping) tiaSkippingEnabled = repositorySettings.ItrEnabled && repositorySettings.TestsSkipping + if tiaSkippingEnabled && !fullTestDiscoverySupported { + slog.Info("Framework does not support full test discovery; TIA skippables will not be applied during planning", "framework", testFramework.Name()) + tiaSkippingEnabled = false + } + if !tiaSkippingEnabled { slog.Info("TIA or test skipping disabled, cancelling full test discovery") cancelDiscovery() @@ -329,13 +337,18 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { // Goroutine 2: Tests discovery (respects context cancellation) g.Go(func() error { + if !fullTestDiscoverySupported { + slog.Info("Full test discovery is not supported by framework; using fast test file discovery fallback", "framework", testFramework.Name()) + return nil + } + if res, ok := discoveryCache.restore(); ok { discoveredTests = res fullDiscoverySucceeded = true return nil } - res, discoveryErr := discoverLocalTests(discoveryCtx, framework, resolvedTestFiles) + res, discoveryErr := discoverLocalTests(discoveryCtx, testFramework, resolvedTestFiles) if discoveryErr != nil { return nil // Don't fail the entire process, we have fast discovery as fallback. } @@ -349,13 +362,13 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { // Goroutine 3: Test files discovery (fast, must always complete) g.Go(func() error { startTime := time.Now() - slog.Info("Discovering test files (fast)...", "framework", framework.Name()) + slog.Info("Discovering test files (fast)...", "framework", testFramework.Name()) var res []string if resolvedTestFiles.UseExplicitFiles() { res = resolvedTestFiles.ExplicitFiles } else { var discErr error - res, discErr = discovery.DiscoverTestFiles(resolvedTestFiles.Pattern, settings.GetTestsExcludePattern()) + res, discErr = discovery.DiscoverTestFiles(resolvedTestFiles.Pattern, testExcludePattern) if discErr != nil { fastDiscoveryErr = discErr slog.Warn("Fast test discovery failed", "error", discErr) @@ -412,6 +425,30 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { return nil } +func effectiveTestExcludePattern(testFramework framework.Framework) string { + return mergeTestExcludePatterns(testFramework.TestExcludePattern(), settings.GetTestsExcludePattern()) +} + +func mergeTestExcludePatterns(patterns ...string) string { + normalizedPatterns := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + normalized := utils.NormalizePattern(pattern) + if normalized == "" { + continue + } + normalizedPatterns = append(normalizedPatterns, normalized) + } + + switch len(normalizedPatterns) { + case 0: + return "" + case 1: + return normalizedPatterns[0] + default: + return "{" + strings.Join(normalizedPatterns, ",") + "}" + } +} + func discoverLocalTests(ctx context.Context, testFramework framework.Framework, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) { startTime := time.Now() slog.Info("Discovering local tests...", "framework", testFramework.Name()) diff --git a/internal/planner/planner_test.go b/internal/planner/planner_test.go index 12ee9bb..3ec6272 100644 --- a/internal/planner/planner_test.go +++ b/internal/planner/planner_test.go @@ -70,16 +70,17 @@ func (m *MockPlatform) SanityCheck() error { // MockFramework mocks a testing framework type MockFramework struct { - FrameworkName string - TestPatternValue string - Tests []testoptimization.Test - TestFiles []string - Err error - DiscoverTestsErr error // If set, overrides Err for DiscoverTests - OnDiscoverTests func() - RunTestsCalls []RunTestsCall - DiscoverTestsFiles []discovery.TestFileSet - mu sync.Mutex + FrameworkName string + TestPatternValue string + TestExcludePatternValue string + Tests []testoptimization.Test + TestFiles []string + Err error + DiscoverTestsErr error // If set, overrides Err for DiscoverTests + OnDiscoverTests func() + RunTestsCalls []RunTestsCall + DiscoverTestsFiles []discovery.TestFileSet + mu sync.Mutex } type RunTestsCall struct { @@ -153,6 +154,10 @@ func (m *MockFramework) TestPattern() string { return mockTestFilesPattern(m.TestFiles) } +func (m *MockFramework) TestExcludePattern() string { + return m.TestExcludePatternValue +} + var ( mockTestFilesMu sync.Mutex mockTestFilesCreated []string @@ -306,16 +311,29 @@ func (m *longRunningDiscoveryFramework) DiscoverTests(ctx context.Context, testF return nil, ctx.Err() } +type noFullDiscoveryFramework struct { + MockFramework +} + +func (m *noFullDiscoveryFramework) SupportsFullTestDiscovery() bool { + return false +} + +func (m *noFullDiscoveryFramework) DiscoverTests(ctx context.Context, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) { + return nil, framework.ErrFullTestDiscoveryUnsupported +} + // MockTestOptimizationClient mocks the test optimization client type MockTestOptimizationClient struct { - InitializeCalled bool - InitializeErr error - Settings *net.SettingsResponseData - SkippableTests map[string]bool - KnownTests *net.KnownTestsResponseData - TestManagementTests *net.TestManagementTestsResponseDataModules - ShutdownCalled bool - Tags map[string]string + InitializeCalled bool + InitializeErr error + Settings *net.SettingsResponseData + SkippableTests map[string]bool + GetSkippableTestsCalled bool + KnownTests *net.KnownTestsResponseData + TestManagementTests *net.TestManagementTestsResponseDataModules + ShutdownCalled bool + Tags map[string]string } func (m *MockTestOptimizationClient) Initialize(tags map[string]string) error { @@ -332,6 +350,7 @@ func (m *MockTestOptimizationClient) GetSettings() *net.SettingsResponseData { } func (m *MockTestOptimizationClient) GetSkippableTests() map[string]bool { + m.GetSkippableTestsCalled = true return m.SkippableTests } @@ -676,6 +695,57 @@ func TestTestPlanner_Plan_WritesManifestAndRunnerLayout(t *testing.T) { assertFileContent(t, filepath.Join(constants.TestsSplitDir, "runner-0"), expectedTestFiles) } +func TestTestPlanner_Plan_FrameworkWithoutFullDiscoveryDoesNotFetchSkippables(t *testing.T) { + tempDir := t.TempDir() + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + t.Cleanup(func() { settings.Init() }) + t.Setenv("DD_TEST_OPTIMIZATION_RUNNER_MIN_PARALLELISM", "1") + t.Setenv("DD_TEST_OPTIMIZATION_RUNNER_MAX_PARALLELISM", "1") + t.Setenv("DD_TEST_OPTIMIZATION_RUNNER_REPORT_ENABLED", "false") + settings.Init() + + mockFramework := &noFullDiscoveryFramework{ + MockFramework: MockFramework{ + FrameworkName: "jest", + TestFiles: []string{"src/a.test.js", "src/b.test.ts"}, + }, + } + mockPlatform := &MockPlatform{ + PlatformName: "javascript", + Tags: map[string]string{"language": "javascript"}, + Framework: mockFramework, + } + mockOptimizationClient := &MockTestOptimizationClient{ + Settings: testOptimizationSettings(true, true, false), + SkippableTests: map[string]bool{"jest.src/a.test.js..": true}, + } + + runner := NewWithDependencies( + &MockPlatformDetector{Platform: mockPlatform}, + mockOptimizationClient, + &MockTestSuiteDurationsClient{}, + newDefaultMockCIProviderDetector(), + ) + + if err := runner.Plan(context.Background()); err != nil { + t.Fatalf("Plan() should not return error, got: %v", err) + } + + if mockOptimizationClient.GetSkippableTestsCalled { + t.Fatal("expected planner not to fetch skippables when full test discovery is unsupported") + } + + expectedTestFiles := "src/a.test.js\nsrc/b.test.ts\n" + assertFileContent(t, constants.TestFilesOutputPath, expectedTestFiles) + assertFileContent(t, constants.SkippablePercentageOutputPath, "0.00") + assertFileContent(t, filepath.Join(constants.TestsSplitDir, "runner-0"), expectedTestFiles) +} + func TestTestPlanner_Plan_DoesNotPrintReportWhenDisabled(t *testing.T) { tempDir := t.TempDir() oldWd, _ := os.Getwd() diff --git a/internal/platform/javascript.go b/internal/platform/javascript.go new file mode 100644 index 0000000..2b40fef --- /dev/null +++ b/internal/platform/javascript.go @@ -0,0 +1,141 @@ +package platform + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "log/slog" + "maps" + "os" + "strings" + + "github.com/DataDog/ddtest/internal/constants" + "github.com/DataDog/ddtest/internal/ext" + "github.com/DataDog/ddtest/internal/framework" + "github.com/DataDog/ddtest/internal/settings" +) + +//go:embed scripts/javascript_env.js +var javascriptEnvScript string + +const ( + nodeOptionsEnvVar = "NODE_OPTIONS" + ddTraceCIInitModule = "dd-trace/ci/init" + nodeOptionsDDTraceCIArg = "-r " + ddTraceCIInitModule +) + +type JavaScript struct { + executor ext.CommandExecutor +} + +func NewJavaScript() *JavaScript { + return &JavaScript{ + executor: &ext.DefaultCommandExecutor{}, + } +} + +func (j *JavaScript) Name() string { + return "javascript" +} + +// GetPlatformEnv returns environment variables required for JS commands. +func (j *JavaScript) GetPlatformEnv() map[string]string { + // Check if the NODE_OPTIONS is set in the env, and if so, + // check if it contains the dd-trace init option + // (the minimum required to start the libary) + currentValue, exists := os.LookupEnv(nodeOptionsEnvVar) + if exists && strings.Contains(currentValue, ddTraceCIInitModule) { + return map[string]string{} + } + + // If the NODE_OPTIONS contained something, prepend the dd-trace + // init option at the beggining + nodeOptions := nodeOptionsDDTraceCIArg + if strings.TrimSpace(currentValue) != "" { + nodeOptions = nodeOptionsDDTraceCIArg + " " + currentValue + } + + // If NODE_OPTIONS is not set, just set it to '-r dd-trace/ci/init' + slog.Debug("Setting NODE_OPTIONS to auto-instrument with dd-trace-js", "nodeOptions", nodeOptions) + return map[string]string{ + nodeOptionsEnvVar: nodeOptions, + } +} + +func (j *JavaScript) CreateTagsMap() (map[string]string, error) { + tags := make(map[string]string) + tags["language"] = j.Name() + + // Create plan directory if it doesn't exist + if err := os.MkdirAll(constants.PlanDirectory, 0755); err != nil { + return nil, fmt.Errorf("failed to create plan directory: %w", err) + } + + // Create a temporary file for the JavaScript script output + tempFile := constants.JavaScriptEnvOutputPath + defer func() { _ = os.Remove(tempFile) }() + + // Execute the embedded JavaScript script to get runtime tags + if err := j.executor.Run(context.Background(), "node", []string{"-e", javascriptEnvScript, tempFile}, nil); err != nil { + return nil, fmt.Errorf("failed to execute JavaScript script: %w", err) + } + + // Read the JSON output from the temp file + fileContent, err := os.ReadFile(tempFile) + if err != nil { + return nil, fmt.Errorf("failed to read JavaScript script output file: %w", err) + } + + // Parse the JSON output. + // The extracted tags from the node process are: + // "os.platform", "os.architecture", "os.version", "runtime.name" & "runtime.version" + var javascriptTags map[string]string + if err := json.Unmarshal(fileContent, &javascriptTags); err != nil { + return nil, fmt.Errorf("failed to parse runtime tags JSON: %w, tried to parse: %s", err, string(fileContent)) + } + + // Merge the tags from the JavaScript output + maps.Copy(tags, javascriptTags) + + return tags, nil +} + +func (j *JavaScript) DetectFramework() (framework.Framework, error) { + frameworkName := settings.GetFramework() + platformEnv := j.GetPlatformEnv() + + var fw framework.Framework + switch frameworkName { + case "jest": + fw = framework.NewJest() + default: + return nil, fmt.Errorf("framework '%s' is not supported by platform 'javascript'", frameworkName) + } + + fw.SetPlatformEnv(platformEnv) + return fw, nil +} + +// Confirm that Node.js is installed by running 'node --version' +// and confirm that the dd-trace package is resolvable +func (j *JavaScript) SanityCheck() error { + if output, err := j.executor.CombinedOutput(context.Background(), "node", []string{"--version"}, nil); err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return fmt.Errorf("node --version command failed: %w", err) + } + return fmt.Errorf("node --version command failed: %s", message) + } + + output, err := j.executor.CombinedOutput(context.Background(), "node", []string{"-e", fmt.Sprintf("require.resolve(%q)", ddTraceCIInitModule)}, nil) + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return fmt.Errorf("failed to resolve %s: %w", ddTraceCIInitModule, err) + } + return fmt.Errorf("failed to resolve %s: %s", ddTraceCIInitModule, message) + } + + return nil +} diff --git a/internal/platform/javascript_test.go b/internal/platform/javascript_test.go new file mode 100644 index 0000000..1750936 --- /dev/null +++ b/internal/platform/javascript_test.go @@ -0,0 +1,357 @@ +package platform + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "strings" + "testing" + + "github.com/DataDog/ddtest/internal/constants" + "github.com/DataDog/ddtest/internal/settings" + "github.com/spf13/viper" +) + +// sequentialMockExecutor returns pre-configured responses in order for CombinedOutput calls. +// Used when successive calls to SanityCheck need different return values. +type sequentialMockExecutor struct { + responses []struct { + output []byte + err error + } + index int +} + +func (m *sequentialMockExecutor) CombinedOutput(_ context.Context, _ string, _ []string, _ map[string]string) ([]byte, error) { + if m.index >= len(m.responses) { + return nil, nil + } + r := m.responses[m.index] + m.index++ + return r.output, r.err +} + +func (m *sequentialMockExecutor) Run(_ context.Context, _ string, _ []string, _ map[string]string) error { + return nil +} + +func TestJavaScript_Name(t *testing.T) { + javascript := NewJavaScript() + if javascript.Name() != "javascript" { + t.Errorf("expected %q, got %q", "javascript", javascript.Name()) + } +} + +func TestJavaScript_GetPlatformEnv_SetsNODEOPTIONS(t *testing.T) { + t.Setenv(nodeOptionsEnvVar, "") + + javascript := NewJavaScript() + envMap := javascript.GetPlatformEnv() + + if envMap[nodeOptionsEnvVar] != nodeOptionsDDTraceCIArg { + t.Errorf("expected NODE_OPTIONS to be %q, got %q", nodeOptionsDDTraceCIArg, envMap[nodeOptionsEnvVar]) + } +} + +func TestJavaScript_GetPlatformEnv_PreservesExistingNODEOPTIONS(t *testing.T) { + t.Setenv(nodeOptionsEnvVar, "--max-old-space-size=4096") + + javascript := NewJavaScript() + envMap := javascript.GetPlatformEnv() + + expected := nodeOptionsDDTraceCIArg + " --max-old-space-size=4096" + if envMap[nodeOptionsEnvVar] != expected { + t.Errorf("expected NODE_OPTIONS to be %q, got %q", expected, envMap[nodeOptionsEnvVar]) + } +} + +func TestJavaScript_GetPlatformEnv_DoesNotDuplicateDDTraceInit(t *testing.T) { + t.Setenv(nodeOptionsEnvVar, "-r dd-trace/ci/init --max-old-space-size=4096") + + javascript := NewJavaScript() + envMap := javascript.GetPlatformEnv() + + if len(envMap) != 0 { + t.Errorf("expected empty env map when dd-trace init is already present, got %v", envMap) + } +} + +func TestJavaScript_CreateTagsMap_Success(t *testing.T) { + defer func() { + _ = os.RemoveAll(constants.PlanDirectory) + }() + + expectedJavaScriptTags := map[string]string{ + "os.platform": "darwin", + "os.architecture": "arm64", + "os.version": "24.5.0", + "runtime.name": "node", + "runtime.version": "v22.16.0", + } + + expectedOutput, err := json.Marshal(expectedJavaScriptTags) + if err != nil { + t.Fatalf("failed to marshal expected tags: %v", err) + } + + mockExecutor := &mockCommandExecutor{ + onRun: func(name string, args []string, envMap map[string]string) { + if name != "node" { + t.Errorf("expected command to be 'node', got %q", name) + } + if len(args) != 3 { + t.Errorf("expected 3 args, got %d: %v", len(args), args) + return + } + if args[0] != "-e" { + t.Errorf("expected first arg to be '-e', got %q", args[0]) + } + if args[1] == "" { + t.Error("javascript script should not be empty") + } + if err := os.WriteFile(args[2], expectedOutput, 0644); err != nil { + t.Errorf("failed to write temp file: %v", err) + } + }, + } + + javascript := &JavaScript{executor: mockExecutor} + tags, err := javascript.CreateTagsMap() + if err != nil { + t.Fatalf("CreateTagsMap failed: %v", err) + } + + if tags["language"] != "javascript" { + t.Errorf("expected language tag to be 'javascript', got %q", tags["language"]) + } + for key, expectedValue := range expectedJavaScriptTags { + if actualValue := tags[key]; actualValue != expectedValue { + t.Errorf("expected tag %q to be %q, got %q", key, expectedValue, actualValue) + } + } +} + +func TestJavaScript_CreateTagsMap_CommandFailure(t *testing.T) { + defer func() { + _ = os.RemoveAll(constants.PlanDirectory) + }() + + javascript := &JavaScript{ + executor: &mockCommandExecutor{runErr: &exec.ExitError{}}, + } + + tags, err := javascript.CreateTagsMap() + if err == nil { + t.Fatal("expected error when node command fails") + } + if tags != nil { + t.Error("expected nil tags when command fails") + } + if !strings.Contains(err.Error(), "failed to execute JavaScript script") { + t.Errorf("expected JavaScript execution error, got %v", err) + } +} + +func TestJavaScript_CreateTagsMap_InvalidJSON(t *testing.T) { + defer func() { + _ = os.RemoveAll(constants.PlanDirectory) + }() + + mockExecutor := &mockCommandExecutor{ + onRun: func(name string, args []string, envMap map[string]string) { + if err := os.WriteFile(args[2], []byte("{invalid json}"), 0644); err != nil { + t.Errorf("failed to write temp file: %v", err) + } + }, + } + + javascript := &JavaScript{executor: mockExecutor} + tags, err := javascript.CreateTagsMap() + if err == nil { + t.Fatal("expected error when JSON is invalid") + } + if tags != nil { + t.Error("expected nil tags when JSON parsing fails") + } + if !strings.Contains(err.Error(), "failed to parse runtime tags JSON") { + t.Errorf("expected JSON parsing error, got %v", err) + } +} + +func TestJavaScript_DetectFramework_Jest(t *testing.T) { + viper.Reset() + viper.Set("framework", "jest") + settings.Init() + defer func() { + viper.Reset() + settings.Init() + }() + + javascript := NewJavaScript() + fw, err := javascript.DetectFramework() + if err != nil { + t.Fatalf("DetectFramework failed: %v", err) + } + if fw.Name() != "jest" { + t.Errorf("expected framework name to be 'jest', got %q", fw.Name()) + } +} + +func TestJavaScript_DetectFramework_Unsupported(t *testing.T) { + viper.Reset() + viper.Set("framework", "rspec") + settings.Init() + defer func() { + viper.Reset() + settings.Init() + }() + + javascript := NewJavaScript() + fw, err := javascript.DetectFramework() + if err == nil { + t.Fatalf("expected unsupported framework error, got framework %v", fw) + } + if fw != nil { + t.Error("expected nil framework for unsupported framework") + } + expectedError := "framework 'rspec' is not supported by platform 'javascript'" + if err.Error() != expectedError { + t.Errorf("expected error %q, got %q", expectedError, err.Error()) + } +} + +func TestJavaScript_SanityCheck_Passes(t *testing.T) { + calls := 0 + mockExecutor := &mockCommandExecutor{ + combinedOutput: []byte("v22.16.0\n"), + onCombinedOutput: func(name string, args []string, envMap map[string]string) { + calls++ + if name != "node" { + t.Fatalf("expected command 'node', got %q", name) + } + if calls == 1 && (len(args) != 1 || args[0] != "--version") { + t.Fatalf("expected node --version, got %v", args) + } + if calls == 2 && (len(args) != 2 || args[0] != "-e" || !strings.Contains(args[1], ddTraceCIInitModule)) { + t.Fatalf("expected node require.resolve command, got %v", args) + } + }, + } + + javascript := &JavaScript{executor: mockExecutor} + if err := javascript.SanityCheck(); err != nil { + t.Fatalf("SanityCheck() unexpected error: %v", err) + } + if calls != 2 { + t.Fatalf("expected 2 sanity check commands, got %d", calls) + } +} + +func TestJavaScript_SanityCheck_FailsWhenNodeMissing(t *testing.T) { + javascript := &JavaScript{ + executor: &mockCommandExecutor{ + combinedOutput: []byte("node: command not found"), + combinedOutputErr: &exec.ExitError{}, + }, + } + + err := javascript.SanityCheck() + if err == nil { + t.Fatal("expected sanity check error") + } + if !strings.Contains(err.Error(), "node --version command failed") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestJavaScript_EmbeddedScript(t *testing.T) { + if javascriptEnvScript == "" { + t.Error("embedded JavaScript script should not be empty") + } + + for _, expected := range []string{ + "require(\"os\")", + "process.version", + "process.arch", + "process.platform", + "fs.writeFileSync", + } { + if !strings.Contains(javascriptEnvScript, expected) { + t.Errorf("expected JavaScript script to contain %q", expected) + } + } +} + +func TestJavaScript_SanityCheck_FailsWhenDDTraceMissing(t *testing.T) { + mockExecutor := &sequentialMockExecutor{ + responses: []struct { + output []byte + err error + }{ + {output: []byte("v22.16.0\n"), err: nil}, + {output: []byte("Cannot find module 'dd-trace/ci/init'"), err: &exec.ExitError{}}, + }, + } + + javascript := &JavaScript{executor: mockExecutor} + err := javascript.SanityCheck() + if err == nil { + t.Fatal("expected sanity check error when dd-trace is missing") + } + if !strings.Contains(err.Error(), "failed to resolve "+ddTraceCIInitModule) { + t.Errorf("unexpected error: %v", err) + } +} + +func TestJavaScript_DetectFramework_SetsPlatformEnv(t *testing.T) { + t.Setenv(nodeOptionsEnvVar, "") + + viper.Reset() + viper.Set("framework", "jest") + settings.Init() + defer func() { + viper.Reset() + settings.Init() + }() + + javascript := NewJavaScript() + fw, err := javascript.DetectFramework() + if err != nil { + t.Fatalf("DetectFramework failed: %v", err) + } + if fw == nil { + t.Fatal("expected framework to be non-nil") + } + + frameworkPlatformEnv := fw.GetPlatformEnv() + if frameworkPlatformEnv[nodeOptionsEnvVar] != nodeOptionsDDTraceCIArg { + t.Errorf("expected framework platformEnv %s=%q, got %q", nodeOptionsEnvVar, nodeOptionsDDTraceCIArg, frameworkPlatformEnv[nodeOptionsEnvVar]) + } +} + +func TestDetectPlatform_JavaScript(t *testing.T) { + viper.Reset() + viper.Set("platform", "javascript") + settings.Init() + defer func() { + viper.Reset() + settings.Init() + }() + + platform, err := DetectPlatform() + if err != nil { + // SanityCheck failed — verify the error names the javascript platform + if !strings.Contains(err.Error(), "javascript") { + t.Errorf("expected error to mention javascript platform, got: %v", err) + } + if platform != nil { + t.Error("expected nil platform when sanity check fails") + } + } else { + // SanityCheck passed (node + dd-trace available in this environment) + if platform.Name() != "javascript" { + t.Errorf("expected platform name 'javascript', got %q", platform.Name()) + } + } +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 87b7317..216b798 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -32,6 +32,8 @@ func DetectPlatform() (Platform, error) { switch platformName { case "ruby": platform = NewRuby() + case "javascript": + platform = NewJavaScript() default: return nil, fmt.Errorf("unsupported platform: %s", platformName) } diff --git a/internal/platform/scripts/javascript_env.js b/internal/platform/scripts/javascript_env.js new file mode 100644 index 0000000..b2d6a81 --- /dev/null +++ b/internal/platform/scripts/javascript_env.js @@ -0,0 +1,13 @@ +const fs = require("fs") +const os = require("os") + +const tagsMap = { + "os.platform": process.platform, + "os.architecture": process.arch, + "os.version": os.release(), + "runtime.name": "node", + "runtime.version": process.version, +} + +const outputFile = process.argv[1] +fs.writeFileSync(outputFile, JSON.stringify(tagsMap)) diff --git a/internal/runner/test_helpers_test.go b/internal/runner/test_helpers_test.go index b01b70d..b2ae0ff 100644 --- a/internal/runner/test_helpers_test.go +++ b/internal/runner/test_helpers_test.go @@ -84,6 +84,10 @@ func (m *MockFramework) TestPattern() string { return m.TestPatternValue } +func (m *MockFramework) TestExcludePattern() string { + return "" +} + func (m *MockFramework) DiscoverTests(ctx context.Context, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) { if m.DiscoverTestsErr != nil { return nil, m.DiscoverTestsErr From 8cb9ef9480fa9e2e8f9f8b638b133ddab16bf639 Mon Sep 17 00:00:00 2001 From: Sebastian Conde Lorenzo Date: Thu, 18 Jun 2026 14:41:43 +0200 Subject: [PATCH 02/12] lint --- internal/framework/framework.go | 69 --------------------------------- 1 file changed, 69 deletions(-) diff --git a/internal/framework/framework.go b/internal/framework/framework.go index e3132bf..2107b56 100644 --- a/internal/framework/framework.go +++ b/internal/framework/framework.go @@ -2,17 +2,10 @@ package framework import ( "context" - "encoding/json" "errors" - "log/slog" - "os" - "path/filepath" - "time" "github.com/DataDog/ddtest/internal/discovery" - "github.com/DataDog/ddtest/internal/ext" "github.com/DataDog/ddtest/internal/testoptimization" - "github.com/bmatcuk/doublestar/v4" ) type Framework interface { @@ -39,68 +32,6 @@ func SupportsFullTestDiscovery(f Framework) bool { return !ok || supporter.SupportsFullTestDiscovery() } -// cleanupDiscoveryFile removes the discovery file, ignoring "not exists" errors -func cleanupDiscoveryFile(filePath string) { - if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { - slog.Warn("Warning: Failed to delete existing discovery file", "filePath", filePath, "error", err) - } -} - -// executeDiscoveryCommand runs the discovery command and logs timing -func executeDiscoveryCommand(ctx context.Context, executor ext.CommandExecutor, name string, args []string, envMap map[string]string, frameworkName string) ([]byte, error) { - slog.Debug("Starting test discovery...", "framework", frameworkName) - startTime := time.Now() - - output, err := executor.CombinedOutput(ctx, name, args, envMap) - if err != nil { - slog.Warn("Failed to run test discovery", "framework", frameworkName, "output", string(output), "error", err) - return nil, err - } - - duration := time.Since(startTime) - slog.Debug("Finished test discovery", "framework", frameworkName, "duration", duration) - - return output, nil -} - -// parseDiscoveryFile reads and parses the test discovery JSON file -func parseDiscoveryFile(filePath string) ([]testoptimization.Test, error) { - file, err := os.Open(filePath) - if err != nil { - slog.Error("Error opening JSON file", "error", err) - return nil, err - } - defer func() { - _ = file.Close() - }() - - var tests []testoptimization.Test - decoder := json.NewDecoder(file) - for decoder.More() { - var test testoptimization.Test - if err := decoder.Decode(&test); err != nil { - slog.Error("Error parsing JSON", "error", err) - return nil, err - } - tests = append(tests, test) - } - - return tests, nil -} - -func defaultTestPattern(rootDir, filePattern string) string { - return filepath.Join(rootDir, "**", filePattern) -} - -func globTestFiles(pattern string) ([]string, error) { - matches, err := doublestar.FilepathGlob(pattern, doublestar.WithFilesOnly()) - if err != nil { - return nil, err - } - - return matches, nil -} - // BaseDiscoveryEnv returns environment variables required for all test discovery processes. // These env vars ensure the test framework runs in discovery mode without requiring // actual Datadog credentials or agent connectivity. From a8271f9d3e1a0ee7dcc0a8f8cc7f4fd5d7af1f8b Mon Sep 17 00:00:00 2001 From: Sebastian Conde Lorenzo Date: Thu, 18 Jun 2026 15:27:12 +0200 Subject: [PATCH 03/12] Removed FullTestDiscoverySupporter abstraction layer. Integrated it inside the Framework interface --- internal/framework/framework.go | 14 -------- internal/framework/jest.go | 4 ++- internal/framework/jest_test.go | 2 +- internal/framework/minitest.go | 4 +++ internal/framework/rspec.go | 4 +++ internal/planner/planner.go | 2 +- internal/planner/planner_test.go | 51 +++++++++++++--------------- internal/runner/test_helpers_test.go | 19 +++++++---- 8 files changed, 48 insertions(+), 52 deletions(-) diff --git a/internal/framework/framework.go b/internal/framework/framework.go index 2107b56..c4c04af 100644 --- a/internal/framework/framework.go +++ b/internal/framework/framework.go @@ -2,7 +2,6 @@ package framework import ( "context" - "errors" "github.com/DataDog/ddtest/internal/discovery" "github.com/DataDog/ddtest/internal/testoptimization" @@ -16,22 +15,9 @@ type Framework interface { RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error SetPlatformEnv(platformEnv map[string]string) GetPlatformEnv() map[string]string -} - -var ErrFullTestDiscoveryUnsupported = errors.New("full test discovery is not supported") - -type FullTestDiscoverySupporter interface { SupportsFullTestDiscovery() bool } -// Asumme, by default, that every framework supports full test discovery. -// If it implements the FullTestDiscoverySupporter interface, then -// delegate the choice to SupportFullTestDiscovery() -func SupportsFullTestDiscovery(f Framework) bool { - supporter, ok := f.(FullTestDiscoverySupporter) - return !ok || supporter.SupportsFullTestDiscovery() -} - // BaseDiscoveryEnv returns environment variables required for all test discovery processes. // These env vars ensure the test framework runs in discovery mode without requiring // actual Datadog credentials or agent connectivity. diff --git a/internal/framework/jest.go b/internal/framework/jest.go index 8b99e0a..46e845f 100644 --- a/internal/framework/jest.go +++ b/internal/framework/jest.go @@ -2,6 +2,7 @@ package framework import ( "context" + "errors" "log/slog" "maps" "os" @@ -17,6 +18,8 @@ import ( const binJestPath = "node_modules/.bin/jest" +var ErrFullTestDiscoveryUnsupported = errors.New("full test discovery is not supported") + var ( jestTestFileExtensions = []string{"js", "jsx", "ts", "tsx", "mjs", "cjs"} jestExcludedDirs = []string{"node_modules", ".git", "dist", "build", "coverage", ".next"} @@ -48,7 +51,6 @@ func (j *Jest) Name() string { return "jest" } -// Makes it a FullTestDiscoverySupporter. // We will not be discovering tests, but test suites. // We'll be working outside of the Node.js process func (j *Jest) SupportsFullTestDiscovery() bool { diff --git a/internal/framework/jest_test.go b/internal/framework/jest_test.go index e3c5c47..b46e752 100644 --- a/internal/framework/jest_test.go +++ b/internal/framework/jest_test.go @@ -38,7 +38,7 @@ func TestJest_DiscoverTests_Unsupported(t *testing.T) { if !errors.Is(err, ErrFullTestDiscoveryUnsupported) { t.Fatalf("expected ErrFullTestDiscoveryUnsupported, got %v", err) } - if SupportsFullTestDiscovery(jest) { + if jest.SupportsFullTestDiscovery() { t.Error("expected Jest to report unsupported full test discovery") } } diff --git a/internal/framework/minitest.go b/internal/framework/minitest.go index 9b20a42..bd5457a 100644 --- a/internal/framework/minitest.go +++ b/internal/framework/minitest.go @@ -167,3 +167,7 @@ func (m *Minitest) getMinitestCommand() (string, []string, bool) { slog.Info("No Ruby on Rails found. Using bundle exec rake test for Minitest commands") return "bundle", []string{"exec", "rake", "test"}, false } + +func (m *Minitest) SupportsFullTestDiscovery() bool { + return true +} diff --git a/internal/framework/rspec.go b/internal/framework/rspec.go index 8f03b02..c41af7c 100644 --- a/internal/framework/rspec.go +++ b/internal/framework/rspec.go @@ -104,3 +104,7 @@ func (r *RSpec) getRSpecCommand() (string, []string) { slog.Debug("Using bundle exec rspec for RSpec commands") return "bundle", []string{"exec", "rspec"} } + +func (r *RSpec) SupportsFullTestDiscovery() bool { + return true +} diff --git a/internal/planner/planner.go b/internal/planner/planner.go index 8bce446..5fa59ab 100644 --- a/internal/planner/planner.go +++ b/internal/planner/planner.go @@ -272,7 +272,7 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { return fmt.Errorf("failed to detect framework: %w", err) } slog.Info("Framework detected", "framework", testFramework.Name()) - fullTestDiscoverySupported := framework.SupportsFullTestDiscovery(testFramework) + fullTestDiscoverySupported := testFramework.SupportsFullTestDiscovery() tp.runInfo = runmetadata.New(utils.GetCITags()) tp.planInfo = NewPlanInfo(tags, detectedPlatform.Name(), testFramework.Name()) diff --git a/internal/planner/planner_test.go b/internal/planner/planner_test.go index 8d7fb1b..2a12180 100644 --- a/internal/planner/planner_test.go +++ b/internal/planner/planner_test.go @@ -70,17 +70,18 @@ func (m *MockPlatform) SanityCheck() error { // MockFramework mocks a testing framework type MockFramework struct { - FrameworkName string - TestPatternValue string - TestExcludePatternValue string - Tests []testoptimization.Test - TestFiles []string - Err error - DiscoverTestsErr error // If set, overrides Err for DiscoverTests - OnDiscoverTests func() - RunTestsCalls []RunTestsCall - DiscoverTestsFiles []discovery.TestFileSet - mu sync.Mutex + FrameworkName string + TestPatternValue string + TestExcludePatternValue string + Tests []testoptimization.Test + TestFiles []string + Err error + DiscoverTestsErr error // If set, overrides Err for DiscoverTests + OnDiscoverTests func() + RunTestsCalls []RunTestsCall + DiscoverTestsFiles []discovery.TestFileSet + FullDiscoveryUnsupported bool + mu sync.Mutex } type RunTestsCall struct { @@ -239,6 +240,9 @@ func (m *MockFramework) DiscoverTests(ctx context.Context, testFiles discovery.T m.mu.Lock() m.DiscoverTestsFiles = append(m.DiscoverTestsFiles, testFiles) m.mu.Unlock() + if m.FullDiscoveryUnsupported { + return nil, framework.ErrFullTestDiscoveryUnsupported + } if m.OnDiscoverTests != nil { m.OnDiscoverTests() } @@ -290,6 +294,10 @@ func (m *MockFramework) GetPlatformEnv() map[string]string { return nil } +func (m *MockFramework) SupportsFullTestDiscovery() bool { + return !m.FullDiscoveryUnsupported +} + func (m *MockFramework) GetRunTestsCallsCount() int { m.mu.Lock() defer m.mu.Unlock() @@ -311,18 +319,6 @@ func (m *longRunningDiscoveryFramework) DiscoverTests(ctx context.Context, testF return nil, ctx.Err() } -type noFullDiscoveryFramework struct { - MockFramework -} - -func (m *noFullDiscoveryFramework) SupportsFullTestDiscovery() bool { - return false -} - -func (m *noFullDiscoveryFramework) DiscoverTests(ctx context.Context, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) { - return nil, framework.ErrFullTestDiscoveryUnsupported -} - // MockTestOptimizationClient mocks the test optimization client type MockTestOptimizationClient struct { InitializeCalled bool @@ -698,11 +694,10 @@ func TestTestPlanner_Plan_FrameworkWithoutFullDiscoveryDoesNotFetchSkippables(t t.Setenv("DD_TEST_OPTIMIZATION_RUNNER_REPORT_ENABLED", "false") settings.Init() - mockFramework := &noFullDiscoveryFramework{ - MockFramework: MockFramework{ - FrameworkName: "jest", - TestFiles: []string{"src/a.test.js", "src/b.test.ts"}, - }, + mockFramework := &MockFramework{ + FrameworkName: "jest", + TestFiles: []string{"src/a.test.js", "src/b.test.ts"}, + FullDiscoveryUnsupported: true, } mockPlatform := &MockPlatform{ PlatformName: "javascript", diff --git a/internal/runner/test_helpers_test.go b/internal/runner/test_helpers_test.go index b2ae0ff..daba7c6 100644 --- a/internal/runner/test_helpers_test.go +++ b/internal/runner/test_helpers_test.go @@ -62,13 +62,14 @@ func (m *MockPlatform) SanityCheck() error { } type MockFramework struct { - FrameworkName string - TestPatternValue string - Tests []testoptimization.Test - Err error - DiscoverTestsErr error - RunTestsCalls []RunTestsCall - mu sync.Mutex + FrameworkName string + TestPatternValue string + Tests []testoptimization.Test + Err error + DiscoverTestsErr error + RunTestsCalls []RunTestsCall + FullDiscoveryUnsupported bool + mu sync.Mutex } type RunTestsCall struct { @@ -112,6 +113,10 @@ func (m *MockFramework) GetPlatformEnv() map[string]string { return nil } +func (m *MockFramework) SupportsFullTestDiscovery() bool { + return !m.FullDiscoveryUnsupported +} + func (m *MockFramework) GetRunTestsCallsCount() int { m.mu.Lock() defer m.mu.Unlock() From f026e53ccbe03c8f5209d7ae74003f312ca005c6 Mon Sep 17 00:00:00 2001 From: Sebastian Conde Lorenzo Date: Thu, 18 Jun 2026 15:59:42 +0200 Subject: [PATCH 04/12] Removed BaseDiscoveryEnv form framework.go (was there bc of merge drift) --- internal/framework/framework.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/internal/framework/framework.go b/internal/framework/framework.go index c4c04af..0bacd78 100644 --- a/internal/framework/framework.go +++ b/internal/framework/framework.go @@ -17,16 +17,3 @@ type Framework interface { GetPlatformEnv() map[string]string SupportsFullTestDiscovery() bool } - -// BaseDiscoveryEnv returns environment variables required for all test discovery processes. -// These env vars ensure the test framework runs in discovery mode without requiring -// actual Datadog credentials or agent connectivity. -func BaseDiscoveryEnv() map[string]string { - return map[string]string{ - "DD_CIVISIBILITY_ENABLED": "1", - "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", - "DD_API_KEY": "dummy_key", - "DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED": "1", - "DD_TEST_OPTIMIZATION_DISCOVERY_FILE": discovery.TestsFilePath, - } -} From 3e332743160e452b18bee0a61d9287f258710dcf Mon Sep 17 00:00:00 2001 From: Sebastian Conde Lorenzo Date: Thu, 18 Jun 2026 17:39:24 +0200 Subject: [PATCH 05/12] Normalize Jest test discovery excludes. Skip node_modules in shared discovery and remove Jest's built-in exclude list, so generated directories remain discoverable unless users exclude them explicitly. --- internal/discovery/discovery.go | 5 +++++ internal/discovery/discovery_test.go | 27 +++++++++++++++++++++++++++ internal/framework/jest.go | 16 ++-------------- internal/framework/jest_test.go | 4 ++++ 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index 307f4cb..aeb44c4 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -28,6 +28,7 @@ const ( MaxExplicitTestFiles = 8_000 discoveryCommandLogMaxLength = 300 discoveryCommandLogTruncSuffix = "..." + nodeModulesDir = "node_modules" ) type Excluder struct { @@ -125,6 +126,10 @@ func DiscoverTestFiles(includePattern, excludePattern string) ([]string, error) return nil } + if entry.IsDir() && entry.Name() == nodeModulesDir { + return filepath.SkipDir + } + normalizedPath := utils.NormalizePath(filePath) if normalizedPath == "" { return nil diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go index 612ab6d..d33e33f 100644 --- a/internal/discovery/discovery_test.go +++ b/internal/discovery/discovery_test.go @@ -99,6 +99,33 @@ func TestDiscoverTestFilesWithExcludeDirectory(t *testing.T) { } } +func TestDiscoverTestFilesSkipsNodeModules(t *testing.T) { + root := createDiscoveryFixture(t) + t.Chdir(root) + + if err := os.MkdirAll(filepath.Join("node_modules", "pkg"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("node_modules", "pkg", "ignored_test.rb"), []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + files, err := DiscoverTestFiles(filepath.Join("**", "*_test.rb"), "") + if err != nil { + t.Fatal(err) + } + + expected := []string{ + "test/system/payment_test.rb", + "test/system/users_test.rb", + "test/unit/order_test.rb", + "test/unit/user_test.rb", + } + if !slices.Equal(files, expected) { + t.Fatalf("expected %v, got %v", expected, files) + } +} + func TestDiscoverTestFilesNormalizesPaths(t *testing.T) { root := createDiscoveryFixture(t) t.Chdir(root) diff --git a/internal/framework/jest.go b/internal/framework/jest.go index 46e845f..f86cd7d 100644 --- a/internal/framework/jest.go +++ b/internal/framework/jest.go @@ -20,10 +20,7 @@ const binJestPath = "node_modules/.bin/jest" var ErrFullTestDiscoveryUnsupported = errors.New("full test discovery is not supported") -var ( - jestTestFileExtensions = []string{"js", "jsx", "ts", "tsx", "mjs", "cjs"} - jestExcludedDirs = []string{"node_modules", ".git", "dist", "build", "coverage", ".next"} -) +var jestTestFileExtensions = []string{"js", "jsx", "ts", "tsx", "mjs", "cjs"} type Jest struct { executor ext.CommandExecutor @@ -68,16 +65,7 @@ func (j *Jest) TestPattern() string { } func (j *Jest) TestExcludePattern() string { - patterns := make([]string, 0, len(jestExcludedDirs)*4) - for _, dir := range jestExcludedDirs { - patterns = append(patterns, - filepath.ToSlash(dir), - filepath.ToSlash(filepath.Join(dir, "**")), - filepath.ToSlash(filepath.Join("**", dir)), - filepath.ToSlash(filepath.Join("**", dir, "**")), - ) - } - return "{" + strings.Join(patterns, ",") + "}" + return "" } func (j *Jest) DiscoverTests(ctx context.Context, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) { diff --git a/internal/framework/jest_test.go b/internal/framework/jest_test.go index b46e752..a529ab1 100644 --- a/internal/framework/jest_test.go +++ b/internal/framework/jest_test.go @@ -59,6 +59,7 @@ func TestJest_DiscoverTestFiles_DefaultPatterns(t *testing.T) { "node_modules/pkg/bad.test.js", "dist/bad.spec.js", "coverage/bad.test.ts", + ".next/bad.test.tsx", } for _, file := range filesToCreate { if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { @@ -76,6 +77,9 @@ func TestJest_DiscoverTestFiles_DefaultPatterns(t *testing.T) { } expected := []string{ + ".next/bad.test.tsx", + "coverage/bad.test.ts", + "dist/bad.spec.js", "src/__tests__/bar.jsx", "src/foo.spec.ts", "src/foo.test.js", From 5655488723885ded2fd029174867806671bb0033 Mon Sep 17 00:00:00 2001 From: Sebastian Conde Lorenzo Date: Mon, 22 Jun 2026 14:18:59 +0200 Subject: [PATCH 06/12] Made exclude pattern cleanup --- internal/framework/framework.go | 1 - internal/planner/discovery_cache.go | 5 ++--- internal/planner/planner.go | 30 ++--------------------------- 3 files changed, 4 insertions(+), 32 deletions(-) diff --git a/internal/framework/framework.go b/internal/framework/framework.go index 0bacd78..bd4ad12 100644 --- a/internal/framework/framework.go +++ b/internal/framework/framework.go @@ -10,7 +10,6 @@ import ( type Framework interface { Name() string TestPattern() string - TestExcludePattern() string DiscoverTests(ctx context.Context, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error SetPlatformEnv(platformEnv map[string]string) diff --git a/internal/planner/discovery_cache.go b/internal/planner/discovery_cache.go index daedf89..cc573fb 100644 --- a/internal/planner/discovery_cache.go +++ b/internal/planner/discovery_cache.go @@ -255,7 +255,7 @@ func (c discoveryCache) store() { Platform: c.platformName, Framework: c.testFramework.Name(), TestsLocation: c.testFramework.TestPattern(), - TestsExcludePattern: effectiveTestExcludePattern(c.testFramework), + TestsExcludePattern: settings.GetTestsExcludePattern(), } if err := appendDiscoveryCacheMetadata(c.filePath, metadata); err != nil { slog.Warn("Failed to append test discovery cache metadata", "error", err) @@ -271,7 +271,6 @@ func (c discoveryCache) validate() error { return fmt.Errorf("schema version mismatch: %d", metadata.SchemaVersion) } testPattern := c.testFramework.TestPattern() - testExcludePattern := effectiveTestExcludePattern(c.testFramework) for _, check := range []struct { name string got string @@ -280,7 +279,7 @@ func (c discoveryCache) validate() error { {"platform", metadata.Platform, c.platformName}, {"framework", metadata.Framework, c.testFramework.Name()}, {"tests location", metadata.TestsLocation, testPattern}, - {"tests exclude pattern", metadata.TestsExcludePattern, testExcludePattern}, + {"tests exclude pattern", metadata.TestsExcludePattern, settings.GetTestsExcludePattern()}, } { if check.got != check.want { return fmt.Errorf("%s mismatch: %s", check.name, check.got) diff --git a/internal/planner/planner.go b/internal/planner/planner.go index ff37440..d1bf5d9 100644 --- a/internal/planner/planner.go +++ b/internal/planner/planner.go @@ -10,7 +10,6 @@ import ( "os" "slices" "strconv" - "strings" "time" "github.com/DataDog/ddtest/internal/constants" @@ -278,8 +277,7 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { tp.runInfo = runmetadata.New(environment.GetCITags()) tp.planInfo = NewPlanInfo(tags, detectedPlatform.Name(), testFramework.Name()) - testExcludePattern := effectiveTestExcludePattern(testFramework) - resolvedTestFiles, err := discovery.ResolveTestFiles(testFramework.TestPattern(), testExcludePattern) + resolvedTestFiles, err := discovery.ResolveTestFiles(testFramework.TestPattern(), settings.GetTestsExcludePattern()) if err != nil { return err } @@ -379,7 +377,7 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { res = resolvedTestFiles.ExplicitFiles } else { var discErr error - res, discErr = discovery.DiscoverTestFiles(resolvedTestFiles.Pattern, testExcludePattern) + res, discErr = discovery.DiscoverTestFiles(resolvedTestFiles.Pattern, settings.GetTestsExcludePattern()) if discErr != nil { fastDiscoveryErr = discErr slog.Warn("Fast test discovery failed", "error", discErr) @@ -436,30 +434,6 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { return nil } -func effectiveTestExcludePattern(testFramework framework.Framework) string { - return mergeTestExcludePatterns(testFramework.TestExcludePattern(), settings.GetTestsExcludePattern()) -} - -func mergeTestExcludePatterns(patterns ...string) string { - normalizedPatterns := make([]string, 0, len(patterns)) - for _, pattern := range patterns { - normalized := utils.NormalizePattern(pattern) - if normalized == "" { - continue - } - normalizedPatterns = append(normalizedPatterns, normalized) - } - - switch len(normalizedPatterns) { - case 0: - return "" - case 1: - return normalizedPatterns[0] - default: - return "{" + strings.Join(normalizedPatterns, ",") + "}" - } -} - func discoverLocalTests(ctx context.Context, testFramework framework.Framework, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) { startTime := time.Now() slog.Info("Discovering local tests...", "framework", testFramework.Name()) From 3d8ba0cd6f5489e07f6b6787067151e0b0974596 Mon Sep 17 00:00:00 2001 From: Sebastian Conde Lorenzo Date: Mon, 22 Jun 2026 14:24:40 +0200 Subject: [PATCH 07/12] changed nodeModulesDir into excludedDirs in order to allow the exclusion of more dirs in the future --- internal/discovery/discovery.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index aeb44c4..d1bfdf9 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -28,9 +28,10 @@ const ( MaxExplicitTestFiles = 8_000 discoveryCommandLogMaxLength = 300 discoveryCommandLogTruncSuffix = "..." - nodeModulesDir = "node_modules" ) +var excludedDirs = []string{"node_modules"} + type Excluder struct { pattern string } @@ -126,7 +127,7 @@ func DiscoverTestFiles(includePattern, excludePattern string) ([]string, error) return nil } - if entry.IsDir() && entry.Name() == nodeModulesDir { + if entry.IsDir() && slices.Contains(excludedDirs, entry.Name()) { return filepath.SkipDir } From 4972b79fdadb0120b0ab17e2ec56b71a4f03e1ab Mon Sep 17 00:00:00 2001 From: Sebastian Conde Lorenzo Date: Mon, 22 Jun 2026 14:34:27 +0200 Subject: [PATCH 08/12] Added SupportsFullTestDiscovery method to pytest framework --- internal/framework/pytest.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/framework/pytest.go b/internal/framework/pytest.go index 7017865..248bbfb 100644 --- a/internal/framework/pytest.go +++ b/internal/framework/pytest.go @@ -103,6 +103,10 @@ func braceExpand(items []string) string { return "{" + strings.Join(items, ",") + "}" } +func (p *PyTest) SupportsFullTestDiscovery() bool { + return true +} + func (p *PyTest) RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error { command := "python" args := []string{"-m", "pytest"} From e80125b3b90695f7c3076674f2657def7418238a Mon Sep 17 00:00:00 2001 From: "datadog-prod-us1-5[bot]" <266081015+datadog-prod-us1-5[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:49:17 +0000 Subject: [PATCH 09/12] Add tests for framework methods and platform env Source: PR Agent Co-authored-by: cbasitodx <92582590+cbasitodx@users.noreply.github.com> --- internal/framework/jest_test.go | 21 ++++++++++++ internal/framework/minitest_test.go | 14 ++++++++ internal/framework/pytest_test.go | 7 ++++ internal/framework/rspec_test.go | 14 ++++++++ internal/platform/javascript_test.go | 51 ++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+) diff --git a/internal/framework/jest_test.go b/internal/framework/jest_test.go index a529ab1..2d7b516 100644 --- a/internal/framework/jest_test.go +++ b/internal/framework/jest_test.go @@ -201,6 +201,27 @@ func TestJest_RunTests_UsesNpxFallback(t *testing.T) { } } +func TestJest_SetPlatformEnv(t *testing.T) { + jest := NewJest() + env := map[string]string{"NODE_OPTIONS": "-r dd-trace/ci/init", "FOO": "bar"} + jest.SetPlatformEnv(env) + + got := jest.GetPlatformEnv() + if got["NODE_OPTIONS"] != "-r dd-trace/ci/init" { + t.Errorf("expected NODE_OPTIONS %q, got %q", "-r dd-trace/ci/init", got["NODE_OPTIONS"]) + } + if got["FOO"] != "bar" { + t.Errorf("expected FOO %q, got %q", "bar", got["FOO"]) + } +} + +func TestJest_TestExcludePattern(t *testing.T) { + jest := NewJest() + if got := jest.TestExcludePattern(); got != "" { + t.Errorf("expected empty TestExcludePattern, got %q", got) + } +} + func TestJest_RunTests_WithOverride(t *testing.T) { var capturedName string var capturedArgs []string diff --git a/internal/framework/minitest_test.go b/internal/framework/minitest_test.go index 525be6a..970e5f7 100644 --- a/internal/framework/minitest_test.go +++ b/internal/framework/minitest_test.go @@ -1701,3 +1701,17 @@ func TestMinitest_RunTests_RailsApplication_UsesPlatformEnv(t *testing.T) { t.Errorf("expected RUBYOPT to be %q, got %q", platformEnv["RUBYOPT"], mockExecutor.capturedEnvMap["RUBYOPT"]) } } + +func TestMinitest_TestExcludePattern(t *testing.T) { + minitest := NewMinitest() + if got := minitest.TestExcludePattern(); got != "" { + t.Errorf("expected empty TestExcludePattern, got %q", got) + } +} + +func TestMinitest_SupportsFullTestDiscovery(t *testing.T) { + minitest := NewMinitest() + if !minitest.SupportsFullTestDiscovery() { + t.Error("expected Minitest to support full test discovery") + } +} diff --git a/internal/framework/pytest_test.go b/internal/framework/pytest_test.go index f45d084..111cf66 100644 --- a/internal/framework/pytest_test.go +++ b/internal/framework/pytest_test.go @@ -177,3 +177,10 @@ func TestPyTest_DiscoverTests_Success(t *testing.T) { } } } + +func TestPyTest_SupportsFullTestDiscovery(t *testing.T) { + pytest := NewPytest() + if !pytest.SupportsFullTestDiscovery() { + t.Error("expected PyTest to support full test discovery") + } +} diff --git a/internal/framework/rspec_test.go b/internal/framework/rspec_test.go index 7ebdd14..5435d3c 100644 --- a/internal/framework/rspec_test.go +++ b/internal/framework/rspec_test.go @@ -1302,3 +1302,17 @@ func TestRSpec_DiscoverTests_UsesPlatformEnv(t *testing.T) { t.Error("expected DD_API_KEY to be set") } } + +func TestRSpec_TestExcludePattern(t *testing.T) { + rspec := NewRSpec() + if got := rspec.TestExcludePattern(); got != "" { + t.Errorf("expected empty TestExcludePattern, got %q", got) + } +} + +func TestRSpec_SupportsFullTestDiscovery(t *testing.T) { + rspec := NewRSpec() + if !rspec.SupportsFullTestDiscovery() { + t.Error("expected RSpec to support full test discovery") + } +} diff --git a/internal/platform/javascript_test.go b/internal/platform/javascript_test.go index 1750936..8cd8efb 100644 --- a/internal/platform/javascript_test.go +++ b/internal/platform/javascript_test.go @@ -330,6 +330,57 @@ func TestJavaScript_DetectFramework_SetsPlatformEnv(t *testing.T) { } } +func TestJavaScript_GetPlatformEnv_UnsetNODEOPTIONS(t *testing.T) { + // When NODE_OPTIONS is completely unset (not just empty), we should still + // set it to the dd-trace init argument. + os.Unsetenv(nodeOptionsEnvVar) + + javascript := NewJavaScript() + envMap := javascript.GetPlatformEnv() + + if envMap[nodeOptionsEnvVar] != nodeOptionsDDTraceCIArg { + t.Errorf("expected NODE_OPTIONS to be %q, got %q", nodeOptionsDDTraceCIArg, envMap[nodeOptionsEnvVar]) + } +} + +func TestJavaScript_SanityCheck_NodeFailsEmptyOutput(t *testing.T) { + javascript := &JavaScript{ + executor: &mockCommandExecutor{ + combinedOutput: []byte(""), + combinedOutputErr: &exec.ExitError{}, + }, + } + + err := javascript.SanityCheck() + if err == nil { + t.Fatal("expected sanity check error") + } + if !strings.Contains(err.Error(), "node --version command failed") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestJavaScript_SanityCheck_DDTraceFailsEmptyOutput(t *testing.T) { + mockExecutor := &sequentialMockExecutor{ + responses: []struct { + output []byte + err error + }{ + {output: []byte("v22.16.0\n"), err: nil}, + {output: []byte(""), err: &exec.ExitError{}}, + }, + } + + javascript := &JavaScript{executor: mockExecutor} + err := javascript.SanityCheck() + if err == nil { + t.Fatal("expected sanity check error when dd-trace resolve fails with empty output") + } + if !strings.Contains(err.Error(), "failed to resolve "+ddTraceCIInitModule) { + t.Errorf("unexpected error: %v", err) + } +} + func TestDetectPlatform_JavaScript(t *testing.T) { viper.Reset() viper.Set("platform", "javascript") From 4ddd6c556cf1b7c9369cce72991295ce7740eb7e Mon Sep 17 00:00:00 2001 From: Sebastian Conde Lorenzo Date: Mon, 22 Jun 2026 14:52:20 +0200 Subject: [PATCH 10/12] lint fixed for bits push --- internal/platform/javascript_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/platform/javascript_test.go b/internal/platform/javascript_test.go index 8cd8efb..64ba1a3 100644 --- a/internal/platform/javascript_test.go +++ b/internal/platform/javascript_test.go @@ -333,7 +333,9 @@ func TestJavaScript_DetectFramework_SetsPlatformEnv(t *testing.T) { func TestJavaScript_GetPlatformEnv_UnsetNODEOPTIONS(t *testing.T) { // When NODE_OPTIONS is completely unset (not just empty), we should still // set it to the dd-trace init argument. - os.Unsetenv(nodeOptionsEnvVar) + if err := os.Unsetenv(nodeOptionsEnvVar); err != nil { + t.Fatal(err) + } javascript := NewJavaScript() envMap := javascript.GetPlatformEnv() From 3cf894d27a97e448e07ae96c951796e70f43118b Mon Sep 17 00:00:00 2001 From: Sebastian Conde Lorenzo Date: Mon, 22 Jun 2026 15:32:47 +0200 Subject: [PATCH 11/12] attempt to improve code coverage --- internal/discovery/discovery_test.go | 39 ++++++++++++++++++ internal/framework/minitest_test.go | 12 ++++++ internal/framework/pytest_test.go | 61 ++++++++++++++++++++++++++++ internal/framework/rspec_test.go | 12 ++++++ internal/platform/platform_test.go | 42 +++++++++++++++++++ 5 files changed, 166 insertions(+) diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go index b242f57..fa0a29d 100644 --- a/internal/discovery/discovery_test.go +++ b/internal/discovery/discovery_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -126,6 +127,44 @@ func TestDiscoverTestFilesSkipsNodeModules(t *testing.T) { } } +func TestDiscoverTestFilesSkipsUnreadableEntries(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory permission semantics differ on windows") + } + + root := t.TempDir() + t.Chdir(root) + + visibleFile := filepath.Join("test", "visible_test.rb") + if err := os.MkdirAll(filepath.Dir(visibleFile), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(visibleFile, []byte("# test\n"), 0644); err != nil { + t.Fatal(err) + } + + unreadableDir := filepath.Join("test", "unreadable") + if err := os.MkdirAll(unreadableDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(unreadableDir, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = os.Chmod(unreadableDir, 0755) + }) + + files, err := DiscoverTestFiles(filepath.Join("test", "**", "*_test.rb"), "") + if err != nil { + t.Fatal(err) + } + + expected := []string{visibleFile} + if !slices.Equal(files, expected) { + t.Fatalf("expected %v, got %v", expected, files) + } +} + func TestDiscoverTestFilesNormalizesPaths(t *testing.T) { root := createDiscoveryFixture(t) t.Chdir(root) diff --git a/internal/framework/minitest_test.go b/internal/framework/minitest_test.go index 970e5f7..83aedb9 100644 --- a/internal/framework/minitest_test.go +++ b/internal/framework/minitest_test.go @@ -1702,6 +1702,18 @@ func TestMinitest_RunTests_RailsApplication_UsesPlatformEnv(t *testing.T) { } } +func TestMinitest_DefaultTestPattern(t *testing.T) { + t.Setenv("DD_TEST_OPTIMIZATION_RUNNER_TESTS_LOCATION", "") + settings.Init() + t.Cleanup(settings.Init) + + minitest := NewMinitest() + expected := filepath.Join(minitestRootDir, "**", minitestTestFilePattern) + if got := minitest.TestPattern(); got != expected { + t.Errorf("expected default test pattern %q, got %q", expected, got) + } +} + func TestMinitest_TestExcludePattern(t *testing.T) { minitest := NewMinitest() if got := minitest.TestExcludePattern(); got != "" { diff --git a/internal/framework/pytest_test.go b/internal/framework/pytest_test.go index 111cf66..1a0517e 100644 --- a/internal/framework/pytest_test.go +++ b/internal/framework/pytest_test.go @@ -178,9 +178,70 @@ func TestPyTest_DiscoverTests_Success(t *testing.T) { } } +func TestPyTest_MetadataAndPlatformEnv(t *testing.T) { + pytest := NewPytest() + if pytest.Name() != "pytest" { + t.Errorf("expected framework name pytest, got %q", pytest.Name()) + } + if !pytest.SupportsFullTestDiscovery() { + t.Error("expected PyTest to support full test discovery") + } + + platformEnv := map[string]string{"PYTEST_ADDOPTS": "--ddtrace"} + pytest.SetPlatformEnv(platformEnv) + if got := pytest.GetPlatformEnv(); got["PYTEST_ADDOPTS"] != platformEnv["PYTEST_ADDOPTS"] { + t.Errorf("expected platform env to be retained, got %v", got) + } +} + func TestPyTest_SupportsFullTestDiscovery(t *testing.T) { pytest := NewPytest() if !pytest.SupportsFullTestDiscovery() { t.Error("expected PyTest to support full test discovery") } } + +func TestPyTest_RunTests(t *testing.T) { + testFiles := []string{"tests/test_user.py", "tests/test_auth.py"} + envMap := map[string]string{ + "APP_ENV": "test", + "SHARED_VAR": "override", + } + + var capturedName string + var capturedArgs []string + mockExecutor := &mockCommandExecutor{ + onExecution: func(name string, args []string) { + capturedName = name + capturedArgs = args + }, + } + + pytest := &PyTest{ + executor: mockExecutor, + platformEnv: map[string]string{ + "PYTEST_ADDOPTS": "--ddtrace", + "SHARED_VAR": "platform", + }, + } + if err := pytest.RunTests(context.Background(), testFiles, envMap); err != nil { + t.Fatalf("RunTests failed: %v", err) + } + + if capturedName != "python" { + t.Fatalf("expected command python, got %q", capturedName) + } + expectedArgs := []string{"-m", "pytest", "tests/test_user.py", "tests/test_auth.py"} + if !slices.Equal(capturedArgs, expectedArgs) { + t.Fatalf("expected args %v, got %v", expectedArgs, capturedArgs) + } + if mockExecutor.capturedEnvMap["PYTEST_ADDOPTS"] != "--ddtrace" { + t.Errorf("expected PYTEST_ADDOPTS from platform env, got %q", mockExecutor.capturedEnvMap["PYTEST_ADDOPTS"]) + } + if mockExecutor.capturedEnvMap["APP_ENV"] != "test" { + t.Errorf("expected APP_ENV from run env, got %q", mockExecutor.capturedEnvMap["APP_ENV"]) + } + if mockExecutor.capturedEnvMap["SHARED_VAR"] != "override" { + t.Errorf("expected run env to override platform env, got %q", mockExecutor.capturedEnvMap["SHARED_VAR"]) + } +} diff --git a/internal/framework/rspec_test.go b/internal/framework/rspec_test.go index 5435d3c..f7d47cc 100644 --- a/internal/framework/rspec_test.go +++ b/internal/framework/rspec_test.go @@ -1303,6 +1303,18 @@ func TestRSpec_DiscoverTests_UsesPlatformEnv(t *testing.T) { } } +func TestRSpec_DefaultTestPattern(t *testing.T) { + t.Setenv("DD_TEST_OPTIMIZATION_RUNNER_TESTS_LOCATION", "") + settings.Init() + t.Cleanup(settings.Init) + + rspec := NewRSpec() + expected := filepath.Join(rspecRootDir, "**", rspecTestFilePattern) + if got := rspec.TestPattern(); got != expected { + t.Errorf("expected default test pattern %q, got %q", expected, got) + } +} + func TestRSpec_TestExcludePattern(t *testing.T) { rspec := NewRSpec() if got := rspec.TestExcludePattern(); got != "" { diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go index 52695b4..df4c157 100644 --- a/internal/platform/platform_test.go +++ b/internal/platform/platform_test.go @@ -1,6 +1,9 @@ package platform import ( + "os" + "path/filepath" + "runtime" "strings" "testing" @@ -8,6 +11,45 @@ import ( "github.com/spf13/viper" ) +func TestNewPlatformDetector(t *testing.T) { + if _, ok := NewPlatformDetector().(*DatadogPlatformDetector); !ok { + t.Fatal("expected NewPlatformDetector to return DatadogPlatformDetector") + } +} + +func TestDetectPlatformPythonWithFakeInterpreter(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell script as the fake python executable") + } + + viper.Reset() + t.Cleanup(func() { + viper.Reset() + settings.Init() + }) + + binDir := t.TempDir() + pythonPath := filepath.Join(binDir, "python") + if err := os.WriteFile(pythonPath, []byte("#!/bin/sh\nprintf '4.10.3\\n'\n"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + viper.Set("platform", "python") + settings.Init() + + detectedPlatform, err := DetectPlatform() + if err != nil { + t.Fatalf("DetectPlatform() unexpected error: %v", err) + } + if detectedPlatform == nil { + t.Fatal("expected platform to be detected") + } + if detectedPlatform.Name() != "python" { + t.Fatalf("expected python platform, got %q", detectedPlatform.Name()) + } +} + func TestDetectPlatformUnsupported(t *testing.T) { viper.Reset() t.Cleanup(func() { From 6db042420d7da348868b4ca939570b3d7257e076 Mon Sep 17 00:00:00 2001 From: Sebastian Conde Lorenzo Date: Mon, 22 Jun 2026 17:04:50 +0200 Subject: [PATCH 12/12] more cleanup --- internal/framework/jest.go | 4 ---- internal/framework/jest_test.go | 11 ++--------- internal/framework/minitest.go | 4 ---- internal/framework/minitest_test.go | 7 ------- internal/framework/rspec.go | 4 ---- internal/framework/rspec_test.go | 7 ------- internal/planner/planner_test.go | 5 ----- internal/runner/test_helpers_test.go | 4 ---- 8 files changed, 2 insertions(+), 44 deletions(-) diff --git a/internal/framework/jest.go b/internal/framework/jest.go index f86cd7d..6c01dfa 100644 --- a/internal/framework/jest.go +++ b/internal/framework/jest.go @@ -64,10 +64,6 @@ func (j *Jest) TestPattern() string { "}" } -func (j *Jest) TestExcludePattern() string { - return "" -} - func (j *Jest) DiscoverTests(ctx context.Context, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) { return nil, ErrFullTestDiscoveryUnsupported } diff --git a/internal/framework/jest_test.go b/internal/framework/jest_test.go index 2d7b516..1e96428 100644 --- a/internal/framework/jest_test.go +++ b/internal/framework/jest_test.go @@ -71,7 +71,7 @@ func TestJest_DiscoverTestFiles_DefaultPatterns(t *testing.T) { } jest := NewJest() - files, err := discovery.DiscoverTestFiles(jest.TestPattern(), jest.TestExcludePattern()) + files, err := discovery.DiscoverTestFiles(jest.TestPattern(), "") if err != nil { t.Fatalf("generic discovery failed: %v", err) } @@ -109,7 +109,7 @@ func TestJest_DiscoverTestFiles_WithTestsLocation(t *testing.T) { setTestsLocation(t, "custom/*.check.js") jest := NewJest() - files, err := discovery.DiscoverTestFiles(jest.TestPattern(), jest.TestExcludePattern()) + files, err := discovery.DiscoverTestFiles(jest.TestPattern(), "") if err != nil { t.Fatalf("generic discovery failed: %v", err) } @@ -215,13 +215,6 @@ func TestJest_SetPlatformEnv(t *testing.T) { } } -func TestJest_TestExcludePattern(t *testing.T) { - jest := NewJest() - if got := jest.TestExcludePattern(); got != "" { - t.Errorf("expected empty TestExcludePattern, got %q", got) - } -} - func TestJest_RunTests_WithOverride(t *testing.T) { var capturedName string var capturedArgs []string diff --git a/internal/framework/minitest.go b/internal/framework/minitest.go index bd5457a..ddca2f3 100644 --- a/internal/framework/minitest.go +++ b/internal/framework/minitest.go @@ -78,10 +78,6 @@ func (m *Minitest) TestPattern() string { return filepath.Join(minitestRootDir, "**", minitestTestFilePattern) } -func (m *Minitest) TestExcludePattern() string { - return "" -} - func (m *Minitest) RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error { command, args, isRails := m.getMinitestCommand() slog.Info("Running tests with command", "command", command, "args", args) diff --git a/internal/framework/minitest_test.go b/internal/framework/minitest_test.go index 83aedb9..8aec169 100644 --- a/internal/framework/minitest_test.go +++ b/internal/framework/minitest_test.go @@ -1714,13 +1714,6 @@ func TestMinitest_DefaultTestPattern(t *testing.T) { } } -func TestMinitest_TestExcludePattern(t *testing.T) { - minitest := NewMinitest() - if got := minitest.TestExcludePattern(); got != "" { - t.Errorf("expected empty TestExcludePattern, got %q", got) - } -} - func TestMinitest_SupportsFullTestDiscovery(t *testing.T) { minitest := NewMinitest() if !minitest.SupportsFullTestDiscovery() { diff --git a/internal/framework/rspec.go b/internal/framework/rspec.go index c41af7c..d26123d 100644 --- a/internal/framework/rspec.go +++ b/internal/framework/rspec.go @@ -70,10 +70,6 @@ func (r *RSpec) TestPattern() string { return filepath.Join(rspecRootDir, "**", rspecTestFilePattern) } -func (r *RSpec) TestExcludePattern() string { - return "" -} - func (r *RSpec) RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error { command, baseArgs := r.getRSpecCommand() args := append(baseArgs, "--format", "progress") diff --git a/internal/framework/rspec_test.go b/internal/framework/rspec_test.go index f7d47cc..f77f947 100644 --- a/internal/framework/rspec_test.go +++ b/internal/framework/rspec_test.go @@ -1315,13 +1315,6 @@ func TestRSpec_DefaultTestPattern(t *testing.T) { } } -func TestRSpec_TestExcludePattern(t *testing.T) { - rspec := NewRSpec() - if got := rspec.TestExcludePattern(); got != "" { - t.Errorf("expected empty TestExcludePattern, got %q", got) - } -} - func TestRSpec_SupportsFullTestDiscovery(t *testing.T) { rspec := NewRSpec() if !rspec.SupportsFullTestDiscovery() { diff --git a/internal/planner/planner_test.go b/internal/planner/planner_test.go index 3054557..7557c29 100644 --- a/internal/planner/planner_test.go +++ b/internal/planner/planner_test.go @@ -71,7 +71,6 @@ func (m *MockPlatform) SanityCheck() error { type MockFramework struct { FrameworkName string TestPatternValue string - TestExcludePatternValue string Tests []testoptimization.Test TestFiles []string Err error @@ -154,10 +153,6 @@ func (m *MockFramework) TestPattern() string { return mockTestFilesPattern(m.TestFiles) } -func (m *MockFramework) TestExcludePattern() string { - return m.TestExcludePatternValue -} - var ( mockTestFilesMu sync.Mutex mockTestFilesCreated []string diff --git a/internal/runner/test_helpers_test.go b/internal/runner/test_helpers_test.go index daba7c6..6e98a43 100644 --- a/internal/runner/test_helpers_test.go +++ b/internal/runner/test_helpers_test.go @@ -85,10 +85,6 @@ func (m *MockFramework) TestPattern() string { return m.TestPatternValue } -func (m *MockFramework) TestExcludePattern() string { - return "" -} - func (m *MockFramework) DiscoverTests(ctx context.Context, testFiles discovery.TestFileSet) ([]testoptimization.Test, error) { if m.DiscoverTestsErr != nil { return nil, m.DiscoverTestsErr