From 0a6bb4822163e44d59b6b23b0dea936574815296 Mon Sep 17 00:00:00 2001 From: Andrey Marchenko Date: Mon, 22 Jun 2026 09:47:40 +0200 Subject: [PATCH] Instrument CI with Datadog Test Optimization --- .github/workflows/ci.yml | 30 ++- internal/cmd/cmd.go | 128 +++++----- internal/cmd/cmd_test.go | 187 ++++++++++++--- internal/discovery/discovery_test.go | 136 +++++++++++ internal/environment/ci_providers_test.go | 45 ++++ internal/ext/command.go | 32 ++- internal/ext/command_test.go | 89 ++++--- internal/git/git_test.go | 92 +++++++ internal/git/gittest/gittest_test.go | 82 +++++++ internal/planner/discovery_cache_test.go | 181 ++++++++++++++ internal/planner/report_test.go | 85 +++++++ internal/platform/platform_test.go | 30 +++ internal/runmetadata/runmetadata_test.go | 37 ++- internal/runner/report_test.go | 43 ++++ internal/runner/runner_test.go | 277 ++++++++++++++++++++++ internal/settings/settings_test.go | 8 + internal/utils/home_test.go | 87 +++++++ internal/utils/path_test.go | 51 ++++ internal/version/version_test.go | 60 +++++ main.go | 11 +- main_test.go | 41 ++++ 21 files changed, 1590 insertions(+), 142 deletions(-) create mode 100644 internal/git/gittest/gittest_test.go create mode 100644 internal/platform/platform_test.go create mode 100644 internal/utils/home_test.go create mode 100644 main_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b9d7f6..535a9c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,14 +3,13 @@ name: CI on: push: branches: - - "main" - pull_request: - branches: - - "main" + - "**" jobs: test: runs-on: ubuntu-latest + env: + DATADOG_API_KEY_CONFIGURED: ${{ secrets.DATADOG_API_KEY != '' }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -47,8 +46,29 @@ jobs: - name: Vet run: go vet ./... + - name: Configure Datadog Test Optimization + if: ${{ env.DATADOG_API_KEY_CONFIGURED == 'true' }} + uses: datadog/test-visibility-github-action@cdf298ef8277c8c3bec321499084ce5d9e4ff1e8 # v2.10.0 + with: + languages: go + api_key: ${{ secrets.DATADOG_API_KEY }} + site: datadoghq.com + service: ddtest + - name: Run tests - run: go test -race -coverprofile=coverage.out -covermode=atomic ./... + run: go test -race -coverpkg=./... -coverprofile=coverage.out -covermode=atomic ./... + env: + DD_ENV: ci + + - name: Upload coverage report to Datadog + if: ${{ env.DATADOG_API_KEY_CONFIGURED == 'true' }} + uses: DataDog/coverage-upload-github-action@6c4bd935248daa6f0ef94e3e6ba71ad5ad079998 # v1.0.3 + with: + api_key: ${{ secrets.DATADOG_API_KEY }} + site: datadoghq.com + files: coverage.out + format: go-coverprofile + version: v5.19.0 lint: runs-on: ubuntu-latest diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 0d52e46..dce0a9c 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -29,6 +29,12 @@ var rootCmd = &cobra.Command{ }, } +var ( + planCommand = planner.Plan + newRunner = func() runner.Runner { return runner.New() } + exitProcess = os.Exit +) + var planCmd = &cobra.Command{ Use: "plan", Short: "Prepare test optimization data", @@ -37,27 +43,35 @@ var planCmd = &cobra.Command{ constants.TestFilesOutputPath, constants.SkippablePercentageOutputPath, ), - Run: func(cmd *cobra.Command, args []string) { - ctx := context.Background() - if err := planner.Plan(ctx); err != nil { - slog.Error("Planner failed", "error", err) - os.Exit(1) - } - }, + Run: runPlanCommand, } var runCmd = &cobra.Command{ Use: "run", Short: "Run tests using test optimization", Long: "Runs tests using Datadog Test Optimization to execute only necessary test files based on code changes.", - Run: func(cmd *cobra.Command, args []string) { - ctx := context.Background() - testRunner := runner.New() - if err := testRunner.Run(ctx); err != nil { - slog.Error("Runner failed", "error", err) - os.Exit(1) - } - }, + Run: runTestCommand, +} + +type persistentFlagBinding struct { + configKey string + flagName string +} + +var rootPersistentFlagBindings = []persistentFlagBinding{ + {configKey: "platform", flagName: "platform"}, + {configKey: "framework", flagName: "framework"}, + {configKey: "min_parallelism", flagName: "min-parallelism"}, + {configKey: "max_parallelism", flagName: "max-parallelism"}, + {configKey: "parallel_runner_overhead", flagName: "ci-job-overhead"}, + {configKey: "worker_env", flagName: "worker-env"}, + {configKey: "ci_node", flagName: "ci-node"}, + {configKey: "ci_node_workers", flagName: "ci-node-workers"}, + {configKey: "command", flagName: "command"}, + {configKey: "tests_location", flagName: "tests-location"}, + {configKey: "tests_exclude_pattern", flagName: "tests-exclude-pattern"}, + {configKey: "test_discovery_cache", flagName: "test-discovery-cache"}, + {configKey: "runtime_tags", flagName: "runtime-tags"}, } func init() { @@ -76,56 +90,8 @@ func init() { rootCmd.PersistentFlags().String("tests-exclude-pattern", "", "Glob pattern used to exclude test files from discovery") rootCmd.PersistentFlags().String("test-discovery-cache", "", "Path to a restored test discovery cache file to import before planning") 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) - } - if err := viper.BindPFlag("framework", rootCmd.PersistentFlags().Lookup("framework")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding framework flag: %v\n", err) - os.Exit(1) - } - if err := viper.BindPFlag("min_parallelism", rootCmd.PersistentFlags().Lookup("min-parallelism")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding min-parallelism flag: %v\n", err) - os.Exit(1) - } - if err := viper.BindPFlag("max_parallelism", rootCmd.PersistentFlags().Lookup("max-parallelism")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding max-parallelism flag: %v\n", err) - os.Exit(1) - } - if err := viper.BindPFlag("parallel_runner_overhead", rootCmd.PersistentFlags().Lookup("ci-job-overhead")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding ci-job-overhead flag: %v\n", err) - os.Exit(1) - } - if err := viper.BindPFlag("worker_env", rootCmd.PersistentFlags().Lookup("worker-env")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding worker-env flag: %v\n", err) - os.Exit(1) - } - if err := viper.BindPFlag("ci_node", rootCmd.PersistentFlags().Lookup("ci-node")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding ci-node 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) - } - if err := viper.BindPFlag("tests_location", rootCmd.PersistentFlags().Lookup("tests-location")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding tests-location flag: %v\n", err) - os.Exit(1) - } - if err := viper.BindPFlag("tests_exclude_pattern", rootCmd.PersistentFlags().Lookup("tests-exclude-pattern")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding tests-exclude-pattern flag: %v\n", err) - os.Exit(1) - } - if err := viper.BindPFlag("test_discovery_cache", rootCmd.PersistentFlags().Lookup("test-discovery-cache")); err != nil { - fmt.Fprintf(os.Stderr, "Error binding test-discovery-cache 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) + if err := bindPersistentFlags(rootCmd, rootPersistentFlagBindings); err != nil { + fmt.Fprintf(os.Stderr, "Error binding CLI flags: %v\n", err) os.Exit(1) } @@ -135,6 +101,38 @@ func init() { cobra.OnInitialize(settings.Init) } +func bindPersistentFlags(cmd *cobra.Command, bindings []persistentFlagBinding) error { + for _, binding := range bindings { + flag := cmd.PersistentFlags().Lookup(binding.flagName) + if flag == nil { + return fmt.Errorf("flag %q not found", binding.flagName) + } + if err := viper.BindPFlag(binding.configKey, flag); err != nil { + return fmt.Errorf("bind %s flag: %w", binding.flagName, err) + } + } + return nil +} + +func runPlanCommand(cmd *cobra.Command, args []string) { + ctx := context.Background() + if err := planCommand(ctx); err != nil { + slog.Error("Planner failed", "error", err) + exitProcess(1) + return + } +} + +func runTestCommand(cmd *cobra.Command, args []string) { + ctx := context.Background() + testRunner := newRunner() + if err := testRunner.Run(ctx); err != nil { + slog.Error("Runner failed", "error", err) + exitProcess(1) + return + } +} + func Execute() error { return rootCmd.Execute() } diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index 1d8e8d0..afe90f5 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -2,12 +2,14 @@ package cmd import ( "bytes" + "context" "errors" "os" "strings" "testing" "github.com/DataDog/ddtest/internal/git" + runnerpkg "github.com/DataDog/ddtest/internal/runner" "github.com/DataDog/ddtest/internal/settings" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -132,6 +134,116 @@ func TestCommandHierarchy(t *testing.T) { } } +func TestRootPersistentPreRunChecksGitAvailability(t *testing.T) { + originalLookPathFunc := git.LookPathFunc + git.LookPathFunc = func(file string) (string, error) { + return "", errors.New("missing git") + } + t.Cleanup(func() { + git.LookPathFunc = originalLookPathFunc + }) + + err := rootCmd.PersistentPreRunE(rootCmd, nil) + if err == nil || !strings.Contains(err.Error(), "git executable not found") { + t.Fatalf("PersistentPreRunE() error = %v, want git availability error", err) + } +} + +func TestRunPlanCommand(t *testing.T) { + originalPlanCommand := planCommand + originalExitProcess := exitProcess + t.Cleanup(func() { + planCommand = originalPlanCommand + exitProcess = originalExitProcess + }) + + calls := 0 + planCommand = func(ctx context.Context) error { + calls++ + return nil + } + exitProcess = func(code int) { + t.Fatalf("exitProcess(%d) should not be called", code) + } + + runPlanCommand(&cobra.Command{}, nil) + + if calls != 1 { + t.Fatalf("expected plan command to be called once, got %d", calls) + } +} + +func TestRunPlanCommandExitsOnError(t *testing.T) { + originalPlanCommand := planCommand + originalExitProcess := exitProcess + t.Cleanup(func() { + planCommand = originalPlanCommand + exitProcess = originalExitProcess + }) + + planErr := errors.New("planner failed") + planCommand = func(ctx context.Context) error { + return planErr + } + var exitCodes []int + exitProcess = func(code int) { + exitCodes = append(exitCodes, code) + } + + runPlanCommand(&cobra.Command{}, nil) + + if len(exitCodes) != 1 || exitCodes[0] != 1 { + t.Fatalf("expected exit code 1, got %v", exitCodes) + } +} + +func TestRunTestCommand(t *testing.T) { + originalNewRunner := newRunner + originalExitProcess := exitProcess + t.Cleanup(func() { + newRunner = originalNewRunner + exitProcess = originalExitProcess + }) + + fake := &fakeCommandRunner{} + newRunner = func() runnerpkg.Runner { + return fake + } + exitProcess = func(code int) { + t.Fatalf("exitProcess(%d) should not be called", code) + } + + runTestCommand(&cobra.Command{}, nil) + + if fake.calls != 1 { + t.Fatalf("expected runner to be called once, got %d", fake.calls) + } +} + +func TestRunTestCommandExitsOnError(t *testing.T) { + originalNewRunner := newRunner + originalExitProcess := exitProcess + t.Cleanup(func() { + newRunner = originalNewRunner + exitProcess = originalExitProcess + }) + + fake := &fakeCommandRunner{err: errors.New("runner failed")} + newRunner = func() runnerpkg.Runner { + return fake + } + var exitCodes []int + exitProcess = func(code int) { + exitCodes = append(exitCodes, code) + } + + runTestCommand(&cobra.Command{}, nil) + + if len(exitCodes) != 1 || exitCodes[0] != 1 { + t.Fatalf("expected exit code 1, got %v", exitCodes) + } +} + func TestExecute(t *testing.T) { // Save original args originalArgs := os.Args @@ -196,34 +308,9 @@ func TestFlagBinding(t *testing.T) { // Reset viper viper.Reset() - // Flags are already defined in init(), so we can use them directly - // Rebind flags to ensure they work with viper - if err := viper.BindPFlag("platform", rootCmd.PersistentFlags().Lookup("platform")); err != nil { - t.Fatalf("Error binding platform flag: %v", err) - } - if err := viper.BindPFlag("framework", rootCmd.PersistentFlags().Lookup("framework")); err != nil { - t.Fatalf("Error binding framework flag: %v", err) - } - if err := viper.BindPFlag("command", rootCmd.PersistentFlags().Lookup("command")); err != nil { - t.Fatalf("Error binding command flag: %v", err) - } - if err := viper.BindPFlag("tests_location", rootCmd.PersistentFlags().Lookup("tests-location")); err != nil { - t.Fatalf("Error binding tests-location flag: %v", err) - } - if err := viper.BindPFlag("tests_exclude_pattern", rootCmd.PersistentFlags().Lookup("tests-exclude-pattern")); err != nil { - t.Fatalf("Error binding tests-exclude-pattern flag: %v", err) - } - if err := viper.BindPFlag("test_discovery_cache", rootCmd.PersistentFlags().Lookup("test-discovery-cache")); err != nil { - t.Fatalf("Error binding test-discovery-cache flag: %v", err) - } - if err := viper.BindPFlag("ci_node_workers", rootCmd.PersistentFlags().Lookup("ci-node-workers")); err != nil { - t.Fatalf("Error binding ci-node-workers flag: %v", err) - } - if err := viper.BindPFlag("ci_node", rootCmd.PersistentFlags().Lookup("ci-node")); err != nil { - t.Fatalf("Error binding ci-node flag: %v", err) - } - if err := viper.BindPFlag("parallel_runner_overhead", rootCmd.PersistentFlags().Lookup("ci-job-overhead")); err != nil { - t.Fatalf("Error binding ci-job-overhead flag: %v", err) + // Flags are already defined in init(), so we can use them directly. + if err := bindPersistentFlags(rootCmd, rootPersistentFlagBindings); err != nil { + t.Fatalf("bindPersistentFlags() failed: %v", err) } // Set flag values @@ -285,6 +372,40 @@ func TestFlagBinding(t *testing.T) { } } +func TestBindPersistentFlags(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + + testCmd := &cobra.Command{} + testCmd.PersistentFlags().String("example-flag", "default", "example flag") + + err := bindPersistentFlags(testCmd, []persistentFlagBinding{ + {configKey: "example_config", flagName: "example-flag"}, + }) + if err != nil { + t.Fatalf("bindPersistentFlags() failed: %v", err) + } + + if err := testCmd.PersistentFlags().Set("example-flag", "configured"); err != nil { + t.Fatalf("failed to set example flag: %v", err) + } + if got := viper.GetString("example_config"); got != "configured" { + t.Fatalf("viper example_config = %q, want configured", got) + } +} + +func TestBindPersistentFlagsMissingFlag(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + + err := bindPersistentFlags(&cobra.Command{}, []persistentFlagBinding{ + {configKey: "missing_config", flagName: "missing-flag"}, + }) + if err == nil || !strings.Contains(err.Error(), `flag "missing-flag" not found`) { + t.Fatalf("bindPersistentFlags() error = %v, want missing flag", err) + } +} + func TestCommandUsage(t *testing.T) { // Get all commands including root and subcommands allCommands := []*cobra.Command{rootCmd} @@ -364,3 +485,13 @@ func TestInitFunction(t *testing.T) { t.Error("init should add commands to root") } } + +type fakeCommandRunner struct { + calls int + err error +} + +func (f *fakeCommandRunner) Run(ctx context.Context) error { + f.calls++ + return f.err +} diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go index 612ab6d..fc35f73 100644 --- a/internal/discovery/discovery_test.go +++ b/internal/discovery/discovery_test.go @@ -131,6 +131,142 @@ func TestDiscoverTestFilesWithInvalidExcludePattern(t *testing.T) { } } +func TestDiscoverTestFilesEmptyAndMissingRoot(t *testing.T) { + t.Chdir(t.TempDir()) + + files, err := DiscoverTestFiles("", "") + if err != nil { + t.Fatalf("empty pattern returned error: %v", err) + } + if len(files) != 0 { + t.Fatalf("empty pattern returned files: %v", files) + } + + files, err = DiscoverTestFiles(filepath.Join("missing", "**", "*_test.rb"), "") + if err != nil { + t.Fatalf("missing root returned error: %v", err) + } + if len(files) != 0 { + t.Fatalf("missing root returned files: %v", files) + } +} + +func TestResolveTestFilesWithoutExcludePattern(t *testing.T) { + pattern := filepath.Join("test", "**", "*_test.rb") + + files, err := ResolveTestFiles(pattern, "") + if err != nil { + t.Fatal(err) + } + + if files.Pattern != pattern { + t.Fatalf("Pattern = %q, want %q", files.Pattern, pattern) + } + if files.UseExplicitFiles() { + t.Fatalf("expected discovery pattern to be used, got explicit files %#v", files.ExplicitFiles) + } + if files.Empty() { + t.Fatal("pattern-based discovery should not be empty") + } +} + +func TestResolveTestFilesWithExcludePattern(t *testing.T) { + root := createDiscoveryFixture(t) + t.Chdir(root) + + files, err := ResolveTestFiles( + filepath.Join("test", "**", "*_test.rb"), + filepath.Join("test", "system", "**", "*_test.rb"), + ) + if err != nil { + t.Fatal(err) + } + + expected := []string{ + "test/unit/order_test.rb", + "test/unit/user_test.rb", + } + if !files.UseExplicitFiles() { + t.Fatal("expected filtered discovery to use explicit files") + } + if !slices.Equal(files.ExplicitFiles, expected) { + t.Fatalf("expected %v, got %v", expected, files.ExplicitFiles) + } + if files.Empty() { + t.Fatal("filtered discovery should not be empty") + } +} + +func TestResolveTestFilesWithExcludePatternEmptyResult(t *testing.T) { + root := createDiscoveryFixture(t) + t.Chdir(root) + + files, err := ResolveTestFiles( + filepath.Join("test", "**", "*_test.rb"), + filepath.Join("test", "**", "*_test.rb"), + ) + if err != nil { + t.Fatal(err) + } + + if !files.UseExplicitFiles() { + t.Fatal("expected empty filtered discovery to use explicit files") + } + if !files.Empty() { + t.Fatalf("expected empty explicit files, got %#v", files.ExplicitFiles) + } +} + +func TestCleanupRemovesDiscoveryFile(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + + if err := os.MkdirAll(filepath.Dir(TestsFilePath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(TestsFilePath, []byte("[]"), 0o644); err != nil { + t.Fatal(err) + } + + Cleanup() + + if _, err := os.Stat(TestsFilePath); !os.IsNotExist(err) { + t.Fatalf("expected discovery file to be removed, stat error: %v", err) + } + Cleanup() +} + +func TestDiscoverTestsReturnsParseError(t *testing.T) { + logs := captureDiscoveryLogs(t) + root := t.TempDir() + t.Chdir(root) + + if err := os.MkdirAll(filepath.Dir(TestsFilePath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(TestsFilePath, []byte("{"), 0o644); err != nil { + t.Fatal(err) + } + + tests, err := DiscoverTests(context.Background(), successfulDiscoveryExecutor{}, "bundle", []string{"exec", "rspec"}, map[string]string{"APP_ENV": "test"}) + if err == nil { + t.Fatal("expected parse error") + } + if tests != nil { + t.Fatalf("expected nil tests on parse error, got %+v", tests) + } + if !strings.Contains(logs.String(), "Error parsing JSON") { + t.Fatalf("expected parse error log, got: %s", logs.String()) + } +} + +func TestDiscoveryCommandLogValueShortCommand(t *testing.T) { + got := discoveryCommandLogValue("bundle", []string{"exec", "rspec"}) + if got != "bundle exec rspec" { + t.Fatalf("discoveryCommandLogValue() = %q, want bundle exec rspec", got) + } +} + func TestDiscoverTestsLogsTruncatedCommand(t *testing.T) { logs := captureDiscoveryLogs(t) root := t.TempDir() diff --git a/internal/environment/ci_providers_test.go b/internal/environment/ci_providers_test.go index a450f4a..0f67317 100644 --- a/internal/environment/ci_providers_test.go +++ b/internal/environment/ci_providers_test.go @@ -383,6 +383,30 @@ func TestTravisPullRequestNumberIsSet(t *testing.T) { } } +func TestGithubActionsJobIDFromEnvironment(t *testing.T) { + originalDiagEnabled := githubActionsDiagnosticsEnabled + githubActionsDiagnosticsEnabled = false + t.Cleanup(func() { + githubActionsDiagnosticsEnabled = originalDiagEnabled + }) + + t.Run("numeric env wins", func(t *testing.T) { + t.Setenv(githubJobCheckRunIDEnv, " 12345678901 ") + + if got := getGithubActionsJobID(); got != "12345678901" { + t.Fatalf("getGithubActionsJobID() = %q, want 12345678901", got) + } + }) + + t.Run("non numeric env ignored", func(t *testing.T) { + t.Setenv(githubJobCheckRunIDEnv, "not-a-number") + + if got := getGithubActionsJobID(); got != "" { + t.Fatalf("getGithubActionsJobID() = %q, want empty", got) + } + }) +} + func TestIsNumericJobID(t *testing.T) { tests := []struct { input string @@ -411,6 +435,27 @@ func TestIsNumericJobID(t *testing.T) { } } +func TestDeduplicatePaths(t *testing.T) { + got := deduplicatePaths([]string{ + "", + "/tmp/actions-runner/_diag", + "/tmp/actions-runner/_diag", + "/var/actions-runner/_diag", + "", + "/tmp/actions-runner/_diag", + }) + want := []string{"/tmp/actions-runner/_diag", "/var/actions-runner/_diag"} + + if len(got) != len(want) { + t.Fatalf("deduplicatePaths() = %#v, want %#v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("deduplicatePaths()[%d] = %q, want %q; full result %#v", i, got[i], want[i], got) + } + } +} + func TestGithubActionsJobIDFromDiagnostics(t *testing.T) { t.Run("valid JSON", func(t *testing.T) { diagDir := t.TempDir() diff --git a/internal/ext/command.go b/internal/ext/command.go index c091555..51dc61b 100644 --- a/internal/ext/command.go +++ b/internal/ext/command.go @@ -13,7 +13,32 @@ type CommandExecutor interface { Run(ctx context.Context, name string, args []string, envMap map[string]string) error } -type DefaultCommandExecutor struct{} +type signalNotifier interface { + Notify(chan<- os.Signal, ...os.Signal) + Stop(chan<- os.Signal) +} + +type osSignalNotifier struct{} + +func (n osSignalNotifier) Notify(c chan<- os.Signal, signals ...os.Signal) { + signal.Notify(c, signals...) +} + +func (n osSignalNotifier) Stop(c chan<- os.Signal) { + signal.Stop(c) +} + +type DefaultCommandExecutor struct { + signalNotifier signalNotifier +} + +func (e *DefaultCommandExecutor) notifier() signalNotifier { + if e.signalNotifier != nil { + return e.signalNotifier + } + + return osSignalNotifier{} +} // applyEnvMap applies environment variables from envMap to the command func applyEnvMap(cmd *exec.Cmd, envMap map[string]string) { @@ -55,8 +80,9 @@ func (e *DefaultCommandExecutor) Run(ctx context.Context, name string, args []st // SIGHUP - hangup/connection loss // SIGQUIT - quit signal sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, syscall.SIGQUIT) - defer signal.Stop(sigChan) + notifier := e.notifier() + notifier.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, syscall.SIGQUIT) + defer notifier.Stop(sigChan) // Wait for command completion in a goroutine errChan := make(chan error, 1) diff --git a/internal/ext/command_test.go b/internal/ext/command_test.go index 3d99797..fd28079 100644 --- a/internal/ext/command_test.go +++ b/internal/ext/command_test.go @@ -9,6 +9,55 @@ import ( "time" ) +type fakeSignalNotifier struct { + signal os.Signal + notified chan []os.Signal +} + +func (n *fakeSignalNotifier) Notify(c chan<- os.Signal, signals ...os.Signal) { + n.notified <- append([]os.Signal(nil), signals...) + + go func() { + time.Sleep(200 * time.Millisecond) + c <- n.signal + }() +} + +func (n *fakeSignalNotifier) Stop(c chan<- os.Signal) {} + +func assertSignalForwarded(t *testing.T, signal os.Signal) error { + t.Helper() + + notifier := &fakeSignalNotifier{ + signal: signal, + notified: make(chan []os.Signal, 1), + } + executor := &DefaultCommandExecutor{signalNotifier: notifier} + + err := executor.Run(context.Background(), "sleep", []string{"30"}, nil) + + select { + case registeredSignals := <-notifier.notified: + if !containsSignal(registeredSignals, signal) { + t.Fatalf("expected %v to be registered, got %v", signal, registeredSignals) + } + default: + t.Fatal("expected signal notifier to be registered") + } + + return err +} + +func containsSignal(signals []os.Signal, target os.Signal) bool { + for _, signal := range signals { + if signal == target { + return true + } + } + + return false +} + func TestDefaultCommandExecutor_CombinedOutput_Success(t *testing.T) { executor := &DefaultCommandExecutor{} @@ -253,25 +302,7 @@ func TestDefaultCommandExecutor_Run_ContextTimeout(t *testing.T) { } func TestDefaultCommandExecutor_Run_SignalForwarding(t *testing.T) { - executor := &DefaultCommandExecutor{} - - // Send SIGINT to the test process itself after a delay - // The executor should forward this to the child sleep process - go func() { - time.Sleep(200 * time.Millisecond) - pid := os.Getpid() - process, err := os.FindProcess(pid) - if err != nil { - t.Errorf("failed to find test process: %v", err) - return - } - if err := process.Signal(syscall.SIGINT); err != nil { - t.Errorf("failed to send SIGINT to test process: %v", err) - } - }() - - // Run a long-running command - it should be interrupted by the signal forwarding - err := executor.Run(context.Background(), "sleep", []string{"30"}, nil) + err := assertSignalForwarded(t, syscall.SIGINT) // The process should have been interrupted if err == nil { @@ -282,25 +313,7 @@ func TestDefaultCommandExecutor_Run_SignalForwarding(t *testing.T) { } func TestDefaultCommandExecutor_Run_SignalForwardingSIGTERM(t *testing.T) { - executor := &DefaultCommandExecutor{} - - // Send SIGTERM to the test process itself after a delay - // The executor should forward this to the child sleep process - go func() { - time.Sleep(200 * time.Millisecond) - pid := os.Getpid() - process, err := os.FindProcess(pid) - if err != nil { - t.Errorf("failed to find test process: %v", err) - return - } - if err := process.Signal(syscall.SIGTERM); err != nil { - t.Errorf("failed to send SIGTERM to test process: %v", err) - } - }() - - // Run a long-running command - it should be terminated by the signal forwarding - err := executor.Run(context.Background(), "sleep", []string{"30"}, nil) + err := assertSignalForwarded(t, syscall.SIGTERM) // The process should have been terminated if err == nil { diff --git a/internal/git/git_test.go b/internal/git/git_test.go index b23a823..3895e72 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -153,6 +153,30 @@ func TestGitVersion(t *testing.T) { } } +func TestGitVersionCommandError(t *testing.T) { + fakeBin := t.TempDir() + writeFakeGit(t, fakeBin, "#!/bin/sh\nexit 1\n") + t.Setenv("PATH", fakeBin) + resetGitPackageState(t) + + _, _, _, err := getGitVersion() + if err == nil { + t.Fatal("expected git version error") + } +} + +func TestGitVersionInvalidOutput(t *testing.T) { + fakeBin := t.TempDir() + writeFakeGit(t, fakeBin, "#!/bin/sh\nprintf 'git version wat\\n'\n") + t.Setenv("PATH", fakeBin) + resetGitPackageState(t) + + _, _, _, err := getGitVersion() + if err == nil || !strings.Contains(err.Error(), "invalid git version") { + t.Fatalf("getGitVersion() error = %v, want invalid version", err) + } +} + func TestCheckAvailableIntegrationWithGitDir(t *testing.T) { repo := gittest.NewRepository(t) t.Setenv("GIT_DIR", filepath.Join(repo.Path, ".git")) @@ -408,6 +432,67 @@ func TestExecGitStringWithInput(t *testing.T) { } } +func TestExecGitStringWithInputGitNotInstalled(t *testing.T) { + resetGitPackageState(t) + LookPathFunc = func(file string) (string, error) { + return "", errors.New("missing git") + } + + _, err := execGitStringWithInput("hello", "hash-object", "--stdin") + if !errors.Is(err, errGitExecutableNotFound) { + t.Fatalf("execGitStringWithInput() error = %v, want git executable not found", err) + } +} + +func TestGitHelpersOutsideRepositoryFallbacks(t *testing.T) { + gittest.RequireGit(t) + t.Chdir(t.TempDir()) + resetGitPackageState(t) + + if got := getSafeDirectoryConfig(); got != "" { + t.Fatalf("expected empty safe.directory outside repository, got %q", got) + } + if got := GetSourceRoot(); got != "" { + t.Fatalf("expected empty source root outside repository, got %q", got) + } + if got := GetLastLocalGitCommitShas(); len(got) != 0 { + t.Fatalf("expected no local commit SHAs outside repository, got %v", got) + } + if got := getObjectsSha([]string{"HEAD"}, nil); len(got) != 0 { + t.Fatalf("expected no object SHAs outside repository, got %v", got) + } + if got := CreatePackFiles([]string{"HEAD"}, nil); len(got) != 0 { + t.Fatalf("expected no pack files outside repository, got %v", got) + } + remote, err := getRemoteName() + if err != nil { + t.Fatalf("expected remote name fallback without error, got %v", err) + } + if remote != "origin" { + t.Fatalf("expected origin remote fallback, got %q", remote) + } +} + +func TestGitLogHasMoreThanOneCommitFalseForSingleCommit(t *testing.T) { + repo := t.TempDir() + gittest.RequireGit(t) + gittest.Run(t, repo, "init") + gittest.Run(t, repo, "config", "user.name", gittest.AuthorName) + gittest.Run(t, repo, "config", "user.email", gittest.AuthorEmail) + gittest.WriteFile(t, repo, "README.md", "hello\n") + gittest.CommitAll(t, repo, "initial commit", time.Now().UTC().Truncate(time.Second)) + t.Chdir(repo) + resetGitPackageState(t) + + moreThanOne, err := hasTheGitLogHaveMoreThanOneCommits() + if err != nil { + t.Fatalf("expected git log count check to succeed, got %v", err) + } + if moreThanOne { + t.Fatal("expected single-commit repository not to have more than one commit") + } +} + func resetGitPackageState(t *testing.T) { t.Helper() @@ -436,6 +521,13 @@ func resetGitPackageState(t *testing.T) { }) } +func writeFakeGit(t *testing.T, dir string, script string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0755); err != nil { + t.Fatalf("failed to write fake git: %v", err) + } +} + func assertCommitData(t *testing.T, got LocalCommitData, wantSHA string, wantMessage string, wantAuthorDate time.Time) { t.Helper() diff --git a/internal/git/gittest/gittest_test.go b/internal/git/gittest/gittest_test.go new file mode 100644 index 0000000..da6faba --- /dev/null +++ b/internal/git/gittest/gittest_test.go @@ -0,0 +1,82 @@ +package gittest + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNewRepository(t *testing.T) { + repo := NewRepository(t) + + if _, err := os.Stat(filepath.Join(repo.Path, ".git")); err != nil { + t.Fatalf("expected git repository at %s: %v", repo.Path, err) + } + if len(repo.Commits) != 2 { + t.Fatalf("expected 2 commits, got %v", repo.Commits) + } + if len(repo.AuthorDates) != 2 || !repo.AuthorDates[1].After(repo.AuthorDates[0]) { + t.Fatalf("unexpected author dates: %v", repo.AuthorDates) + } + if got := Run(t, repo.Path, "rev-parse", "--abbrev-ref", "HEAD"); got != "main" { + t.Fatalf("branch = %q, want main", got) + } + if got := Run(t, repo.Path, "remote", "get-url", "origin"); got != "https://token@example.com/org/repo.git" { + t.Fatalf("origin URL = %q", got) + } + if got := Run(t, repo.Path, "log", "--format=%s", "-n", "2"); !strings.Contains(got, "second commit") || !strings.Contains(got, "initial commit") { + t.Fatalf("unexpected log output: %q", got) + } + + WriteFile(t, repo.Path, "CHANGELOG.md", "changes\n") + thirdCommit := CommitAll(t, repo.Path, "third commit", repo.AuthorDates[1].Add(time.Minute)) + if thirdCommit == "" || thirdCommit == repo.Commits[1] { + t.Fatalf("unexpected third commit SHA: %q", thirdCommit) + } + if got := Run(t, repo.Path, "log", "-1", "--format=%s"); got != "third commit" { + t.Fatalf("latest commit message = %q, want third commit", got) + } +} + +func TestNewShallowRepository(t *testing.T) { + repo := NewShallowRepository(t) + + if _, err := os.Stat(filepath.Join(repo.Path, ".git")); err != nil { + t.Fatalf("expected git repository at %s: %v", repo.Path, err) + } + if len(repo.Commits) != 3 { + t.Fatalf("expected 3 source commits, got %v", repo.Commits) + } + if len(repo.AuthorDates) != 3 || !repo.AuthorDates[2].After(repo.AuthorDates[1]) { + t.Fatalf("unexpected author dates: %v", repo.AuthorDates) + } + if got := Run(t, repo.Path, "rev-parse", "--is-shallow-repository"); got != "true" { + t.Fatalf("shallow state = %q, want true", got) + } + if got := Run(t, repo.Path, "rev-list", "--count", "HEAD"); got != "1" { + t.Fatalf("visible commit count = %q, want 1", got) + } + if got := Run(t, repo.Path, "log", "-1", "--format=%H"); got != repo.Commits[2] { + t.Fatalf("shallow HEAD = %q, want %q", got, repo.Commits[2]) + } +} + +func TestEnvIncludesDeterministicGitSettings(t *testing.T) { + env := strings.Join(Env(), "\n") + for _, want := range []string{ + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_AUTHOR_NAME=" + AuthorName, + "GIT_AUTHOR_EMAIL=" + AuthorEmail, + "GIT_COMMITTER_NAME=" + CommitterName, + "GIT_COMMITTER_EMAIL=" + CommitterEmail, + "GIT_ALLOW_PROTOCOL=file", + } { + if !strings.Contains(env, want) { + t.Fatalf("Env() missing %q in %q", want, env) + } + } +} diff --git a/internal/planner/discovery_cache_test.go b/internal/planner/discovery_cache_test.go index 313eb49..208f3e5 100644 --- a/internal/planner/discovery_cache_test.go +++ b/internal/planner/discovery_cache_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "os" "path/filepath" "slices" @@ -81,6 +82,72 @@ func TestReadDiscoveryCacheMetadata_LargeFileFindsFinalMetadata(t *testing.T) { } } +func TestReadDiscoveryCacheMetadataErrors(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + _, err := readDiscoveryCacheMetadata(filepath.Join(t.TempDir(), "missing.json")) + if err == nil { + t.Fatal("expected missing file error") + } + }) + + t.Run("invalid json", func(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "tests.json") + if err := os.WriteFile(filePath, []byte("{"), 0644); err != nil { + t.Fatalf("write discovery cache: %v", err) + } + + _, err := readDiscoveryCacheMetadata(filePath) + if err == nil { + t.Fatal("expected invalid metadata JSON error") + } + }) + + t.Run("last record is not metadata", func(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "tests.json") + if err := os.WriteFile(filePath, []byte(`{"module":"rspec","suite":"Cart","name":"adds item"}`+"\n"), 0644); err != nil { + t.Fatalf("write discovery cache: %v", err) + } + + _, err := readDiscoveryCacheMetadata(filePath) + if err == nil || !strings.Contains(err.Error(), "does not contain") { + t.Fatalf("readDiscoveryCacheMetadata() error = %v, want missing metadata", err) + } + }) + + t.Run("empty file", func(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "tests.json") + if err := os.WriteFile(filePath, []byte("\n\n"), 0644); err != nil { + t.Fatalf("write discovery cache: %v", err) + } + + _, err := readDiscoveryCacheMetadata(filePath) + if !errors.Is(err, io.EOF) { + t.Fatalf("readDiscoveryCacheMetadata() error = %v, want EOF", err) + } + }) +} + +func TestParseCachedDiscoveryTestsErrors(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + _, err := parseCachedDiscoveryTests(filepath.Join(t.TempDir(), "missing.json")) + if err == nil { + t.Fatal("expected missing file error") + } + }) + + t.Run("invalid json", func(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "tests.json") + if err := os.WriteFile(filePath, []byte("{"), 0644); err != nil { + t.Fatalf("write discovery cache: %v", err) + } + + _, err := parseCachedDiscoveryTests(filePath) + if err == nil { + t.Fatal("expected invalid cached test JSON error") + } + }) +} + func TestDiscoveryCacheHitUsesCachedTests(t *testing.T) { t.Chdir(t.TempDir()) mockDiscoveryCacheGit(t, mockDiscoveryCacheGitRunner{ @@ -228,6 +295,47 @@ func TestDiscoveryCacheImportsExternalCacheBeforeValidation(t *testing.T) { } } +func TestDiscoveryCacheRestoreRejectsEmptyCache(t *testing.T) { + t.Chdir(t.TempDir()) + mockDiscoveryCacheGit(t, mockDiscoveryCacheGitRunner{}) + + pattern := filepath.Join("spec", "**", "*_spec.rb") + writePlannerDiscoveryCache(t, "base-sha", "ruby", "rspec", pattern, "", nil) + cache := newDiscoveryCache("ruby", &MockFramework{FrameworkName: "rspec", TestPatternValue: pattern}) + + tests, ok := cache.restore() + if ok || tests != nil { + t.Fatalf("expected empty cache restore to fail, got ok=%v tests=%v", ok, tests) + } +} + +func TestDiscoveryCacheStoreSkipsWhenGitHeadFails(t *testing.T) { + t.Chdir(t.TempDir()) + mockDiscoveryCacheGit(t, mockDiscoveryCacheGitRunner{outputErr: errors.New("git failed")}) + + cache := newDiscoveryCache("ruby", &MockFramework{FrameworkName: "rspec", TestPatternValue: "spec/**/*_spec.rb"}) + cache.store() + + if _, err := os.Stat(discovery.TestsFilePath); !os.IsNotExist(err) { + t.Fatalf("expected no discovery cache file to be created, got err=%v", err) + } +} + +func TestDiscoveryCacheStoreHandlesAppendError(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + mockDiscoveryCacheGit(t, mockDiscoveryCacheGitRunner{head: "head-sha"}) + if err := os.MkdirAll(filepath.Dir(filepath.Dir(discovery.TestsFilePath)), 0755); err != nil { + t.Fatalf("failed to create discovery parent path: %v", err) + } + if err := os.WriteFile(filepath.Dir(discovery.TestsFilePath), []byte("not a directory"), 0644); err != nil { + t.Fatalf("failed to create broken discovery path: %v", err) + } + + cache := newDiscoveryCache("ruby", &MockFramework{FrameworkName: "rspec", TestPatternValue: "spec/**/*_spec.rb"}) + cache.store() +} + func TestDiscoveryCacheValidation(t *testing.T) { t.Chdir(t.TempDir()) ciUtils.ResetCwdSubdirPrefixForTesting() @@ -318,6 +426,79 @@ func TestDiscoveryCacheValidation(t *testing.T) { }) } +func TestDiscoveryCacheParseGitDiffNameStatus(t *testing.T) { + output := []byte(strings.Join([]string{ + "M", + "app/models/cart.rb", + "R100", + "spec/old_cart_spec.rb", + "spec/new_cart_spec.rb", + "C75", + "lib/source.rb", + "lib/copied.rb", + "R100", + "spec/dangling_old_spec.rb", + }, "\x00") + "\x00") + + got := discoveryCacheParseGitDiffNameStatus(output) + want := []string{ + "app/models/cart.rb", + "spec/old_cart_spec.rb", + "spec/new_cart_spec.rb", + "lib/source.rb", + "lib/copied.rb", + "spec/dangling_old_spec.rb", + } + if !slices.Equal(got, want) { + t.Fatalf("discoveryCacheParseGitDiffNameStatus() = %#v, want %#v", got, want) + } +} + +func TestDiscoveryCacheParseGitStatusPorcelain(t *testing.T) { + output := []byte(strings.Join([]string{ + " M app/models/cart.rb", + "?? spec/new_cart_spec.rb", + "R spec/old_cart_spec.rb", + "spec/new_cart_spec.rb", + "C lib/source.rb", + "lib/copied.rb", + "bad", + " M ", + }, "\x00") + "\x00") + + got := discoveryCacheParseGitStatusPorcelain(output) + want := []string{ + "app/models/cart.rb", + "spec/new_cart_spec.rb", + "spec/old_cart_spec.rb", + "spec/new_cart_spec.rb", + "lib/source.rb", + "lib/copied.rb", + } + if !slices.Equal(got, want) { + t.Fatalf("discoveryCacheParseGitStatusPorcelain() = %#v, want %#v", got, want) + } +} + +func TestDiscoveryPathMatchesFailsClosedForInvalidPattern(t *testing.T) { + if !discoveryPathMatches("[", "app/models/cart.rb") { + t.Fatal("discoveryPathMatches() should match when the pattern is invalid") + } +} + +func TestDiscoveryCacheDebugGitOutputTruncates(t *testing.T) { + output := []byte(strings.Repeat("a", discoveryCacheDebugGitOutputMaxBytes+10)) + + got := discoveryCacheDebugGitOutput(output) + + if len(got) != discoveryCacheDebugGitOutputMaxBytes { + t.Fatalf("debug output length = %d, want %d", len(got), discoveryCacheDebugGitOutputMaxBytes) + } + if got != strings.Repeat("a", discoveryCacheDebugGitOutputMaxBytes) { + t.Fatal("debug output did not preserve the expected prefix") + } +} + func TestCopyFileSamePathDoesNotTruncate(t *testing.T) { filePath := filepath.Join(t.TempDir(), "tests.json") contents := []byte("cached discovery") diff --git a/internal/planner/report_test.go b/internal/planner/report_test.go index be78184..a2c0e79 100644 --- a/internal/planner/report_test.go +++ b/internal/planner/report_test.go @@ -402,3 +402,88 @@ func TestTestPlanner_SlowestTestSuitesOverallReport(t *testing.T) { t.Fatalf("expected 10th slowest suite to be SuiteC, got %+v", report[9]) } } + +func TestAverageRunnerRuntimeDuration(t *testing.T) { + tests := []struct { + name string + split splitScore + want time.Duration + }{ + {name: "no runners", split: splitScore{parallelRunners: 0, totalRuntime: 1000}, want: 0}, + {name: "no runtime", split: splitScore{parallelRunners: 2, totalRuntime: 0}, want: 0}, + {name: "average", split: splitScore{parallelRunners: 4, totalRuntime: 10_000}, want: 2500 * time.Millisecond}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := averageRunnerRuntimeDuration(tt.split); got != tt.want { + t.Fatalf("averageRunnerRuntimeDuration() = %s, want %s", got, tt.want) + } + }) + } +} + +func TestCompareSeparateRunnerSuiteTiming(t *testing.T) { + suite := func(runner int, sourceFile string, estimated, total time.Duration) testSuiteTimingReport { + return testSuiteTimingReport{ + Runner: runner, + Module: "rspec", + Suite: "Suite", + SourceFile: sourceFile, + EstimatedDuration: estimated, + TotalDuration: total, + } + } + + tests := []struct { + name string + a testSuiteTimingReport + b testSuiteTimingReport + want int + }{ + {name: "runner ascending", a: suite(0, "spec/a_spec.rb", time.Second, time.Second), b: suite(1, "spec/a_spec.rb", time.Second, time.Second), want: -1}, + {name: "runner descending", a: suite(1, "spec/a_spec.rb", time.Second, time.Second), b: suite(0, "spec/a_spec.rb", time.Second, time.Second), want: 1}, + {name: "estimated duration descending", a: suite(0, "spec/a_spec.rb", 2*time.Second, time.Second), b: suite(0, "spec/a_spec.rb", time.Second, time.Second), want: -1}, + {name: "total duration descending", a: suite(0, "spec/a_spec.rb", time.Second, 2*time.Second), b: suite(0, "spec/a_spec.rb", time.Second, time.Second), want: -1}, + {name: "source file tie breaker", a: suite(0, "spec/a_spec.rb", time.Second, time.Second), b: suite(0, "spec/b_spec.rb", time.Second, time.Second), want: -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := compareSeparateRunnerSuiteTiming(tt.a, tt.b); got != tt.want { + t.Fatalf("compareSeparateRunnerSuiteTiming() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestCompareSuiteIdentity(t *testing.T) { + base := testSuiteTimingReport{ + Module: "rspec", + Suite: "CheckoutSuite", + SourceFile: "spec/checkout_spec.rb", + } + + tests := []struct { + name string + a testSuiteTimingReport + b testSuiteTimingReport + want int + }{ + {name: "source file before", a: testSuiteTimingReport{SourceFile: "spec/a_spec.rb", Module: base.Module, Suite: base.Suite}, b: base, want: -1}, + {name: "source file after", a: testSuiteTimingReport{SourceFile: "spec/z_spec.rb", Module: base.Module, Suite: base.Suite}, b: base, want: 1}, + {name: "module before", a: testSuiteTimingReport{SourceFile: base.SourceFile, Module: "minitest", Suite: base.Suite}, b: base, want: -1}, + {name: "module after", a: testSuiteTimingReport{SourceFile: base.SourceFile, Module: "testunit", Suite: base.Suite}, b: base, want: 1}, + {name: "suite before", a: testSuiteTimingReport{SourceFile: base.SourceFile, Module: base.Module, Suite: "CartSuite"}, b: base, want: -1}, + {name: "suite after", a: testSuiteTimingReport{SourceFile: base.SourceFile, Module: base.Module, Suite: "OrderSuite"}, b: base, want: 1}, + {name: "equal", a: base, b: base, want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := compareSuiteIdentity(tt.a, tt.b); got != tt.want { + t.Fatalf("compareSuiteIdentity() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go new file mode 100644 index 0000000..52695b4 --- /dev/null +++ b/internal/platform/platform_test.go @@ -0,0 +1,30 @@ +package platform + +import ( + "strings" + "testing" + + "github.com/DataDog/ddtest/internal/settings" + "github.com/spf13/viper" +) + +func TestDetectPlatformUnsupported(t *testing.T) { + viper.Reset() + t.Cleanup(func() { + viper.Reset() + settings.Init() + }) + t.Setenv("DD_TEST_OPTIMIZATION_RUNNER_PLATFORM", "node") + settings.Init() + + _, err := DetectPlatform() + if err == nil || !strings.Contains(err.Error(), "unsupported platform: node") { + t.Fatalf("DetectPlatform() error = %v, want unsupported platform", err) + } + + detector := &DatadogPlatformDetector{} + _, err = detector.DetectPlatform() + if err == nil || !strings.Contains(err.Error(), "unsupported platform: node") { + t.Fatalf("DatadogPlatformDetector.DetectPlatform() error = %v, want unsupported platform", err) + } +} diff --git a/internal/runmetadata/runmetadata_test.go b/internal/runmetadata/runmetadata_test.go index ce2f39f..794c9e1 100644 --- a/internal/runmetadata/runmetadata_test.go +++ b/internal/runmetadata/runmetadata_test.go @@ -1,6 +1,10 @@ package runmetadata -import "testing" +import ( + "testing" + + "github.com/DataDog/ddtest/internal/constants" +) func TestServiceNameFromRepositoryURL(t *testing.T) { tests := []struct { @@ -53,3 +57,34 @@ func TestResolveServiceNamePrefersDDService(t *testing.T) { t.Errorf("ResolveServiceName() = %q, want %q", got, "custom-service") } } + +func TestNewRunInfo(t *testing.T) { + info := New(map[string]string{ + constants.GitRepositoryURL: "https://github.com/DataDog/ddtest.git", + constants.GitCommitSHA: "abc123", + constants.GitBranch: "main", + }) + + if info.Service != "ddtest" || info.Repository != "https://github.com/DataDog/ddtest.git" || + info.Commit != "abc123" || info.Branch != "main" { + t.Fatalf("New() = %+v", info) + } +} + +func TestRunInfoIsZero(t *testing.T) { + if !(RunInfo{}).IsZero() { + t.Fatal("empty RunInfo should be zero") + } + if (RunInfo{Service: "ddtest"}).IsZero() { + t.Fatal("RunInfo with service should not be zero") + } + if (RunInfo{Repository: "https://github.com/DataDog/ddtest.git"}).IsZero() { + t.Fatal("RunInfo with repository should not be zero") + } + if (RunInfo{Commit: "abc123"}).IsZero() { + t.Fatal("RunInfo with commit should not be zero") + } + if (RunInfo{Branch: "main"}).IsZero() { + t.Fatal("RunInfo with branch should not be zero") + } +} diff --git a/internal/runner/report_test.go b/internal/runner/report_test.go index d2ba751..ba6761f 100644 --- a/internal/runner/report_test.go +++ b/internal/runner/report_test.go @@ -85,3 +85,46 @@ func TestPrintRunReport_Failed(t *testing.T) { t.Errorf("expected failed run report, got:\n%s", report) } } + +func TestFormatPlatform(t *testing.T) { + tests := []struct { + name string + platform string + framework string + want string + }{ + {name: "none", want: "not available"}, + {name: "platform only", platform: "ruby", want: "ruby"}, + {name: "framework only", framework: "rspec", want: "rspec"}, + {name: "both", platform: "python", framework: "pytest", want: "python / pytest"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatPlatform(tt.platform, tt.framework); got != tt.want { + t.Fatalf("formatPlatform() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatCount(t *testing.T) { + tests := []struct { + count int + want string + }{ + {count: 0, want: "0"}, + {count: 999, want: "999"}, + {count: 1000, want: "1,000"}, + {count: 1234567, want: "1,234,567"}, + {count: -1234567, want: "-1,234,567"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := formatCount(tt.count); got != tt.want { + t.Fatalf("formatCount(%d) = %q, want %q", tt.count, got, tt.want) + } + }) + } +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 4bb7f14..a6798d5 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -50,6 +50,12 @@ func (f *fakePlanner) DistributeTestFiles(testFiles []string, parallelRunners in return distributeRoundRobin(testFiles, parallelRunners) } +func TestNew(t *testing.T) { + if runner := New(); runner == nil { + t.Fatal("New() returned nil") + } +} + func TestTestRunner_Run_PlansThroughPublicClientWhenArtifactsMissing(t *testing.T) { withRunnerTestSettings(t) chdirTemp(t) @@ -147,6 +153,277 @@ func TestTestRunner_Run_ReturnsErrorWhenPlanUnavailable(t *testing.T) { } } +func TestTestRunner_Run_ReturnsPlanErrorWhenArtifactsAreMissing(t *testing.T) { + withRunnerTestSettings(t) + chdirTemp(t) + + planErr := errors.New("planning failed") + testPlanner := &fakePlanner{ + planFunc: func(ctx context.Context) error { + return planErr + }, + } + runner := NewWithDependencies(&MockPlatformDetector{}, testPlanner) + + err := runner.Run(context.Background()) + if !errors.Is(err, planErr) { + t.Fatalf("expected Run() to return planning error, got %v", err) + } + if testPlanner.planCalls != 1 { + t.Fatalf("expected Plan() to be called once, got %d", testPlanner.planCalls) + } + if testPlanner.loadCalls != 0 { + t.Fatalf("expected LoadPlan() not to be called after planning failure, got %d", testPlanner.loadCalls) + } +} + +func TestTestRunner_Run_ReturnsStatErrorForBrokenRunnerArtifactsPath(t *testing.T) { + withRunnerTestSettings(t) + chdirTemp(t) + + if err := os.WriteFile(constants.PlanDirectory, []byte("not a directory"), 0644); err != nil { + t.Fatalf("failed to create broken plan path: %v", err) + } + testPlanner := &fakePlanner{} + runner := NewWithDependencies(&MockPlatformDetector{}, testPlanner) + + err := runner.Run(context.Background()) + if err == nil || !strings.Contains(err.Error(), "failed to check parallel runners count") { + t.Fatalf("Run() error = %v, want stat failure", err) + } + if testPlanner.planCalls != 0 { + t.Fatalf("expected Plan() not to be called for stat failure, got %d", testPlanner.planCalls) + } +} + +func TestTestRunner_Run_ReturnsErrorForInvalidParallelRunnerCount(t *testing.T) { + withRunnerTestSettings(t) + chdirTemp(t) + writeRunnerTestFile(t, constants.ParallelRunnersOutputPath, "many") + + testPlanner := &fakePlanner{} + runner := NewWithDependencies(&MockPlatformDetector{}, testPlanner) + + err := runner.Run(context.Background()) + if err == nil || !strings.Contains(err.Error(), "failed to parse parallel runners count") { + t.Fatalf("Run() error = %v, want parse failure", err) + } +} + +func TestTestRunner_Run_ReturnsPlatformDetectionError(t *testing.T) { + withRunnerTestSettings(t) + chdirTemp(t) + writeRunnerTestFile(t, constants.ParallelRunnersOutputPath, "1") + + detectErr := errors.New("no platform") + testPlanner := &fakePlanner{} + runner := NewWithDependencies(&MockPlatformDetector{Err: detectErr}, testPlanner) + + err := runner.Run(context.Background()) + if !errors.Is(err, detectErr) { + t.Fatalf("expected Run() to return platform detection error, got %v", err) + } +} + +func TestTestRunner_Run_ReturnsFrameworkDetectionError(t *testing.T) { + withRunnerTestSettings(t) + chdirTemp(t) + writeRunnerTestFile(t, constants.ParallelRunnersOutputPath, "1") + + frameworkErr := errors.New("no framework") + platform := &MockPlatform{PlatformName: "ruby", FrameworkErr: frameworkErr} + testPlanner := &fakePlanner{} + runner := NewWithDependencies(&MockPlatformDetector{Platform: platform}, testPlanner) + + err := runner.Run(context.Background()) + if !errors.Is(err, frameworkErr) { + t.Fatalf("expected Run() to return framework detection error, got %v", err) + } +} + +func TestTestRunner_Run_WritesReportWhenEnabled(t *testing.T) { + t.Setenv("DD_TEST_OPTIMIZATION_RUNNER_REPORT_ENABLED", "true") + t.Setenv("DD_TEST_OPTIMIZATION_RUNNER_CI_NODE", "-1") + t.Setenv("DD_TEST_OPTIMIZATION_RUNNER_CI_NODE_WORKERS", "1") + viper.Reset() + settings.Init() + t.Cleanup(func() { + viper.Reset() + settings.Init() + }) + chdirTemp(t) + writeRunnerTestFile(t, constants.ParallelRunnersOutputPath, "1") + writeRunnerTestFile(t, constants.TestFilesOutputPath, "spec/report_spec.rb\n") + + framework := &MockFramework{FrameworkName: "rspec"} + platform := &MockPlatform{PlatformName: "ruby", Framework: framework} + testPlanner := &fakePlanner{ + plan: planner.PlanInfo{ + Platform: "ruby", + Framework: "rspec", + }, + } + runner := NewWithDependencies(&MockPlatformDetector{Platform: platform}, testPlanner) + var report strings.Builder + runner.reportWriter = &report + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run() returned error: %v", err) + } + if report.Len() == 0 { + t.Fatal("expected run report output") + } +} + +func TestRunSequentialMissingTestFile(t *testing.T) { + chdirTemp(t) + + executor := newTestExecutor(context.Background(), &MockFramework{}, nil, roundRobinTestPlanner{}) + result := executor.runSequential() + + if result.err == nil || !strings.Contains(result.err.Error(), "failed to read test files") { + t.Fatalf("runSequential() error = %v, want missing test file error", result.err) + } +} + +func TestRunSequentialWithEmptyTestFile(t *testing.T) { + chdirTemp(t) + writeRunnerTestFile(t, constants.TestFilesOutputPath, "\n") + framework := &MockFramework{} + + executor := newTestExecutor(context.Background(), framework, nil, roundRobinTestPlanner{}) + result := executor.runSequential() + + if result.err != nil { + t.Fatalf("runSequential() returned error: %v", result.err) + } + if result.report.TestFilesRun != 0 { + t.Fatalf("expected no test files to run, got %d", result.report.TestFilesRun) + } + if framework.GetRunTestsCallsCount() != 0 { + t.Fatalf("expected no framework calls, got %d", framework.GetRunTestsCallsCount()) + } +} + +func TestRunSequentialReturnsWorkerError(t *testing.T) { + chdirTemp(t) + writeRunnerTestFile(t, constants.TestFilesOutputPath, "spec/failing_spec.rb\n") + framework := &MockFramework{Err: errors.New("worker failed")} + + executor := newTestExecutor(context.Background(), framework, nil, roundRobinTestPlanner{}) + result := executor.runSequential() + + if result.err == nil || !strings.Contains(result.err.Error(), "failed to run tests") { + t.Fatalf("runSequential() error = %v, want worker error", result.err) + } +} + +func TestRunParallelMissingSplitDirectory(t *testing.T) { + chdirTemp(t) + + executor := newTestExecutor(context.Background(), &MockFramework{}, nil, roundRobinTestPlanner{}) + result := executor.runParallel() + + if result.err == nil || !strings.Contains(result.err.Error(), "failed to read tests split directory") { + t.Fatalf("runParallel() error = %v, want missing split directory error", result.err) + } +} + +func TestRunParallelSkipsDirectoriesAndEmptyBatches(t *testing.T) { + chdirTemp(t) + if err := os.MkdirAll(filepath.Join(constants.TestsSplitDir, "subdir"), 0755); err != nil { + t.Fatalf("failed to create split subdir: %v", err) + } + writeRunnerTestFile(t, filepath.Join(constants.TestsSplitDir, "runner-0"), "\n") + framework := &MockFramework{} + + executor := newTestExecutor(context.Background(), framework, nil, roundRobinTestPlanner{}) + result := executor.runParallel() + + if result.err != nil { + t.Fatalf("runParallel() returned error: %v", result.err) + } + if result.report.LocalWorkers != 1 { + t.Fatalf("expected one file worker, got %d", result.report.LocalWorkers) + } + if framework.GetRunTestsCallsCount() != 0 { + t.Fatalf("expected no framework calls for empty batch, got %d", framework.GetRunTestsCallsCount()) + } +} + +func TestRunParallelReturnsWorkerError(t *testing.T) { + chdirTemp(t) + writeRunnerTestFile(t, filepath.Join(constants.TestsSplitDir, "runner-0"), "spec/failing_spec.rb\n") + framework := &MockFramework{Err: errors.New("worker failed")} + + executor := newTestExecutor(context.Background(), framework, nil, roundRobinTestPlanner{}) + result := executor.runParallel() + + if result.err == nil || !strings.Contains(result.err.Error(), "failed to run parallel tests") { + t.Fatalf("runParallel() error = %v, want worker error", result.err) + } +} + +func TestNewCINodeExecutionReportDefaultsWorkers(t *testing.T) { + report := newCINodeExecutionReport(3, 0) + + if report.LocalWorkers != 1 { + t.Fatalf("expected ci-node workers to default to 1, got %d", report.LocalWorkers) + } + if report.CINode != 3 { + t.Fatalf("expected ci-node 3, got %d", report.CINode) + } +} + +func TestLoadCINodeTestFilesMissingFile(t *testing.T) { + chdirTemp(t) + + _, err := loadCINodeTestFiles(7) + if err == nil || !strings.Contains(err.Error(), "runner file for ci-node 7 does not exist") { + t.Fatalf("loadCINodeTestFiles() error = %v, want missing runner file", err) + } +} + +func TestRunCINodeSingleWorkerWithEmptyBatch(t *testing.T) { + framework := &MockFramework{} + executor := newTestExecutor(context.Background(), framework, nil, roundRobinTestPlanner{}) + + err := executor.runCINodeSingleWorker(0, nil) + if err != nil { + t.Fatalf("runCINodeSingleWorker() returned error: %v", err) + } + if framework.GetRunTestsCallsCount() != 0 { + t.Fatalf("expected no framework calls for empty batch, got %d", framework.GetRunTestsCallsCount()) + } +} + +func TestRunCINodeWorkersWithEmptyBatch(t *testing.T) { + framework := &MockFramework{} + executor := newTestExecutor(context.Background(), framework, nil, roundRobinTestPlanner{}) + + err := executor.runCINodeWorkers(0, 2, nil) + if err != nil { + t.Fatalf("runCINodeWorkers() returned error: %v", err) + } + if framework.GetRunTestsCallsCount() != 0 { + t.Fatalf("expected no framework calls for empty batch, got %d", framework.GetRunTestsCallsCount()) + } +} + +func TestRunCINodeWorkerGroupsSkipsEmptyGroups(t *testing.T) { + framework := &MockFramework{} + executor := newTestExecutor(context.Background(), framework, nil, roundRobinTestPlanner{}) + + err := executor.runCINodeWorkerGroups(0, [][]string{{}, {"spec/one_spec.rb"}}) + if err != nil { + t.Fatalf("runCINodeWorkerGroups() returned error: %v", err) + } + calls := framework.GetRunTestsCalls() + if len(calls) != 1 || !slices.Equal(calls[0].TestFiles, []string{"spec/one_spec.rb"}) { + t.Fatalf("expected one non-empty worker call, got %+v", calls) + } +} + func TestTestRunner_Run_CINodeWorkersRunWithoutLoadedWeights(t *testing.T) { withRunnerTestSettings(t) t.Setenv("DD_TEST_OPTIMIZATION_RUNNER_CI_NODE", "0") diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index bfe31ba..536a3c5 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -72,6 +72,14 @@ func TestPhysicalCPUCountFromTopology(t *testing.T) { detectedLogicalCores: 4, expected: 2, }, + { + name: "caps to detected physical cores without SMT topology", + availableLogicalCPUs: 8, + threadsPerCore: 1, + detectedPhysicalCores: 4, + detectedLogicalCores: 4, + expected: 4, + }, { name: "clamps invalid available logical CPU count", availableLogicalCPUs: 0, diff --git a/internal/utils/home_test.go b/internal/utils/home_test.go new file mode 100644 index 0000000..5d588ef --- /dev/null +++ b/internal/utils/home_test.go @@ -0,0 +1,87 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024 Datadog, Inc. + +package utils + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestGetHomeDirFallsBackToSystemLookup(t *testing.T) { + if runtime.GOOS == "windows" || runtime.GOOS == "plan9" { + t.Skip("system lookup fallback test is for Unix-like hosts") + } + + tempDir := t.TempDir() + fakeHome := filepath.Join(tempDir, "home") + t.Setenv("HOME", "") + t.Setenv("PATH", tempDir) + t.Setenv("DDTEST_FAKE_HOME", fakeHome) + + if runtime.GOOS == "darwin" { + writeExecutable(t, filepath.Join(tempDir, "sh"), "#!/bin/sh\nprintf '%s\\n' \"$DDTEST_FAKE_HOME\"\n") + } else { + writeExecutable(t, filepath.Join(tempDir, "getent"), "#!/bin/sh\nprintf 'user:x:1:1::%s:/bin/sh\\n' \"$DDTEST_FAKE_HOME\"\n") + } + + if got := getHomeDir(); got != fakeHome { + t.Fatalf("getHomeDir() = %q, want %q", got, fakeHome) + } +} + +func TestGetHomeDirUsesHomeEnvironment(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if got := getHomeDir(); got != home { + t.Fatalf("getHomeDir() = %q, want %q", got, home) + } +} + +func TestGetHomeDirFallsBackToShell(t *testing.T) { + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" || runtime.GOOS == "plan9" { + t.Skip("shell fallback test is for non-darwin Unix-like hosts") + } + + tempDir := t.TempDir() + fakeHome := filepath.Join(tempDir, "shell-home") + t.Setenv("HOME", "") + t.Setenv("PATH", tempDir) + t.Setenv("DDTEST_FAKE_HOME", fakeHome) + + writeExecutable(t, filepath.Join(tempDir, "getent"), "#!/bin/sh\nexit 1\n") + writeExecutable(t, filepath.Join(tempDir, "sh"), "#!/bin/sh\nprintf '%s\\n' \"$DDTEST_FAKE_HOME\"\n") + + if got := getHomeDir(); got != fakeHome { + t.Fatalf("getHomeDir() = %q, want %q", got, fakeHome) + } +} + +func TestGetHomeDirReturnsEmptyWhenFallbacksFail(t *testing.T) { + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" || runtime.GOOS == "plan9" { + t.Skip("fallback failure test is for non-darwin Unix-like hosts") + } + + tempDir := t.TempDir() + t.Setenv("HOME", "") + t.Setenv("PATH", tempDir) + + writeExecutable(t, filepath.Join(tempDir, "getent"), "#!/bin/sh\nexit 1\n") + writeExecutable(t, filepath.Join(tempDir, "sh"), "#!/bin/sh\nexit 1\n") + + if got := getHomeDir(); got != "" { + t.Fatalf("getHomeDir() = %q, want empty string", got) + } +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("write executable %s: %v", path, err) + } +} diff --git a/internal/utils/path_test.go b/internal/utils/path_test.go index 0701a1f..69eaf14 100644 --- a/internal/utils/path_test.go +++ b/internal/utils/path_test.go @@ -12,6 +12,57 @@ import ( "testing" ) +func TestExpandPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tests := []struct { + name string + path string + want string + }{ + {name: "empty", path: "", want: ""}, + {name: "plain path", path: "spec/models/user_spec.rb", want: "spec/models/user_spec.rb"}, + {name: "other user", path: "~other/spec.rb", want: "~other/spec.rb"}, + {name: "home only", path: "~", want: home}, + {name: "home path", path: "~/spec/models/user_spec.rb", want: filepath.Join(home, "spec/models/user_spec.rb")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ExpandPath(tt.path); got != tt.want { + t.Fatalf("ExpandPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestNormalizePath(t *testing.T) { + tests := []struct { + path string + want string + }{ + {path: "", want: ""}, + {path: ".", want: ""}, + {path: "./spec/../spec/models/user_spec.rb", want: "spec/models/user_spec.rb"}, + {path: filepath.Join("spec", "models", "user_spec.rb"), want: "spec/models/user_spec.rb"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + if got := NormalizePath(tt.path); got != tt.want { + t.Fatalf("NormalizePath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestNormalizePattern(t *testing.T) { + if got := NormalizePattern(" ./spec/**/*_spec.rb "); got != "spec/**/*_spec.rb" { + t.Fatalf("NormalizePattern() = %q", got) + } +} + func TestStripCwdSubdirPrefix_SubdirPrefixMatch_StripsPrefix(t *testing.T) { repoRoot := t.TempDir() initGitRepoInDir(t, repoRoot) diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 512d8ac..b202805 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -253,4 +253,64 @@ func TestCompareStrings(t *testing.T) { if _, err := CompareStrings("1.2", "1.a"); err == nil { t.Fatal("expected invalid version comparison to fail") } + + if _, err := CompareStrings("1.a", "1.2"); err == nil { + t.Fatal("expected invalid first version to fail") + } +} + +func TestMustParsePanicsForInvalidVersion(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected MustParse to panic") + } + }() + + _ = MustParse("not-a-version") +} + +func TestStringBuildsVersionWhenRawValueIsUnavailable(t *testing.T) { + tests := []struct { + name string + v Version + want string + }{ + { + name: "empty", + v: Version{}, + want: "", + }, + { + name: "components only", + v: Version{components: []int{1, 2, 3}}, + want: "1.2.3", + }, + { + name: "pre-release and build metadata", + v: Version{ + components: []int{2, 0, 0}, + preRelease: "rc.1", + buildMeta: "build.7", + }, + want: "2.0.0-rc.1+build.7", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.v.String(); got != tt.want { + t.Fatalf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestComponentsReturnsDefensiveCopy(t *testing.T) { + v := Version{components: []int{1, 2, 3}} + components := v.Components() + components[0] = 99 + + if got := v.Components()[0]; got != 1 { + t.Fatalf("Components() exposed internal slice, got first component %d", got) + } } diff --git a/main.go b/main.go index 7481842..5fa07b6 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,13 @@ import ( "github.com/DataDog/ddtest/internal/cmd" ) +var executeCommand = cmd.Execute + func main() { + os.Exit(run(executeCommand)) +} + +func run(execute func() error) int { // it doesn't make sense to use ddtest without test optimization mode, // so we just enable it _ = os.Setenv("DD_CIVISIBILITY_ENABLED", "1") @@ -16,8 +22,9 @@ func main() { // warning that is polluting the logs when Datadog Agent is not available _ = os.Setenv("DD_TELEMETRY_DEPENDENCY_COLLECTION_ENABLED", "0") - if err := cmd.Execute(); err != nil { + if err := execute(); err != nil { slog.Error("FAILURE", "error", err) - os.Exit(1) + return 1 } + return 0 } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..917c871 --- /dev/null +++ b/main_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "errors" + "os" + "testing" +) + +func TestRunSuccess(t *testing.T) { + t.Setenv("DD_CIVISIBILITY_ENABLED", "") + t.Setenv("DD_TELEMETRY_DEPENDENCY_COLLECTION_ENABLED", "") + + calls := 0 + exitCode := run(func() error { + calls++ + return nil + }) + + if exitCode != 0 { + t.Fatalf("run() exit code = %d, want 0", exitCode) + } + if calls != 1 { + t.Fatalf("expected execute to be called once, got %d", calls) + } + if got := os.Getenv("DD_CIVISIBILITY_ENABLED"); got != "1" { + t.Fatalf("DD_CIVISIBILITY_ENABLED = %q, want 1", got) + } + if got := os.Getenv("DD_TELEMETRY_DEPENDENCY_COLLECTION_ENABLED"); got != "0" { + t.Fatalf("DD_TELEMETRY_DEPENDENCY_COLLECTION_ENABLED = %q, want 0", got) + } +} + +func TestRunFailure(t *testing.T) { + exitCode := run(func() error { + return errors.New("boom") + }) + + if exitCode != 1 { + t.Fatalf("run() exit code = %d, want 1", exitCode) + } +}