diff --git a/README.md b/README.md index e862c83..8ac1ad1 100644 --- a/README.md +++ b/README.md @@ -142,16 +142,17 @@ In CI‑node mode, DDTest also fans out across local CPUs on that node and furth ### Settings (flags and environment variables) -| CLI flag | Environment variable | Default | What it does | -| ------------------- | --------------------------------------------- | ---------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--platform` | `DD_TEST_OPTIMIZATION_RUNNER_PLATFORM` | `ruby` | Language/platform (currently supported values: `ruby`). | -| `--framework` | `DD_TEST_OPTIMIZATION_RUNNER_FRAMEWORK` | `rspec` | Test framework (currently supported values: `rspec`, `minitest`). | -| `--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}}"`. | -| `--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. | +| CLI flag | Environment variable | Default | What it does | +| ------------------- | --------------------------------------------- | ---------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--platform` | `DD_TEST_OPTIMIZATION_RUNNER_PLATFORM` | `ruby` | Language/platform (currently supported values: `ruby`). | +| `--framework` | `DD_TEST_OPTIMIZATION_RUNNER_FRAMEWORK` | `rspec` | Test framework (currently supported values: `rspec`, `minitest`). | +| `--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}}"`. | +| `--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"}'`). | #### Note about the `--command` flag @@ -463,6 +464,59 @@ Rake::TestTask.new(:test) do |test| end ``` +## Running tests locally with CI's skippable tests + +When using Test Impact Analysis, skippable tests are scoped by runtime environment (OS, architecture, language version). This means tests skipped in your Linux CI won't automatically be skipped on your macOS development machine because the runtime tags differ. + +The `--runtime-tags` option lets you override your local runtime tags to match your CI environment, enabling you to benefit from Test Impact Analysis locally without re-running your entire test suite. + +### How to use + +1. **Find your CI's runtime tags in Datadog** + + Open any test run from your CI in [Datadog Test Optimization](https://app.datadoghq.com/ci/test-runs). In the test details panel, look for the `os` and `runtime` sections: + + ![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`) + - `runtime.name` (e.g., `ruby`) + - `runtime.version` (e.g., `3.3.0`) + +2. **Create the runtime tags JSON** + + Build a JSON object with these tags: + + ```json + { + "os.architecture": "x86_64", + "os.platform": "linux", + "os.version": "6.8.0-aws", + "runtime.name": "ruby", + "runtime.version": "3.3.0" + } + ``` + +3. **Run ddtest with the override** + + Pass the JSON as a single-line string: + + ```bash + ddtest run --runtime-tags '{"os.architecture":"x86_64","os.platform":"linux","os.version":"6.8.0-aws","runtime.name":"ruby","runtime.version":"3.3.0"}' + ``` + + Or use an environment variable (useful for shell aliases): + + ```bash + export DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS='{"os.architecture":"x86_64","os.platform":"linux","os.version":"6.8.0-aws","runtime.name":"ruby","runtime.version":"3.3.0"}' + ddtest run + ``` + +> **Note:** Test Impact Analysis works on committed changes. Make sure to commit your changes before running ddtest to see accurate skippable tests. + ## Development ### Prerequisites diff --git a/docs/images/runtime-tags-datadog.png b/docs/images/runtime-tags-datadog.png new file mode 100644 index 0000000..df7d4b4 Binary files /dev/null and b/docs/images/runtime-tags-datadog.png differ diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index bb15992..0e2b1a2 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -59,6 +59,7 @@ func init() { rootCmd.PersistentFlags().String("worker-env", "", "Worker environment configuration") 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\"}')") if err := viper.BindPFlag("platform", rootCmd.PersistentFlags().Lookup("platform")); err != nil { fmt.Fprintf(os.Stderr, "Error binding platform flag: %v\n", err) os.Exit(1) @@ -87,6 +88,10 @@ func init() { fmt.Fprintf(os.Stderr, "Error binding tests-location flag: %v\n", err) os.Exit(1) } + if err := viper.BindPFlag("runtime_tags", rootCmd.PersistentFlags().Lookup("runtime-tags")); err != nil { + fmt.Fprintf(os.Stderr, "Error binding runtime-tags flag: %v\n", err) + os.Exit(1) + } rootCmd.AddCommand(planCmd) rootCmd.AddCommand(runCmd) diff --git a/internal/framework/framework.go b/internal/framework/framework.go index 197094b..f839aa0 100644 --- a/internal/framework/framework.go +++ b/internal/framework/framework.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "log/slog" - "maps" "os" "path/filepath" "time" @@ -89,15 +88,6 @@ func globTestFiles(pattern string) ([]string, error) { return matches, nil } -// mergeEnvMaps merges base env vars with overrides. -// Values in overrides take precedence over base values. -func mergeEnvMaps(base, overrides map[string]string) map[string]string { - result := make(map[string]string) - maps.Copy(result, base) - maps.Copy(result, overrides) - return result -} - // 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/framework_test.go b/internal/framework/framework_test.go index cff3cef..65c10ae 100644 --- a/internal/framework/framework_test.go +++ b/internal/framework/framework_test.go @@ -28,64 +28,3 @@ func TestBaseDiscoveryEnv(t *testing.T) { t.Errorf("expected %d env vars, got %d", len(expectedVars), len(env)) } } - -func TestMergeEnvMaps(t *testing.T) { - t.Run("merges two maps", func(t *testing.T) { - platform := map[string]string{"A": "1", "B": "2"} - additional := map[string]string{"C": "3", "D": "4"} - - result := mergeEnvMaps(platform, additional) - - if len(result) != 4 { - t.Errorf("expected 4 keys, got %d", len(result)) - } - if result["A"] != "1" || result["B"] != "2" || result["C"] != "3" || result["D"] != "4" { - t.Errorf("unexpected result: %v", result) - } - }) - - t.Run("additional overrides platform", func(t *testing.T) { - platform := map[string]string{"A": "platform", "B": "2"} - additional := map[string]string{"A": "additional", "C": "3"} - - result := mergeEnvMaps(platform, additional) - - if result["A"] != "additional" { - t.Errorf("expected additional to override platform, got %q", result["A"]) - } - if result["B"] != "2" { - t.Errorf("expected B to be preserved, got %q", result["B"]) - } - if result["C"] != "3" { - t.Errorf("expected C to be present, got %q", result["C"]) - } - }) - - t.Run("handles nil maps", func(t *testing.T) { - result := mergeEnvMaps(nil, nil) - if result == nil { - t.Error("expected non-nil result") - } - if len(result) != 0 { - t.Errorf("expected empty map, got %v", result) - } - }) - - t.Run("handles nil platform", func(t *testing.T) { - additional := map[string]string{"A": "1"} - result := mergeEnvMaps(nil, additional) - - if result["A"] != "1" { - t.Errorf("expected A=1, got %q", result["A"]) - } - }) - - t.Run("handles nil additional", func(t *testing.T) { - platform := map[string]string{"A": "1"} - result := mergeEnvMaps(platform, nil) - - if result["A"] != "1" { - t.Errorf("expected A=1, got %q", result["A"]) - } - }) -} diff --git a/internal/framework/minitest.go b/internal/framework/minitest.go index d25da85..0f3ddf1 100644 --- a/internal/framework/minitest.go +++ b/internal/framework/minitest.go @@ -3,6 +3,7 @@ package framework import ( "context" "log/slog" + "maps" "os" "strings" @@ -51,7 +52,9 @@ func (m *Minitest) DiscoverTests(ctx context.Context) ([]testoptimization.Test, slog.Debug("Using test discovery pattern", "pattern", pattern) // Merge env maps: platform env -> base discovery env - envMap := mergeEnvMaps(m.platformEnv, BaseDiscoveryEnv()) + envMap := make(map[string]string) + maps.Copy(envMap, m.platformEnv) + maps.Copy(envMap, BaseDiscoveryEnv()) if isRails { args = append(args, pattern) @@ -109,7 +112,9 @@ func (m *Minitest) RunTests(ctx context.Context, testFiles []string, envMap map[ } } - mergedEnv := mergeEnvMaps(m.platformEnv, envMap) + mergedEnv := make(map[string]string) + maps.Copy(mergedEnv, m.platformEnv) + maps.Copy(mergedEnv, envMap) return m.executor.Run(ctx, command, args, mergedEnv) } diff --git a/internal/framework/rspec.go b/internal/framework/rspec.go index 4b10d3d..7289753 100644 --- a/internal/framework/rspec.go +++ b/internal/framework/rspec.go @@ -3,6 +3,7 @@ package framework import ( "context" "log/slog" + "maps" "os" "github.com/DataDog/ddtest/internal/ext" @@ -51,7 +52,9 @@ func (r *RSpec) DiscoverTests(ctx context.Context) ([]testoptimization.Test, err args = append(args, "--pattern", pattern) // Merge env maps: platform env -> base discovery env - envMap := mergeEnvMaps(r.platformEnv, BaseDiscoveryEnv()) + envMap := make(map[string]string) + maps.Copy(envMap, r.platformEnv) + maps.Copy(envMap, BaseDiscoveryEnv()) slog.Info("Using test discovery pattern", "pattern", pattern) slog.Info("Discovering tests with command", "command", name, "args", args) @@ -92,7 +95,9 @@ func (r *RSpec) RunTests(ctx context.Context, testFiles []string, envMap map[str slog.Info("Running tests with command", "command", command, "args", args) args = append(args, testFiles...) - mergedEnv := mergeEnvMaps(r.platformEnv, envMap) + mergedEnv := make(map[string]string) + maps.Copy(mergedEnv, r.platformEnv) + maps.Copy(mergedEnv, envMap) return r.executor.Run(ctx, command, args, mergedEnv) } diff --git a/internal/runner/dd_test_optimization.go b/internal/runner/dd_test_optimization.go index 842c26f..0b5573a 100644 --- a/internal/runner/dd_test_optimization.go +++ b/internal/runner/dd_test_optimization.go @@ -4,8 +4,10 @@ import ( "context" "fmt" "log/slog" + "maps" "time" + "github.com/DataDog/ddtest/internal/settings" "github.com/DataDog/ddtest/internal/testoptimization" "golang.org/x/sync/errgroup" ) @@ -16,12 +18,25 @@ func (tr *TestRunner) PrepareTestOptimization(ctx context.Context) error { return fmt.Errorf("failed to detect platform: %w", err) } + // Get platform-detected tags first tags, err := detectedPlatform.CreateTagsMap() if err != nil { return fmt.Errorf("failed to create platform tags: %w", err) } - slog.Info("Preparing test optimization data", "runtimeTags", tags, "platform", detectedPlatform.Name()) + // Check if runtime tags override is provided and merge onto detected tags + overrideTags, err := settings.GetRuntimeTagsMap() + if err != nil { + return fmt.Errorf("failed to parse runtime tags override: %w", err) + } + + if overrideTags != nil { + // Merge override tags onto detected tags (override values take precedence) + maps.Copy(tags, overrideTags) + slog.Info("Merged runtime tags override from --runtime-tags", "overrideTags", overrideTags, "mergedTags", tags) + } else { + slog.Info("Preparing test optimization data", "runtimeTags", tags, "platform", detectedPlatform.Name()) + } // Detect framework once to avoid duplicate work framework, err := detectedPlatform.DetectFramework() diff --git a/internal/runner/dd_test_optimization_test.go b/internal/runner/dd_test_optimization_test.go index d2f47d8..cedd0d1 100644 --- a/internal/runner/dd_test_optimization_test.go +++ b/internal/runner/dd_test_optimization_test.go @@ -3,9 +3,11 @@ package runner import ( "context" "errors" + "os" "strings" "testing" + "github.com/DataDog/ddtest/internal/settings" "github.com/DataDog/ddtest/internal/testoptimization" ) @@ -308,3 +310,186 @@ func TestTestRunner_PrepareTestOptimization_AllTestsSkipped(t *testing.T) { t.Errorf("PrepareTestOptimization() should calculate 100%% skippable when all tests are skipped, got %.2f", runner.skippablePercentage) } } + +func TestTestRunner_PrepareTestOptimization_RuntimeTagsOverride(t *testing.T) { + ctx := context.Background() + + // Set runtime tags override via environment variable - only override some tags + overrideTags := `{"os.platform":"linux","runtime.version":"3.2.0"}` + _ = os.Setenv("DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS", overrideTags) + defer func() { + _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS") + }() + + // Reinitialize settings to pick up the environment variable + settings.Init() + + mockFramework := &MockFramework{ + FrameworkName: "rspec", + Tests: []testoptimization.Test{ + {Suite: "TestSuite1", Name: "test1", Parameters: "", SuiteSourceFile: "test/file1_test.rb"}, + }, + } + + // Platform tags should have more tags than the override + mockPlatform := &MockPlatform{ + PlatformName: "ruby", + Tags: map[string]string{ + "os.platform": "darwin", + "os.architecture": "arm64", + "runtime.name": "ruby", + "runtime.version": "3.3.0", + "language": "ruby", + }, + Framework: mockFramework, + } + + mockPlatformDetector := &MockPlatformDetector{ + Platform: mockPlatform, + } + + mockOptimizationClient := &MockTestOptimizationClient{ + SkippableTests: map[string]bool{}, + } + + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) + + err := runner.PrepareTestOptimization(ctx) + + if err != nil { + t.Errorf("PrepareTestOptimization() should not return error, got: %v", err) + } + + // Verify optimization client was initialized + if !mockOptimizationClient.InitializeCalled { + t.Error("PrepareTestOptimization() should initialize optimization client") + } + + // Check that override tags replaced the detected values + if mockOptimizationClient.Tags["os.platform"] != "linux" { + t.Errorf("Expected os.platform to be 'linux' from override, got %q", mockOptimizationClient.Tags["os.platform"]) + } + + if mockOptimizationClient.Tags["runtime.version"] != "3.2.0" { + t.Errorf("Expected runtime.version to be '3.2.0' from override, got %q", mockOptimizationClient.Tags["runtime.version"]) + } + + // Check that detected tags NOT in override were preserved + if mockOptimizationClient.Tags["os.architecture"] != "arm64" { + t.Errorf("Expected os.architecture to be 'arm64' from detected tags (not overridden), got %q", mockOptimizationClient.Tags["os.architecture"]) + } + + if mockOptimizationClient.Tags["runtime.name"] != "ruby" { + t.Errorf("Expected runtime.name to be 'ruby' from detected tags (not overridden), got %q", mockOptimizationClient.Tags["runtime.name"]) + } + + if mockOptimizationClient.Tags["language"] != "ruby" { + t.Errorf("Expected language to be 'ruby' from detected tags (not overridden), got %q", mockOptimizationClient.Tags["language"]) + } +} + +func TestTestRunner_PrepareTestOptimization_RuntimeTagsOverrideInvalidJSON(t *testing.T) { + ctx := context.Background() + + // Set invalid JSON as runtime tags override + _ = os.Setenv("DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS", `{invalid json}`) + defer func() { + _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS") + }() + + // Reinitialize settings to pick up the environment variable + settings.Init() + + mockFramework := &MockFramework{ + FrameworkName: "rspec", + Tests: []testoptimization.Test{}, + } + + mockPlatform := &MockPlatform{ + PlatformName: "ruby", + Tags: map[string]string{"platform": "ruby"}, + Framework: mockFramework, + } + + mockPlatformDetector := &MockPlatformDetector{ + Platform: mockPlatform, + } + + mockOptimizationClient := &MockTestOptimizationClient{} + + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) + + err := runner.PrepareTestOptimization(ctx) + + if err == nil { + t.Error("PrepareTestOptimization() should return error when runtime tags JSON is invalid") + } + + expectedMsg := "failed to parse runtime tags override" + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("PrepareTestOptimization() error should contain '%s', got: %v", expectedMsg, err) + } + + // Optimization client should not be initialized when there's a parse error + if mockOptimizationClient.InitializeCalled { + t.Error("PrepareTestOptimization() should not initialize optimization client when runtime tags JSON is invalid") + } +} + +func TestTestRunner_PrepareTestOptimization_NoRuntimeTagsOverride(t *testing.T) { + ctx := context.Background() + + // Ensure no runtime tags override is set + _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS") + + // Reinitialize settings to ensure clean state + settings.Init() + + mockFramework := &MockFramework{ + FrameworkName: "rspec", + Tests: []testoptimization.Test{ + {Suite: "TestSuite1", Name: "test1", Parameters: "", SuiteSourceFile: "test/file1_test.rb"}, + }, + } + + // Platform tags that should be used when no override is provided + mockPlatform := &MockPlatform{ + PlatformName: "ruby", + Tags: map[string]string{ + "os.platform": "darwin", + "runtime.version": "3.3.0", + "language": "ruby", + }, + Framework: mockFramework, + } + + mockPlatformDetector := &MockPlatformDetector{ + Platform: mockPlatform, + } + + mockOptimizationClient := &MockTestOptimizationClient{ + SkippableTests: map[string]bool{}, + } + + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) + + err := runner.PrepareTestOptimization(ctx) + + if err != nil { + t.Errorf("PrepareTestOptimization() should not return error, got: %v", err) + } + + // Verify optimization client was initialized with platform tags + if !mockOptimizationClient.InitializeCalled { + t.Error("PrepareTestOptimization() should initialize optimization client") + } + + // Check that platform tags were used (not override) + if mockOptimizationClient.Tags["os.platform"] != "darwin" { + t.Errorf("Expected os.platform to be 'darwin' from platform, got %q", mockOptimizationClient.Tags["os.platform"]) + } + + if mockOptimizationClient.Tags["runtime.version"] != "3.3.0" { + t.Errorf("Expected runtime.version to be '3.3.0' from platform, got %q", mockOptimizationClient.Tags["runtime.version"]) + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index aa68e38..0e07688 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -1,6 +1,7 @@ package settings import ( + "encoding/json" "fmt" "os" "strings" @@ -17,6 +18,7 @@ type Config struct { CiNode int `mapstructure:"ci_node"` Command string `mapstructure:"command"` TestsLocation string `mapstructure:"tests_location"` + RuntimeTags string `mapstructure:"runtime_tags"` } var ( @@ -46,6 +48,7 @@ func setDefaults() { viper.SetDefault("ci_node", -1) viper.SetDefault("command", "") viper.SetDefault("tests_location", "") + viper.SetDefault("runtime_tags", "") } func Get() *Config { @@ -87,6 +90,26 @@ func GetTestsLocation() string { return Get().TestsLocation } +func GetRuntimeTags() string { + return Get().RuntimeTags +} + +// GetRuntimeTagsMap parses the runtime_tags setting as JSON and returns it as a map. +// Returns nil if runtime_tags is empty or not set. +// Returns an error if the JSON is invalid. +func GetRuntimeTagsMap() (map[string]string, error) { + runtimeTags := GetRuntimeTags() + if runtimeTags == "" { + return nil, nil + } + + var tagsMap map[string]string + if err := json.Unmarshal([]byte(runtimeTags), &tagsMap); err != nil { + return nil, fmt.Errorf("failed to parse runtime-tags as JSON: %w. The runtime tags value was: %s", err, runtimeTags) + } + return tagsMap, nil +} + // GetWorkerEnvMap parses the worker_env setting and returns it as a map // The format is "KEY=value;KEY2=value2" func GetWorkerEnvMap() map[string]string { diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 2e284f0..78b4657 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -43,6 +43,9 @@ func TestInit(t *testing.T) { if config.TestsLocation != "" { t.Errorf("expected default tests_location to be empty, got %q", config.TestsLocation) } + if config.RuntimeTags != "" { + t.Errorf("expected default runtime_tags to be empty, got %q", config.RuntimeTags) + } } func TestSetDefaults(t *testing.T) { @@ -74,6 +77,9 @@ func TestSetDefaults(t *testing.T) { if viper.GetString("tests_location") != "" { t.Errorf("expected default tests_location to be empty, got %q", viper.GetString("tests_location")) } + if viper.GetString("runtime_tags") != "" { + t.Errorf("expected default runtime_tags to be empty, got %q", viper.GetString("runtime_tags")) + } } func TestGet(t *testing.T) { @@ -144,6 +150,7 @@ func TestEnvironmentVariables(t *testing.T) { _ = os.Setenv("DD_TEST_OPTIMIZATION_RUNNER_CI_NODE", "5") _ = 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"}`) defer func() { _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_PLATFORM") _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_FRAMEWORK") @@ -153,6 +160,7 @@ func TestEnvironmentVariables(t *testing.T) { _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_CI_NODE") _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_COMMAND") _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_TESTS_LOCATION") + _ = os.Unsetenv("DD_TEST_OPTIMIZATION_RUNNER_RUNTIME_TAGS") }() Init() @@ -181,6 +189,9 @@ func TestEnvironmentVariables(t *testing.T) { if config.TestsLocation != "spec/**/*_spec.rb" { t.Errorf("expected tests_location from env var to be 'spec/**/*_spec.rb', got %q", config.TestsLocation) } + if config.RuntimeTags != `{"os.platform":"linux","runtime.version":"3.2.0"}` { + t.Errorf("expected runtime_tags from env var to be JSON string, got %q", config.RuntimeTags) + } } func TestGetMinParallelism(t *testing.T) { @@ -269,6 +280,84 @@ func TestGetTestsLocation(t *testing.T) { } } +func TestGetRuntimeTags(t *testing.T) { + config = nil + viper.Reset() + + runtimeTags := GetRuntimeTags() + if runtimeTags != "" { + t.Errorf("expected runtime_tags to be empty by default, got %q", runtimeTags) + } + + config = &Config{RuntimeTags: `{"os.platform":"linux","runtime.version":"3.2.0"}`} + runtimeTags = GetRuntimeTags() + if runtimeTags != `{"os.platform":"linux","runtime.version":"3.2.0"}` { + t.Errorf("expected runtime_tags to be JSON string, got %q", runtimeTags) + } +} + +func TestGetRuntimeTagsMap(t *testing.T) { + t.Run("empty runtime tags", func(t *testing.T) { + config = &Config{RuntimeTags: ""} + result, err := GetRuntimeTagsMap() + + if err != nil { + t.Errorf("expected no error for empty runtime_tags, got %v", err) + } + if result != nil { + t.Errorf("expected nil for empty runtime_tags, got %v", result) + } + }) + + t.Run("valid JSON", func(t *testing.T) { + config = &Config{RuntimeTags: `{"os.platform":"linux","runtime.version":"3.2.0","language":"ruby"}`} + result, err := GetRuntimeTagsMap() + + if err != nil { + t.Errorf("expected no error for valid JSON, got %v", err) + } + + expected := map[string]string{ + "os.platform": "linux", + "runtime.version": "3.2.0", + "language": "ruby", + } + + if len(result) != 3 { + t.Errorf("expected 3 entries, got %d", len(result)) + } + for k, v := range expected { + if result[k] != v { + t.Errorf("expected %s=%s, got %s=%s", k, v, k, result[k]) + } + } + }) + + t.Run("invalid JSON", func(t *testing.T) { + config = &Config{RuntimeTags: `{invalid json}`} + result, err := GetRuntimeTagsMap() + + if err == nil { + t.Error("expected error for invalid JSON") + } + if result != nil { + t.Errorf("expected nil result for invalid JSON, got %v", result) + } + }) + + t.Run("single key-value pair", func(t *testing.T) { + config = &Config{RuntimeTags: `{"os.platform":"darwin"}`} + result, err := GetRuntimeTagsMap() + + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if len(result) != 1 || result["os.platform"] != "darwin" { + t.Errorf("expected {os.platform: darwin}, got %v", result) + } + }) +} + func TestGetCiNode(t *testing.T) { // Test with defaults config = nil