diff --git a/README.md b/README.md index 8ac1ad1..01c5647 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,8 @@ In CI‑node mode, DDTest also fans out across local CPUs on that node and furth | `--min-parallelism` | `DD_TEST_OPTIMIZATION_RUNNER_MIN_PARALLELISM` | vCPU count | Minimum workers to use for the split. | | `--max-parallelism` | `DD_TEST_OPTIMIZATION_RUNNER_MAX_PARALLELISM` | vCPU count | Maximum workers to use for the split. | | | `DD_TEST_OPTIMIZATION_RUNNER_CI_NODE` | `-1` (off) | Restrict this run to the slice assigned to node **N** (0‑indexed). Also parallelizes within the node across its CPUs. | -| `--worker-env` | `DD_TEST_OPTIMIZATION_RUNNER_WORKER_ENV` | `""` | Template env vars per local worker (e.g., isolate DBs): `--worker-env "DATABASE_NAME_TEST=app_test{{nodeIndex}}"`. | +| `--ci-node-workers` | `DD_TEST_OPTIMIZATION_RUNNER_CI_NODE_WORKERS` | vCPU count | Number of parallel workers per CI node. Tests assigned to a CI node are further split among this many local workers. | +| `--worker-env` | `DD_TEST_OPTIMIZATION_RUNNER_WORKER_ENV` | `""` | Template env vars per worker: `--worker-env "DATABASE_NAME_TEST=app_test{{nodeIndex}}"`. In CI-node mode, `{{nodeIndex}}` is the global worker index (`ciNode * ciNodeWorkers + localWorkerIndex`). | | `--command` | `DD_TEST_OPTIMIZATION_RUNNER_COMMAND` | `""` | Override the default test command used by the framework. When provided, takes precedence over auto-detection (e.g., `--command "bundle exec custom-rspec"`). | | `--tests-location` | `DD_TEST_OPTIMIZATION_RUNNER_TESTS_LOCATION` | `""` | Custom glob pattern to discover test files (e.g., `--tests-location "custom/spec/**/*_spec.rb"`). Defaults to `spec/**/*_spec.rb` for RSpec, `test/**/*_test.rb` for Minitest. | | `--runtime-tags` | `DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS` | `""` | JSON string to override runtime tags used to fetch skippable tests. Useful for local development on a different OS than CI (e.g., `--runtime-tags '{"os.platform":"linux","runtime.version":"3.2.0"}'`). | @@ -479,7 +480,6 @@ The `--runtime-tags` option lets you override your local runtime tags to match y ![Runtime tags in Datadog](docs/images/runtime-tags-datadog.png) Note the following tags: - - `os.architecture` (e.g., `x86_64`) - `os.platform` (e.g., `linux`) - `os.version` (e.g., `6.8.0-aws`) diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 689a3cc..40ed712 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -60,6 +60,7 @@ func init() { rootCmd.PersistentFlags().Int("min-parallelism", defaultParallelism, "Minimum number of parallel test processes (default: number of CPUs)") rootCmd.PersistentFlags().Int("max-parallelism", defaultParallelism, "Maximum number of parallel test processes (default: number of CPUs)") rootCmd.PersistentFlags().String("worker-env", "", "Worker environment configuration") + rootCmd.PersistentFlags().Int("ci-node-workers", defaultParallelism, "Number of parallel workers per CI node (default: number of CPUs)") rootCmd.PersistentFlags().String("command", "", "Test command that ddtest should wrap") rootCmd.PersistentFlags().String("tests-location", "", "Glob pattern used to discover test files") rootCmd.PersistentFlags().String("runtime-tags", "", "JSON string to override runtime tags (e.g. '{\"os.platform\":\"linux\",\"runtime.version\":\"3.2.0\"}')") @@ -83,6 +84,10 @@ func init() { fmt.Fprintf(os.Stderr, "Error binding worker-env flag: %v\n", err) os.Exit(1) } + if err := viper.BindPFlag("ci_node_workers", rootCmd.PersistentFlags().Lookup("ci-node-workers")); err != nil { + fmt.Fprintf(os.Stderr, "Error binding ci-node-workers flag: %v\n", err) + os.Exit(1) + } if err := viper.BindPFlag("command", rootCmd.PersistentFlags().Lookup("command")); err != nil { fmt.Fprintf(os.Stderr, "Error binding command flag: %v\n", err) os.Exit(1) diff --git a/internal/runner/executor.go b/internal/runner/executor.go index 710239c..151c687 100644 --- a/internal/runner/executor.go +++ b/internal/runner/executor.go @@ -10,23 +10,98 @@ import ( "github.com/DataDog/ddtest/internal/constants" "github.com/DataDog/ddtest/internal/framework" + "github.com/DataDog/ddtest/internal/settings" "golang.org/x/sync/errgroup" ) +// splitTestFilesIntoGroups splits a slice of test files into n groups +// using simple round-robin distribution +func splitTestFilesIntoGroups(testFiles []string, n int) [][]string { + if n <= 0 { + n = 1 + } + + result := make([][]string, n) + for i := range result { + result[i] = []string{} + } + + for i, file := range testFiles { + groupIndex := i % n + result[groupIndex] = append(result[groupIndex], file) + } + + return result +} + // runCINodeTests executes tests for a specific CI node (one split, not the whole tests set) +// It further splits the node's tests among local workers based on ci_node_workers setting. func runCINodeTests(ctx context.Context, framework framework.Framework, workerEnvMap map[string]string, ciNode int) error { + return runCINodeTestsWithWorkers(ctx, framework, workerEnvMap, ciNode, settings.GetCiNodeWorkers()) +} + +// runCINodeTestsWithWorkers is the internal implementation that accepts ciNodeWorkers as a parameter +// for easier testing. +func runCINodeTestsWithWorkers(ctx context.Context, framework framework.Framework, workerEnvMap map[string]string, ciNode int, ciNodeWorkers int) error { runnerFilePath := fmt.Sprintf("%s/runner-%d", constants.TestsSplitDir, ciNode) if _, err := os.Stat(runnerFilePath); os.IsNotExist(err) { return fmt.Errorf("runner file for ci-node %d does not exist: %s", ciNode, runnerFilePath) } - slog.Info("Running tests for specific CI node", "ciNode", ciNode, "filePath", runnerFilePath) - if err := runTestsFromFile(ctx, framework, runnerFilePath, workerEnvMap, ciNode); err != nil { + testFiles, err := readTestFilesFromFile(runnerFilePath) + if err != nil { + return fmt.Errorf("failed to read test files for ci-node %d from %s: %w", ciNode, runnerFilePath, err) + } + + if len(testFiles) == 0 { + slog.Info("No tests to run for CI node", "ciNode", ciNode) + return nil + } + + // Single worker mode: run all tests with global index = ciNode * ciNodeWorkers + if ciNodeWorkers <= 1 { + globalIndex := ciNode + slog.Info("Running tests for CI node in single-worker mode", "ciNode", ciNode, "globalIndex", globalIndex) + return runTestsWithGlobalIndex(ctx, framework, testFiles, workerEnvMap, globalIndex) + } + + // Multi-worker mode: split tests among local workers + slog.Info("Running tests for CI node in parallel mode", + "ciNode", ciNode, "ciNodeWorkers", ciNodeWorkers, "testFilesCount", len(testFiles)) + + groups := splitTestFilesIntoGroups(testFiles, ciNodeWorkers) + + var g errgroup.Group + for localIndex, groupFiles := range groups { + if len(groupFiles) == 0 { + continue + } + + // Global index = ciNode * ciNodeWorkers + localIndex + globalIndex := ciNode*ciNodeWorkers + localIndex + g.Go(func() error { + return runTestsWithGlobalIndex(ctx, framework, groupFiles, workerEnvMap, globalIndex) + }) + } + + if err := g.Wait(); err != nil { return fmt.Errorf("failed to run tests for ci-node %d: %w", ciNode, err) } return nil } +// runTestsWithGlobalIndex runs a set of test files with the given global worker index for env templating +func runTestsWithGlobalIndex(ctx context.Context, framework framework.Framework, testFiles []string, workerEnvMap map[string]string, globalIndex int) error { + // Create a copy of the worker env map and replace nodeIndex placeholder with global index + workerEnv := make(map[string]string) + for key, value := range workerEnvMap { + workerEnv[key] = strings.ReplaceAll(value, constants.NodeIndexPlaceholder, fmt.Sprintf("%d", globalIndex)) + } + + slog.Info("Running tests in worker", "globalIndex", globalIndex, "testFilesCount", len(testFiles), "workerEnv", workerEnv) + return framework.RunTests(ctx, testFiles, workerEnv) +} + // runParallelTests executes tests across multiple parallel runners on a single node func runParallelTests(ctx context.Context, framework framework.Framework, workerEnvMap map[string]string) error { slog.Info("Running tests in parallel mode") diff --git a/internal/runner/executor_test.go b/internal/runner/executor_test.go index 3d9db48..023571a 100644 --- a/internal/runner/executor_test.go +++ b/internal/runner/executor_test.go @@ -11,7 +11,7 @@ import ( "github.com/DataDog/ddtest/internal/constants" ) -func TestRunCINodeTests_Success(t *testing.T) { +func TestRunCINodeTests_SingleWorker(t *testing.T) { tempDir := t.TempDir() oldWd, _ := os.Getwd() defer func() { _ = os.Chdir(oldWd) }() @@ -26,9 +26,10 @@ func TestRunCINodeTests_Success(t *testing.T) { RunTestsCalls: []RunTestsCall{}, } - err := runCINodeTests(context.Background(), mockFramework, map[string]string{}, 1) + // Test with single worker (ciNodeWorkers=1) + err := runCINodeTestsWithWorkers(context.Background(), mockFramework, map[string]string{}, 1, 1) if err != nil { - t.Fatalf("runCINodeTests() should not return error, got: %v", err) + t.Fatalf("runCINodeTestsWithWorkers() should not return error, got: %v", err) } // Verify RunTests was called exactly once @@ -44,6 +45,130 @@ func TestRunCINodeTests_Success(t *testing.T) { } } +func TestRunCINodeTests_MultipleWorkers(t *testing.T) { + tempDir := t.TempDir() + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + _ = os.Chdir(tempDir) + + // Setup test split directory and files - 4 test files for ci-node 1 + _ = os.MkdirAll(filepath.Join(constants.PlanDirectory, "tests-split"), 0755) + _ = os.WriteFile(filepath.Join(constants.PlanDirectory, "tests-split", "runner-1"), + []byte("test/file1_test.rb\ntest/file2_test.rb\ntest/file3_test.rb\ntest/file4_test.rb\n"), 0644) + + mockFramework := &MockFramework{ + FrameworkName: "rspec", + RunTestsCalls: []RunTestsCall{}, + } + + // Test with 2 workers on ci-node 1 + err := runCINodeTestsWithWorkers(context.Background(), mockFramework, map[string]string{}, 1, 2) + if err != nil { + t.Fatalf("runCINodeTestsWithWorkers() should not return error, got: %v", err) + } + + // Verify RunTests was called twice (once per worker) + if mockFramework.GetRunTestsCallsCount() != 2 { + t.Fatalf("Expected RunTests to be called twice, got %d calls", mockFramework.GetRunTestsCallsCount()) + } + + // Verify all test files were distributed + calls := mockFramework.GetRunTestsCalls() + allFiles := make([]string, 0) + for _, call := range calls { + allFiles = append(allFiles, call.TestFiles...) + } + slices.Sort(allFiles) + + expectedFiles := []string{"test/file1_test.rb", "test/file2_test.rb", "test/file3_test.rb", "test/file4_test.rb"} + if !slices.Equal(allFiles, expectedFiles) { + t.Errorf("Expected all test files %v to be distributed, got %v", expectedFiles, allFiles) + } +} + +func TestRunCINodeTests_GlobalIndexCalculation(t *testing.T) { + tempDir := t.TempDir() + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + _ = os.Chdir(tempDir) + + // Setup test split directory and files - 2 test files for ci-node 1 + _ = os.MkdirAll(filepath.Join(constants.PlanDirectory, "tests-split"), 0755) + _ = os.WriteFile(filepath.Join(constants.PlanDirectory, "tests-split", "runner-1"), + []byte("test/file1_test.rb\ntest/file2_test.rb\n"), 0644) + + mockFramework := &MockFramework{ + FrameworkName: "rspec", + RunTestsCalls: []RunTestsCall{}, + } + + workerEnvMap := map[string]string{ + "NODE_INDEX": "{{nodeIndex}}", + } + + // Test with 2 workers on ci-node 1 + // Global indices should be: 1*2+0=2 and 1*2+1=3 + err := runCINodeTestsWithWorkers(context.Background(), mockFramework, workerEnvMap, 1, 2) + if err != nil { + t.Fatalf("runCINodeTestsWithWorkers() should not return error, got: %v", err) + } + + // Verify RunTests was called twice + if mockFramework.GetRunTestsCallsCount() != 2 { + t.Fatalf("Expected RunTests to be called twice, got %d calls", mockFramework.GetRunTestsCallsCount()) + } + + // Collect all NODE_INDEX values + calls := mockFramework.GetRunTestsCalls() + nodeIndices := make([]string, 0) + for _, call := range calls { + nodeIndices = append(nodeIndices, call.EnvMap["NODE_INDEX"]) + } + slices.Sort(nodeIndices) + + // Global indices for ci-node=1 with ciNodeWorkers=2 should be 2 and 3 + expectedIndices := []string{"2", "3"} + if !slices.Equal(nodeIndices, expectedIndices) { + t.Errorf("Expected global indices %v, got %v", expectedIndices, nodeIndices) + } +} + +func TestRunCINodeTests_SingleWorkerGlobalIndex(t *testing.T) { + tempDir := t.TempDir() + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + _ = os.Chdir(tempDir) + + // Setup test split directory and files + _ = os.MkdirAll(filepath.Join(constants.PlanDirectory, "tests-split"), 0755) + _ = os.WriteFile(filepath.Join(constants.PlanDirectory, "tests-split", "runner-2"), + []byte("test/file1_test.rb\n"), 0644) + + mockFramework := &MockFramework{ + FrameworkName: "rspec", + RunTestsCalls: []RunTestsCall{}, + } + + workerEnvMap := map[string]string{ + "NODE_INDEX": "{{nodeIndex}}", + } + + // Single worker mode on ci-node 2 - global index should be 2 (just the ciNode) + err := runCINodeTestsWithWorkers(context.Background(), mockFramework, workerEnvMap, 2, 1) + if err != nil { + t.Fatalf("runCINodeTestsWithWorkers() should not return error, got: %v", err) + } + + calls := mockFramework.GetRunTestsCalls() + if len(calls) != 1 { + t.Fatalf("Expected 1 call, got %d", len(calls)) + } + + if calls[0].EnvMap["NODE_INDEX"] != "2" { + t.Errorf("Expected NODE_INDEX=2 for single worker on ci-node 2, got %s", calls[0].EnvMap["NODE_INDEX"]) + } +} + func TestRunCINodeTests_FileNotFound(t *testing.T) { tempDir := t.TempDir() oldWd, _ := os.Getwd() @@ -55,9 +180,9 @@ func TestRunCINodeTests_FileNotFound(t *testing.T) { mockFramework := &MockFramework{FrameworkName: "rspec"} - err := runCINodeTests(context.Background(), mockFramework, map[string]string{}, 2) + err := runCINodeTestsWithWorkers(context.Background(), mockFramework, map[string]string{}, 2, 1) if err == nil { - t.Error("runCINodeTests() should return error when runner file doesn't exist") + t.Error("runCINodeTestsWithWorkers() should return error when runner file doesn't exist") } expectedMsg := "runner file for ci-node 2 does not exist" @@ -66,6 +191,30 @@ func TestRunCINodeTests_FileNotFound(t *testing.T) { } } +func TestRunCINodeTests_EmptyFile(t *testing.T) { + tempDir := t.TempDir() + oldWd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWd) }() + _ = os.Chdir(tempDir) + + // Setup test split directory with empty runner file + _ = os.MkdirAll(filepath.Join(constants.PlanDirectory, "tests-split"), 0755) + _ = os.WriteFile(filepath.Join(constants.PlanDirectory, "tests-split", "runner-0"), []byte(""), 0644) + + mockFramework := &MockFramework{FrameworkName: "rspec"} + + // Should not error for empty file, just not run any tests + err := runCINodeTestsWithWorkers(context.Background(), mockFramework, map[string]string{}, 0, 2) + if err != nil { + t.Fatalf("runCINodeTestsWithWorkers() should not return error for empty file, got: %v", err) + } + + // Verify no tests were run + if mockFramework.GetRunTestsCallsCount() != 0 { + t.Errorf("Expected no RunTests calls for empty file, got %d", mockFramework.GetRunTestsCallsCount()) + } +} + func TestRunParallelTests_Success(t *testing.T) { tempDir := t.TempDir() oldWd, _ := os.Getwd() @@ -228,3 +377,149 @@ func TestReadTestFilesFromFile_WithContent(t *testing.T) { t.Errorf("Expected files %v, got %v", expected, files) } } + +func TestSplitTestFilesIntoGroups(t *testing.T) { + t.Run("even split", func(t *testing.T) { + files := []string{"a", "b", "c", "d"} + result := splitTestFilesIntoGroups(files, 2) + + if len(result) != 2 { + t.Fatalf("Expected 2 groups, got %d", len(result)) + } + + // Round-robin: a->0, b->1, c->0, d->1 + expected0 := []string{"a", "c"} + expected1 := []string{"b", "d"} + + if !slices.Equal(result[0], expected0) { + t.Errorf("Expected group 0 to be %v, got %v", expected0, result[0]) + } + if !slices.Equal(result[1], expected1) { + t.Errorf("Expected group 1 to be %v, got %v", expected1, result[1]) + } + }) + + t.Run("uneven split", func(t *testing.T) { + files := []string{"a", "b", "c", "d", "e"} + result := splitTestFilesIntoGroups(files, 2) + + if len(result) != 2 { + t.Fatalf("Expected 2 groups, got %d", len(result)) + } + + // Round-robin: a->0, b->1, c->0, d->1, e->0 + expected0 := []string{"a", "c", "e"} + expected1 := []string{"b", "d"} + + if !slices.Equal(result[0], expected0) { + t.Errorf("Expected group 0 to be %v, got %v", expected0, result[0]) + } + if !slices.Equal(result[1], expected1) { + t.Errorf("Expected group 1 to be %v, got %v", expected1, result[1]) + } + }) + + t.Run("more groups than files", func(t *testing.T) { + files := []string{"a", "b"} + result := splitTestFilesIntoGroups(files, 4) + + if len(result) != 4 { + t.Fatalf("Expected 4 groups, got %d", len(result)) + } + + // a->0, b->1, groups 2 and 3 are empty + if !slices.Equal(result[0], []string{"a"}) { + t.Errorf("Expected group 0 to be [a], got %v", result[0]) + } + if !slices.Equal(result[1], []string{"b"}) { + t.Errorf("Expected group 1 to be [b], got %v", result[1]) + } + if len(result[2]) != 0 { + t.Errorf("Expected group 2 to be empty, got %v", result[2]) + } + if len(result[3]) != 0 { + t.Errorf("Expected group 3 to be empty, got %v", result[3]) + } + }) + + t.Run("single group", func(t *testing.T) { + files := []string{"a", "b", "c"} + result := splitTestFilesIntoGroups(files, 1) + + if len(result) != 1 { + t.Fatalf("Expected 1 group, got %d", len(result)) + } + + if !slices.Equal(result[0], files) { + t.Errorf("Expected group 0 to be %v, got %v", files, result[0]) + } + }) + + t.Run("empty input", func(t *testing.T) { + result := splitTestFilesIntoGroups([]string{}, 3) + + if len(result) != 3 { + t.Fatalf("Expected 3 groups, got %d", len(result)) + } + + for i, group := range result { + if len(group) != 0 { + t.Errorf("Expected group %d to be empty, got %v", i, group) + } + } + }) + + t.Run("zero groups defaults to 1", func(t *testing.T) { + files := []string{"a", "b"} + result := splitTestFilesIntoGroups(files, 0) + + if len(result) != 1 { + t.Fatalf("Expected 1 group for n=0, got %d", len(result)) + } + + if !slices.Equal(result[0], files) { + t.Errorf("Expected all files in single group, got %v", result[0]) + } + }) +} + +func TestRunTestsWithGlobalIndex(t *testing.T) { + mockFramework := &MockFramework{ + FrameworkName: "rspec", + RunTestsCalls: []RunTestsCall{}, + } + + testFiles := []string{"test/file1_test.rb", "test/file2_test.rb"} + workerEnvMap := map[string]string{ + "NODE_INDEX": "{{nodeIndex}}", + "DB_NAME": "test_db_{{nodeIndex}}", + "STATIC": "value", + } + + err := runTestsWithGlobalIndex(context.Background(), mockFramework, testFiles, workerEnvMap, 5) + if err != nil { + t.Fatalf("runTestsWithGlobalIndex() should not return error, got: %v", err) + } + + if mockFramework.GetRunTestsCallsCount() != 1 { + t.Fatalf("Expected 1 call, got %d", mockFramework.GetRunTestsCallsCount()) + } + + call := mockFramework.GetRunTestsCalls()[0] + + if !slices.Equal(call.TestFiles, testFiles) { + t.Errorf("Expected test files %v, got %v", testFiles, call.TestFiles) + } + + if call.EnvMap["NODE_INDEX"] != "5" { + t.Errorf("Expected NODE_INDEX=5, got %s", call.EnvMap["NODE_INDEX"]) + } + + if call.EnvMap["DB_NAME"] != "test_db_5" { + t.Errorf("Expected DB_NAME=test_db_5, got %s", call.EnvMap["DB_NAME"]) + } + + if call.EnvMap["STATIC"] != "value" { + t.Errorf("Expected STATIC=value, got %s", call.EnvMap["STATIC"]) + } +} diff --git a/internal/runner/parallelism.go b/internal/runner/parallelism.go index e364d3a..f32fc07 100644 --- a/internal/runner/parallelism.go +++ b/internal/runner/parallelism.go @@ -14,7 +14,8 @@ func calculateParallelRunners(skippablePercentage float64) int { } func calculateParallelRunnersWithParams(skippablePercentage float64, minParallelism, maxParallelism int) int { - if maxParallelism == 1 { + // maxParallelism could be 0 or negative! + if maxParallelism <= 1 { return 1 } diff --git a/internal/runner/parallelism_test.go b/internal/runner/parallelism_test.go index 14c0747..645122f 100644 --- a/internal/runner/parallelism_test.go +++ b/internal/runner/parallelism_test.go @@ -28,6 +28,37 @@ func TestCalculateParallelRunners_MaxParallelismIsOne(t *testing.T) { } } +func TestCalculateParallelRunners_MaxParallelismZeroOrNegative(t *testing.T) { + tests := []struct { + name string + maxParallelism int + }{ + {"maxParallelism is 0", 0}, + {"maxParallelism is -1", -1}, + {"maxParallelism is -100", -100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Should always return 1 regardless of skippable percentage + result := testCalculateParallelRunners(0.0, 1, tt.maxParallelism) + if result != 1 { + t.Errorf("calculateParallelRunners(0.0) with maxParallelism=%d = %d, expected 1", tt.maxParallelism, result) + } + + result = testCalculateParallelRunners(50.0, 1, tt.maxParallelism) + if result != 1 { + t.Errorf("calculateParallelRunners(50.0) with maxParallelism=%d = %d, expected 1", tt.maxParallelism, result) + } + + result = testCalculateParallelRunners(100.0, 1, tt.maxParallelism) + if result != 1 { + t.Errorf("calculateParallelRunners(100.0) with maxParallelism=%d = %d, expected 1", tt.maxParallelism, result) + } + }) + } +} + func TestCalculateParallelRunners_MinParallelismLessThanOne(t *testing.T) { tests := []struct { name string diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 3e20ebf..b8612cc 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -23,6 +23,7 @@ type Config struct { MaxParallelism int `mapstructure:"max_parallelism"` WorkerEnv string `mapstructure:"worker_env"` CiNode int `mapstructure:"ci_node"` + CiNodeWorkers int `mapstructure:"ci_node_workers"` Command string `mapstructure:"command"` TestsLocation string `mapstructure:"tests_location"` RuntimeTags string `mapstructure:"runtime_tags"` @@ -53,6 +54,7 @@ func setDefaults() { viper.SetDefault("max_parallelism", DefaultParallelism()) viper.SetDefault("worker_env", "") viper.SetDefault("ci_node", -1) + viper.SetDefault("ci_node_workers", DefaultParallelism()) viper.SetDefault("command", "") viper.SetDefault("tests_location", "") viper.SetDefault("runtime_tags", "") @@ -89,6 +91,10 @@ func GetCiNode() int { return Get().CiNode } +func GetCiNodeWorkers() int { + return Get().CiNodeWorkers +} + func GetCommand() string { return Get().Command } diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 735c414..1230cb9 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -51,6 +51,9 @@ func TestInit(t *testing.T) { if config.CiNode != -1 { t.Errorf("expected default ci_node to be -1, got %d", config.CiNode) } + if config.CiNodeWorkers != expectedParallelism { + t.Errorf("expected default ci_node_workers to be %d (CPU count), got %d", expectedParallelism, config.CiNodeWorkers) + } if config.Command != "" { t.Errorf("expected default command to be empty, got %q", config.Command) } @@ -86,6 +89,9 @@ func TestSetDefaults(t *testing.T) { if viper.GetInt("ci_node") != -1 { t.Errorf("expected default ci_node to be -1, got %d", viper.GetInt("ci_node")) } + if viper.GetInt("ci_node_workers") != expectedParallelism { + t.Errorf("expected default ci_node_workers to be %d (CPU count), got %d", expectedParallelism, viper.GetInt("ci_node_workers")) + } if viper.GetString("command") != "" { t.Errorf("expected default command to be empty, got %q", viper.GetString("command")) } @@ -163,6 +169,7 @@ func TestEnvironmentVariables(t *testing.T) { _ = os.Setenv("DD_TEST_OPTIMIZATION_RUNNER_MAX_PARALLELISM", "8") _ = os.Setenv("DD_TEST_OPTIMIZATION_RUNNER_WORKER_ENV", "RAILS_DB=my_project_dev_{{nodeIndex}}") _ = os.Setenv("DD_TEST_OPTIMIZATION_RUNNER_CI_NODE", "5") + _ = os.Setenv("DD_TEST_OPTIMIZATION_RUNNER_CI_NODE_WORKERS", "4") _ = os.Setenv("DD_TEST_OPTIMIZATION_RUNNER_COMMAND", "bundle exec rspec") _ = os.Setenv("DD_TEST_OPTIMIZATION_RUNNER_TESTS_LOCATION", "spec/**/*_spec.rb") _ = os.Setenv("DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS", `{"os.platform":"linux","runtime.version":"3.2.0"}`) @@ -173,6 +180,7 @@ func TestEnvironmentVariables(t *testing.T) { _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_MAX_PARALLELISM") _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_WORKER_ENV") _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_CI_NODE") + _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_CI_NODE_WORKERS") _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_COMMAND") _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_TESTS_LOCATION") _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS") @@ -198,6 +206,9 @@ func TestEnvironmentVariables(t *testing.T) { if config.CiNode != 5 { t.Errorf("expected ci_node from env var to be 5, got %d", config.CiNode) } + if config.CiNodeWorkers != 4 { + t.Errorf("expected ci_node_workers from env var to be 4, got %d", config.CiNodeWorkers) + } if config.Command != "bundle exec rspec" { t.Errorf("expected command from env var to be 'bundle exec rspec', got %q", config.Command) } @@ -393,6 +404,25 @@ func TestGetCiNode(t *testing.T) { } } +func TestGetCiNodeWorkers(t *testing.T) { + // Test with defaults + config = nil + viper.Reset() + + expectedParallelism := runtime.NumCPU() + ciNodeWorkers := GetCiNodeWorkers() + if ciNodeWorkers != expectedParallelism { + t.Errorf("expected ci_node_workers to be %d (CPU count), got %d", expectedParallelism, ciNodeWorkers) + } + + // Test with custom value + config = &Config{CiNodeWorkers: 4} + ciNodeWorkers = GetCiNodeWorkers() + if ciNodeWorkers != 4 { + t.Errorf("expected ci_node_workers to be 4, got %d", ciNodeWorkers) + } +} + func TestGetWorkerEnvMap(t *testing.T) { t.Run("empty worker env", func(t *testing.T) { config = &Config{WorkerEnv: ""}