diff --git a/internal/constants/app.go b/internal/constants/app.go index 8746427..42dcf36 100644 --- a/internal/constants/app.go +++ b/internal/constants/app.go @@ -37,4 +37,12 @@ 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 ( + NodeIndexPlaceholder = "{{nodeIndex}}" + WorkerIndexPlaceholder = "{{workerIndex}}" +) + var PythonEnvOutputPath = filepath.Join(PlanDirectory, "python_env.json") diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index 307f4cb..d1bfdf9 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -30,6 +30,8 @@ const ( discoveryCommandLogTruncSuffix = "..." ) +var excludedDirs = []string{"node_modules"} + type Excluder struct { pattern string } @@ -125,6 +127,10 @@ func DiscoverTestFiles(includePattern, excludePattern string) ([]string, error) return nil } + if entry.IsDir() && slices.Contains(excludedDirs, entry.Name()) { + 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 fc35f73..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" @@ -99,6 +100,71 @@ 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 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/framework.go b/internal/framework/framework.go index dc61984..bd4ad12 100644 --- a/internal/framework/framework.go +++ b/internal/framework/framework.go @@ -14,4 +14,5 @@ type Framework interface { RunTests(ctx context.Context, testFiles []string, envMap map[string]string) error SetPlatformEnv(platformEnv map[string]string) GetPlatformEnv() map[string]string + SupportsFullTestDiscovery() bool } diff --git a/internal/framework/jest.go b/internal/framework/jest.go new file mode 100644 index 0000000..6c01dfa --- /dev/null +++ b/internal/framework/jest.go @@ -0,0 +1,102 @@ +package framework + +import ( + "context" + "errors" + "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 ErrFullTestDiscoveryUnsupported = errors.New("full test discovery is not supported") + +var jestTestFileExtensions = []string{"js", "jsx", "ts", "tsx", "mjs", "cjs"} + +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" +} + +// 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) 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..1e96428 --- /dev/null +++ b/internal/framework/jest_test.go @@ -0,0 +1,244 @@ +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 jest.SupportsFullTestDiscovery() { + 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", + ".next/bad.test.tsx", + } + 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(), "") + if err != nil { + t.Fatalf("generic discovery failed: %v", err) + } + + 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", + } + 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(), "") + 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_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_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..ddca2f3 100644 --- a/internal/framework/minitest.go +++ b/internal/framework/minitest.go @@ -163,3 +163,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/minitest_test.go b/internal/framework/minitest_test.go index 525be6a..8aec169 100644 --- a/internal/framework/minitest_test.go +++ b/internal/framework/minitest_test.go @@ -1701,3 +1701,22 @@ func TestMinitest_RunTests_RailsApplication_UsesPlatformEnv(t *testing.T) { t.Errorf("expected RUBYOPT to be %q, got %q", platformEnv["RUBYOPT"], mockExecutor.capturedEnvMap["RUBYOPT"]) } } + +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_SupportsFullTestDiscovery(t *testing.T) { + minitest := NewMinitest() + if !minitest.SupportsFullTestDiscovery() { + t.Error("expected Minitest to support full test discovery") + } +} 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"} diff --git a/internal/framework/pytest_test.go b/internal/framework/pytest_test.go index f45d084..1a0517e 100644 --- a/internal/framework/pytest_test.go +++ b/internal/framework/pytest_test.go @@ -177,3 +177,71 @@ 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.go b/internal/framework/rspec.go index dde4bf5..d26123d 100644 --- a/internal/framework/rspec.go +++ b/internal/framework/rspec.go @@ -100,3 +100,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/framework/rspec_test.go b/internal/framework/rspec_test.go index 7ebdd14..f77f947 100644 --- a/internal/framework/rspec_test.go +++ b/internal/framework/rspec_test.go @@ -1302,3 +1302,22 @@ func TestRSpec_DiscoverTests_UsesPlatformEnv(t *testing.T) { t.Error("expected DD_API_KEY to be set") } } + +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_SupportsFullTestDiscovery(t *testing.T) { + rspec := NewRSpec() + if !rspec.SupportsFullTestDiscovery() { + t.Error("expected RSpec to support full test discovery") + } +} diff --git a/internal/planner/planner.go b/internal/planner/planner.go index 86bb9fb..d1bf5d9 100644 --- a/internal/planner/planner.go +++ b/internal/planner/planner.go @@ -268,15 +268,16 @@ 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 := testFramework.SupportsFullTestDiscovery() tp.runInfo = runmetadata.New(environment.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()) + resolvedTestFiles, err := discovery.ResolveTestFiles(testFramework.TestPattern(), settings.GetTestsExcludePattern()) if err != nil { return err } @@ -295,7 +296,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. @@ -317,6 +318,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() @@ -340,13 +346,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. } @@ -360,7 +371,7 @@ 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 diff --git a/internal/planner/planner_test.go b/internal/planner/planner_test.go index 40aafba..7557c29 100644 --- a/internal/planner/planner_test.go +++ b/internal/planner/planner_test.go @@ -69,16 +69,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 + 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 { @@ -233,6 +234,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() } @@ -284,6 +288,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() @@ -307,17 +315,18 @@ func (m *longRunningDiscoveryFramework) DiscoverTests(ctx context.Context, testF // MockTestOptimizationClient mocks the test optimization client type MockTestOptimizationClient struct { - InitializeCalled bool - InitializeErr error - Settings *api.SettingsResponseData - SkippableTests map[string]bool - KnownTests *api.KnownTestsResponseData - TestManagementTests *api.TestManagementTestsResponseDataModules - DisabledTests map[string]bool - Durations map[string]map[string]api.TestSuiteDurationInfo - DurationsCalled bool - ShutdownCalled bool - Tags map[string]string + InitializeCalled bool + InitializeErr error + Settings *api.SettingsResponseData + SkippableTests map[string]bool + GetSkippableTestsCalled bool + KnownTests *api.KnownTestsResponseData + TestManagementTests *api.TestManagementTestsResponseDataModules + DisabledTests map[string]bool + Durations map[string]map[string]api.TestSuiteDurationInfo + DurationsCalled bool + ShutdownCalled bool + Tags map[string]string } func (m *MockTestOptimizationClient) Initialize(tags map[string]string) error { @@ -334,6 +343,7 @@ func (m *MockTestOptimizationClient) GetSettings() *api.SettingsResponseData { } func (m *MockTestOptimizationClient) GetSkippableTests() map[string]bool { + m.GetSkippableTestsCalled = true return m.SkippableTests } @@ -694,6 +704,55 @@ 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 := &MockFramework{ + FrameworkName: "jest", + TestFiles: []string{"src/a.test.js", "src/b.test.ts"}, + FullDiscoveryUnsupported: true, + } + 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, + 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..64ba1a3 --- /dev/null +++ b/internal/platform/javascript_test.go @@ -0,0 +1,410 @@ +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 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. + if err := os.Unsetenv(nodeOptionsEnvVar); err != nil { + t.Fatal(err) + } + + 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") + 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 85b846b..ad54eea 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() case "python": platform = NewPython() default: 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() { 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..6e98a43 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 { @@ -108,6 +109,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()