diff --git a/civisibility/civisibility.go b/civisibility/civisibility.go index 510a6b8..c58293a 100644 --- a/civisibility/civisibility.go +++ b/civisibility/civisibility.go @@ -34,8 +34,7 @@ const ( var DefaultTraceAgentUDSPath = "/var/run/datadog/apm.socket" var ( - status atomic.Int32 - isTestMode atomic.Bool + status atomic.Int32 ) func GetState() State { @@ -48,14 +47,6 @@ func SetState(state State) { status.Store(int32(state)) } -func SetTestMode() { - isTestMode.Store(true) -} - -func IsTestMode() bool { - return isTestMode.Load() -} - // AgentURLFromEnv resolves the URL for the trace agent based on // the default host/port and UDS path, and via standard environment variables. // AgentURLFromEnv has the following priority order: diff --git a/civisibility/constants/env.go b/civisibility/constants/env.go index 869e476..477845f 100644 --- a/civisibility/constants/env.go +++ b/civisibility/constants/env.go @@ -6,56 +6,37 @@ package constants const ( - // CIVisibilityEnabledEnvironmentVariable indicates if CI Visibility mode is enabled. - // This environment variable should be set to "1" or "true" to enable CI Visibility mode, which activates tracing and other - // features related to CI Visibility in the Datadog platform. - CIVisibilityEnabledEnvironmentVariable = "DD_CIVISIBILITY_ENABLED" + // TestOptimizationEnabledEnvironmentVariable indicates if Test Optimization mode is enabled. + // This environment variable should be set to "1" or "true" to enable Test Optimization mode. + TestOptimizationEnabledEnvironmentVariable = "DD_CIVISIBILITY_ENABLED" - // CIVisibilityAgentlessEnabledEnvironmentVariable indicates if CI Visibility agentless mode is enabled. - // This environment variable should be set to "1" or "true" to enable agentless mode for CI Visibility, where traces + // TestOptimizationAgentlessEnabledEnvironmentVariable indicates if Test Optimization agentless mode is enabled. + // This environment variable should be set to "1" or "true" to enable agentless mode for Test Optimization, where traces // are sent directly to Datadog without using a local agent. - CIVisibilityAgentlessEnabledEnvironmentVariable = "DD_CIVISIBILITY_AGENTLESS_ENABLED" + TestOptimizationAgentlessEnabledEnvironmentVariable = "DD_CIVISIBILITY_AGENTLESS_ENABLED" - // CIVisibilityAgentlessURLEnvironmentVariable forces the agentless URL to a custom one. - // This environment variable allows you to specify a custom URL for the agentless intake in CI Visibility mode. - CIVisibilityAgentlessURLEnvironmentVariable = "DD_CIVISIBILITY_AGENTLESS_URL" + // TestOptimizationAgentlessURLEnvironmentVariable forces the agentless URL to a custom one. + // This environment variable allows you to specify a custom URL for the agentless intake in Test Optimization mode. + TestOptimizationAgentlessURLEnvironmentVariable = "DD_CIVISIBILITY_AGENTLESS_URL" // APIKeyEnvironmentVariable indicates the API key to be used for agentless intake. // This environment variable should be set to your Datadog API key, allowing the agentless mode to authenticate and // send data directly to the Datadog platform. APIKeyEnvironmentVariable = "DD_API_KEY" - // CIVisibilityTestSessionNameEnvironmentVariable indicate the test session name to be used on CI Visibility payloads - CIVisibilityTestSessionNameEnvironmentVariable = "DD_TEST_SESSION_NAME" + // TestOptimizationTestSessionNameEnvironmentVariable indicates the test session name to be used on Test Optimization payloads. + TestOptimizationTestSessionNameEnvironmentVariable = "DD_TEST_SESSION_NAME" - // CIVisibilityFlakyRetryEnabledEnvironmentVariable kill-switch that allows to explicitly disable retries even if the remote setting is enabled. + // TestOptimizationFlakyRetryEnabledEnvironmentVariable kill-switch that allows to explicitly disable retries even if the remote setting is enabled. // This environment variable should be set to "0" or "false" to disable the flaky retry feature. - CIVisibilityFlakyRetryEnabledEnvironmentVariable = "DD_CIVISIBILITY_FLAKY_RETRY_ENABLED" + TestOptimizationFlakyRetryEnabledEnvironmentVariable = "DD_CIVISIBILITY_FLAKY_RETRY_ENABLED" - // CIVisibilityFlakyRetryCountEnvironmentVariable indicates the maximum number of retry attempts for a single test case. - CIVisibilityFlakyRetryCountEnvironmentVariable = "DD_CIVISIBILITY_FLAKY_RETRY_COUNT" + // TestOptimizationManagementEnabledEnvironmentVariable indicates if the test management feature is enabled. + TestOptimizationManagementEnabledEnvironmentVariable = "DD_TEST_MANAGEMENT_ENABLED" - // CIVisibilityTotalFlakyRetryCountEnvironmentVariable indicates the maximum number of retry attempts for the entire session. - CIVisibilityTotalFlakyRetryCountEnvironmentVariable = "DD_CIVISIBILITY_TOTAL_FLAKY_RETRY_COUNT" + // TestOptimizationAttemptToFixRetriesEnvironmentVariable indicates the maximum number of retries for the attempt to fix a test. + TestOptimizationAttemptToFixRetriesEnvironmentVariable = "DD_TEST_MANAGEMENT_ATTEMPT_TO_FIX_RETRIES" - // CIVisibilityTestManagementEnabledEnvironmentVariable indicates if the test management feature is enabled. - CIVisibilityTestManagementEnabledEnvironmentVariable = "DD_TEST_MANAGEMENT_ENABLED" - - // CIVisibilityTestManagementAttemptToFixRetriesEnvironmentVariable indicates the maximum number of retries for the attempt to fix a test. - CIVisibilityTestManagementAttemptToFixRetriesEnvironmentVariable = "DD_TEST_MANAGEMENT_ATTEMPT_TO_FIX_RETRIES" - - // CIVisibilityAutoInstrumentationProviderEnvironmentVariable indicates that the auto-instrumentation script was used. - CIVisibilityAutoInstrumentationProviderEnvironmentVariable = "DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER" - - // CIVisibilityEnvironmentDataFilePath is the environment variable that holds the path to the file containing the environmental data. - CIVisibilityEnvironmentDataFilePath = "DD_TEST_OPTIMIZATION_ENV_DATA_FILE" - - // CIVisibilityImpactedTestsDetectionEnabled indicates if the impacted tests detection feature is enabled. - CIVisibilityImpactedTestsDetectionEnabled = "DD_CIVISIBILITY_IMPACTED_TESTS_DETECTION_ENABLED" - - // CIVisibilityInternalParallelEarlyFlakeDetectionEnabled indicates if the internal parallel early flake detection feature is enabled. - CIVisibilityInternalParallelEarlyFlakeDetectionEnabled = "DD_CIVISIBILITY_INTERNAL_PARALLEL_EARLY_FLAKE_DETECTION_ENABLED" - - // CIVisibilitySubtestFeaturesEnabled indicates if subtest-specific management and retry features are enabled. - CIVisibilitySubtestFeaturesEnabled = "DD_CIVISIBILITY_SUBTEST_FEATURES_ENABLED" + // TestOptimizationEnvironmentDataFilePath is the environment variable that holds the path to the file containing the environmental data. + TestOptimizationEnvironmentDataFilePath = "DD_TEST_OPTIMIZATION_ENV_DATA_FILE" ) diff --git a/civisibility/constants/tags.go b/civisibility/constants/tags.go index ba1499a..742827f 100644 --- a/civisibility/constants/tags.go +++ b/civisibility/constants/tags.go @@ -10,10 +10,6 @@ const ( // This tag helps in identifying the source of the trace data. Origin = "_dd.origin" - // LogicalCPUCores is a tag used to indicate the number of logical cpu cores - // This tag is used by the backend to perform calculations - LogicalCPUCores = "_dd.host.vcpu_count" - // CIAppTestOrigin defines the CIApp test origin value. // This constant is used to tag traces that originate from CIApp test executions. CIAppTestOrigin = "ciapp-test" diff --git a/civisibility/constants/test_tags.go b/civisibility/constants/test_tags.go index cd8c40d..955597b 100644 --- a/civisibility/constants/test_tags.go +++ b/civisibility/constants/test_tags.go @@ -50,10 +50,6 @@ const ( // This constant is used to tag traces with the line number in the source file where the test ends. TestSourceEndLine = "test.source.end" - // TestCodeOwners indicates the test code owners. - // This constant is used to tag traces with the code owners responsible for the test. - TestCodeOwners = "test.codeowners" - // TestCommand indicates the test command. // This constant is used to tag traces with the command used to execute the test. TestCommand = "test.command" diff --git a/civisibility/integrations/civisibility.go b/civisibility/integrations/civisibility.go deleted file mode 100644 index dff7edd..0000000 --- a/civisibility/integrations/civisibility.go +++ /dev/null @@ -1,111 +0,0 @@ -// 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 integrations - -import ( - "log/slog" - "os" - "os/signal" - "sync" - "syscall" - - "github.com/DataDog/ddtest/civisibility" - "github.com/DataDog/ddtest/civisibility/constants" - "github.com/DataDog/ddtest/internal/utils" - "github.com/DataDog/ddtest/stableconfig" -) - -// ciVisibilityCloseAction defines an action to be executed when CI visibility is closing. -type ciVisibilityCloseAction func() - -var ( - // ciVisibilityInitializationOnce ensures we initialize CI visibility only once. - ciVisibilityInitializationOnce sync.Once - - // closeActions holds CI visibility close actions. - closeActions []ciVisibilityCloseAction - - // closeActionsMutex synchronizes access to closeActions. - closeActionsMutex sync.Mutex -) - -// EnsureCiVisibilityInitialization initializes CI visibility support if it hasn't been initialized already. -func EnsureCiVisibilityInitialization() { - internalCiVisibilityInitialization() -} - -func internalCiVisibilityInitialization() { - ciVisibilityInitializationOnce.Do(func() { - civisibility.SetState(civisibility.StateInitializing) - defer civisibility.SetState(civisibility.StateInitialized) - - slog.SetLogLoggerLevel(slog.LevelInfo) - // check the debug flag to enable debug logs. The tracer initialization happens - // after the CI Visibility initialization so we need to handle this flag ourselves - if enabled, _ := stableconfig.Bool("DD_TRACE_DEBUG", false); enabled { - slog.SetLogLoggerLevel(slog.LevelDebug) - } - - slog.Debug("civisibility: initializing") - - // Since calling this method indicates we are in CI Visibility mode, set the environment variable. - _ = os.Setenv(constants.CIVisibilityEnabledEnvironmentVariable, "1") - - // Avoid sampling rate warning (in CI Visibility mode we send all data) - _ = os.Setenv("DD_TRACE_SAMPLE_RATE", "1") - - // Preload the CodeOwner file - _ = utils.GetCodeOwners() - - // Preload all CI, Git, and CodeOwners tags. - ciTags := utils.GetCITags() - _ = utils.GetCIMetrics() - - if _, ok := ciTags[constants.GitRepositoryURL]; !ok { - slog.Debug("civisibility: git repository URL tag was not detected") - } - - // Initializing additional features asynchronously. - go func() { ensureAdditionalFeaturesInitialization(autoDetectServiceName) }() - - // Handle SIGINT and SIGTERM signals to ensure close actions run before exiting. - signals := make(chan os.Signal, 1) - signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-signals - ExitCiVisibility() - os.Exit(1) - }() - }) -} - -// PushCiVisibilityCloseAction adds a close action to be executed when CI visibility exits. -func PushCiVisibilityCloseAction(action ciVisibilityCloseAction) { - closeActionsMutex.Lock() - defer closeActionsMutex.Unlock() - closeActions = append([]ciVisibilityCloseAction{action}, closeActions...) -} - -// ExitCiVisibility executes all registered close actions. -func ExitCiVisibility() { - if civisibility.GetState() != civisibility.StateInitialized { - slog.Debug("civisibility: already closed or not initialized") - return - } - - civisibility.SetState(civisibility.StateExiting) - defer civisibility.SetState(civisibility.StateExited) - slog.Debug("civisibility: exiting") - closeActionsMutex.Lock() - defer closeActionsMutex.Unlock() - defer func() { - closeActions = []ciVisibilityCloseAction{} - slog.Debug("civisibility: done.") - }() - for _, v := range closeActions { - v() - } -} diff --git a/civisibility/integrations/civisibility_features.go b/civisibility/integrations/civisibility_features.go deleted file mode 100644 index de69e80..0000000 --- a/civisibility/integrations/civisibility_features.go +++ /dev/null @@ -1,512 +0,0 @@ -// 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 integrations - -import ( - "encoding/json" - "fmt" - "log/slog" - "os" - "slices" - "sync" - "time" - - "github.com/DataDog/ddtest/civisibility" - "github.com/DataDog/ddtest/civisibility/constants" - "github.com/DataDog/ddtest/internal/utils" - "github.com/DataDog/ddtest/internal/utils/impactedtests" - "github.com/DataDog/ddtest/internal/utils/net" -) - -const ( - DefaultFlakyRetryCount = 5 - DefaultFlakyTotalRetryCount = 1_000 - - // autoDetectServiceName preserves the existing public getter behavior: - // an empty service name makes net.NewClientWithServiceName derive the - // service from DD_SERVICE or repository tags. The ensure helpers are - // sync.Once-guarded, so raw-response getters do not repeat initialization - // or backend requests beyond the matching processed getter path. - autoDetectServiceName = "" -) - -type ( - // FlakyRetriesSetting struct to hold all the settings related to flaky tests retries - FlakyRetriesSetting struct { - RetryCount int64 - TotalRetryCount int64 - RemainingTotalRetryCount int64 - } - - searchCommitsResponse struct { - LocalCommits []string - RemoteCommits []string - IsOk bool - } -) - -var ( - // settingsInitializationOnce ensures we do the settings initialization just once - settingsInitializationOnce sync.Once - - // additionalFeaturesInitializationOnce ensures we do the additional features initialization just once - additionalFeaturesInitializationOnce sync.Once - - // ciVisibilityRapidClient contains the http rapid client to do CI Visibility queries and upload to the rapid backend - ciVisibilityClient net.Client - - // ciVisibilitySettings contains the CI Visibility settings for this session - ciVisibilitySettings net.SettingsResponseData - - // ciVisibilityKnownTests contains the CI Visibility Known Tests data for this session - ciVisibilityKnownTests net.KnownTestsResponseData - - // ciVisibilityFlakyRetriesSettings contains the CI Visibility Flaky Retries settings for this session - ciVisibilityFlakyRetriesSettings FlakyRetriesSetting - - // ciVisibilitySkippables contains the CI Visibility skippable tests for this session - ciVisibilitySkippables net.SkippableTests - - // ciVisibilityTestManagementTests contains the CI Visibility test management tests for this session - ciVisibilityTestManagementTests net.TestManagementTestsResponseDataModules - - // ciVisibilityImpactedTestsAnalyzer contains the CI Visibility impacted tests analyzer - ciVisibilityImpactedTestsAnalyzer *impactedtests.ImpactedTestAnalyzer -) - -func ensureSettingsInitialization(serviceName string) { - settingsInitializationOnce.Do(func() { - slog.Debug("civisibility: initializing settings") - defer slog.Debug("civisibility: settings initialization complete") - - // Create the CI Visibility client - ciVisibilityClient = net.NewClientWithServiceName(serviceName) - if ciVisibilityClient == nil { - slog.Error("civisibility: error getting the ci visibility http client") - return - } - - // upload the repository changes - var uploadChannel = make(chan struct{}) - go func() { - defer func() { - close(uploadChannel) - }() - bytes, err := uploadRepositoryChanges() - if err != nil { - slog.Error("civisibility: error uploading repository changes:", "error", err.Error()) - } else { - slog.Debug("civisibility: uploaded bytes in pack files", "count", bytes) - } - }() - - //Wait for the upload with timeout func - waitUpload := func(timeout time.Duration) bool { - select { - case <-uploadChannel: - // All ok, upload succeeded - return true - case <-time.After(timeout): - slog.Warn("civisibility: timeout waiting for upload repository changes") - return false - } - } - // returns a closure suitable for PushCiVisibilityCloseAction that will wait - // for the upload to complete (or time out) using the given timeout. - waitUploadFactory := func(timeout time.Duration) func() { - return func() { waitUpload(timeout) } - } - - // Get the CI Visibility settings payload for this test session - ciSettings, err := ciVisibilityClient.GetSettings() - if err != nil || ciSettings == nil { - slog.Error("civisibility: error getting CI visibility settings", "error", err.Error()) - slog.Debug("civisibility: no need to wait for the git upload to finish") - // Enqueue a close action to wait for the upload to finish before finishing the process - PushCiVisibilityCloseAction(waitUploadFactory(time.Minute)) - return - } - - // check if we need to wait for the upload to finish and repeat the settings request or we can just continue - if ciSettings.RequireGit { - slog.Debug("civisibility: waiting for the git upload to finish and repeating the settings request") - if !waitUpload(1 * time.Minute) { - slog.Error("civisibility: error getting CI visibility settings due to timeout") - return - } - ciSettings, err = ciVisibilityClient.GetSettings() - if err != nil { - slog.Error("civisibility: error getting CI visibility settings", "error", err.Error()) - return - } - } - - // check if we need to disable EFD because known tests is not enabled - if !ciSettings.KnownTestsEnabled { - // "known_tests_enabled" parameter works as a kill-switch for EFD, so if “known_tests_enabled” is false it - // will disable EFD even if “early_flake_detection.enabled” is set to true (which should not happen normally, - // the backend should disable both of them in that case) - ciSettings.EarlyFlakeDetection.Enabled = false - } - - // check if flaky test retries is disabled by env-vars - if ciSettings.FlakyTestRetriesEnabled && !civisibility.BoolEnv(constants.CIVisibilityFlakyRetryEnabledEnvironmentVariable, true) { - slog.Warn("civisibility: flaky test retries was disabled by the environment variable") - ciSettings.FlakyTestRetriesEnabled = false - } - - // check if impacted tests is disabled by env-vars - if ciSettings.ImpactedTestsEnabled && !civisibility.BoolEnv(constants.CIVisibilityImpactedTestsDetectionEnabled, true) { - slog.Warn("civisibility: impacted tests was disabled by the environment variable") - ciSettings.ImpactedTestsEnabled = false - } - - // check if test management is disabled by env-vars - if ciSettings.TestManagement.Enabled && !civisibility.BoolEnv(constants.CIVisibilityTestManagementEnabledEnvironmentVariable, true) { - slog.Warn("civisibility: test management was disabled by the environment variable") - ciSettings.TestManagement.Enabled = false - } - - // overwrite the test management attempt to fix retries with the env var if set - testManagementAttemptToFixRetriesEnv := civisibility.IntEnv(constants.CIVisibilityTestManagementAttemptToFixRetriesEnvironmentVariable, -1) - if testManagementAttemptToFixRetriesEnv != -1 { - ciSettings.TestManagement.AttemptToFixRetries = testManagementAttemptToFixRetriesEnv - } - - // determine if subtest-specific features are enabled via environment variables - subtestFeaturesEnabled := civisibility.BoolEnv(constants.CIVisibilitySubtestFeaturesEnabled, true) - if !subtestFeaturesEnabled { - slog.Debug("civisibility: subtest test management features disabled by environment variable") - } - ciSettings.SubtestFeaturesEnabled = subtestFeaturesEnabled - - // check if we need to wait for the upload to finish before continuing - if ciSettings.ImpactedTestsEnabled { - slog.Debug("civisibility: impacted tests is enabled we need to wait for the upload to finish (for the unshallow process)") - waitUpload(30 * time.Second) - } else { - slog.Debug("civisibility: no need to wait for the git upload to finish") - // Enqueue a close action to wait for the upload to finish before finishing the process - PushCiVisibilityCloseAction(waitUploadFactory(time.Minute)) - } - - // set the ciVisibilitySettings with the settings from the backend - ciVisibilitySettings = *ciSettings - }) -} - -// ensureAdditionalFeaturesInitialization initialize all the additional features -func ensureAdditionalFeaturesInitialization(_ string) { - additionalFeaturesInitializationOnce.Do(func() { - slog.Debug("civisibility: initializing additional features") - defer slog.Debug("civisibility: additional features initialization complete") - - // get a copy of the settings instance - currentSettings := *GetSettings() - - // if there's no ciVisibilityClient then we don't need to do anything - if ciVisibilityClient == nil { - return - } - - // map to store the additional tags we want to add (Capabilities and CorrelationId) - additionalTags := make(map[string]string) - defer func() { - if len(additionalTags) > 0 { - slog.Debug("civisibility: adding additional tags", "tags", additionalTags) //nolint:gocritic // Map structure logging for debugging - utils.AddCITagsMap(additionalTags) - } - }() - - // set the default values for the additional tags - additionalTags[constants.LibraryCapabilitiesEarlyFlakeDetection] = "1" - additionalTags[constants.LibraryCapabilitiesAutoTestRetries] = "1" - additionalTags[constants.LibraryCapabilitiesTestImpactAnalysis] = "1" - additionalTags[constants.LibraryCapabilitiesTestManagementQuarantine] = "1" - additionalTags[constants.LibraryCapabilitiesTestManagementDisable] = "1" - additionalTags[constants.LibraryCapabilitiesTestManagementAttemptToFix] = "5" - - // mutex to protect the additional tags map - var aTagsMutex sync.Mutex - // function to set additional tags locking with the mutex - setAdditionalTags := func(key string, value string) { - aTagsMutex.Lock() - defer aTagsMutex.Unlock() - additionalTags[key] = value - } - - // if flaky test retries is enabled then let's load the flaky retries settings - if currentSettings.FlakyTestRetriesEnabled { - totalRetriesCount := (int64)(civisibility.IntEnv(constants.CIVisibilityTotalFlakyRetryCountEnvironmentVariable, DefaultFlakyTotalRetryCount)) - retryCount := (int64)(civisibility.IntEnv(constants.CIVisibilityFlakyRetryCountEnvironmentVariable, DefaultFlakyRetryCount)) - ciVisibilityFlakyRetriesSettings = FlakyRetriesSetting{ - RetryCount: retryCount, - TotalRetryCount: totalRetriesCount, - RemainingTotalRetryCount: totalRetriesCount, - } - slog.Debug("civisibility: automatic test retries enabled", "retryCount", retryCount, "totalRetriesCount", totalRetriesCount) - } - - // wait group to wait for all the additional features to be loaded - var wg sync.WaitGroup - - // if early flake detection is enabled then we run the known tests request - if currentSettings.KnownTestsEnabled { - wg.Add(1) - go func() { - defer wg.Done() - ciEfdData, err := ciVisibilityClient.GetKnownTests() - if err != nil { - slog.Error("civisibility: error getting CI visibility known tests data", "err", err.Error()) - } else if ciEfdData != nil { - ciVisibilityKnownTests = *ciEfdData - slog.Debug("civisibility: known tests data loaded.") - } - }() - } - - // if ITR is enabled then we do the skippable tests request - if currentSettings.TestsSkipping { - wg.Add(1) - go func() { - defer wg.Done() - // get the skippable tests - correlationID, skippableTests, err := ciVisibilityClient.GetSkippableTests() - if err != nil { - slog.Error("civisibility: error getting CI visibility skippable tests", "err", err.Error()) - } else if skippableTests != nil { - slog.Debug("civisibility: skippable tests loaded", "count", len(skippableTests)) - setAdditionalTags(constants.ItrCorrelationIDTag, correlationID) - ciVisibilitySkippables = skippableTests - } - }() - } - - // if test management is enabled then we do the test management request - if currentSettings.TestManagement.Enabled { - wg.Add(1) - go func() { - defer wg.Done() - testManagementTests, err := ciVisibilityClient.GetTestManagementTests() - if err != nil { - slog.Error("civisibility: error getting CI visibility test management tests", "err", err.Error()) - } else if testManagementTests != nil { - ciVisibilityTestManagementTests = *testManagementTests - slog.Debug("civisibility: test management loaded", "attemptToFixRetries", currentSettings.TestManagement.AttemptToFixRetries) - } - }() - } - - // if wheter the settings response or the env var is true we load the impacted tests analyzer - if currentSettings.ImpactedTestsEnabled { - wg.Add(1) - go func() { - defer wg.Done() - iTests, err := impactedtests.NewImpactedTestAnalyzer() - if err != nil { - slog.Error("civisibility: error getting CI visibility impacted tests analyzer", "err", err.Error()) - } else { - ciVisibilityImpactedTestsAnalyzer = iTests - slog.Debug("civisibility: impacted tests analyzer loaded") - } - }() - } - - // wait for all the additional features to be loaded - wg.Wait() - }) -} - -// GetSettings gets the settings from the backend settings endpoint -func GetSettings() *net.SettingsResponseData { - // call to ensure the settings features initialization is completed - ensureSettingsInitialization(autoDetectServiceName) - return &ciVisibilitySettings -} - -func GetSettingsRawResponse() json.RawMessage { - ensureSettingsInitialization(autoDetectServiceName) - if ciVisibilityClient == nil { - return nil - } - return ciVisibilityClient.GetSettingsRawResponse() -} - -// GetKnownTests gets the known tests data -func GetKnownTests() *net.KnownTestsResponseData { - // call to ensure the additional features initialization is completed - ensureAdditionalFeaturesInitialization(autoDetectServiceName) - return &ciVisibilityKnownTests -} - -func GetKnownTestsRawResponse() json.RawMessage { - ensureAdditionalFeaturesInitialization(autoDetectServiceName) - if ciVisibilityClient == nil { - return nil - } - return ciVisibilityClient.GetKnownTestsRawResponse() -} - -// GetTestManagementTestsData gets the test management tests data -func GetTestManagementTestsData() *net.TestManagementTestsResponseDataModules { - // call to ensure the additional features initialization is completed - ensureAdditionalFeaturesInitialization(autoDetectServiceName) - return &ciVisibilityTestManagementTests -} - -func GetTestManagementTestsRawResponse() json.RawMessage { - ensureAdditionalFeaturesInitialization(autoDetectServiceName) - if ciVisibilityClient == nil { - return nil - } - return ciVisibilityClient.GetTestManagementTestsRawResponse() -} - -// GetFlakyRetriesSettings gets the flaky retries settings -func GetFlakyRetriesSettings() *FlakyRetriesSetting { - // call to ensure the additional features initialization is completed - ensureAdditionalFeaturesInitialization(autoDetectServiceName) - return &ciVisibilityFlakyRetriesSettings -} - -// GetSkippableTests gets the skippable tests from the backend -func GetSkippableTests() net.SkippableTests { - // call to ensure the additional features initialization is completed - ensureAdditionalFeaturesInitialization(autoDetectServiceName) - return ciVisibilitySkippables -} - -func GetSkippableTestsRawResponse() json.RawMessage { - ensureAdditionalFeaturesInitialization(autoDetectServiceName) - if ciVisibilityClient == nil { - return nil - } - return ciVisibilityClient.GetSkippableTestsRawResponse() -} - -// GetImpactedTestsAnalyzer gets the impacted tests analyzer -func GetImpactedTestsAnalyzer() *impactedtests.ImpactedTestAnalyzer { - // call to ensure the additional features initialization is completed - ensureAdditionalFeaturesInitialization(autoDetectServiceName) - return ciVisibilityImpactedTestsAnalyzer -} - -func uploadRepositoryChanges() (bytes int64, err error) { - // get the search commits response - initialCommitData, err := getSearchCommits() - if err != nil { - return 0, fmt.Errorf("civisibility: error getting the search commits response: %s", err) - } - - // let's check if we could retrieve commit data - if !initialCommitData.IsOk { - return 0, nil - } - - // if there are no commits then we don't need to do anything - if !initialCommitData.hasCommits() { - slog.Debug("civisibility: no commits found") - return 0, nil - } - - // If: - // - we have local commits - // - there are not missing commits (backend has the total number of local commits already) - // then we are good to go with it, we don't need to check if we need to unshallow or anything and just go with that. - if initialCommitData.hasCommits() && len(initialCommitData.missingCommits()) == 0 { - slog.Debug("civisibility: initial commit data has everything already, we don't need to upload anything") - return 0, nil - } - - // there's some missing commits on the backend, first we need to check if we need to unshallow before sending anything... - hasBeenUnshallowed, err := utils.UnshallowGitRepository() - if err != nil || !hasBeenUnshallowed { - if err != nil { - slog.Warn(err.Error()) - } - // if unshallowing the repository failed or if there's nothing to unshallow then we try to upload the packfiles from - // the initial commit data - - // send the pack file with the missing commits - return sendObjectsPackFile(initialCommitData.LocalCommits[0], initialCommitData.missingCommits(), initialCommitData.RemoteCommits) - } - - // after unshallowing the repository we need to get the search commits to calculate the missing commits again - commitsData, err := getSearchCommits() - if err != nil { - return 0, fmt.Errorf("civisibility: error getting the search commits response: %s", err) - } - - // let's check if we could retrieve commit data - if !commitsData.IsOk { - return 0, nil - } - - // send the pack file with the missing commits - return sendObjectsPackFile(commitsData.LocalCommits[0], commitsData.missingCommits(), commitsData.RemoteCommits) -} - -// getSearchCommits gets the search commits response with the local and remote commits -func getSearchCommits() (*searchCommitsResponse, error) { - localCommits := utils.GetLastLocalGitCommitShas() - if len(localCommits) == 0 { - slog.Debug("civisibility: no local commits found") - return newSearchCommitsResponse(nil, nil, false), nil - } - - slog.Debug("civisibility: local commits found", "count", len(localCommits)) - remoteCommits, err := ciVisibilityClient.GetCommits(localCommits) - return newSearchCommitsResponse(localCommits, remoteCommits, true), err -} - -// newSearchCommitsResponse creates a new search commits response -func newSearchCommitsResponse(localCommits []string, remoteCommits []string, isOk bool) *searchCommitsResponse { - return &searchCommitsResponse{ - LocalCommits: localCommits, - RemoteCommits: remoteCommits, - IsOk: isOk, - } -} - -// hasCommits returns true if the search commits response has commits -func (r *searchCommitsResponse) hasCommits() bool { - return len(r.LocalCommits) > 0 -} - -// missingCommits returns the missing commits between the local and remote commits -func (r *searchCommitsResponse) missingCommits() []string { - var missingCommits []string - for _, localCommit := range r.LocalCommits { - if !slices.Contains(r.RemoteCommits, localCommit) { - missingCommits = append(missingCommits, localCommit) - } - } - - return missingCommits -} - -func sendObjectsPackFile(commitSha string, commitsToInclude []string, commitsToExclude []string) (bytes int64, err error) { - // get the pack files to send - packFiles := utils.CreatePackFiles(commitsToInclude, commitsToExclude) - if len(packFiles) == 0 { - slog.Debug("civisibility: no pack files to send") - return 0, nil - } - - // send the pack files - slog.Debug("civisibility: sending pack file with missing commits", "count", packFiles) //nolint:gocritic // File list logging for debugging - - // try to remove the pack files after sending them - defer func(files []string) { - // best effort to remove the pack files after sending - for _, file := range files { - _ = os.Remove(file) - } - }(packFiles) - - // send the pack files - return ciVisibilityClient.SendPackFiles(commitSha, packFiles) -} diff --git a/internal/planner/discovery_cache_test.go b/internal/planner/discovery_cache_test.go index b16265d..313eb49 100644 --- a/internal/planner/discovery_cache_test.go +++ b/internal/planner/discovery_cache_test.go @@ -116,7 +116,6 @@ func TestDiscoveryCacheHitUsesCachedTests(t *testing.T) { runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -162,7 +161,6 @@ func TestDiscoveryCacheMissRunsFullDiscoveryAndStoresMetadata(t *testing.T) { runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -216,7 +214,6 @@ func TestDiscoveryCacheImportsExternalCacheBeforeValidation(t *testing.T) { runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) diff --git a/internal/planner/high_skippable_integration_test.go b/internal/planner/high_skippable_integration_test.go index df2969c..3b1617c 100644 --- a/internal/planner/high_skippable_integration_test.go +++ b/internal/planner/high_skippable_integration_test.go @@ -13,17 +13,18 @@ import ( "github.com/DataDog/ddtest/internal/constants" "github.com/DataDog/ddtest/internal/settings" "github.com/DataDog/ddtest/internal/testoptimization" + "github.com/DataDog/ddtest/internal/testoptimization/api" ciUtils "github.com/DataDog/ddtest/internal/utils" ) type highSkippableIntegrationFixture struct { - Tests []testoptimization.Test `json:"tests"` - TestFiles []string `json:"testFiles"` - SkippableTests []string `json:"skippableTests"` - TestSuiteDurations map[string]map[string]testoptimization.TestSuiteDurationInfo `json:"testSuiteDurations"` - ExpectedRunnableTestFiles []string `json:"expectedRunnableTestFiles"` - OriginalParallelRunners int `json:"originalParallelRunners"` - ExpectedParallelRunners int `json:"expectedParallelRunners"` + Tests []testoptimization.Test `json:"tests"` + TestFiles []string `json:"testFiles"` + SkippableTests []string `json:"skippableTests"` + TestSuiteDurations map[string]map[string]api.TestSuiteDurationInfo `json:"testSuiteDurations"` + ExpectedRunnableTestFiles []string `json:"expectedRunnableTestFiles"` + OriginalParallelRunners int `json:"originalParallelRunners"` + ExpectedParallelRunners int `json:"expectedParallelRunners"` } func TestTestPlanner_Plan_HighSkippableIntegrationSelectsExpectedRunnerCountAndRunnableFiles(t *testing.T) { @@ -83,8 +84,8 @@ func TestTestPlanner_Plan_HighSkippableIntegrationSelectsExpectedRunnerCountAndR &MockTestOptimizationClient{ Settings: testOptimizationSettings(true, true, false), SkippableTests: fixture.skippableTestSet(), + Durations: fixture.TestSuiteDurations, }, - &MockTestSuiteDurationsClient{Durations: fixture.TestSuiteDurations}, newDefaultMockCIProviderDetector(), ) diff --git a/internal/planner/planner.go b/internal/planner/planner.go index fe091a1..a677f09 100644 --- a/internal/planner/planner.go +++ b/internal/planner/planner.go @@ -21,6 +21,7 @@ import ( "github.com/DataDog/ddtest/internal/runmetadata" "github.com/DataDog/ddtest/internal/settings" "github.com/DataDog/ddtest/internal/testoptimization" + "github.com/DataDog/ddtest/internal/testoptimization/api" "github.com/DataDog/ddtest/internal/utils" "golang.org/x/sync/errgroup" ) @@ -31,6 +32,16 @@ type Planner interface { DistributeTestFiles(testFiles []string, parallelRunners int) [][]string } +type testOptimizationClient interface { + Initialize(tags map[string]string) error + GetSettings() *api.SettingsResponseData + GetSkippableTests() map[string]bool + GetKnownTests() *api.KnownTestsResponseData + GetTestManagementTestsData() *api.TestManagementTestsResponseDataModules + GetTestSuiteDurations() *api.TestSuiteDurationsResponseData + StoreCacheAndExit() +} + type PlanInfo struct { Platform string `json:"platform"` Framework string `json:"framework"` @@ -58,7 +69,7 @@ type TestPlanner struct { testFiles map[string]struct{} suiteAggregates map[testSuiteKey]testSuiteAggregate suitesBySourceFile map[string][]testSuiteKey - testSuiteDurations map[string]map[string]testoptimization.TestSuiteDurationInfo + testSuiteDurations map[string]map[string]api.TestSuiteDurationInfo testFileWeights map[string]int testFileDurationSources map[string]testFileDurationSource skippablePercentage float64 @@ -67,8 +78,7 @@ type TestPlanner struct { runInfo runmetadata.RunInfo planInfo PlanInfo platformDetector platform.PlatformDetector - optimizationClient testoptimization.TestOptimizationClient - durationsClient testoptimization.TestSuiteDurationsClient + optimizationClient testOptimizationClient ciProviderDetector ciprovider.CIProviderDetector reportWriter io.Writer } @@ -135,22 +145,19 @@ func Plan(ctx context.Context) error { func New() *TestPlanner { planner := newTestPlannerWithDefaults() planner.platformDetector = platform.NewPlatformDetector() - planner.optimizationClient = testoptimization.NewDatadogClient() - planner.durationsClient = testoptimization.NewDurationsClient() + planner.optimizationClient = testoptimization.NewTestOptimizationClient() planner.ciProviderDetector = ciprovider.NewCIProviderDetector() return planner } func NewWithDependencies( platformDetector platform.PlatformDetector, - optimizationClient testoptimization.TestOptimizationClient, - durationsClient testoptimization.TestSuiteDurationsClient, + optimizationClient testOptimizationClient, ciProviderDetector ciprovider.CIProviderDetector, ) *TestPlanner { planner := newTestPlannerWithDefaults() planner.platformDetector = platformDetector planner.optimizationClient = optimizationClient - planner.durationsClient = durationsClient planner.ciProviderDetector = ciProviderDetector return planner } @@ -160,7 +167,7 @@ func newTestPlannerWithDefaults() *TestPlanner { testFiles: make(map[string]struct{}), suiteAggregates: make(map[testSuiteKey]testSuiteAggregate), suitesBySourceFile: make(map[string][]testSuiteKey), - testSuiteDurations: make(map[string]map[string]testoptimization.TestSuiteDurationInfo), + testSuiteDurations: make(map[string]map[string]api.TestSuiteDurationInfo), testFileWeights: make(map[string]int), testFileDurationSources: make(map[string]testFileDurationSource), skippablePercentage: 0.0, @@ -280,7 +287,7 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { var tiaSkippingEnabled bool tp.resetDiscoveryResults() - tp.testSuiteDurations = make(map[string]map[string]testoptimization.TestSuiteDurationInfo) + tp.testSuiteDurations = make(map[string]map[string]api.TestSuiteDurationInfo) if cwdSubdirPrefix := utils.CwdSubdirPrefix(); cwdSubdirPrefix != "" { slog.Info("Running from subdirectory, will normalize repo-root-relative paths", "subdirPrefix", cwdSubdirPrefix) @@ -322,7 +329,9 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error { cancelDiscovery() } - tp.testSuiteDurations = tp.durationsClient.GetTestSuiteDurations() + if testSuiteDurations := tp.optimizationClient.GetTestSuiteDurations(); testSuiteDurations != nil && testSuiteDurations.TestSuites != nil { + tp.testSuiteDurations = testSuiteDurations.TestSuites + } return nil }) @@ -525,17 +534,17 @@ func (tp *TestPlanner) addDurationDataForFastDiscoveryFallback() { } func getTestSuiteDuration( - testSuiteDurations map[string]map[string]testoptimization.TestSuiteDurationInfo, + testSuiteDurations map[string]map[string]api.TestSuiteDurationInfo, key testSuiteKey, -) (testoptimization.TestSuiteDurationInfo, bool) { +) (api.TestSuiteDurationInfo, bool) { if suiteDurations, ok := testSuiteDurations[key.Module]; ok { suiteInfo, ok := suiteDurations[key.Suite] return suiteInfo, ok } - return testoptimization.TestSuiteDurationInfo{}, false + return api.TestSuiteDurationInfo{}, false } -func parseDurationP50(suiteInfo testoptimization.TestSuiteDurationInfo) (float64, bool) { +func parseDurationP50(suiteInfo api.TestSuiteDurationInfo) (float64, bool) { p50, err := strconv.ParseInt(suiteInfo.Duration.P50, 10, 64) if err != nil { return 0, false diff --git a/internal/planner/planner_test.go b/internal/planner/planner_test.go index 12ee9bb..1e75221 100644 --- a/internal/planner/planner_test.go +++ b/internal/planner/planner_test.go @@ -26,8 +26,8 @@ import ( "github.com/DataDog/ddtest/internal/platform" "github.com/DataDog/ddtest/internal/settings" "github.com/DataDog/ddtest/internal/testoptimization" + "github.com/DataDog/ddtest/internal/testoptimization/api" ciUtils "github.com/DataDog/ddtest/internal/utils" - "github.com/DataDog/ddtest/internal/utils/net" ) // Mock implementations for testing @@ -310,10 +310,12 @@ func (m *longRunningDiscoveryFramework) DiscoverTests(ctx context.Context, testF type MockTestOptimizationClient struct { InitializeCalled bool InitializeErr error - Settings *net.SettingsResponseData + Settings *api.SettingsResponseData SkippableTests map[string]bool - KnownTests *net.KnownTestsResponseData - TestManagementTests *net.TestManagementTestsResponseDataModules + KnownTests *api.KnownTestsResponseData + TestManagementTests *api.TestManagementTestsResponseDataModules + Durations map[string]map[string]api.TestSuiteDurationInfo + DurationsCalled bool ShutdownCalled bool Tags map[string]string } @@ -327,7 +329,7 @@ func (m *MockTestOptimizationClient) Initialize(tags map[string]string) error { return m.InitializeErr } -func (m *MockTestOptimizationClient) GetSettings() *net.SettingsResponseData { +func (m *MockTestOptimizationClient) GetSettings() *api.SettingsResponseData { return m.Settings } @@ -335,14 +337,24 @@ func (m *MockTestOptimizationClient) GetSkippableTests() map[string]bool { return m.SkippableTests } -func (m *MockTestOptimizationClient) GetKnownTests() *net.KnownTestsResponseData { +func (m *MockTestOptimizationClient) GetKnownTests() *api.KnownTestsResponseData { return m.KnownTests } -func (m *MockTestOptimizationClient) GetTestManagementTestsData() *net.TestManagementTestsResponseDataModules { +func (m *MockTestOptimizationClient) GetTestManagementTestsData() *api.TestManagementTestsResponseDataModules { return m.TestManagementTests } +func (m *MockTestOptimizationClient) GetTestSuiteDurations() *api.TestSuiteDurationsResponseData { + m.DurationsCalled = true + if m.Durations == nil { + return &api.TestSuiteDurationsResponseData{ + TestSuites: map[string]map[string]api.TestSuiteDurationInfo{}, + } + } + return &api.TestSuiteDurationsResponseData{TestSuites: m.Durations} +} + func (m *MockTestOptimizationClient) StoreCacheAndExit() { m.ShutdownCalled = true } @@ -354,7 +366,7 @@ type waitForDiscoveryOptimizationClient struct { timedOut bool } -func (m *waitForDiscoveryOptimizationClient) GetSettings() *net.SettingsResponseData { +func (m *waitForDiscoveryOptimizationClient) GetSettings() *api.SettingsResponseData { select { case <-m.discoveryStarted: case <-time.After(500 * time.Millisecond): @@ -371,19 +383,6 @@ func (m *waitForDiscoveryOptimizationClient) TimedOut() bool { return m.timedOut } -type MockTestSuiteDurationsClient struct { - Durations map[string]map[string]testoptimization.TestSuiteDurationInfo - Called bool -} - -func (m *MockTestSuiteDurationsClient) GetTestSuiteDurations() map[string]map[string]testoptimization.TestSuiteDurationInfo { - m.Called = true - if m.Durations == nil { - return map[string]map[string]testoptimization.TestSuiteDurationInfo{} - } - return m.Durations -} - // MockCIProvider mocks a CI provider type MockCIProvider struct { ProviderName string @@ -439,8 +438,8 @@ func gitTestEnv() []string { ) } -func testOptimizationSettings(tiaEnabled, testsSkipping, testManagementEnabled bool) *net.SettingsResponseData { - settings := &net.SettingsResponseData{ +func testOptimizationSettings(tiaEnabled, testsSkipping, testManagementEnabled bool) *api.SettingsResponseData { + settings := &api.SettingsResponseData{ ItrEnabled: tiaEnabled, TestsSkipping: testsSkipping, } @@ -493,19 +492,14 @@ func TestNew(t *testing.T) { if runner.optimizationClient == nil { t.Error("New() should initialize optimizationClient") } - - if runner.durationsClient == nil { - t.Error("New() should initialize durationsClient") - } } func TestNewWithDependencies(t *testing.T) { mockPlatformDetector := &MockPlatformDetector{} mockOptimizationClient := &MockTestOptimizationClient{} - mockDurationsClient := &MockTestSuiteDurationsClient{} mockCIProviderDetector := newDefaultMockCIProviderDetector() - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, mockDurationsClient, mockCIProviderDetector) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, mockCIProviderDetector) if runner == nil { t.Error("NewWithDependencies() should return non-nil TestPlanner") @@ -520,10 +514,6 @@ func TestNewWithDependencies(t *testing.T) { t.Error("NewWithDependencies() should use injected optimizationClient") } - if runner.durationsClient != mockDurationsClient { - t.Error("NewWithDependencies() should use injected durationsClient") - } - if len(runner.testFiles) != 0 { t.Error("NewWithDependencies() should initialize testFiles to empty map") } @@ -586,7 +576,7 @@ func TestTestPlanner_Setup_WithParallelRunners(t *testing.T) { }, } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) var reportOutput bytes.Buffer runner.reportWriter = &reportOutput @@ -657,7 +647,6 @@ func TestTestPlanner_Plan_WritesManifestAndRunnerLayout(t *testing.T) { runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, &MockTestOptimizationClient{SkippableTests: map[string]bool{}}, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -708,7 +697,6 @@ func TestTestPlanner_Plan_DoesNotPrintReportWhenDisabled(t *testing.T) { runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, &MockTestOptimizationClient{SkippableTests: map[string]bool{}}, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) var output strings.Builder @@ -773,7 +761,6 @@ func TestTestPlanner_Plan_ChoosesParallelismFromFanoutAdjustedSplit(t *testing.T Settings: testOptimizationSettings(true, true, false), SkippableTests: skippableTests, }, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -836,7 +823,7 @@ func TestTestPlanner_Setup_WithCIProvider(t *testing.T) { CIProvider: mockCIProvider, } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, mockCIProviderDetector) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, mockCIProviderDetector) // Run Setup err := runner.Plan(context.Background()) @@ -890,7 +877,7 @@ func TestTestPlanner_Setup_CIProviderDetectionFailure(t *testing.T) { Err: errors.New("no CI provider detected"), } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, mockCIProviderDetector) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, mockCIProviderDetector) // Run Setup - should succeed even if CI provider detection fails err := runner.Plan(context.Background()) @@ -935,7 +922,7 @@ func TestTestPlanner_Setup_CIProviderConfigureFailure(t *testing.T) { CIProvider: mockCIProvider, } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, mockCIProviderDetector) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, mockCIProviderDetector) // Run Setup - should succeed even if CI provider configuration fails err := runner.Plan(context.Background()) @@ -992,7 +979,7 @@ func TestTestPlanner_Setup_WithTestSplit(t *testing.T) { SkippableTests: map[string]bool{}, // No tests skipped } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) // Run Setup err := runner.Plan(context.Background()) @@ -1082,7 +1069,7 @@ func TestTestPlanner_Setup_WithTestSplit(t *testing.T) { // Reinitialize settings to pick up environment variables settings.Init() - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) // Run Setup err := runner.Plan(context.Background()) @@ -1200,7 +1187,7 @@ func TestTestPlanner_Plan_SubdirRootRelativeDiscovery_WritesNormalizedPaths(t *t mockPlatformDetector := &MockPlatformDetector{Platform: mockPlatform} mockOptimizationClient := testOptimizationClientRequiringFullDiscovery() - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.Plan(context.Background()) if err != nil { @@ -1286,19 +1273,17 @@ func TestTestPlanner_PreparePlanningData_Success(t *testing.T) { (&testoptimization.Test{Module: "rspec", Suite: "TestSuite1", Name: "test2"}).DatadogTestId(): true, // Skip test2 (&testoptimization.Test{Module: "rspec", Suite: "TestSuite3", Name: "test4"}).DatadogTestId(): true, // Skip test4 }, - } - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + Durations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "TestSuite1": { SourceFile: "test/file1_test.rb", - Duration: testoptimization.DurationPercentiles{P50: "7000000", P90: "2000000"}, + Duration: api.DurationPercentiles{P50: "7000000", P90: "2000000"}, }, }, }, } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) @@ -1377,7 +1362,7 @@ func TestTestPlanner_PreparePlanningData_Success(t *testing.T) { expectedPercentage, runner.skippablePercentage) } - if !mockDurationsClient.Called { + if !mockOptimizationClient.DurationsCalled { t.Error("PreparePlanningData() should fetch test suite durations") } } @@ -1406,18 +1391,18 @@ func TestTestPlanner_PreparePlanningData_DisabledTestManagementTestsAreSkipped(t SkippableTests: map[string]bool{ (&testoptimization.Test{Module: "rspec", Suite: "Suite1", Name: "test2"}).DatadogTestId(): true, }, - TestManagementTests: &net.TestManagementTestsResponseDataModules{ - Modules: map[string]net.TestManagementTestsResponseDataSuites{ + TestManagementTests: &api.TestManagementTestsResponseDataModules{ + Modules: map[string]api.TestManagementTestsResponseDataSuites{ "rspec": { - Suites: map[string]net.TestManagementTestsResponseDataTests{ + Suites: map[string]api.TestManagementTestsResponseDataTests{ "Suite2": { - Tests: map[string]net.TestManagementTestsResponseDataTestProperties{ - "test3": {Properties: net.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, + Tests: map[string]api.TestManagementTestsResponseDataTestProperties{ + "test3": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, }, }, "Suite3": { - Tests: map[string]net.TestManagementTestsResponseDataTestProperties{ - "test4": {Properties: net.TestManagementTestsResponseDataTestPropertiesAttributes{Quarantined: true}}, + Tests: map[string]api.TestManagementTestsResponseDataTestProperties{ + "test4": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{Quarantined: true}}, }, }, }, @@ -1429,7 +1414,6 @@ func TestTestPlanner_PreparePlanningData_DisabledTestManagementTestsAreSkipped(t runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -1499,7 +1483,6 @@ func TestTestPlanner_PreparePlanningData_TIASkipsRequireParametersMatch(t *testi runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -1537,13 +1520,13 @@ func TestTestPlanner_PreparePlanningData_ModuleQualifiedSkipsDoNotCrossModules(t SkippableTests: map[string]bool{ (&testoptimization.Test{Module: "module-a", Suite: "SharedSuite", Name: "same name"}).DatadogTestId(): true, }, - TestManagementTests: &net.TestManagementTestsResponseDataModules{ - Modules: map[string]net.TestManagementTestsResponseDataSuites{ + TestManagementTests: &api.TestManagementTestsResponseDataModules{ + Modules: map[string]api.TestManagementTestsResponseDataSuites{ "module-c": { - Suites: map[string]net.TestManagementTestsResponseDataTests{ + Suites: map[string]api.TestManagementTestsResponseDataTests{ "ManagedSuite": { - Tests: map[string]net.TestManagementTestsResponseDataTestProperties{ - "same name": {Properties: net.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, + Tests: map[string]api.TestManagementTestsResponseDataTestProperties{ + "same name": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, }, }, }, @@ -1555,7 +1538,6 @@ func TestTestPlanner_PreparePlanningData_ModuleQualifiedSkipsDoNotCrossModules(t runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -1604,18 +1586,18 @@ func TestTestPlanner_PreparePlanningData_TestManagementDoesNotKeepFullDiscoveryW SkippableTests: map[string]bool{ (&testoptimization.Test{Module: "rspec", Suite: "Suite2", Name: "not_applied"}).DatadogTestId(): true, }, - TestManagementTests: &net.TestManagementTestsResponseDataModules{ - Modules: map[string]net.TestManagementTestsResponseDataSuites{ + TestManagementTests: &api.TestManagementTestsResponseDataModules{ + Modules: map[string]api.TestManagementTestsResponseDataSuites{ "rspec": { - Suites: map[string]net.TestManagementTestsResponseDataTests{ + Suites: map[string]api.TestManagementTestsResponseDataTests{ "Suite1": { - Tests: map[string]net.TestManagementTestsResponseDataTestProperties{ - "disabled": {Properties: net.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, + Tests: map[string]api.TestManagementTestsResponseDataTestProperties{ + "disabled": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, }, }, "Suite3": { - Tests: map[string]net.TestManagementTestsResponseDataTestProperties{ - "disabled": {Properties: net.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, + Tests: map[string]api.TestManagementTestsResponseDataTestProperties{ + "disabled": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, }, }, }, @@ -1627,7 +1609,6 @@ func TestTestPlanner_PreparePlanningData_TestManagementDoesNotKeepFullDiscoveryW runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -1678,7 +1659,6 @@ func TestTestPlanner_PreparePlanningData_CancelsFullDiscoveryWhenNoTIASkippableT runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -1742,7 +1722,6 @@ func TestTestPlanner_PreparePlanningData_RunsFullDiscoveryInParallelWithBackend( runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -1786,7 +1765,6 @@ func TestTestPlanner_PreparePlanningData_UsesCompletedFullDiscoveryWhenNoTIASkip runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -1825,16 +1803,15 @@ func TestTestPlanner_PreparePlanningData_EmptyDurationsContinues(t *testing.T) { } mockPlatformDetector := &MockPlatformDetector{Platform: mockPlatform} mockOptimizationClient := &MockTestOptimizationClient{} - mockDurationsClient := &MockTestSuiteDurationsClient{} - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { t.Fatalf("PreparePlanningData() should not fail with empty durations, got: %v", err) } - if !mockDurationsClient.Called { + if !mockOptimizationClient.DurationsCalled { t.Error("PreparePlanningData() should fetch test suite durations") } @@ -1865,23 +1842,22 @@ func TestTestPlanner_PreparePlanningData_NonEmptyDurationsUsesP50ForMatchingSuit Framework: mockFramework, } mockPlatformDetector := &MockPlatformDetector{Platform: mockPlatform} - mockOptimizationClient := &MockTestOptimizationClient{} - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + mockOptimizationClient := &MockTestOptimizationClient{ + Durations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "Suite1": { SourceFile: "spec/file1_test.rb", - Duration: testoptimization.DurationPercentiles{P50: "10000000", P90: "20000000"}, + Duration: api.DurationPercentiles{P50: "10000000", P90: "20000000"}, }, "Suite2": { SourceFile: "spec/file2_test.rb", - Duration: testoptimization.DurationPercentiles{P50: "30000000", P90: "40000000"}, + Duration: api.DurationPercentiles{P50: "30000000", P90: "40000000"}, }, }, }, } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -1933,23 +1909,21 @@ func TestTestPlanner_PreparePlanningData_SkippablePercentageUsesDurations(t *tes mockOptimizationClient := &MockTestOptimizationClient{ Settings: testOptimizationSettings(true, true, false), SkippableTests: map[string]bool{skippedTest.DatadogTestId(): true}, - } - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + Durations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "SlowSuite": { SourceFile: "spec/slow_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "8000000000"}, + Duration: api.DurationPercentiles{P50: "8000000000"}, }, "FastSuite": { SourceFile: "spec/fast_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "2000000000"}, + Duration: api.DurationPercentiles{P50: "2000000000"}, }, }, }, } - runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -1963,7 +1937,7 @@ func TestTestPlanner_PreparePlanningData_SkippablePercentageUsesDurations(t *tes } func TestTestPlanner_TestFileWeight_CountFallbackForMissingSuiteDuration(t *testing.T) { - runner := NewWithDependencies(&MockPlatformDetector{}, &MockTestOptimizationClient{}, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{}, &MockTestOptimizationClient{}, newDefaultMockCIProviderDetector()) runner.testFiles = map[string]struct{}{ "spec/file1_test.rb": {}, "spec/file2_test.rb": {}, @@ -1984,11 +1958,11 @@ func TestTestPlanner_TestFileWeight_CountFallbackForMissingSuiteDuration(t *test }, } - runner.testSuiteDurations = map[string]map[string]testoptimization.TestSuiteDurationInfo{ + runner.testSuiteDurations = map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "Suite1": { SourceFile: "spec/file1_test.rb", - Duration: testoptimization.DurationPercentiles{P50: "11000000", P90: "22000000"}, + Duration: api.DurationPercentiles{P50: "11000000", P90: "22000000"}, }, }, } @@ -2022,7 +1996,7 @@ func TestTestPlanner_TestFileWeight_CountFallbackForMissingSuiteDuration(t *test } func TestTestPlanner_TestFileWeight_InvalidP50FallsBackForFullDiscoveryAggregate(t *testing.T) { - runner := NewWithDependencies(&MockPlatformDetector{}, &MockTestOptimizationClient{}, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{}, &MockTestOptimizationClient{}, newDefaultMockCIProviderDetector()) runner.testFiles = map[string]struct{}{ "spec/file1_test.rb": {}, } @@ -2036,11 +2010,11 @@ func TestTestPlanner_TestFileWeight_InvalidP50FallsBackForFullDiscoveryAggregate }, } - runner.testSuiteDurations = map[string]map[string]testoptimization.TestSuiteDurationInfo{ + runner.testSuiteDurations = map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "Suite1": { SourceFile: "spec/file1_test.rb", - Duration: testoptimization.DurationPercentiles{P50: "not-a-number"}, + Duration: api.DurationPercentiles{P50: "not-a-number"}, }, }, } @@ -2061,7 +2035,7 @@ func TestTestPlanner_TestFileWeight_InvalidP50FallsBackForFullDiscoveryAggregate } func TestTestPlanner_TestFileWeight_ZeroP50FallsBackForFullDiscoveryAggregate(t *testing.T) { - runner := NewWithDependencies(&MockPlatformDetector{}, &MockTestOptimizationClient{}, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{}, &MockTestOptimizationClient{}, newDefaultMockCIProviderDetector()) runner.testFiles = map[string]struct{}{ "spec/file1_test.rb": {}, } @@ -2075,11 +2049,11 @@ func TestTestPlanner_TestFileWeight_ZeroP50FallsBackForFullDiscoveryAggregate(t }, } - runner.testSuiteDurations = map[string]map[string]testoptimization.TestSuiteDurationInfo{ + runner.testSuiteDurations = map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "Suite1": { SourceFile: "spec/file1_test.rb", - Duration: testoptimization.DurationPercentiles{P50: "0"}, + Duration: api.DurationPercentiles{P50: "0"}, }, }, } @@ -2100,7 +2074,7 @@ func TestTestPlanner_TestFileWeight_ZeroP50FallsBackForFullDiscoveryAggregate(t } func TestTestPlanner_TestFileWeight_SubMillisecondP50MinimumWeight(t *testing.T) { - runner := NewWithDependencies(&MockPlatformDetector{}, &MockTestOptimizationClient{}, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{}, &MockTestOptimizationClient{}, newDefaultMockCIProviderDetector()) runner.testFiles = map[string]struct{}{ "spec/fast_test.rb": {}, } @@ -2113,11 +2087,11 @@ func TestTestPlanner_TestFileWeight_SubMillisecondP50MinimumWeight(t *testing.T) }, } - runner.testSuiteDurations = map[string]map[string]testoptimization.TestSuiteDurationInfo{ + runner.testSuiteDurations = map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "FastSuite": { SourceFile: "spec/fast_test.rb", - Duration: testoptimization.DurationPercentiles{P50: "500000"}, + Duration: api.DurationPercentiles{P50: "500000"}, }, }, } @@ -2131,7 +2105,7 @@ func TestTestPlanner_TestFileWeight_SubMillisecondP50MinimumWeight(t *testing.T) } func TestTestPlanner_TestFileWeight_SkipsFullySkippedSuites(t *testing.T) { - runner := NewWithDependencies(&MockPlatformDetector{}, &MockTestOptimizationClient{}, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{}, &MockTestOptimizationClient{}, newDefaultMockCIProviderDetector()) runner.testFiles = map[string]struct{}{ "spec/skipped_test.rb": {}, } @@ -2225,18 +2199,18 @@ func TestTestPlanner_PreparePlanningData_FastDiscoveryUsesBackendDurations(t *te }, Framework: mockFramework, } - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + mockOptimizationClient := &MockTestOptimizationClient{ + Durations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "BackendOnlySuite": { SourceFile: "spec/backend_only_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "42000000", P90: "84000000"}, + Duration: api.DurationPercentiles{P50: "42000000", P90: "84000000"}, }, }, }, } - runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, &MockTestOptimizationClient{}, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -2266,22 +2240,22 @@ func TestTestPlanner_PreparePlanningData_FastDiscoveryUsesOneBackendDurationPerS }, Framework: mockFramework, } - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + mockOptimizationClient := &MockTestOptimizationClient{ + Durations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "BackendOnlySuite": { SourceFile: "spec/backend_only_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "42000000"}, + Duration: api.DurationPercentiles{P50: "42000000"}, }, "DuplicateBackendOnlySuite": { SourceFile: "spec/backend_only_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "84000000"}, + Duration: api.DurationPercentiles{P50: "84000000"}, }, }, }, } - runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, &MockTestOptimizationClient{}, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -2314,18 +2288,18 @@ func TestTestPlanner_PreparePlanningData_IgnoresZeroBackendDurationForFastDiscov }, Framework: mockFramework, } - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + mockOptimizationClient := &MockTestOptimizationClient{ + Durations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "BrokenZeroDurationSuite": { SourceFile: "spec/backend_only_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "0", P90: "0"}, + Duration: api.DurationPercentiles{P50: "0", P90: "0"}, }, }, }, } - runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, &MockTestOptimizationClient{}, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -2369,26 +2343,26 @@ func TestTestPlanner_PreparePlanningData_BackendDurationSubdirMatchesFastDiscove }, Framework: mockFramework, } - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + mockOptimizationClient := &MockTestOptimizationClient{ + Durations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "OrderSuite": { SourceFile: "core/spec/models/order_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "55000000", P90: "110000000"}, + Duration: api.DurationPercentiles{P50: "55000000", P90: "110000000"}, }, }, }, } ciUtils.AddCITagsMap(map[string]string{ciConstants.GitRepositoryURL: repoRoot}) - runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, &MockTestOptimizationClient{}, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { t.Fatalf("PreparePlanningData() should not fail, got: %v", err) } - if !mockDurationsClient.Called { - t.Fatal("Expected durations client to be called") + if !mockOptimizationClient.DurationsCalled { + t.Fatal("Expected optimization client to fetch test suite durations") } if weight, ok := runner.testFileWeight("spec/models/order_spec.rb"); !ok || weight != 55 { @@ -2418,18 +2392,18 @@ func TestTestPlanner_PreparePlanningData_IgnoresBackendDurationsForUndiscoveredF }, Framework: mockFramework, } - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + mockOptimizationClient := &MockTestOptimizationClient{ + Durations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "StaleSuite": { SourceFile: "spec/stale_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "99000000", P90: "198000000"}, + Duration: api.DurationPercentiles{P50: "99000000", P90: "198000000"}, }, }, }, } - runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, &MockTestOptimizationClient{}, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -2471,7 +2445,7 @@ func TestTestPlanner_PreparePlanningData_FullDiscoveryIgnoresFastOnlyFiles(t *te Framework: mockFramework, } - runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, testOptimizationClientRequiringFullDiscovery(), &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, testOptimizationClientRequiringFullDiscovery(), newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -2518,25 +2492,24 @@ func TestTestPlanner_PreparePlanningData_FullDiscoveryDoesNotReintroduceFastOnly }, Framework: mockFramework, } - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ - "rspec": { - "FastOnlySuite": { - SourceFile: "spec/fast_only_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "42000000", P90: "84000000"}, - }, + mockOptimizationClient := testOptimizationClientRequiringFullDiscovery() + mockOptimizationClient.Durations = map[string]map[string]api.TestSuiteDurationInfo{ + "rspec": { + "FastOnlySuite": { + SourceFile: "spec/fast_only_spec.rb", + Duration: api.DurationPercentiles{P50: "42000000", P90: "84000000"}, }, }, } - runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, testOptimizationClientRequiringFullDiscovery(), mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { t.Fatalf("PreparePlanningData() should not fail, got: %v", err) } - if !mockDurationsClient.Called { - t.Fatal("Expected durations client to be called") + if !mockOptimizationClient.DurationsCalled { + t.Fatal("Expected optimization client to fetch test suite durations") } if _, ok := runner.suiteAggregates[testSuiteKey{Module: "rspec", Suite: "FastOnlySuite"}]; ok { @@ -2569,27 +2542,25 @@ func TestTestPlanner_PreparePlanningData_FastDiscoveryDoesNotRunStaleBackendFile Framework: mockFramework, } mockOptimizationClient := &MockTestOptimizationClient{ - Settings: &net.SettingsResponseData{ + Settings: &api.SettingsResponseData{ ItrEnabled: true, TestsSkipping: false, }, - } - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + Durations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "LocalSuite": { SourceFile: "spec/local_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "11000000"}, + Duration: api.DurationPercentiles{P50: "11000000"}, }, "DeletedSuite": { SourceFile: "spec/deleted_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "99000000"}, + Duration: api.DurationPercentiles{P50: "99000000"}, }, }, }, } - runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -2638,19 +2609,17 @@ func TestTestPlanner_PreparePlanningData_BackendDoesNotReintroduceFullySkippedSu mockOptimizationClient := &MockTestOptimizationClient{ Settings: testOptimizationSettings(true, true, false), SkippableTests: map[string]bool{skippedTest.DatadogTestId(): true}, - } - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + Durations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "SkippedSuite": { SourceFile: "spec/skipped_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "99000000", P90: "198000000"}, + Duration: api.DurationPercentiles{P50: "99000000", P90: "198000000"}, }, }, }, } - runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -2694,19 +2663,17 @@ func TestTestPlanner_PreparePlanningData_BackendDoesNotDuplicateDiscoveredSource mockOptimizationClient := &MockTestOptimizationClient{ Settings: testOptimizationSettings(true, true, false), SkippableTests: map[string]bool{skippedTest.DatadogTestId(): true}, - } - mockDurationsClient := &MockTestSuiteDurationsClient{ - Durations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + Durations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "BackendDuplicateSuite": { SourceFile: "spec/skipped_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "99000000"}, + Duration: api.DurationPercentiles{P50: "99000000"}, }, }, }, } - runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, mockDurationsClient, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(&MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -2782,15 +2749,15 @@ func TestTestPlanner_RecordFastDiscoveryFallbackFiles_ExcludedBackendDurationsAr t.Fatalf("recordFastDiscoveryFallbackFiles() should not fail, got: %v", err) } - runner.testSuiteDurations = map[string]map[string]testoptimization.TestSuiteDurationInfo{ + runner.testSuiteDurations = map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "User": { SourceFile: "spec/models/user_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "100000000"}, + Duration: api.DurationPercentiles{P50: "100000000"}, }, "Checkout": { SourceFile: "spec/system/checkout_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "200000000"}, + Duration: api.DurationPercentiles{P50: "200000000"}, }, }, } @@ -2849,7 +2816,6 @@ func TestTestPlanner_PreparePlanningData_ResolvesFilteredTestFilesOnce(t *testin runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, mockOptimizationClient, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -2925,7 +2891,6 @@ func TestTestPlanner_PreparePlanningData_PostFiltersFullDiscoveryWhenExplicitFil runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, testOptimizationClientRequiringFullDiscovery(), - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -3005,7 +2970,7 @@ func TestTestPlanner_PreparePlanningData_PlatformDetectionError(t *testing.T) { mockOptimizationClient := &MockTestOptimizationClient{} - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) @@ -3032,7 +2997,7 @@ func TestTestPlanner_PreparePlanningData_TagsCreationError(t *testing.T) { mockOptimizationClient := &MockTestOptimizationClient{} - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) @@ -3068,7 +3033,7 @@ func TestTestPlanner_PreparePlanningData_OptimizationClientInitError(t *testing. InitializeErr: errors.New("client initialization failed"), } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) @@ -3096,7 +3061,7 @@ func TestTestPlanner_PreparePlanningData_FrameworkDetectionError(t *testing.T) { mockOptimizationClient := &MockTestOptimizationClient{} - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) @@ -3128,7 +3093,7 @@ func TestTestPlanner_PreparePlanningData_TestDiscoveryError(t *testing.T) { mockOptimizationClient := &MockTestOptimizationClient{} - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) @@ -3164,7 +3129,7 @@ func TestTestPlanner_PreparePlanningData_EmptyTests(t *testing.T) { }, } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) @@ -3216,7 +3181,7 @@ func TestTestPlanner_PreparePlanningData_AllTestsSkipped(t *testing.T) { }, } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) @@ -3271,7 +3236,7 @@ func TestTestPlanner_PreparePlanningData_RuntimeTagsOverride(t *testing.T) { SkippableTests: map[string]bool{}, } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) @@ -3330,7 +3295,7 @@ func TestTestPlanner_PreparePlanningData_RuntimeTagsOverrideInvalidJSON(t *testi mockOptimizationClient := &MockTestOptimizationClient{} - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) @@ -3381,7 +3346,7 @@ func TestTestPlanner_PreparePlanningData_NoRuntimeTagsOverride(t *testing.T) { SkippableTests: map[string]bool{}, } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) @@ -3467,7 +3432,7 @@ func TestPreparePlanningData_ITRFullDiscovery_SubdirRootRelativePath_NormalizesT mockPlatformDetector := &MockPlatformDetector{Platform: mockPlatform} mockOptimizationClient := testOptimizationClientRequiringFullDiscovery() - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -3531,7 +3496,7 @@ func TestPreparePlanningData_RepoRootRun_LeavesRepoRelativePathsUnchanged(t *tes mockPlatformDetector := &MockPlatformDetector{Platform: mockPlatform} mockOptimizationClient := testOptimizationClientRequiringFullDiscovery() - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -3568,7 +3533,7 @@ func TestPreparePlanningData_FastDiscovery_PathsRemainUnchanged(t *testing.T) { Settings: testOptimizationSettings(true, false, false), } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -3633,7 +3598,7 @@ func TestPreparePlanningData_ITRPathNormalization_PrefixMismatchUnchanged(t *tes mockPlatformDetector := &MockPlatformDetector{Platform: mockPlatform} mockOptimizationClient := testOptimizationClientRequiringFullDiscovery() - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { @@ -3717,7 +3682,7 @@ func TestPreparePlanningData_ITRSubdir_SkipMatching_WithSuitePathsMatchingCwd(t }, } - runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector()) + runner := NewWithDependencies(mockPlatformDetector, mockOptimizationClient, newDefaultMockCIProviderDetector()) err := runner.PreparePlanningData(ctx) if err != nil { diff --git a/internal/planner/report.go b/internal/planner/report.go index 19f6097..2873f21 100644 --- a/internal/planner/report.go +++ b/internal/planner/report.go @@ -76,7 +76,6 @@ func printDatadogSettingsReport(w io.Writer, report datadogSettingsReport) { reportFprintf(w, " Test skipping: %s\n", enabledWord(report.TestSkipping)) reportFprintf(w, " Test impact collection: %s\n", enabledWord(report.TestImpactCollection)) reportFprintf(w, " Known tests: %s\n", enabledWord(report.KnownTests)) - reportFprintf(w, " Impacted tests: %s\n", enabledWord(report.ImpactedTests)) reportFprintf(w, " Early flake detection: %s\n", enabledWord(report.EarlyFlakeDetection)) reportFprintf(w, " Auto test retries: %s\n", enabledWord(report.AutoTestRetries)) reportFprintf(w, " Flaky test management: %s\n", enabledWord(report.FlakyTestManagement)) diff --git a/internal/planner/report_data.go b/internal/planner/report_data.go index f7a5ac3..927ad2d 100644 --- a/internal/planner/report_data.go +++ b/internal/planner/report_data.go @@ -6,7 +6,7 @@ import ( "github.com/DataDog/ddtest/internal/runmetadata" "github.com/DataDog/ddtest/internal/settings" - "github.com/DataDog/ddtest/internal/utils/net" + "github.com/DataDog/ddtest/internal/testoptimization/api" ) const slowestTestSuitesReportLimit = 10 @@ -17,13 +17,12 @@ type datadogSettingsReport struct { TestSkipping bool TestImpactCollection bool KnownTests bool - ImpactedTests bool EarlyFlakeDetection bool AutoTestRetries bool FlakyTestManagement bool } -func newDatadogSettingsReport(settings *net.SettingsResponseData) datadogSettingsReport { +func newDatadogSettingsReport(settings *api.SettingsResponseData) datadogSettingsReport { if settings == nil { return datadogSettingsReport{} } @@ -33,7 +32,6 @@ func newDatadogSettingsReport(settings *net.SettingsResponseData) datadogSetting TestSkipping: settings.TestsSkipping, TestImpactCollection: settings.CodeCoverage, KnownTests: settings.KnownTestsEnabled, - ImpactedTests: settings.ImpactedTestsEnabled, EarlyFlakeDetection: settings.EarlyFlakeDetection.Enabled, AutoTestRetries: settings.FlakyTestRetriesEnabled, FlakyTestManagement: settings.TestManagement.Enabled, @@ -47,7 +45,7 @@ type knownTestsReport struct { Tests int } -func newKnownTestsReport(knownTests *net.KnownTestsResponseData) knownTestsReport { +func newKnownTestsReport(knownTests *api.KnownTestsResponseData) knownTestsReport { if knownTests == nil { return knownTestsReport{} } @@ -73,7 +71,7 @@ type managedFlakyTestsReport struct { AttemptToFix int } -func newManagedFlakyTestsReport(testManagementTests *net.TestManagementTestsResponseDataModules) managedFlakyTestsReport { +func newManagedFlakyTestsReport(testManagementTests *api.TestManagementTestsResponseDataModules) managedFlakyTestsReport { if testManagementTests == nil { return managedFlakyTestsReport{} } diff --git a/internal/planner/report_test.go b/internal/planner/report_test.go index f6a694e..be78184 100644 --- a/internal/planner/report_test.go +++ b/internal/planner/report_test.go @@ -7,7 +7,7 @@ import ( "github.com/DataDog/ddtest/internal/runmetadata" "github.com/DataDog/ddtest/internal/settings" - "github.com/DataDog/ddtest/internal/utils/net" + "github.com/DataDog/ddtest/internal/testoptimization/api" ) func TestPrintPlanReport_AllData(t *testing.T) { @@ -53,7 +53,6 @@ func TestPrintPlanReport_AllData(t *testing.T) { TestSkipping: true, TestImpactCollection: false, KnownTests: true, - ImpactedTests: false, EarlyFlakeDetection: true, AutoTestRetries: true, FlakyTestManagement: true, @@ -149,7 +148,6 @@ Datadog settings Test skipping: enabled Test impact collection: disabled Known tests: enabled - Impacted tests: disabled Early flake detection: enabled Auto test retries: enabled Flaky test management: enabled @@ -227,12 +225,12 @@ func TestPrintPlanReport_DisabledFeatures(t *testing.T) { } func TestReportSummaries(t *testing.T) { - known := newKnownTestsReport(&net.KnownTestsResponseData{ - Tests: net.KnownTestsResponseDataModules{ - "module-a": net.KnownTestsResponseDataSuites{ + known := newKnownTestsReport(&api.KnownTestsResponseData{ + Tests: api.KnownTestsResponseDataModules{ + "module-a": api.KnownTestsResponseDataSuites{ "suite-a": []string{"test-a", "test-b"}, }, - "module-b": net.KnownTestsResponseDataSuites{ + "module-b": api.KnownTestsResponseDataSuites{ "suite-b": []string{"test-c"}, "suite-c": []string{"test-d", "test-e"}, }, @@ -242,15 +240,15 @@ func TestReportSummaries(t *testing.T) { t.Errorf("unexpected known test summary: %+v", known) } - managed := newManagedFlakyTestsReport(&net.TestManagementTestsResponseDataModules{ - Modules: map[string]net.TestManagementTestsResponseDataSuites{ + managed := newManagedFlakyTestsReport(&api.TestManagementTestsResponseDataModules{ + Modules: map[string]api.TestManagementTestsResponseDataSuites{ "module-a": { - Suites: map[string]net.TestManagementTestsResponseDataTests{ + Suites: map[string]api.TestManagementTestsResponseDataTests{ "suite-a": { - Tests: map[string]net.TestManagementTestsResponseDataTestProperties{ - "test-a": {Properties: net.TestManagementTestsResponseDataTestPropertiesAttributes{Quarantined: true}}, - "test-b": {Properties: net.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, - "test-c": {Properties: net.TestManagementTestsResponseDataTestPropertiesAttributes{AttemptToFix: true}}, + Tests: map[string]api.TestManagementTestsResponseDataTestProperties{ + "test-a": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{Quarantined: true}}, + "test-b": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, + "test-c": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{AttemptToFix: true}}, }, }, }, diff --git a/internal/planner/test_optimization_plan_cache.go b/internal/planner/test_optimization_plan_cache.go index 4118b6a..32ebdc2 100644 --- a/internal/planner/test_optimization_plan_cache.go +++ b/internal/planner/test_optimization_plan_cache.go @@ -6,16 +6,17 @@ import ( "github.com/DataDog/ddtest/internal/runmetadata" "github.com/DataDog/ddtest/internal/testoptimization" + "github.com/DataDog/ddtest/internal/testoptimization/api" ) type testOptimizationPlanCache struct { - TestSuiteDurations map[string]map[string]testoptimization.TestSuiteDurationInfo `json:"testSuiteDurations"` - SuiteAggregates map[testSuiteKey]testSuiteAggregate `json:"suiteAggregates"` - SuitesBySourceFile map[string][]testSuiteKey `json:"suitesBySourceFile"` - TestFileWeights map[string]int `json:"testFileWeights"` - TestFileDurationSources map[string]testFileDurationSource `json:"testFileDurationSources"` - RunInfo runmetadata.RunInfo `json:"runInfo"` - PlanInfo PlanInfo `json:"planInfo"` + TestSuiteDurations map[string]map[string]api.TestSuiteDurationInfo `json:"testSuiteDurations"` + SuiteAggregates map[testSuiteKey]testSuiteAggregate `json:"suiteAggregates"` + SuitesBySourceFile map[string][]testSuiteKey `json:"suitesBySourceFile"` + TestFileWeights map[string]int `json:"testFileWeights"` + TestFileDurationSources map[string]testFileDurationSource `json:"testFileDurationSources"` + RunInfo runmetadata.RunInfo `json:"runInfo"` + PlanInfo PlanInfo `json:"planInfo"` } func (tp *TestPlanner) storeTestOptimizationPlanCache() error { @@ -89,13 +90,13 @@ type legacyRunInfo struct { func (c *testOptimizationPlanCache) UnmarshalJSON(data []byte) error { var decoded struct { - TestSuiteDurations map[string]map[string]testoptimization.TestSuiteDurationInfo `json:"testSuiteDurations"` - SuiteAggregates map[testSuiteKey]testSuiteAggregate `json:"suiteAggregates"` - SuitesBySourceFile map[string][]testSuiteKey `json:"suitesBySourceFile"` - TestFileWeights map[string]int `json:"testFileWeights"` - TestFileDurationSources map[string]testFileDurationSource `json:"testFileDurationSources"` - RunInfo legacyRunInfo `json:"runInfo"` - PlanInfo PlanInfo `json:"planInfo"` + TestSuiteDurations map[string]map[string]api.TestSuiteDurationInfo `json:"testSuiteDurations"` + SuiteAggregates map[testSuiteKey]testSuiteAggregate `json:"suiteAggregates"` + SuitesBySourceFile map[string][]testSuiteKey `json:"suitesBySourceFile"` + TestFileWeights map[string]int `json:"testFileWeights"` + TestFileDurationSources map[string]testFileDurationSource `json:"testFileDurationSources"` + RunInfo legacyRunInfo `json:"runInfo"` + PlanInfo PlanInfo `json:"planInfo"` } if err := json.Unmarshal(data, &decoded); err != nil { return err @@ -139,7 +140,7 @@ func readAndNormalizeTestOptimizationPlanCache(cache *testOptimizationPlanCache) } if cache.TestSuiteDurations == nil { - cache.TestSuiteDurations = make(map[string]map[string]testoptimization.TestSuiteDurationInfo) + cache.TestSuiteDurations = make(map[string]map[string]api.TestSuiteDurationInfo) } if cache.SuiteAggregates == nil { cache.SuiteAggregates = make(map[testSuiteKey]testSuiteAggregate) @@ -156,7 +157,7 @@ func readAndNormalizeTestOptimizationPlanCache(cache *testOptimizationPlanCache) return nil } -func countTestSuites(testSuiteDurations map[string]map[string]testoptimization.TestSuiteDurationInfo) int { +func countTestSuites(testSuiteDurations map[string]map[string]api.TestSuiteDurationInfo) int { totalSuites := 0 for _, suites := range testSuiteDurations { totalSuites += len(suites) diff --git a/internal/planner/test_optimization_plan_cache_test.go b/internal/planner/test_optimization_plan_cache_test.go index 29123ff..9eb23e3 100644 --- a/internal/planner/test_optimization_plan_cache_test.go +++ b/internal/planner/test_optimization_plan_cache_test.go @@ -11,6 +11,7 @@ import ( "github.com/DataDog/ddtest/internal/constants" "github.com/DataDog/ddtest/internal/settings" "github.com/DataDog/ddtest/internal/testoptimization" + "github.com/DataDog/ddtest/internal/testoptimization/api" ) func TestTestPlanner_Plan_StoresTestOptimizationPlanCache(t *testing.T) { @@ -43,7 +44,6 @@ func TestTestPlanner_Plan_StoresTestOptimizationPlanCache(t *testing.T) { runner := NewWithDependencies( &MockPlatformDetector{Platform: mockPlatform}, &MockTestOptimizationClient{SkippableTests: map[string]bool{}}, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) @@ -59,7 +59,6 @@ func TestTestPlanner_Plan_StoresTestOptimizationPlanCache(t *testing.T) { restored := NewWithDependencies( &MockPlatformDetector{}, &MockTestOptimizationClient{}, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) if err := restored.restoreTestOptimizationPlanCache(); err != nil { @@ -89,14 +88,13 @@ func TestTestPlanner_StoreAndRestoreTestOptimizationPlanCache_RoundTripDurations runner := NewWithDependencies( &MockPlatformDetector{}, &MockTestOptimizationClient{}, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) - runner.testSuiteDurations = map[string]map[string]testoptimization.TestSuiteDurationInfo{ + runner.testSuiteDurations = map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "Suite1": { SourceFile: "spec/suite1_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "5000000000", P90: "7000000000"}, + Duration: api.DurationPercentiles{P50: "5000000000", P90: "7000000000"}, }, }, } @@ -126,7 +124,6 @@ func TestTestPlanner_StoreAndRestoreTestOptimizationPlanCache_RoundTripDurations restored := NewWithDependencies( &MockPlatformDetector{}, &MockTestOptimizationClient{}, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) if err := restored.restoreTestOptimizationPlanCache(); err != nil { @@ -166,17 +163,17 @@ func TestTestPlanner_RestoreTestOptimizationPlanCache_ComputesMissingWeights(t * _ = os.Chdir(tempDir) type partialTestOptimizationPlanCache struct { - TestSuiteDurations map[string]map[string]testoptimization.TestSuiteDurationInfo `json:"testSuiteDurations"` - SuiteAggregates map[testSuiteKey]testSuiteAggregate `json:"suiteAggregates"` - SuitesBySourceFile map[string][]testSuiteKey `json:"suitesBySourceFile"` + TestSuiteDurations map[string]map[string]api.TestSuiteDurationInfo `json:"testSuiteDurations"` + SuiteAggregates map[testSuiteKey]testSuiteAggregate `json:"suiteAggregates"` + SuitesBySourceFile map[string][]testSuiteKey `json:"suitesBySourceFile"` } cache := partialTestOptimizationPlanCache{ - TestSuiteDurations: map[string]map[string]testoptimization.TestSuiteDurationInfo{ + TestSuiteDurations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "Suite1": { SourceFile: "spec/suite1_spec.rb", - Duration: testoptimization.DurationPercentiles{P50: "5000000000", P90: "7000000000"}, + Duration: api.DurationPercentiles{P50: "5000000000", P90: "7000000000"}, }, }, }, @@ -203,7 +200,6 @@ func TestTestPlanner_RestoreTestOptimizationPlanCache_ComputesMissingWeights(t * restored := NewWithDependencies( &MockPlatformDetector{}, &MockTestOptimizationClient{}, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) if err := restored.restoreTestOptimizationPlanCache(); err != nil { @@ -316,7 +312,6 @@ func TestTestSuiteKey_JSONMapKeyRoundTrip(t *testing.T) { runner := NewWithDependencies( &MockPlatformDetector{}, &MockTestOptimizationClient{}, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) runner.suiteAggregates = map[testSuiteKey]testSuiteAggregate{ @@ -338,7 +333,6 @@ func TestTestSuiteKey_JSONMapKeyRoundTrip(t *testing.T) { restored := NewWithDependencies( &MockPlatformDetector{}, &MockTestOptimizationClient{}, - &MockTestSuiteDurationsClient{}, newDefaultMockCIProviderDetector(), ) if err := restored.restoreTestOptimizationPlanCache(); err != nil { diff --git a/internal/runner/worker_env.go b/internal/runner/worker_env.go index 0261b01..a087a2b 100644 --- a/internal/runner/worker_env.go +++ b/internal/runner/worker_env.go @@ -45,17 +45,17 @@ func replaceIndexPlaceholdersInString(value string, nodeIndex int, workerIndex i } func ensureTestSessionName(workerEnv map[string]string, nodeIndex int, workerIndex int) { - if _, ok := workerEnv[ciConstants.CIVisibilityTestSessionNameEnvironmentVariable]; ok { + if _, ok := workerEnv[ciConstants.TestOptimizationTestSessionNameEnvironmentVariable]; ok { return } - if sessionName, ok := os.LookupEnv(ciConstants.CIVisibilityTestSessionNameEnvironmentVariable); ok { - workerEnv[ciConstants.CIVisibilityTestSessionNameEnvironmentVariable] = replaceIndexPlaceholdersInString(sessionName, nodeIndex, workerIndex) + if sessionName, ok := os.LookupEnv(ciConstants.TestOptimizationTestSessionNameEnvironmentVariable); ok { + workerEnv[ciConstants.TestOptimizationTestSessionNameEnvironmentVariable] = replaceIndexPlaceholdersInString(sessionName, nodeIndex, workerIndex) return } service := runmetadata.ResolveServiceName(ciUtils.GetCITags()[ciConstants.GitRepositoryURL]) - workerEnv[ciConstants.CIVisibilityTestSessionNameEnvironmentVariable] = fmt.Sprintf("%s-node-%d-worker-%d", service, nodeIndex, workerIndex) + workerEnv[ciConstants.TestOptimizationTestSessionNameEnvironmentVariable] = fmt.Sprintf("%s-node-%d-worker-%d", service, nodeIndex, workerIndex) } func ensureManifestFile(workerEnv map[string]string) { diff --git a/internal/runner/worker_env_test.go b/internal/runner/worker_env_test.go index bed769c..2560691 100644 --- a/internal/runner/worker_env_test.go +++ b/internal/runner/worker_env_test.go @@ -48,7 +48,7 @@ func chdirForTest(t *testing.T, dir string) { func TestRunBatch_DefaultTestSessionName(t *testing.T) { ciUtils.ResetCITags() t.Cleanup(ciUtils.ResetCITags) - unsetEnvForTest(t, ciConstants.CIVisibilityTestSessionNameEnvironmentVariable) + unsetEnvForTest(t, ciConstants.TestOptimizationTestSessionNameEnvironmentVariable) unsetEnvForTest(t, "DD_SERVICE") t.Setenv("DD_GIT_REPOSITORY_URL", "https://github.com/DataDog/orders.git") @@ -63,7 +63,7 @@ func TestRunBatch_DefaultTestSessionName(t *testing.T) { } call := mockFramework.GetRunTestsCalls()[0] - sessionName := call.EnvMap[ciConstants.CIVisibilityTestSessionNameEnvironmentVariable] + sessionName := call.EnvMap[ciConstants.TestOptimizationTestSessionNameEnvironmentVariable] if sessionName != "orders-node-2-worker-4" { t.Errorf("Expected default DD_TEST_SESSION_NAME=orders-node-2-worker-4, got %s", sessionName) } @@ -72,7 +72,7 @@ func TestRunBatch_DefaultTestSessionName(t *testing.T) { func TestRunBatch_DefaultTestSessionNameUsesDDService(t *testing.T) { ciUtils.ResetCITags() t.Cleanup(ciUtils.ResetCITags) - unsetEnvForTest(t, ciConstants.CIVisibilityTestSessionNameEnvironmentVariable) + unsetEnvForTest(t, ciConstants.TestOptimizationTestSessionNameEnvironmentVariable) t.Setenv("DD_SERVICE", "billing") t.Setenv("DD_GIT_REPOSITORY_URL", "https://github.com/DataDog/orders.git") @@ -87,7 +87,7 @@ func TestRunBatch_DefaultTestSessionNameUsesDDService(t *testing.T) { } call := mockFramework.GetRunTestsCalls()[0] - sessionName := call.EnvMap[ciConstants.CIVisibilityTestSessionNameEnvironmentVariable] + sessionName := call.EnvMap[ciConstants.TestOptimizationTestSessionNameEnvironmentVariable] if sessionName != "billing-node-3-worker-7" { t.Errorf("Expected default DD_TEST_SESSION_NAME=billing-node-3-worker-7, got %s", sessionName) } @@ -96,7 +96,7 @@ func TestRunBatch_DefaultTestSessionNameUsesDDService(t *testing.T) { func TestRunBatch_UserTestSessionNameSupportsPlaceholders(t *testing.T) { ciUtils.ResetCITags() t.Cleanup(ciUtils.ResetCITags) - t.Setenv(ciConstants.CIVisibilityTestSessionNameEnvironmentVariable, "custom-node-{{nodeIndex}}-worker-{{workerIndex}}") + t.Setenv(ciConstants.TestOptimizationTestSessionNameEnvironmentVariable, "custom-node-{{nodeIndex}}-worker-{{workerIndex}}") mockFramework := &MockFramework{ FrameworkName: "rspec", @@ -109,7 +109,7 @@ func TestRunBatch_UserTestSessionNameSupportsPlaceholders(t *testing.T) { } call := mockFramework.GetRunTestsCalls()[0] - sessionName := call.EnvMap[ciConstants.CIVisibilityTestSessionNameEnvironmentVariable] + sessionName := call.EnvMap[ciConstants.TestOptimizationTestSessionNameEnvironmentVariable] if sessionName != "custom-node-5-worker-8" { t.Errorf("Expected DD_TEST_SESSION_NAME placeholders to be replaced, got %s", sessionName) } @@ -118,14 +118,14 @@ func TestRunBatch_UserTestSessionNameSupportsPlaceholders(t *testing.T) { func TestRunBatch_WorkerEnvTestSessionNameTakesPrecedence(t *testing.T) { ciUtils.ResetCITags() t.Cleanup(ciUtils.ResetCITags) - t.Setenv(ciConstants.CIVisibilityTestSessionNameEnvironmentVariable, "outer-session") + t.Setenv(ciConstants.TestOptimizationTestSessionNameEnvironmentVariable, "outer-session") mockFramework := &MockFramework{ FrameworkName: "rspec", RunTestsCalls: []RunTestsCall{}, } workerEnvMap := map[string]string{ - ciConstants.CIVisibilityTestSessionNameEnvironmentVariable: "worker-node-{{nodeIndex}}-worker-{{workerIndex}}", + ciConstants.TestOptimizationTestSessionNameEnvironmentVariable: "worker-node-{{nodeIndex}}-worker-{{workerIndex}}", } err := newTestExecutor(context.Background(), mockFramework, workerEnvMap, roundRobinTestPlanner{}).runBatch([]string{"test/file1_test.rb"}, 9, 1) @@ -134,7 +134,7 @@ func TestRunBatch_WorkerEnvTestSessionNameTakesPrecedence(t *testing.T) { } call := mockFramework.GetRunTestsCalls()[0] - sessionName := call.EnvMap[ciConstants.CIVisibilityTestSessionNameEnvironmentVariable] + sessionName := call.EnvMap[ciConstants.TestOptimizationTestSessionNameEnvironmentVariable] if sessionName != "worker-node-9-worker-1" { t.Errorf("Expected worker env DD_TEST_SESSION_NAME to take precedence, got %s", sessionName) } diff --git a/internal/testoptimization/api/durations_api.go b/internal/testoptimization/api/durations_api.go new file mode 100644 index 0000000..8ad493f --- /dev/null +++ b/internal/testoptimization/api/durations_api.go @@ -0,0 +1,204 @@ +package api + +import ( + "fmt" + "log/slog" + "time" +) + +const ( + durationsRequestType string = "ci_app_ddtest_test_suite_durations_request" + durationsURLPath string = "api/v2/ci/ddtest/test_suite_durations" + + defaultDurationsPageSize int = 500 +) + +type ( + // request types + + durationsRequest struct { + Data durationsRequestData `json:"data"` + } + + durationsRequestData struct { + Type string `json:"type"` + Attributes durationsRequestAttributes `json:"attributes"` + } + + durationsRequestAttributes struct { + RepositoryURL string `json:"repository_url"` + Service string `json:"service,omitempty"` + PageInfo *durationsRequestPageInfo `json:"page_info,omitempty"` + } + + durationsRequestPageInfo struct { + PageSize int `json:"page_size,omitempty"` + PageState string `json:"page_state,omitempty"` + } + + // response types + + durationsResponse struct { + Data struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes durationsResponseAttributes `json:"attributes"` + } `json:"data"` + } + + durationsResponseAttributes struct { + TestSuites map[string]map[string]TestSuiteDurationInfo `json:"test_suites"` + PageInfo *durationsResponsePageInfo `json:"page_info,omitempty"` + } + + durationsResponsePageInfo struct { + Cursor string `json:"cursor,omitempty"` + Size int `json:"size,omitempty"` + HasNext bool `json:"has_next"` + } + + // public response types + + TestSuiteDurationsResponseData struct { + TestSuites map[string]map[string]TestSuiteDurationInfo `json:"test_suites"` + } + + TestSuiteDurationInfo struct { + SourceFile string `json:"source_file"` + Duration DurationPercentiles `json:"duration"` + } + + DurationPercentiles struct { + P50 string `json:"p50"` + P90 string `json:"p90"` + } +) + +func (c *transport) GetTestSuiteDurations() *TestSuiteDurationsResponseData { + startTime := time.Now() + if c.repositoryURL == "" { + slog.Error("Test durations API errored", "duration", time.Since(startTime), "error", "repository URL is required") + return emptyTestSuiteDurationsResponseData() + } + + durations, err := c.fetchTestSuiteDurations(c.repositoryURL, c.serviceName) + if err != nil { + slog.Error("Test durations API errored", + "service", c.serviceName, + "repositoryURL", c.repositoryURL, + "duration", time.Since(startTime), + "error", err) + return emptyTestSuiteDurationsResponseData() + } + + totalSuites := countTestSuiteDurations(durations) + if totalSuites == 0 { + slog.Warn("Test durations API returned no test suites", + "service", c.serviceName, + "repositoryURL", c.repositoryURL, + "modulesCount", len(durations), + "testSuitesCount", totalSuites, + "duration", time.Since(startTime)) + return emptyTestSuiteDurationsResponseData() + } + + slog.Info("Fetched test suite durations", + "service", c.serviceName, + "repositoryURL", c.repositoryURL, + "modulesCount", len(durations), + "testSuitesCount", totalSuites, + "duration", time.Since(startTime)) + return &TestSuiteDurationsResponseData{TestSuites: durations} +} + +func emptyTestSuiteDurationsResponseData() *TestSuiteDurationsResponseData { + return &TestSuiteDurationsResponseData{ + TestSuites: map[string]map[string]TestSuiteDurationInfo{}, + } +} + +func (c *transport) fetchTestSuiteDurations(repositoryURL, service string) (map[string]map[string]TestSuiteDurationInfo, error) { + startTime := time.Now() + allSuites := make(map[string]map[string]TestSuiteDurationInfo) + + slog.Debug("Fetching test suite durations...") + + cursor := "" + for { + data, err := c.fetchTestSuiteDurationsPage(repositoryURL, service, cursor, defaultDurationsPageSize) + if err != nil { + return nil, fmt.Errorf("fetching test suite durations: %w", err) + } + + for module, suites := range data.TestSuites { + if _, ok := allSuites[module]; !ok { + allSuites[module] = make(map[string]TestSuiteDurationInfo) + } + for suite, info := range suites { + allSuites[module][suite] = info + } + } + + if data.PageInfo == nil || !data.PageInfo.HasNext { + break + } + cursor = data.PageInfo.Cursor + } + + duration := time.Since(startTime) + totalSuites := 0 + for _, suites := range allSuites { + totalSuites += len(suites) + } + slog.Debug("Finished fetching test suite durations", "modules", len(allSuites), "suites", totalSuites, "duration", duration) + + return allSuites, nil +} + +func (c *transport) fetchTestSuiteDurationsPage(repositoryURL, service, cursor string, pageSize int) (*durationsResponseAttributes, error) { + if repositoryURL == "" { + return nil, fmt.Errorf("repository URL is required") + } + + var pageInfo *durationsRequestPageInfo + if pageSize > 0 || cursor != "" { + pageInfo = &durationsRequestPageInfo{ + PageSize: pageSize, + PageState: cursor, + } + } + + body := durationsRequest{ + Data: durationsRequestData{ + Type: durationsRequestType, + Attributes: durationsRequestAttributes{ + RepositoryURL: repositoryURL, + Service: service, + PageInfo: pageInfo, + }, + }, + } + + request := c.getPostRequestConfig(durationsURLPath, body) + response, err := c.handler.SendRequest(*request) + if err != nil { + return nil, fmt.Errorf("sending test suite durations request: %s", err) + } + + slog.Debug("test_suite_durations", "responseBody", string(response.Body)) + + var responseObject durationsResponse + if err := response.Unmarshal(&responseObject); err != nil { + return nil, fmt.Errorf("unmarshalling test suite durations response: %s", err) + } + + return &responseObject.Data.Attributes, nil +} + +func countTestSuiteDurations(testSuiteDurations map[string]map[string]TestSuiteDurationInfo) int { + totalSuites := 0 + for _, suites := range testSuiteDurations { + totalSuites += len(suites) + } + return totalSuites +} diff --git a/internal/testoptimization/api/durations_api_test.go b/internal/testoptimization/api/durations_api_test.go new file mode 100644 index 0000000..17a915f --- /dev/null +++ b/internal/testoptimization/api/durations_api_test.go @@ -0,0 +1,452 @@ +package api + +import ( + "bytes" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/DataDog/ddtest/civisibility/constants" +) + +type durationsRequestRecord struct { + RepositoryURL string + Service string + Cursor string + PageSize int +} + +func newDurationsTestClient(server *httptest.Server) *transport { + return &transport{ + agentless: true, + baseURL: server.URL, + serviceName: "my-service", + repositoryURL: "github.com/DataDog/foo", + headers: map[string]string{}, + handler: NewRequestHandlerWithClient(server.Client()), + } +} + +func newDurationsTestServer(t *testing.T, responses []string, records *[]durationsRequestRecord) *httptest.Server { + t.Helper() + requestCount := 0 + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST request, got %s", r.Method) + } + if strings.TrimPrefix(r.URL.Path, "/") != durationsURLPath { + t.Fatalf("unexpected request path %s", r.URL.Path) + } + if requestCount >= len(responses) { + t.Fatalf("unexpected extra request") + } + + var request durationsRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + + record := durationsRequestRecord{ + RepositoryURL: request.Data.Attributes.RepositoryURL, + Service: request.Data.Attributes.Service, + } + if request.Data.Attributes.PageInfo != nil { + record.Cursor = request.Data.Attributes.PageInfo.PageState + record.PageSize = request.Data.Attributes.PageInfo.PageSize + } + *records = append(*records, record) + + w.Header().Set(HeaderContentType, ContentTypeJSON) + _, _ = w.Write([]byte(responses[requestCount])) + requestCount++ + })) +} + +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + originalLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { + slog.SetDefault(originalLogger) + }) + return &buf +} + +func TestClientGetTestSuiteDurationsLogsSuccess(t *testing.T) { + logs := captureLogs(t) + var records []durationsRequestRecord + server := newDurationsTestServer(t, []string{ + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{"test_suites":{"module1":{"suite1":{"source_file":"spec/user_spec.rb","duration":{"p50":"280000000","p90":"350000000"}}}}}}}`, + }, &records) + defer server.Close() + + client := newDurationsTestClient(server) + result := client.GetTestSuiteDurations() + + if len(records) != 1 { + t.Fatalf("expected 1 durations request, got %d", len(records)) + } + if records[0].RepositoryURL != "github.com/DataDog/foo" { + t.Errorf("expected repository URL 'github.com/DataDog/foo', got %q", records[0].RepositoryURL) + } + if records[0].Service != "my-service" { + t.Errorf("expected service 'my-service', got %q", records[0].Service) + } + if records[0].Cursor != "" || records[0].PageSize != defaultDurationsPageSize { + t.Errorf("expected first page request with default page size, got cursor=%q pageSize=%d", records[0].Cursor, records[0].PageSize) + } + if len(result.TestSuites) != 1 { + t.Errorf("expected 1 module, got %d", len(result.TestSuites)) + } + if !strings.Contains(logs.String(), "level=INFO") || + !strings.Contains(logs.String(), "Fetched test suite durations") || + !strings.Contains(logs.String(), "modulesCount=1") || + !strings.Contains(logs.String(), "testSuitesCount=1") || + !strings.Contains(logs.String(), "duration=") { + t.Errorf("expected INFO log for non-empty durations response, got logs: %s", logs.String()) + } +} + +func TestClientGetTestSuiteDurationsMissingRepositoryReturnsEmptyAndLogsError(t *testing.T) { + logs := captureLogs(t) + client := &transport{} + + result := client.GetTestSuiteDurations() + + if len(result.TestSuites) != 0 { + t.Errorf("expected empty durations on missing repository URL, got %v", result) + } + if !strings.Contains(logs.String(), "level=ERROR") || + !strings.Contains(logs.String(), "Test durations API errored") || + !strings.Contains(logs.String(), "repository URL is required") || + !strings.Contains(logs.String(), "duration=") { + t.Errorf("expected ERROR log for missing repository URL, got logs: %s", logs.String()) + } +} + +func TestClientGetTestSuiteDurationsAPIErrorReturnsEmptyAndLogsError(t *testing.T) { + logs := captureLogs(t) + var records []durationsRequestRecord + server := newDurationsTestServer(t, []string{`{"data":`}, &records) + defer server.Close() + + client := newDurationsTestClient(server) + result := client.GetTestSuiteDurations() + + if len(result.TestSuites) != 0 { + t.Errorf("expected empty durations on API error, got %v", result) + } + if !strings.Contains(logs.String(), "level=ERROR") || + !strings.Contains(logs.String(), "Test durations API errored") || + !strings.Contains(logs.String(), "repositoryURL=github.com/DataDog/foo") || + !strings.Contains(logs.String(), "service=my-service") || + !strings.Contains(logs.String(), "unmarshalling") || + !strings.Contains(logs.String(), "duration=") { + t.Errorf("expected ERROR log for durations API failure, got logs: %s", logs.String()) + } +} + +func TestClientGetTestSuiteDurationsEmptyResponseReturnsEmptyAndLogsWarn(t *testing.T) { + logs := captureLogs(t) + var records []durationsRequestRecord + server := newDurationsTestServer(t, []string{ + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{"test_suites":{}}}}`, + }, &records) + defer server.Close() + + client := newDurationsTestClient(server) + result := client.GetTestSuiteDurations() + + if len(result.TestSuites) != 0 { + t.Errorf("expected empty durations on empty response, got %v", result) + } + if !strings.Contains(logs.String(), "level=WARN") || + !strings.Contains(logs.String(), "Test durations API returned no test suites") || + !strings.Contains(logs.String(), "modulesCount=0") || + !strings.Contains(logs.String(), "testSuitesCount=0") || + !strings.Contains(logs.String(), "duration=") { + t.Errorf("expected WARN log for empty durations response, got logs: %s", logs.String()) + } +} + +func TestClientFetchTestSuiteDurationsSinglePage(t *testing.T) { + var records []durationsRequestRecord + server := newDurationsTestServer(t, []string{ + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{"test_suites":{"module1":{"suite1":{"source_file":"spec/user_spec.rb","duration":{"p50":"280000000","p90":"350000000"}},"suite2":{"source_file":"spec/order_spec.rb","duration":{"p50":"100000000","p90":"150000000"}}},"module2":{"suite3":{"source_file":"spec/product_spec.rb","duration":{"p50":"500000000","p90":"600000000"}}}}}}}`, + }, &records) + defer server.Close() + + client := newDurationsTestClient(server) + result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") + + if err != nil { + t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected 1 durations request, got %d", len(records)) + } + if records[0].RepositoryURL != "github.com/DataDog/foo" { + t.Errorf("expected repository URL 'github.com/DataDog/foo', got %q", records[0].RepositoryURL) + } + if records[0].Service != "my-service" { + t.Errorf("expected service 'my-service', got %q", records[0].Service) + } + if len(result) != 2 { + t.Errorf("expected 2 modules, got %d", len(result)) + } + + module1, exists := result["module1"] + if !exists { + t.Error("expected module1 to exist") + return + } + if len(module1) != 2 { + t.Errorf("expected 2 suites in module1, got %d", len(module1)) + } + + suite1, exists := module1["suite1"] + if !exists { + t.Error("expected suite1 to exist in module1") + return + } + if suite1.SourceFile != "spec/user_spec.rb" { + t.Errorf("expected source file 'spec/user_spec.rb', got %q", suite1.SourceFile) + } + if suite1.Duration.P50 != "280000000" { + t.Errorf("expected P50 '280000000', got %q", suite1.Duration.P50) + } + if suite1.Duration.P90 != "350000000" { + t.Errorf("expected P90 '350000000', got %q", suite1.Duration.P90) + } + + module2, exists := result["module2"] + if !exists { + t.Error("expected module2 to exist") + return + } + if len(module2) != 1 { + t.Errorf("expected 1 suite in module2, got %d", len(module2)) + } +} + +func TestClientFetchTestSuiteDurationsPagination(t *testing.T) { + var records []durationsRequestRecord + server := newDurationsTestServer(t, []string{ + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{"test_suites":{"module1":{"suite1":{"source_file":"spec/user_spec.rb","duration":{"p50":"280000000","p90":"350000000"}}}},"page_info":{"cursor":"abc123","size":500,"has_next":true}}}}`, + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{"test_suites":{"module1":{"suite2":{"source_file":"spec/order_spec.rb","duration":{"p50":"100000000","p90":"150000000"}}},"module2":{"suite3":{"source_file":"spec/product_spec.rb","duration":{"p50":"500000000","p90":"600000000"}}}},"page_info":{"size":500,"has_next":false}}}}`, + }, &records) + defer server.Close() + + client := newDurationsTestClient(server) + result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") + + if err != nil { + t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) + } + if len(records) != 2 { + t.Fatalf("expected 2 durations requests, got %d", len(records)) + } + if records[0].Cursor != "" { + t.Errorf("first request should have empty cursor, got %q", records[0].Cursor) + } + if records[1].Cursor != "abc123" { + t.Errorf("second request should have cursor 'abc123', got %q", records[1].Cursor) + } + if len(result) != 2 { + t.Errorf("expected 2 modules, got %d", len(result)) + } + + module1, exists := result["module1"] + if !exists { + t.Error("expected module1 to exist") + return + } + if len(module1) != 2 { + t.Errorf("expected 2 suites in module1 merged from both pages, got %d", len(module1)) + } + if _, exists := module1["suite1"]; !exists { + t.Error("expected suite1 to exist in module1 from page 1") + } + if _, exists := module1["suite2"]; !exists { + t.Error("expected suite2 to exist in module1 from page 2") + } +} + +func TestClientFetchTestSuiteDurationsEmptyResponse(t *testing.T) { + var records []durationsRequestRecord + server := newDurationsTestServer(t, []string{ + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{"test_suites":{}}}}`, + }, &records) + defer server.Close() + + client := newDurationsTestClient(server) + result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") + + if err != nil { + t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) + } + if result == nil { + t.Error("fetchTestSuiteDurations() should return non-nil map even with empty data") + } + if len(result) != 0 { + t.Errorf("fetchTestSuiteDurations() should return empty map, got %d modules", len(result)) + } +} + +func TestClientFetchTestSuiteDurationsNilTestSuites(t *testing.T) { + var records []durationsRequestRecord + server := newDurationsTestServer(t, []string{ + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{}}}`, + }, &records) + defer server.Close() + + client := newDurationsTestClient(server) + result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") + + if err != nil { + t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) + } + if result == nil { + t.Error("fetchTestSuiteDurations() should return non-nil map even with nil test suites") + } + if len(result) != 0 { + t.Errorf("fetchTestSuiteDurations() should return empty map, got %d modules", len(result)) + } +} + +func TestClientFetchTestSuiteDurationsAPIError(t *testing.T) { + var records []durationsRequestRecord + server := newDurationsTestServer(t, []string{`{"data":`}, &records) + defer server.Close() + + client := newDurationsTestClient(server) + result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") + + if err == nil { + t.Error("fetchTestSuiteDurations() should return error when API fails") + } + if result != nil { + t.Error("fetchTestSuiteDurations() should return nil result when API fails") + } +} + +func TestClientFetchTestSuiteDurationsPaginationError(t *testing.T) { + var records []durationsRequestRecord + server := newDurationsTestServer(t, []string{ + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{"test_suites":{"module1":{"suite1":{"source_file":"spec/user_spec.rb","duration":{"p50":"280000000","p90":"350000000"}}}},"page_info":{"cursor":"abc123","size":500,"has_next":true}}}}`, + `{"data":`, + }, &records) + defer server.Close() + + client := newDurationsTestClient(server) + result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") + + if err == nil { + t.Error("fetchTestSuiteDurations() should return error when pagination fails") + } + if result != nil { + t.Error("fetchTestSuiteDurations() should return nil result when pagination fails") + } +} + +func TestClientFetchTestSuiteDurationsNilPageInfo(t *testing.T) { + var records []durationsRequestRecord + server := newDurationsTestServer(t, []string{ + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{"test_suites":{"module1":{"suite1":{"source_file":"spec/user_spec.rb","duration":{"p50":"280000000","p90":"350000000"}}}}}}}`, + }, &records) + defer server.Close() + + client := newDurationsTestClient(server) + result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") + + if err != nil { + t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) + } + if len(result) != 1 { + t.Errorf("expected 1 module, got %d", len(result)) + } + if len(records) != 1 { + t.Errorf("expected 1 request when PageInfo is nil, got %d", len(records)) + } +} + +func TestClientFetchTestSuiteDurationsThreePages(t *testing.T) { + var records []durationsRequestRecord + server := newDurationsTestServer(t, []string{ + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{"test_suites":{"module1":{"suite1":{"source_file":"spec/a_spec.rb","duration":{"p50":"100","p90":"200"}}}},"page_info":{"cursor":"page2","has_next":true}}}}`, + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{"test_suites":{"module1":{"suite2":{"source_file":"spec/b_spec.rb","duration":{"p50":"300","p90":"400"}}}},"page_info":{"cursor":"page3","has_next":true}}}}`, + `{"data":{"id":"durations","type":"ci_app_ddtest_test_suite_durations_request","attributes":{"test_suites":{"module1":{"suite3":{"source_file":"spec/c_spec.rb","duration":{"p50":"500","p90":"600"}}}},"page_info":{"has_next":false}}}}`, + }, &records) + defer server.Close() + + client := newDurationsTestClient(server) + result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") + + if err != nil { + t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) + } + if len(records) != 3 { + t.Errorf("expected 3 requests, got %d", len(records)) + } + if records[0].Cursor != "" { + t.Errorf("first cursor should be empty, got %q", records[0].Cursor) + } + if records[1].Cursor != "page2" { + t.Errorf("second cursor should be 'page2', got %q", records[1].Cursor) + } + if records[2].Cursor != "page3" { + t.Errorf("third cursor should be 'page3', got %q", records[2].Cursor) + } + + module1 := result["module1"] + if len(module1) != 3 { + t.Errorf("expected 3 suites merged in module1, got %d", len(module1)) + } +} + +func TestClientFetchTestSuiteDurationsEmptyRepositoryURL(t *testing.T) { + client := &transport{} + + _, err := client.fetchTestSuiteDurationsPage("", "my-service", "", 100) + if err == nil { + t.Error("fetchTestSuiteDurationsPage() should return error when repository URL is empty") + } +} + +func TestNewTransportWithServiceNameAgentlessMissingAPIKeyReturnsNil(t *testing.T) { + t.Setenv(constants.TestOptimizationAgentlessEnabledEnvironmentVariable, "true") + t.Setenv(constants.APIKeyEnvironmentVariable, "") + + client := NewTransportWithServiceName("my-service") + if client != nil { + t.Fatal("NewTransportWithServiceName() should return nil when agentless mode is missing an API key") + } +} + +func TestNewTransportWithServiceNameAgentUnixSocketConfiguresHTTPTransport(t *testing.T) { + socketPath := "/tmp/ddtest-agent.sock" + t.Setenv(constants.TestOptimizationAgentlessEnabledEnvironmentVariable, "false") + t.Setenv("DD_TRACE_AGENT_URL", "unix://"+socketPath) + t.Setenv("DD_AGENT_HOST", "") + t.Setenv("DD_TRACE_AGENT_PORT", "") + + apiTransport := NewTransportWithServiceName("my-service") + client, ok := apiTransport.(*transport) + if !ok { + t.Fatalf("NewTransportWithServiceName() returned %T", apiTransport) + } + if client.baseURL != "http://UDS__tmp_ddtest-agent.sock" { + t.Errorf("expected UDS base URL host, got %q", client.baseURL) + } + if client.handler == nil || client.handler.Client == nil { + t.Fatal("expected HTTP client to be configured") + } + if _, ok := client.handler.Client.Transport.(*http.Transport); !ok { + t.Fatalf("expected Unix socket HTTP transport, got %T", client.handler.Client.Transport) + } +} diff --git a/internal/utils/net/http.go b/internal/testoptimization/api/http.go similarity index 99% rename from internal/utils/net/http.go rename to internal/testoptimization/api/http.go index 66d4887..1ec64d3 100644 --- a/internal/utils/net/http.go +++ b/internal/testoptimization/api/http.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2024 Datadog, Inc. -package net +package api import ( "bytes" diff --git a/internal/utils/net/known_tests_api.go b/internal/testoptimization/api/known_tests_api.go similarity index 98% rename from internal/utils/net/known_tests_api.go rename to internal/testoptimization/api/known_tests_api.go index 6f016bf..ed2f707 100644 --- a/internal/utils/net/known_tests_api.go +++ b/internal/testoptimization/api/known_tests_api.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2024 Datadog, Inc. -package net +package api import ( "encoding/json" @@ -61,7 +61,7 @@ type ( KnownTestsResponseDataSuites map[string][]string ) -func (c *client) GetKnownTests() (*KnownTestsResponseData, error) { +func (c *transport) GetKnownTests() (*KnownTestsResponseData, error) { if c.repositoryURL == "" || c.commitSha == "" { return nil, fmt.Errorf("civisibility.GetKnownTests: repository URL and commit SHA are required") } diff --git a/internal/utils/net/known_tests_api_test.go b/internal/testoptimization/api/known_tests_api_test.go similarity index 99% rename from internal/utils/net/known_tests_api_test.go rename to internal/testoptimization/api/known_tests_api_test.go index eba081d..202527d 100644 --- a/internal/utils/net/known_tests_api_test.go +++ b/internal/testoptimization/api/known_tests_api_test.go @@ -1,4 +1,4 @@ -package net +package api import ( "encoding/json" diff --git a/internal/utils/net/raw_response_test.go b/internal/testoptimization/api/raw_response_test.go similarity index 98% rename from internal/utils/net/raw_response_test.go rename to internal/testoptimization/api/raw_response_test.go index 18445b9..48808a5 100644 --- a/internal/utils/net/raw_response_test.go +++ b/internal/testoptimization/api/raw_response_test.go @@ -1,4 +1,4 @@ -package net +package api import ( "bytes" @@ -9,8 +9,8 @@ import ( "testing" ) -func newRawResponseTestClient(server *httptest.Server) *client { - return &client{ +func newRawResponseTestClient(server *httptest.Server) *transport { + return &transport{ agentless: true, baseURL: server.URL, environment: "ci", diff --git a/internal/utils/net/searchcommits_api.go b/internal/testoptimization/api/searchcommits_api.go similarity index 94% rename from internal/utils/net/searchcommits_api.go rename to internal/testoptimization/api/searchcommits_api.go index aae8c5c..1fa3467 100644 --- a/internal/utils/net/searchcommits_api.go +++ b/internal/testoptimization/api/searchcommits_api.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2024 Datadog, Inc. -package net +package api import ( "fmt" @@ -28,7 +28,7 @@ type ( } ) -func (c *client) GetCommits(localCommits []string) ([]string, error) { +func (c *transport) GetCommits(localCommits []string) ([]string, error) { if c.repositoryURL == "" { return nil, fmt.Errorf("civisibility.GetCommits: repository URL is required") } diff --git a/internal/testoptimization/api/searchcommits_api_test.go b/internal/testoptimization/api/searchcommits_api_test.go new file mode 100644 index 0000000..8f13ebd --- /dev/null +++ b/internal/testoptimization/api/searchcommits_api_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTransportGetCommitsRequestAndResponse(t *testing.T) { + var captured searchCommits + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/"+searchCommitsURLPath { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set(HeaderContentType, ContentTypeJSON) + _ = json.NewEncoder(w).Encode(searchCommits{ + Data: []searchCommitsData{ + {ID: "remote-1", Type: searchCommitsType}, + {ID: "remote-2", Type: searchCommitsType}, + }, + }) + })) + defer server.Close() + + transport := newRawResponseTestClient(server) + commits, err := transport.GetCommits([]string{"local-1", "local-2"}) + if err != nil { + t.Fatalf("GetCommits() returned error: %v", err) + } + if got, want := captured.Meta.RepositoryURL, transport.repositoryURL; got != want { + t.Fatalf("repository URL = %q, want %q", got, want) + } + if len(captured.Data) != 2 || captured.Data[0].ID != "local-1" || captured.Data[1].ID != "local-2" { + t.Fatalf("unexpected commit request: %#v", captured.Data) + } + for _, commit := range captured.Data { + if commit.Type != searchCommitsType { + t.Fatalf("commit %q type = %q, want %q", commit.ID, commit.Type, searchCommitsType) + } + } + if len(commits) != 2 || commits[0] != "remote-1" || commits[1] != "remote-2" { + t.Fatalf("unexpected remote commits: %#v", commits) + } +} + +func TestTransportGetCommitsErrors(t *testing.T) { + if _, err := (&transport{}).GetCommits([]string{"local"}); err == nil { + t.Fatal("expected repository URL error") + } + + t.Run("request failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "backend error", http.StatusInternalServerError) + })) + defer server.Close() + + commits, err := newRawResponseTestClient(server).GetCommits([]string{"local"}) + if commits != nil { + t.Fatalf("expected nil commits, got %#v", commits) + } + if err == nil || !strings.Contains(err.Error(), "sending search commits request") { + t.Fatalf("expected request error, got %v", err) + } + }) + + t.Run("unmarshal failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(HeaderContentType, ContentTypeJSON) + _, _ = w.Write([]byte(`{"data":`)) + })) + defer server.Close() + + commits, err := newRawResponseTestClient(server).GetCommits([]string{"local"}) + if commits != nil { + t.Fatalf("expected nil commits, got %#v", commits) + } + if err == nil || !strings.Contains(err.Error(), "unmarshalling search commits response") { + t.Fatalf("expected unmarshal error, got %v", err) + } + }) +} diff --git a/internal/utils/net/sendpackfiles_api.go b/internal/testoptimization/api/sendpackfiles_api.go similarity index 94% rename from internal/utils/net/sendpackfiles_api.go rename to internal/testoptimization/api/sendpackfiles_api.go index 2381cd8..364f558 100644 --- a/internal/utils/net/sendpackfiles_api.go +++ b/internal/testoptimization/api/sendpackfiles_api.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2024 Datadog, Inc. -package net +package api import ( "fmt" @@ -28,7 +28,7 @@ type ( } ) -func (c *client) SendPackFiles(commitSha string, packFiles []string) (bytes int64, err error) { +func (c *transport) SendPackFiles(commitSha string, packFiles []string) (bytes int64, err error) { if len(packFiles) == 0 { return 0, nil } diff --git a/internal/testoptimization/api/sendpackfiles_api_test.go b/internal/testoptimization/api/sendpackfiles_api_test.go new file mode 100644 index 0000000..3a1a335 --- /dev/null +++ b/internal/testoptimization/api/sendpackfiles_api_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTransportSendPackFilesRequestAndResponse(t *testing.T) { + var pushed pushedShaBody + var packfiles [][]byte + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.URL.Path != "/"+sendPackFilesURLPath { + t.Fatalf("unexpected path %s", r.URL.Path) + } + reader, err := r.MultipartReader() + if err != nil { + t.Fatalf("multipart reader: %v", err) + } + sawPushedSha := false + sawPackfile := false + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("next part: %v", err) + } + body, _ := io.ReadAll(part) + switch part.FormName() { + case "pushedSha": + if part.Header.Get(HeaderContentType) != ContentTypeJSON { + t.Fatalf("pushedSha content type = %q", part.Header.Get(HeaderContentType)) + } + if err := json.Unmarshal(body, &pushed); err != nil { + t.Fatalf("decode pushedSha: %v", err) + } + sawPushedSha = true + case "packfile": + if part.Header.Get(HeaderContentType) != ContentTypeOctetStream { + t.Fatalf("packfile content type = %q", part.Header.Get(HeaderContentType)) + } + packfiles = append(packfiles, append([]byte(nil), body...)) + sawPackfile = true + default: + t.Fatalf("unexpected multipart field %q", part.FormName()) + } + } + if !sawPushedSha || !sawPackfile { + t.Fatalf("request should include pushedSha and packfile parts, saw pushedSha=%t packfile=%t", sawPushedSha, sawPackfile) + } + w.Header().Set(HeaderContentType, ContentTypeJSON) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + tempDir := t.TempDir() + firstPackPath := filepath.Join(tempDir, "objects-1.pack") + secondPackPath := filepath.Join(tempDir, "objects-2.pack") + if err := os.WriteFile(firstPackPath, []byte("pack-one"), 0o644); err != nil { + t.Fatalf("write first packfile: %v", err) + } + if err := os.WriteFile(secondPackPath, []byte("pack-two"), 0o644); err != nil { + t.Fatalf("write second packfile: %v", err) + } + + transport := newRawResponseTestClient(server) + bytesSent, err := transport.SendPackFiles("", []string{firstPackPath, secondPackPath}) + if err != nil { + t.Fatalf("SendPackFiles() returned error: %v", err) + } + if requests != 2 { + t.Fatalf("expected one request per packfile, got %d", requests) + } + if bytesSent != int64(len("pack-one")+len("pack-two")) { + t.Fatalf("bytes sent = %d, want %d", bytesSent, len("pack-one")+len("pack-two")) + } + if pushed.Data.ID != transport.commitSha || pushed.Data.Type != searchCommitsType { + t.Fatalf("unexpected pushed sha body: %#v", pushed) + } + if pushed.Meta.RepositoryURL != transport.repositoryURL { + t.Fatalf("repository URL = %q, want %q", pushed.Meta.RepositoryURL, transport.repositoryURL) + } + if len(packfiles) != 2 || string(packfiles[0]) != "pack-one" || string(packfiles[1]) != "pack-two" { + t.Fatalf("packfile bodies = %#v", packfiles) + } +} + +func TestTransportSendPackFilesErrors(t *testing.T) { + bytesSent, err := (&transport{}).SendPackFiles("", nil) + if err != nil || bytesSent != 0 { + t.Fatalf("empty packfiles should be a noop, got bytes=%d err=%v", bytesSent, err) + } + if _, err := (&transport{}).SendPackFiles("", []string{"missing"}); err == nil || + !strings.Contains(err.Error(), "repository URL and commit SHA are required") { + t.Fatalf("expected repository and sha error, got %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "bad request", http.StatusBadRequest) + })) + defer server.Close() + transport := newRawResponseTestClient(server) + + if _, err := transport.SendPackFiles(transport.commitSha, []string{"missing"}); err == nil || + !strings.Contains(err.Error(), "failed to read pack file") { + t.Fatalf("expected file read error, got %v", err) + } + + packPath := filepath.Join(t.TempDir(), "objects.pack") + if err := os.WriteFile(packPath, []byte("pack-data"), 0o644); err != nil { + t.Fatalf("write packfile: %v", err) + } + if _, err := transport.SendPackFiles(transport.commitSha, []string{packPath}); err == nil || + !strings.Contains(err.Error(), "unexpected response code 400") { + t.Fatalf("expected bad status error, got %v", err) + } +} diff --git a/internal/utils/net/settings_api.go b/internal/testoptimization/api/settings_api.go similarity index 94% rename from internal/utils/net/settings_api.go rename to internal/testoptimization/api/settings_api.go index fc10cac..88979f0 100644 --- a/internal/utils/net/settings_api.go +++ b/internal/testoptimization/api/settings_api.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2024 Datadog, Inc. -package net +package api import ( "fmt" @@ -60,16 +60,14 @@ type ( RequireGit bool `json:"require_git"` TestsSkipping bool `json:"tests_skipping"` KnownTestsEnabled bool `json:"known_tests_enabled"` - ImpactedTestsEnabled bool `json:"impacted_tests_enabled"` TestManagement struct { Enabled bool `json:"enabled"` AttemptToFixRetries int `json:"attempt_to_fix_retries"` } `json:"test_management"` - SubtestFeaturesEnabled bool `json:"-"` } ) -func (c *client) GetSettings() (*SettingsResponseData, error) { +func (c *transport) GetSettings() (*SettingsResponseData, error) { if c.repositoryURL == "" || c.commitSha == "" { return nil, fmt.Errorf("civisibility.GetSettings: repository URL and commit SHA are required") } diff --git a/internal/testoptimization/api/settings_api_test.go b/internal/testoptimization/api/settings_api_test.go new file mode 100644 index 0000000..53dac0f --- /dev/null +++ b/internal/testoptimization/api/settings_api_test.go @@ -0,0 +1,120 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTransportGetSettingsRequiresRepositoryAndCommit(t *testing.T) { + if _, err := (&transport{}).GetSettings(); err == nil { + t.Fatal("expected settings repository/sha error") + } +} + +func TestTransportGetSettingsRequestAndResponse(t *testing.T) { + var captured settingsRequest + expectedResponse := settingsResponse{} + expectedResponse.Data.Type = settingsRequestType + expectedResponse.Data.Attributes.CodeCoverage = true + expectedResponse.Data.Attributes.EarlyFlakeDetection.Enabled = true + expectedResponse.Data.Attributes.EarlyFlakeDetection.FaultySessionThreshold = 30 + expectedResponse.Data.Attributes.EarlyFlakeDetection.SlowTestRetries.FiveS = 25 + expectedResponse.Data.Attributes.EarlyFlakeDetection.SlowTestRetries.TenS = 20 + expectedResponse.Data.Attributes.EarlyFlakeDetection.SlowTestRetries.ThirtyS = 10 + expectedResponse.Data.Attributes.EarlyFlakeDetection.SlowTestRetries.FiveM = 5 + expectedResponse.Data.Attributes.FlakyTestRetriesEnabled = true + expectedResponse.Data.Attributes.ItrEnabled = true + expectedResponse.Data.Attributes.RequireGit = true + expectedResponse.Data.Attributes.TestsSkipping = true + expectedResponse.Data.Attributes.KnownTestsEnabled = true + expectedResponse.Data.Attributes.TestManagement.Enabled = true + expectedResponse.Data.Attributes.TestManagement.AttemptToFixRetries = 3 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST request, got %s", r.Method) + } + if r.URL.Path != "/"+settingsURLPath { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decode request: %v", err) + } + + expectedResponse.Data.ID = captured.Data.ID + w.Header().Set(HeaderContentType, ContentTypeJSON) + _ = json.NewEncoder(w).Encode(expectedResponse) + })) + defer server.Close() + + client := newRawResponseTestClient(server) + settings, err := client.GetSettings() + if err != nil { + t.Fatalf("GetSettings() returned error: %v", err) + } + + if captured.Data.ID != client.id { + t.Fatalf("request id = %q, want %q", captured.Data.ID, client.id) + } + if captured.Data.Type != settingsRequestType { + t.Fatalf("request type = %q, want %q", captured.Data.Type, settingsRequestType) + } + attributes := captured.Data.Attributes + if attributes.Service != client.serviceName || attributes.Env != client.environment { + t.Fatalf("service/env = %q/%q, want %q/%q", attributes.Service, attributes.Env, client.serviceName, client.environment) + } + if attributes.RepositoryURL != client.repositoryURL || attributes.Branch != client.branchName || attributes.Sha != client.commitSha { + t.Fatalf("repository/branch/sha = %q/%q/%q, want %q/%q/%q", + attributes.RepositoryURL, attributes.Branch, attributes.Sha, + client.repositoryURL, client.branchName, client.commitSha) + } + if attributes.Configurations.OsPlatform != client.testConfigurations.OsPlatform || + attributes.Configurations.OsArchitecture != client.testConfigurations.OsArchitecture || + attributes.Configurations.RuntimeName != client.testConfigurations.RuntimeName || + attributes.Configurations.RuntimeVersion != client.testConfigurations.RuntimeVersion { + t.Fatalf("configurations = %#v, want %#v", attributes.Configurations, client.testConfigurations) + } + + if *settings != expectedResponse.Data.Attributes { + t.Fatalf("settings = %#v, want %#v", *settings, expectedResponse.Data.Attributes) + } + if len(client.GetSettingsRawResponse()) == 0 { + t.Fatal("expected raw settings response to be stored") + } +} + +func TestTransportGetSettingsErrors(t *testing.T) { + t.Run("request failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "backend error", http.StatusInternalServerError) + })) + defer server.Close() + + settings, err := newRawResponseTestClient(server).GetSettings() + if settings != nil { + t.Fatalf("expected nil settings, got %#v", settings) + } + if err == nil || !strings.Contains(err.Error(), "sending get settings request") { + t.Fatalf("expected request error, got %v", err) + } + }) + + t.Run("unmarshal failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(HeaderContentType, ContentTypeJSON) + _, _ = w.Write([]byte(`{"data":`)) + })) + defer server.Close() + + settings, err := newRawResponseTestClient(server).GetSettings() + if settings != nil { + t.Fatalf("expected nil settings, got %#v", settings) + } + if err == nil || !strings.Contains(err.Error(), "unmarshalling settings response") { + t.Fatalf("expected unmarshal error, got %v", err) + } + }) +} diff --git a/internal/utils/net/skippable.go b/internal/testoptimization/api/skippable.go similarity index 97% rename from internal/utils/net/skippable.go rename to internal/testoptimization/api/skippable.go index a751713..eb2e703 100644 --- a/internal/utils/net/skippable.go +++ b/internal/testoptimization/api/skippable.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2024 Datadog, Inc. -package net +package api import ( "fmt" @@ -59,7 +59,7 @@ type ( SkippableTests map[string]bool ) -func (c *client) GetSkippableTests() (correlationID string, skippables SkippableTests, err error) { +func (c *transport) GetSkippableTests() (correlationID string, skippables SkippableTests, err error) { if c.repositoryURL == "" || c.commitSha == "" { err = fmt.Errorf("civisibility.GetSkippableTests: repository URL and commit SHA are required") return diff --git a/internal/testoptimization/api/skippable_test.go b/internal/testoptimization/api/skippable_test.go new file mode 100644 index 0000000..e8e02bc --- /dev/null +++ b/internal/testoptimization/api/skippable_test.go @@ -0,0 +1,178 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTransportGetSkippableTestsRequestAndResponse(t *testing.T) { + var captured skippableRequest + response := skippableResponse{ + Meta: skippableResponseMeta{CorrelationID: "correlation-id"}, + Data: []skippableResponseData{ + { + ID: "id", + Type: "test", + Attributes: SkippableResponseDataAttributes{ + Suite: "suite", + Name: "name", + Parameters: "params", + Configurations: testConfigurations{ + TestBundle: "bundle", + OsPlatform: "linux", + OsArchitecture: "amd64", + RuntimeName: "ruby", + RuntimeVersion: "3.3.0", + }, + }, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST request, got %s", r.Method) + } + if r.URL.Path != "/"+skippableURLPath { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if !strings.Contains(r.Header.Get(HeaderContentType), ContentTypeJSON) { + t.Fatalf("content type = %q, want JSON", r.Header.Get(HeaderContentType)) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decode request: %v", err) + } + + w.Header().Set(HeaderContentType, ContentTypeJSON) + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := newRawResponseTestClient(server) + correlationID, skippable, err := client.GetSkippableTests() + if err != nil { + t.Fatalf("GetSkippableTests() returned error: %v", err) + } + + if captured.Data.Type != skippableRequestType { + t.Fatalf("request type = %q, want %q", captured.Data.Type, skippableRequestType) + } + attributes := captured.Data.Attributes + if attributes.TestLevel != "test" { + t.Fatalf("test level = %q, want test", attributes.TestLevel) + } + if attributes.Service != client.serviceName || attributes.Env != client.environment { + t.Fatalf("service/env = %q/%q, want %q/%q", attributes.Service, attributes.Env, client.serviceName, client.environment) + } + if attributes.RepositoryURL != client.repositoryURL || attributes.Sha != client.commitSha { + t.Fatalf("repository/sha = %q/%q, want %q/%q", attributes.RepositoryURL, attributes.Sha, client.repositoryURL, client.commitSha) + } + if attributes.Configurations.OsPlatform != client.testConfigurations.OsPlatform || + attributes.Configurations.OsArchitecture != client.testConfigurations.OsArchitecture || + attributes.Configurations.RuntimeName != client.testConfigurations.RuntimeName || + attributes.Configurations.RuntimeVersion != client.testConfigurations.RuntimeVersion { + t.Fatalf("configurations = %#v, want %#v", attributes.Configurations, client.testConfigurations) + } + + if correlationID != "correlation-id" { + t.Fatalf("correlation ID = %q, want correlation-id", correlationID) + } + if len(skippable) != 1 || !skippable["bundle.suite.name.params"] { + t.Fatalf("unexpected skippable map: %#v", skippable) + } + if len(client.GetSkippableTestsRawResponse()) == 0 { + t.Fatal("expected raw skippable response to be stored") + } +} + +func TestTransportGetSkippableTestsErrors(t *testing.T) { + if _, _, err := (&transport{}).GetSkippableTests(); err == nil { + t.Fatal("expected repository/sha error") + } + + t.Run("request failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "backend error", http.StatusInternalServerError) + })) + defer server.Close() + + _, _, err := newRawResponseTestClient(server).GetSkippableTests() + if err == nil || !strings.Contains(err.Error(), "sending skippable tests request") { + t.Fatalf("expected request error, got %v", err) + } + }) + + t.Run("unmarshal failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(HeaderContentType, ContentTypeJSON) + _, _ = w.Write([]byte(`{"data":`)) + })) + defer server.Close() + + _, _, err := newRawResponseTestClient(server).GetSkippableTests() + if err == nil || !strings.Contains(err.Error(), "unmarshalling skippable tests response") { + t.Fatalf("expected unmarshal error, got %v", err) + } + }) +} + +func TestTransportGetSkippableTestsFiltersConfigurations(t *testing.T) { + response := `{"meta":{"correlation_id":"cid"},"data":[ + {"attributes":{"suite":"suite-a","name":"match","parameters":"","configurations":{"test.bundle":"rspec","os.platform":"linux","os.version":"ubuntu","os.architecture":"amd64","runtime.name":"ruby","runtime.architecture":"x86_64","runtime.version":"3.3.0"}}}, + {"attributes":{"suite":"suite-a","name":"no-config","parameters":"","configurations":{"test.bundle":"rspec"}}}, + {"attributes":{"suite":"suite-a","name":"wrong-os","parameters":"","configurations":{"test.bundle":"rspec","os.platform":"windows"}}}, + {"attributes":{"suite":"suite-a","name":"wrong-os-version","parameters":"","configurations":{"test.bundle":"rspec","os.version":"debian"}}}, + {"attributes":{"suite":"suite-a","name":"wrong-os-arch","parameters":"","configurations":{"test.bundle":"rspec","os.architecture":"arm64"}}}, + {"attributes":{"suite":"suite-a","name":"wrong-runtime","parameters":"","configurations":{"test.bundle":"rspec","runtime.name":"python"}}}, + {"attributes":{"suite":"suite-a","name":"wrong-runtime-arch","parameters":"","configurations":{"test.bundle":"rspec","runtime.architecture":"arm64"}}}, + {"attributes":{"suite":"suite-a","name":"wrong-runtime-version","parameters":"","configurations":{"test.bundle":"rspec","runtime.version":"3.2.0"}}} + ]}` + server := newRawResponseTestServer(t, map[string]string{skippableURLPath: response}) + defer server.Close() + + client := newRawResponseTestClient(server) + client.testConfigurations.OsVersion = "ubuntu" + client.testConfigurations.RuntimeArchitecture = "x86_64" + + correlationID, skippable, err := client.GetSkippableTests() + if err != nil { + t.Fatalf("GetSkippableTests() returned error: %v", err) + } + if correlationID != "cid" { + t.Fatalf("correlation ID = %q", correlationID) + } + expected := []string{ + "rspec.suite-a.match.", + "rspec.suite-a.no-config.", + } + if len(skippable) != len(expected) { + t.Fatalf("unexpected skippable map: %#v", skippable) + } + for _, key := range expected { + if !skippable[key] { + t.Fatalf("expected skippable key %q in %#v", key, skippable) + } + } +} + +func TestSkippableTestKey(t *testing.T) { + test := SkippableResponseDataAttributes{ + Suite: "suite-a", + Name: "test-a", + Parameters: "params", + Configurations: testConfigurations{ + TestBundle: "rspec", + }, + } + if got, want := skippableTestKey(test), "rspec.suite-a.test-a.params"; got != want { + t.Fatalf("skippableTestKey() = %q, want %q", got, want) + } + + test.Configurations.TestBundle = "" + if got, want := skippableTestKey(test), ".suite-a.test-a.params"; got != want { + t.Fatalf("skippableTestKey() without bundle = %q, want %q", got, want) + } +} diff --git a/internal/utils/net/test_management_tests_api.go b/internal/testoptimization/api/test_management_tests_api.go similarity index 94% rename from internal/utils/net/test_management_tests_api.go rename to internal/testoptimization/api/test_management_tests_api.go index 4f0f171..7b7f65f 100644 --- a/internal/utils/net/test_management_tests_api.go +++ b/internal/testoptimization/api/test_management_tests_api.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2025 Datadog, Inc. -package net +package api import ( "fmt" @@ -64,7 +64,7 @@ type ( } ) -func (c *client) GetTestManagementTests() (*TestManagementTestsResponseDataModules, error) { +func (c *transport) GetTestManagementTests() (*TestManagementTestsResponseDataModules, error) { if c.repositoryURL == "" { return nil, fmt.Errorf("civisibility.GetTestManagementTests: repository URL is required") } @@ -99,7 +99,7 @@ func (c *client) GetTestManagementTests() (*TestManagementTestsResponseDataModul response, err := c.handler.SendRequest(*request) if err != nil { - return nil, fmt.Errorf("sending known tests request: %s", err) + return nil, fmt.Errorf("sending test management tests request: %s", err) } c.testManagementTestsRawResponse = cloneRawMessage(response.Body) diff --git a/internal/testoptimization/api/test_management_tests_api_test.go b/internal/testoptimization/api/test_management_tests_api_test.go new file mode 100644 index 0000000..9c0d04e --- /dev/null +++ b/internal/testoptimization/api/test_management_tests_api_test.go @@ -0,0 +1,156 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTransportGetTestManagementTestsRequiresRepository(t *testing.T) { + if _, err := (&transport{}).GetTestManagementTests(); err == nil { + t.Fatal("expected test management repository error") + } +} + +func TestTransportGetTestManagementTestsRequestAndResponse(t *testing.T) { + var captured testManagementTestsRequest + expectedResponse := testManagementTestsResponse{} + expectedResponse.Data.Type = testManagementTestsRequestType + expectedResponse.Data.Attributes.Modules = map[string]TestManagementTestsResponseDataSuites{ + "module-a": { + Suites: map[string]TestManagementTestsResponseDataTests{ + "suite-a": { + Tests: map[string]TestManagementTestsResponseDataTestProperties{ + "test-a": { + Properties: TestManagementTestsResponseDataTestPropertiesAttributes{ + Quarantined: true, + Disabled: false, + AttemptToFix: true, + }, + }, + "test-b": { + Properties: TestManagementTestsResponseDataTestPropertiesAttributes{ + Quarantined: false, + Disabled: true, + }, + }, + }, + }, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST request, got %s", r.Method) + } + if r.URL.Path != "/"+testManagementTestsURLPath { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decode request: %v", err) + } + + expectedResponse.Data.ID = captured.Data.ID + w.Header().Set(HeaderContentType, ContentTypeJSON) + _ = json.NewEncoder(w).Encode(expectedResponse) + })) + defer server.Close() + + client := newRawResponseTestClient(server) + client.headCommitSha = "head-sha" + client.headCommitMessage = "head commit message" + + tests, err := client.GetTestManagementTests() + if err != nil { + t.Fatalf("GetTestManagementTests() returned error: %v", err) + } + + if captured.Data.ID != client.id { + t.Fatalf("request id = %q, want %q", captured.Data.ID, client.id) + } + if captured.Data.Type != testManagementTestsRequestType { + t.Fatalf("request type = %q, want %q", captured.Data.Type, testManagementTestsRequestType) + } + attributes := captured.Data.Attributes + if attributes.RepositoryURL != client.repositoryURL || attributes.Branch != client.branchName { + t.Fatalf("repository/branch = %q/%q, want %q/%q", + attributes.RepositoryURL, attributes.Branch, client.repositoryURL, client.branchName) + } + if attributes.CommitSha != client.headCommitSha { + t.Fatalf("commit sha = %q, want head commit sha %q", attributes.CommitSha, client.headCommitSha) + } + if attributes.CommitMessage != client.headCommitMessage { + t.Fatalf("commit message = %q, want head commit message %q", attributes.CommitMessage, client.headCommitMessage) + } + + testA := tests.Modules["module-a"].Suites["suite-a"].Tests["test-a"].Properties + if !testA.Quarantined || !testA.AttemptToFix || testA.Disabled { + t.Fatalf("unexpected test-a properties: %#v", testA) + } + testB := tests.Modules["module-a"].Suites["suite-a"].Tests["test-b"].Properties + if !testB.Disabled || testB.Quarantined || testB.AttemptToFix { + t.Fatalf("unexpected test-b properties: %#v", testB) + } + if len(client.GetTestManagementTestsRawResponse()) == 0 { + t.Fatal("expected raw test management response to be stored") + } +} + +func TestTransportGetTestManagementTestsUsesCommitWhenHeadCommitIsMissing(t *testing.T) { + var captured testManagementTestsRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set(HeaderContentType, ContentTypeJSON) + _, _ = w.Write([]byte(`{"data":{"attributes":{"modules":{}}}}`)) + })) + defer server.Close() + + client := newRawResponseTestClient(server) + if _, err := client.GetTestManagementTests(); err != nil { + t.Fatalf("GetTestManagementTests() returned error: %v", err) + } + if captured.Data.Attributes.CommitSha != client.commitSha { + t.Fatalf("commit sha = %q, want %q", captured.Data.Attributes.CommitSha, client.commitSha) + } + if captured.Data.Attributes.CommitMessage != client.commitMessage { + t.Fatalf("commit message = %q, want %q", captured.Data.Attributes.CommitMessage, client.commitMessage) + } +} + +func TestTransportGetTestManagementTestsErrors(t *testing.T) { + t.Run("unmarshal failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(HeaderContentType, ContentTypeJSON) + _, _ = w.Write([]byte(`{"data":`)) + })) + defer server.Close() + + tests, err := newRawResponseTestClient(server).GetTestManagementTests() + if tests != nil { + t.Fatalf("expected nil test management tests, got %#v", tests) + } + if err == nil || !strings.Contains(err.Error(), "unmarshalling test management tests response") { + t.Fatalf("expected unmarshal error, got %v", err) + } + }) + + t.Run("request failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "backend error", http.StatusInternalServerError) + })) + defer server.Close() + + tests, err := newRawResponseTestClient(server).GetTestManagementTests() + if tests != nil { + t.Fatalf("expected nil test management tests, got %#v", tests) + } + if err == nil || !strings.Contains(err.Error(), "sending test management tests request") { + t.Fatalf("expected request error, got %v", err) + } + }) +} diff --git a/internal/utils/net/client.go b/internal/testoptimization/api/transport.go similarity index 82% rename from internal/utils/net/client.go rename to internal/testoptimization/api/transport.go index e778154..91f5062 100644 --- a/internal/utils/net/client.go +++ b/internal/testoptimization/api/transport.go @@ -3,7 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2024 Datadog, Inc. -package net +package api import ( "context" @@ -16,12 +16,12 @@ import ( "net/http" "net/url" "os" - "regexp" "strings" "time" "github.com/DataDog/ddtest/civisibility" "github.com/DataDog/ddtest/civisibility/constants" + "github.com/DataDog/ddtest/internal/runmetadata" "github.com/DataDog/ddtest/internal/utils" ) @@ -33,12 +33,13 @@ const ( ) type ( - // Client is an interface for sending requests to the Datadog backend. - Client interface { + // Transport sends requests to the Datadog backend. + Transport interface { GetSettings() (*SettingsResponseData, error) GetSettingsRawResponse() json.RawMessage GetKnownTests() (*KnownTestsResponseData, error) GetKnownTestsRawResponse() json.RawMessage + GetTestSuiteDurations() *TestSuiteDurationsResponseData GetCommits(localCommits []string) ([]string, error) SendPackFiles(commitSha string, packFiles []string) (bytes int64, err error) GetSkippableTests() (correlationID string, skippables SkippableTests, err error) @@ -47,8 +48,8 @@ type ( GetTestManagementTestsRawResponse() json.RawMessage } - // client is a client for sending requests to the Datadog backend. - client struct { + // transport sends requests to the Datadog backend. + transport struct { id string agentless bool baseURL string @@ -85,11 +86,11 @@ type ( ) var ( - _ Client = &client{} + _ Transport = &transport{} ) -// NewClientWithServiceNameAndSubdomain creates a new client with the given service name and subdomain. -func NewClientWithServiceNameAndSubdomain(serviceName, subdomain string) Client { +// NewTransportWithServiceNameAndSubdomain creates a new transport with the given service name and subdomain. +func NewTransportWithServiceNameAndSubdomain(serviceName, subdomain string) Transport { ciTags := utils.GetCITags() // get the environment @@ -100,18 +101,7 @@ func NewClientWithServiceNameAndSubdomain(serviceName, subdomain string) Client // get the service name if serviceName == "" { - serviceName = os.Getenv("DD_SERVICE") - if serviceName == "" { - if repoURL, ok := ciTags[constants.GitRepositoryURL]; ok { - // regex to sanitize the repository url to be used as a service name - repoRegex := regexp.MustCompile(`(?m)/([a-zA-Z0-9\-_.]*)$`) - matches := repoRegex.FindStringSubmatch(repoURL) - if len(matches) > 1 { - repoURL = strings.TrimSuffix(matches[1], ".git") - } - serviceName = repoURL - } - } + serviceName = runmetadata.ResolveServiceName(ciTags[constants.GitRepositoryURL]) } // get all custom configuration (test.configuration.*) @@ -136,7 +126,7 @@ func NewClientWithServiceNameAndSubdomain(serviceName, subdomain string) Client var agentURL *url.URL var apiKeyValue string - agentlessEnabled := civisibility.BoolEnv(constants.CIVisibilityAgentlessEnabledEnvironmentVariable, false) + agentlessEnabled := civisibility.BoolEnv(constants.TestOptimizationAgentlessEnabledEnvironmentVariable, false) if agentlessEnabled { // Agentless mode is enabled. apiKeyValue = os.Getenv(constants.APIKeyEnvironmentVariable) @@ -148,7 +138,7 @@ func NewClientWithServiceNameAndSubdomain(serviceName, subdomain string) Client defaultHeaders["dd-api-key"] = apiKeyValue // Check for a custom agentless URL. - agentlessURL := os.Getenv(constants.CIVisibilityAgentlessURLEnvironmentVariable) + agentlessURL := os.Getenv(constants.TestOptimizationAgentlessURLEnvironmentVariable) if agentlessURL == "" { // Use the standard agentless URL format. @@ -224,7 +214,7 @@ func NewClientWithServiceNameAndSubdomain(serviceName, subdomain string) Client bName = "auto:git-detached-head" } - return &client{ + return &transport{ id: id, agentless: agentlessEnabled, baseURL: baseURL, @@ -250,9 +240,9 @@ func NewClientWithServiceNameAndSubdomain(serviceName, subdomain string) Client } } -// NewClientWithServiceName creates a new client with the given service name. -func NewClientWithServiceName(serviceName string) Client { - return NewClientWithServiceNameAndSubdomain(serviceName, "api") +// NewTransportWithServiceName creates a new transport with the given service name. +func NewTransportWithServiceName(serviceName string) Transport { + return NewTransportWithServiceNameAndSubdomain(serviceName, "api") } func cloneRawMessage(data []byte) json.RawMessage { @@ -262,24 +252,24 @@ func cloneRawMessage(data []byte) json.RawMessage { return append(json.RawMessage(nil), data...) } -func (c *client) GetSettingsRawResponse() json.RawMessage { +func (c *transport) GetSettingsRawResponse() json.RawMessage { return cloneRawMessage(c.settingsRawResponse) } -func (c *client) GetKnownTestsRawResponse() json.RawMessage { +func (c *transport) GetKnownTestsRawResponse() json.RawMessage { return cloneRawMessage(c.knownTestsRawResponse) } -func (c *client) GetSkippableTestsRawResponse() json.RawMessage { +func (c *transport) GetSkippableTestsRawResponse() json.RawMessage { return cloneRawMessage(c.skippableTestsRawResponse) } -func (c *client) GetTestManagementTestsRawResponse() json.RawMessage { +func (c *transport) GetTestManagementTestsRawResponse() json.RawMessage { return cloneRawMessage(c.testManagementTestsRawResponse) } // getURLPath returns the full URL path for the given URL path. -func (c *client) getURLPath(urlPath string) string { +func (c *transport) getURLPath(urlPath string) string { if c.agentless { return fmt.Sprintf("%s/%s", c.baseURL, urlPath) } @@ -288,7 +278,7 @@ func (c *client) getURLPath(urlPath string) string { } // getPostRequestConfig returns a new RequestConfig for a POST request. -func (c *client) getPostRequestConfig(url string, body interface{}) *RequestConfig { +func (c *transport) getPostRequestConfig(url string, body interface{}) *RequestConfig { return &RequestConfig{ Method: "POST", URL: c.getURLPath(url), diff --git a/internal/testoptimization/api/transport_test.go b/internal/testoptimization/api/transport_test.go new file mode 100644 index 0000000..67a5338 --- /dev/null +++ b/internal/testoptimization/api/transport_test.go @@ -0,0 +1,393 @@ +package api + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "errors" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/DataDog/ddtest/civisibility/constants" + "github.com/DataDog/ddtest/internal/utils" +) + +type failingMsgpUnmarshaler struct{} + +func (f *failingMsgpUnmarshaler) UnmarshalMsg([]byte) ([]byte, error) { + return nil, errors.New("msgpack failed") +} + +type failingMsgpMarshaler struct{} + +func (f failingMsgpMarshaler) MarshalMsg([]byte) ([]byte, error) { + return nil, errors.New("marshal failed") +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestResponseUnmarshalBranches(t *testing.T) { + var target map[string]string + if err := (&Response{StatusCode: http.StatusBadRequest}).Unmarshal(&target); err == nil { + t.Fatal("expected non-unmarshalable response to fail") + } + if err := (&Response{CanUnmarshal: true, Format: "xml"}).Unmarshal(&target); err == nil { + t.Fatal("expected unsupported response format to fail") + } + if err := (&Response{CanUnmarshal: true, Format: FormatMessagePack}).Unmarshal(&failingMsgpUnmarshaler{}); err == nil { + t.Fatal("expected msgpack unmarshal error") + } +} + +func TestRequestHandlerValidationAndRetryBranches(t *testing.T) { + if NewRequestHandler().Client != defaultHTTPClient { + t.Fatal("NewRequestHandler should use the default HTTP client") + } + + handler := NewRequestHandlerWithClient(&http.Client{}) + if _, err := handler.SendRequest(RequestConfig{URL: "http://example.com"}); err == nil { + t.Fatal("expected missing method error") + } + if _, err := handler.SendRequest(RequestConfig{Method: http.MethodGet}); err == nil { + t.Fatal("expected missing URL error") + } + + var attempts int + handler = NewRequestHandlerWithClient(&http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return nil, errors.New("network down") + }), + }) + response, err := handler.SendRequest(RequestConfig{ + Method: http.MethodGet, + URL: "http://example.com", + MaxRetries: 1, + Backoff: time.Nanosecond, + }) + if err == nil || response != nil { + t.Fatalf("expected retry exhaustion error with nil response, got response=%v err=%v", response, err) + } + if attempts != 2 { + t.Fatalf("expected 2 attempts, got %d", attempts) + } +} + +func TestRequestHandlerJSONCompressionAndGzipResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Test"); got != "custom" { + t.Fatalf("expected custom header, got %q", got) + } + if got := r.Header.Get(HeaderAcceptEncoding); got != ContentEncodingGzip { + t.Fatalf("expected gzip accept header, got %q", got) + } + if got := r.Header.Get(HeaderContentEncoding); got != ContentEncodingGzip { + t.Fatalf("expected gzip request body, got %q", got) + } + + reader, err := gzip.NewReader(r.Body) + if err != nil { + t.Fatalf("open gzip request: %v", err) + } + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read gzip request: %v", err) + } + if err := reader.Close(); err != nil { + t.Fatalf("close gzip request: %v", err) + } + + var request map[string]string + if err := json.Unmarshal(body, &request); err != nil { + t.Fatalf("decode request: %v", err) + } + if request["key"] != "value" { + t.Fatalf("unexpected request body: %#v", request) + } + + var response bytes.Buffer + gzipWriter := gzip.NewWriter(&response) + _, _ = gzipWriter.Write([]byte(`{"ok":"yes"}`)) + _ = gzipWriter.Close() + + w.Header().Set(HeaderContentType, ContentTypeJSONAlternative) + w.Header().Set(HeaderContentEncoding, ContentEncodingGzip) + _, _ = w.Write(response.Bytes()) + })) + defer server.Close() + + response, err := NewRequestHandlerWithClient(server.Client()).SendRequest(RequestConfig{ + Method: http.MethodPost, + URL: server.URL, + Headers: map[string]string{"X-Test": "custom"}, + Body: map[string]string{"key": "value"}, + Format: FormatJSON, + Compressed: true, + }) + if err != nil { + t.Fatalf("SendRequest() returned error: %v", err) + } + if !response.Compressed || response.Format != FormatJSON { + t.Fatalf("unexpected response metadata: %#v", response) + } + var decoded map[string]string + if err := response.Unmarshal(&decoded); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if decoded["ok"] != "yes" { + t.Fatalf("unexpected response: %#v", decoded) + } +} + +func TestRequestHandlerMultipartCompression(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get(HeaderContentEncoding); got != ContentEncodingGzip { + t.Fatalf("expected gzip multipart body, got %q", got) + } + gzipReader, err := gzip.NewReader(r.Body) + if err != nil { + t.Fatalf("open gzip body: %v", err) + } + defer func() { _ = gzipReader.Close() }() + r.Body = io.NopCloser(gzipReader) + + if err := r.ParseMultipartForm(10 << 20); err != nil { + t.Fatalf("parse multipart form: %v", err) + } + file1, _, err := r.FormFile("file1") + if err != nil { + t.Fatalf("file1 missing: %v", err) + } + file1Body, _ := io.ReadAll(file1) + if string(file1Body) != `{"key":"value"}` { + t.Fatalf("unexpected json file body: %s", string(file1Body)) + } + file2, _, err := r.FormFile("file2") + if err != nil { + t.Fatalf("file2 missing: %v", err) + } + file2Body, _ := io.ReadAll(file2) + if string(file2Body) != "binary" { + t.Fatalf("unexpected binary file body: %s", string(file2Body)) + } + w.Header().Set(HeaderContentType, ContentTypeJSON) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + response, err := NewRequestHandlerWithClient(server.Client()).SendRequest(RequestConfig{ + Method: http.MethodPost, + URL: server.URL, + Files: []FormFile{ + {FieldName: "file1", FileName: "file1.json", Content: map[string]string{"key": "value"}, ContentType: ContentTypeJSON}, + {FieldName: "file2", FileName: "file2.bin", Content: strings.NewReader("binary"), ContentType: ContentTypeOctetStream}, + }, + Compressed: true, + }) + if err != nil { + t.Fatalf("SendRequest() returned error: %v", err) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("unexpected status code: %d", response.StatusCode) + } +} + +func TestRequestHandlerStatusRetryAndRateLimitBranches(t *testing.T) { + t.Run("server error retries", func(t *testing.T) { + var attempts int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + attempts++ + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer server.Close() + + response, err := NewRequestHandlerWithClient(server.Client()).SendRequest(RequestConfig{ + Method: http.MethodGet, + URL: server.URL, + MaxRetries: 1, + Backoff: time.Nanosecond, + }) + if err == nil || response != nil { + t.Fatalf("expected retry exhaustion, got response=%v err=%v", response, err) + } + if attempts != 2 { + t.Fatalf("expected 2 attempts, got %d", attempts) + } + }) + + t.Run("rate limit reset header", func(t *testing.T) { + var attempts int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + attempts++ + w.Header().Set(HeaderRateLimitReset, "0") + http.Error(w, "limited", HTTPStatusTooManyRequests) + })) + defer server.Close() + + response, err := NewRequestHandlerWithClient(server.Client()).SendRequest(RequestConfig{ + Method: http.MethodGet, + URL: server.URL, + MaxRetries: 1, + Backoff: time.Nanosecond, + }) + if err == nil || response != nil { + t.Fatalf("expected rate-limit retry exhaustion, got response=%v err=%v", response, err) + } + if attempts != 2 { + t.Fatalf("expected 2 attempts, got %d", attempts) + } + }) + + t.Run("rate limit fallback backoff", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "limited", HTTPStatusTooManyRequests) + })) + defer server.Close() + + response, err := NewRequestHandlerWithClient(server.Client()).SendRequest(RequestConfig{ + Method: http.MethodGet, + URL: server.URL, + MaxRetries: 0, + Backoff: time.Nanosecond, + }) + if err == nil || response != nil { + t.Fatalf("expected rate-limit fallback exhaustion, got response=%v err=%v", response, err) + } + }) +} + +func TestHTTPSerializationHelpersErrorBranches(t *testing.T) { + if _, err := compressData(nil); err == nil { + t.Fatal("expected nil compression error") + } + if _, err := decompressData([]byte("not-gzip")); err == nil { + t.Fatal("expected gzip decompression error") + } + if _, err := serializeData("value", "unknown"); err == nil { + t.Fatal("expected unsupported serialization format") + } + if _, err := serializeData(failingMsgpMarshaler{}, FormatMessagePack); err == nil { + t.Fatal("expected msgpack marshal error") + } + if _, err := prepareContent("not-bytes", ContentTypeOctetStream); err == nil { + t.Fatal("expected octet-stream content error") + } + if _, err := prepareContent("value", "application/unknown"); err == nil { + t.Fatal("expected unsupported content type error") + } + if _, _, err := createMultipartFormData([]FormFile{{ + FieldName: "bad", + Content: "value", + ContentType: "application/unknown", + }}, false); err == nil { + t.Fatal("expected multipart unsupported content error") + } + if delay := getExponentialBackoffDuration(20, time.Second); delay != 10*time.Second { + t.Fatalf("expected max backoff cap, got %s", delay) + } +} + +func TestSerializeDataReaderAndBytes(t *testing.T) { + bytesData, err := serializeData([]byte("bytes"), FormatJSON) + if err != nil || string(bytesData) != "bytes" { + t.Fatalf("serialize bytes = %q, %v", string(bytesData), err) + } + readerData, err := serializeData(strings.NewReader("reader"), FormatJSON) + if err != nil || string(readerData) != "reader" { + t.Fatalf("serialize reader = %q, %v", string(readerData), err) + } + content, err := prepareContent(strings.NewReader("reader"), ContentTypeOctetStream) + if err != nil || string(content) != "reader" { + t.Fatalf("prepare reader content = %q, %v", string(content), err) + } +} + +func TestCreateMultipartFormDataWithoutFileName(t *testing.T) { + body, contentType, err := createMultipartFormData([]FormFile{{ + FieldName: "field", + Content: []byte("value"), + ContentType: ContentTypeOctetStream, + }}, false) + if err != nil { + t.Fatalf("createMultipartFormData() returned error: %v", err) + } + _, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("parse content type: %v", err) + } + reader := multipart.NewReader(bytes.NewReader(body), params["boundary"]) + part, err := reader.NextPart() + if err != nil { + t.Fatalf("read part: %v", err) + } + if part.FileName() != "" { + t.Fatalf("expected empty filename, got %q", part.FileName()) + } +} + +func TestTransportConstructorAgentlessAndURLBranches(t *testing.T) { + utils.ResetCITags() + t.Cleanup(utils.ResetCITags) + utils.AddCITagsMap(map[string]string{ + constants.GitRepositoryURL: "https://github.com/DataDog/ddtest.git/", + constants.GitCommitSHA: "sha", + constants.GitBranch: "", + constants.GitTag: "v1.2.3", + constants.OSPlatform: "linux", + constants.OSArchitecture: "amd64", + constants.OSVersion: "ubuntu", + constants.RuntimeName: "ruby", + constants.RuntimeVersion: "3.3.0", + }) + t.Setenv(constants.TestOptimizationAgentlessEnabledEnvironmentVariable, "true") + t.Setenv(constants.APIKeyEnvironmentVariable, "api-key") + t.Setenv("DD_SITE", "datadoghq.eu") + t.Setenv("DD_ENV", "ci") + t.Setenv("DD_TAGS", "test.configuration.flavor:unit,regular:ignored") + + created, ok := NewTransportWithServiceNameAndSubdomain("", "citestcycle").(*transport) + if !ok { + t.Fatalf("expected *transport") + } + if !created.agentless || created.baseURL != "https://citestcycle.datadoghq.eu" { + t.Fatalf("unexpected agentless transport: agentless=%t baseURL=%q", created.agentless, created.baseURL) + } + if created.serviceName != "ddtest" { + t.Fatalf("service name = %q, want ddtest", created.serviceName) + } + if created.environment != "ci" || created.branchName != "v1.2.3" { + t.Fatalf("unexpected env/branch: env=%q branch=%q", created.environment, created.branchName) + } + if created.testConfigurations.Custom["flavor"] != "unit" { + t.Fatalf("custom test configuration missing: %#v", created.testConfigurations.Custom) + } + if created.headers["dd-api-key"] != "api-key" || created.headers["trace_id"] == "" || created.headers["parent_id"] == "" { + t.Fatalf("missing agentless headers: %#v", created.headers) + } + + t.Setenv(constants.TestOptimizationAgentlessURLEnvironmentVariable, "https://custom.example") + custom, ok := NewTransportWithServiceNameAndSubdomain("explicit-service", "api").(*transport) + if !ok { + t.Fatalf("expected *transport") + } + if custom.baseURL != "https://custom.example" || custom.serviceName != "explicit-service" { + t.Fatalf("unexpected custom agentless transport: baseURL=%q service=%q", custom.baseURL, custom.serviceName) + } +} + +func TestTransportGetURLPathForEVPProxy(t *testing.T) { + transport := &transport{baseURL: "http://localhost:8126"} + if got, want := transport.getURLPath("api/v2/test"), "http://localhost:8126/evp_proxy/v2/api/v2/test"; got != want { + t.Fatalf("getURLPath() = %q, want %q", got, want) + } +} diff --git a/internal/testoptimization/cache_test.go b/internal/testoptimization/cache_test.go index c954e45..8d67717 100644 --- a/internal/testoptimization/cache_test.go +++ b/internal/testoptimization/cache_test.go @@ -7,13 +7,14 @@ import ( "testing" appConstants "github.com/DataDog/ddtest/internal/constants" + "github.com/DataDog/ddtest/internal/testoptimization/api" ) type testOptimizationPlanCacheFixture struct { - TestSuiteDurations map[string]map[string]TestSuiteDurationInfo `json:"testSuiteDurations"` - SuiteAggregates []testSuiteAggregateCacheFixture `json:"suiteAggregates"` - SuitesBySourceFile map[string][]testSuiteCacheKeyFixture `json:"suitesBySourceFile"` - TestFileWeights map[string]int `json:"testFileWeights"` + TestSuiteDurations map[string]map[string]api.TestSuiteDurationInfo `json:"testSuiteDurations"` + SuiteAggregates []testSuiteAggregateCacheFixture `json:"suiteAggregates"` + SuitesBySourceFile map[string][]testSuiteCacheKeyFixture `json:"suitesBySourceFile"` + TestFileWeights map[string]int `json:"testFileWeights"` } type testSuiteCacheKeyFixture struct { @@ -33,11 +34,11 @@ type testSuiteAggregateCacheFixture struct { func newTestOptimizationPlanCacheFixture(sourceFile string, weight int) testOptimizationPlanCacheFixture { return testOptimizationPlanCacheFixture{ - TestSuiteDurations: map[string]map[string]TestSuiteDurationInfo{ + TestSuiteDurations: map[string]map[string]api.TestSuiteDurationInfo{ "rspec": { "Suite1": { SourceFile: sourceFile, - Duration: DurationPercentiles{P50: "5000000000", P90: "7000000000"}, + Duration: api.DurationPercentiles{P50: "5000000000", P90: "7000000000"}, }, }, }, diff --git a/internal/testoptimization/client.go b/internal/testoptimization/client.go deleted file mode 100644 index eea9e3b..0000000 --- a/internal/testoptimization/client.go +++ /dev/null @@ -1,184 +0,0 @@ -package testoptimization - -import ( - "encoding/json" - "log/slog" - "time" - - "github.com/DataDog/ddtest/civisibility/integrations" - "github.com/DataDog/ddtest/internal/utils" - "github.com/DataDog/ddtest/internal/utils/net" -) - -// TestOptimizationClient defines interface for test optimization operations -type TestOptimizationClient interface { - Initialize(tags map[string]string) error - GetSettings() *net.SettingsResponseData - GetSkippableTests() map[string]bool - GetKnownTests() *net.KnownTestsResponseData - GetTestManagementTestsData() *net.TestManagementTestsResponseDataModules - StoreCacheAndExit() -} - -// These interfaces define the subset of the copied CI Visibility API ddtest uses. -type CIVisibilityIntegrations interface { - EnsureCiVisibilityInitialization() - ExitCiVisibility() - GetSettings() *net.SettingsResponseData - GetSettingsRawResponse() json.RawMessage - GetSkippableTests() net.SkippableTests - GetSkippableTestsRawResponse() json.RawMessage - GetKnownTests() *net.KnownTestsResponseData - GetKnownTestsRawResponse() json.RawMessage - GetTestManagementTestsData() *net.TestManagementTestsResponseDataModules - GetTestManagementTestsRawResponse() json.RawMessage -} - -type UtilsInterface interface { - AddCITagsMap(tags map[string]string) -} - -// DatadogCIVisibilityIntegrations implements CIVisibilityIntegrations using the copied integrations package. -type DatadogCIVisibilityIntegrations struct{} - -func (d *DatadogCIVisibilityIntegrations) EnsureCiVisibilityInitialization() { - integrations.EnsureCiVisibilityInitialization() -} - -func (d *DatadogCIVisibilityIntegrations) ExitCiVisibility() { - integrations.ExitCiVisibility() -} - -func (d *DatadogCIVisibilityIntegrations) GetSettings() *net.SettingsResponseData { - return integrations.GetSettings() -} - -func (d *DatadogCIVisibilityIntegrations) GetSettingsRawResponse() json.RawMessage { - return integrations.GetSettingsRawResponse() -} - -func (d *DatadogCIVisibilityIntegrations) GetSkippableTests() net.SkippableTests { - return integrations.GetSkippableTests() -} - -func (d *DatadogCIVisibilityIntegrations) GetSkippableTestsRawResponse() json.RawMessage { - return integrations.GetSkippableTestsRawResponse() -} - -func (d *DatadogCIVisibilityIntegrations) GetKnownTests() *net.KnownTestsResponseData { - return integrations.GetKnownTests() -} - -func (d *DatadogCIVisibilityIntegrations) GetKnownTestsRawResponse() json.RawMessage { - return integrations.GetKnownTestsRawResponse() -} - -func (d *DatadogCIVisibilityIntegrations) GetTestManagementTestsData() *net.TestManagementTestsResponseDataModules { - return integrations.GetTestManagementTestsData() -} - -func (d *DatadogCIVisibilityIntegrations) GetTestManagementTestsRawResponse() json.RawMessage { - return integrations.GetTestManagementTestsRawResponse() -} - -// DatadogUtils implements UtilsInterface using the copied utils package. -type DatadogUtils struct{} - -func (d *DatadogUtils) AddCITagsMap(tags map[string]string) { - utils.AddCITagsMap(tags) -} - -type DatadogClient struct { - integrations CIVisibilityIntegrations - utils UtilsInterface - cacheManager *CacheManager - settings *net.SettingsResponseData -} - -func NewDatadogClient() *DatadogClient { - return &DatadogClient{ - integrations: &DatadogCIVisibilityIntegrations{}, - utils: &DatadogUtils{}, - cacheManager: NewCacheManager(), - } -} - -func NewDatadogClientWithDependencies(integrations CIVisibilityIntegrations, utils UtilsInterface) *DatadogClient { - return &DatadogClient{ - integrations: integrations, - utils: utils, - cacheManager: NewCacheManager(), - } -} - -func (c *DatadogClient) Initialize(tags map[string]string) error { - c.utils.AddCITagsMap(tags) - - startTime := time.Now() - c.integrations.EnsureCiVisibilityInitialization() - - // Fetch and store settings - c.settings = c.integrations.GetSettings() - - duration := time.Since(startTime) - slog.Debug("Finished Datadog Test Optimization initialization", "duration", duration) - - return nil -} - -func (c *DatadogClient) GetSettings() *net.SettingsResponseData { - return c.settings -} - -func (c *DatadogClient) GetSkippableTests() map[string]bool { - startTime := time.Now() - - slog.Debug("Fetching skippable tests...") - skippableTests := c.integrations.GetSkippableTests() - if skippableTests == nil { - skippableTests = net.SkippableTests{} - } - - if err := c.cacheManager.StoreSkippableTestsCache(c.integrations.GetSkippableTestsRawResponse()); err != nil { - slog.Warn("Failed to store skippable tests cache", "error", err) - } - - duration := time.Since(startTime) - slog.Debug("Finished fetching skippable tests", "count", len(skippableTests), "duration", duration) - - return skippableTests -} - -func (c *DatadogClient) GetKnownTests() *net.KnownTestsResponseData { - if c.settings == nil || !c.settings.KnownTestsEnabled { - return nil - } - return c.integrations.GetKnownTests() -} - -func (c *DatadogClient) GetTestManagementTestsData() *net.TestManagementTestsResponseDataModules { - if c.settings == nil || !c.settings.TestManagement.Enabled { - return nil - } - return c.integrations.GetTestManagementTestsData() -} - -func (c *DatadogClient) StoreCacheAndExit() { - repositorySettings := c.integrations.GetSettings() - if repositorySettings != nil { - slog.Debug("Repository settings", "itr_enabled", repositorySettings.ItrEnabled, "tests_skipping", repositorySettings.TestsSkipping) - } - if err := c.cacheManager.StoreRepositorySettings(c.integrations.GetSettingsRawResponse()); err != nil { - slog.Warn("Failed to store repository settings cache", "error", err) - } - - if err := c.cacheManager.StoreKnownTestsCache(c.integrations.GetKnownTestsRawResponse()); err != nil { - slog.Warn("Failed to store known tests cache", "error", err) - } - - if err := c.cacheManager.StoreTestManagementTestsCache(c.integrations.GetTestManagementTestsRawResponse()); err != nil { - slog.Warn("Failed to store test management tests cache", "error", err) - } - - c.integrations.ExitCiVisibility() -} diff --git a/internal/testoptimization/durations_client.go b/internal/testoptimization/durations_client.go deleted file mode 100644 index 1ffbb29..0000000 --- a/internal/testoptimization/durations_client.go +++ /dev/null @@ -1,368 +0,0 @@ -package testoptimization - -import ( - "encoding/json" - "fmt" - "io" - "log/slog" - "math" - "math/rand/v2" - "net/http" - "os" - "strings" - "time" - - "github.com/DataDog/ddtest/civisibility" - "github.com/DataDog/ddtest/civisibility/constants" - "github.com/DataDog/ddtest/internal/httptransport" - "github.com/DataDog/ddtest/internal/runmetadata" - "github.com/DataDog/ddtest/internal/utils" -) - -const ( - durationsRequestType string = "ci_app_ddtest_test_suite_durations_request" - durationsURLPath string = "api/v2/ci/ddtest/test_suite_durations" - - defaultDurationsPageSize int = 500 - maxDurationsRetries int = 3 -) - -type ( - // request types - - durationsRequest struct { - Data durationsRequestData `json:"data"` - } - - durationsRequestData struct { - Type string `json:"type"` - Attributes durationsRequestAttributes `json:"attributes"` - } - - durationsRequestAttributes struct { - RepositoryURL string `json:"repository_url"` - Service string `json:"service,omitempty"` - PageInfo *durationsRequestPageInfo `json:"page_info,omitempty"` - } - - durationsRequestPageInfo struct { - PageSize int `json:"page_size,omitempty"` - PageState string `json:"page_state,omitempty"` - } - - // response types - - durationsResponse struct { - Data struct { - ID string `json:"id"` - Type string `json:"type"` - Attributes durationsResponseAttributes `json:"attributes"` - } `json:"data"` - } - - durationsResponseAttributes struct { - TestSuites map[string]map[string]TestSuiteDurationInfo `json:"test_suites"` - PageInfo *durationsResponsePageInfo `json:"page_info,omitempty"` - } - - durationsResponsePageInfo struct { - Cursor string `json:"cursor,omitempty"` - Size int `json:"size,omitempty"` - HasNext bool `json:"has_next"` - } - - // public response types - - TestSuiteDurationInfo struct { - SourceFile string `json:"source_file"` - Duration DurationPercentiles `json:"duration"` - } - - DurationPercentiles struct { - P50 string `json:"p50"` - P90 string `json:"p90"` - } -) - -// TestSuiteDurationsClient defines the interface for fetching test suite durations -type TestSuiteDurationsClient interface { - GetTestSuiteDurations() map[string]map[string]TestSuiteDurationInfo -} - -// DurationsAPI abstracts the HTTP endpoint for testability (equivalent of CIVisibilityIntegrations) -type DurationsAPI interface { - FetchTestSuiteDurations(repositoryURL, service, cursor string, pageSize int) (*durationsResponseAttributes, error) -} - -// DatadogDurationsClient implements TestSuiteDurationsClient (equivalent of DatadogClient) -type DatadogDurationsClient struct { - api DurationsAPI -} - -func NewDurationsClient() *DatadogDurationsClient { - return &DatadogDurationsClient{ - api: NewDatadogDurationsAPI(), - } -} - -func NewDurationsClientWithDependencies(api DurationsAPI) *DatadogDurationsClient { - return &DatadogDurationsClient{ - api: api, - } -} - -func (c *DatadogDurationsClient) GetTestSuiteDurations() map[string]map[string]TestSuiteDurationInfo { - startTime := time.Now() - repositoryURL, service, err := testSuiteDurationsFetchInputs() - if err != nil { - slog.Error("Test durations API errored", "duration", time.Since(startTime), "error", err) - return map[string]map[string]TestSuiteDurationInfo{} - } - - durations, err := c.fetchTestSuiteDurations(repositoryURL, service) - if err != nil { - slog.Error("Test durations API errored", - "service", service, - "repositoryURL", repositoryURL, - "duration", time.Since(startTime), - "error", err) - return map[string]map[string]TestSuiteDurationInfo{} - } - - totalSuites := countTestSuiteDurations(durations) - if totalSuites == 0 { - slog.Warn("Test durations API returned no test suites", - "service", service, - "repositoryURL", repositoryURL, - "modulesCount", len(durations), - "testSuitesCount", totalSuites, - "duration", time.Since(startTime)) - return map[string]map[string]TestSuiteDurationInfo{} - } - - slog.Info("Fetched test suite durations", - "service", service, - "repositoryURL", repositoryURL, - "modulesCount", len(durations), - "testSuitesCount", totalSuites, - "duration", time.Since(startTime)) - return durations -} - -func testSuiteDurationsFetchInputs() (string, string, error) { - ciTags := utils.GetCITags() - repositoryURL := ciTags[constants.GitRepositoryURL] - if repositoryURL == "" { - return "", "", fmt.Errorf("repository URL is required") - } - - service := runmetadata.ResolveServiceName(repositoryURL) - return repositoryURL, service, nil -} - -func (c *DatadogDurationsClient) fetchTestSuiteDurations(repositoryURL, service string) (map[string]map[string]TestSuiteDurationInfo, error) { - startTime := time.Now() - allSuites := make(map[string]map[string]TestSuiteDurationInfo) - - slog.Debug("Fetching test suite durations...") - - cursor := "" - for { - data, err := c.api.FetchTestSuiteDurations(repositoryURL, service, cursor, defaultDurationsPageSize) - if err != nil { - return nil, fmt.Errorf("fetching test suite durations: %w", err) - } - - for module, suites := range data.TestSuites { - if _, ok := allSuites[module]; !ok { - allSuites[module] = make(map[string]TestSuiteDurationInfo) - } - for suite, info := range suites { - allSuites[module][suite] = info - } - } - - if data.PageInfo == nil || !data.PageInfo.HasNext { - break - } - cursor = data.PageInfo.Cursor - } - - duration := time.Since(startTime) - totalSuites := 0 - for _, suites := range allSuites { - totalSuites += len(suites) - } - slog.Debug("Finished fetching test suite durations", "modules", len(allSuites), "suites", totalSuites, "duration", duration) - - return allSuites, nil -} - -func countTestSuiteDurations(testSuiteDurations map[string]map[string]TestSuiteDurationInfo) int { - totalSuites := 0 - for _, suites := range testSuiteDurations { - totalSuites += len(suites) - } - return totalSuites -} - -// DatadogDurationsAPI implements DurationsAPI using real HTTP calls (equivalent of DatadogCIVisibilityIntegrations) -type DatadogDurationsAPI struct { - baseURL string - headers map[string]string - httpClient *http.Client - err error -} - -func NewDatadogDurationsAPI() *DatadogDurationsAPI { - headers := map[string]string{} - var baseURL string - httpClient := &http.Client{ - Timeout: 45 * time.Second, - } - - agentlessEnabled := civisibility.BoolEnv(constants.CIVisibilityAgentlessEnabledEnvironmentVariable, false) - if agentlessEnabled { - apiKey := os.Getenv(constants.APIKeyEnvironmentVariable) - if apiKey == "" { - slog.Error("An API key is required for agentless mode. Use the DD_API_KEY env variable to set it") - return &DatadogDurationsAPI{ - headers: headers, - httpClient: httpClient, - err: fmt.Errorf("DD_API_KEY is required when DD_CIVISIBILITY_AGENTLESS_ENABLED is true"), - } - } - headers["dd-api-key"] = apiKey - - agentlessURL := os.Getenv(constants.CIVisibilityAgentlessURLEnvironmentVariable) - if agentlessURL == "" { - site := "datadoghq.com" - if v := os.Getenv("DD_SITE"); v != "" { - site = v - } - baseURL = fmt.Sprintf("https://api.%s", site) - } else { - baseURL = agentlessURL - } - } else { - headers["X-Datadog-EVP-Subdomain"] = "api" - agentURL := civisibility.AgentURLFromEnv() - if agentURL.Scheme == "unix" { - httpClient = httptransport.UnixSocketClient(agentURL.Path, 45*time.Second) - agentURL = httptransport.UnixSocketURL(agentURL.Path) - } - baseURL = agentURL.String() - } - - id := fmt.Sprint(rand.Uint64() & math.MaxInt64) - headers["trace_id"] = id - headers["parent_id"] = id - - slog.Debug("DurationsAPI: client created", - "agentless", agentlessEnabled, "url", baseURL) - - return &DatadogDurationsAPI{ - baseURL: baseURL, - headers: headers, - httpClient: httpClient, - } -} - -func (c *DatadogDurationsAPI) FetchTestSuiteDurations(repositoryURL, service, cursor string, pageSize int) (*durationsResponseAttributes, error) { - if c.err != nil { - return nil, c.err - } - if repositoryURL == "" { - return nil, fmt.Errorf("repository URL is required") - } - - var pageInfo *durationsRequestPageInfo - if pageSize > 0 || cursor != "" { - pageInfo = &durationsRequestPageInfo{ - PageSize: pageSize, - PageState: cursor, - } - } - - body := durationsRequest{ - Data: durationsRequestData{ - Type: durationsRequestType, - Attributes: durationsRequestAttributes{ - RepositoryURL: repositoryURL, - Service: service, - PageInfo: pageInfo, - }, - }, - } - - requestURL := c.getURLPath(durationsURLPath) - - var lastErr error - for attempt := range maxDurationsRetries { - result, err := c.doPost(requestURL, body) - if err == nil { - return result, nil - } - lastErr = err - slog.Debug("DurationsAPI: request failed, retrying", "attempt", attempt+1, "error", err) - time.Sleep(time.Duration(100*(1<= 300 { - return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, truncateBody(respBody)) - } - - slog.Debug("test_suite_durations", "responseBody", string(respBody)) - - var responseObject durationsResponse - if err := json.Unmarshal(respBody, &responseObject); err != nil { - return nil, fmt.Errorf("unmarshalling response: %w", err) - } - - return &responseObject.Data.Attributes, nil -} - -func truncateBody(body []byte) string { - s := string(body) - if len(s) > 256 { - return s[:256] + "..." - } - return s -} diff --git a/internal/testoptimization/durations_client_test.go b/internal/testoptimization/durations_client_test.go deleted file mode 100644 index 221864c..0000000 --- a/internal/testoptimization/durations_client_test.go +++ /dev/null @@ -1,631 +0,0 @@ -package testoptimization - -import ( - "bytes" - "fmt" - "log/slog" - "net/http" - "os" - "strings" - "testing" - - "github.com/DataDog/ddtest/civisibility/constants" - ciUtils "github.com/DataDog/ddtest/internal/utils" -) - -// MockDurationsAPI implements DurationsAPI for testing (equivalent of MockCIVisibilityIntegrations) -type MockDurationsAPI struct { - FetchCalled bool - RepositoryURL string - Service string - Cursors []string - Responses []*durationsResponseAttributes - ResponseErrors []error - callIndex int -} - -func (m *MockDurationsAPI) FetchTestSuiteDurations(repositoryURL, service, cursor string, pageSize int) (*durationsResponseAttributes, error) { - m.FetchCalled = true - m.RepositoryURL = repositoryURL - m.Service = service - m.Cursors = append(m.Cursors, cursor) - - if m.callIndex < len(m.ResponseErrors) && m.ResponseErrors[m.callIndex] != nil { - err := m.ResponseErrors[m.callIndex] - m.callIndex++ - return nil, err - } - - if m.callIndex < len(m.Responses) { - resp := m.Responses[m.callIndex] - m.callIndex++ - return resp, nil - } - - return &durationsResponseAttributes{ - TestSuites: make(map[string]map[string]TestSuiteDurationInfo), - }, nil -} - -func captureLogs(t *testing.T) *bytes.Buffer { - t.Helper() - var buf bytes.Buffer - originalLogger := slog.Default() - slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) - t.Cleanup(func() { - slog.SetDefault(originalLogger) - }) - return &buf -} - -func addRepositoryTag(t *testing.T, repositoryURL string) { - t.Helper() - ciUtils.ResetCITags() - t.Cleanup(ciUtils.ResetCITags) - ciUtils.AddCITagsMap(map[string]string{constants.GitRepositoryURL: repositoryURL}) -} - -func TestNewDurationsClientWithDependencies(t *testing.T) { - mockAPI := &MockDurationsAPI{} - client := NewDurationsClientWithDependencies(mockAPI) - - if client == nil { - t.Error("NewDurationsClientWithDependencies() should return non-nil client") - } -} - -func TestDurationsClient_GetTestSuiteDurations_DerivesInputsAndLogsSuccess(t *testing.T) { - addRepositoryTag(t, "github.com/DataDog/foo") - t.Setenv("DD_SERVICE", "my-service") - logs := captureLogs(t) - mockAPI := &MockDurationsAPI{ - Responses: []*durationsResponseAttributes{ - { - TestSuites: map[string]map[string]TestSuiteDurationInfo{ - "module1": { - "suite1": { - SourceFile: "spec/user_spec.rb", - Duration: DurationPercentiles{P50: "280000000", P90: "350000000"}, - }, - }, - }, - }, - }, - } - - client := NewDurationsClientWithDependencies(mockAPI) - result := client.GetTestSuiteDurations() - - if !mockAPI.FetchCalled { - t.Error("GetTestSuiteDurations() should call FetchTestSuiteDurations") - } - if mockAPI.RepositoryURL != "github.com/DataDog/foo" { - t.Errorf("Expected repository URL 'github.com/DataDog/foo', got '%s'", mockAPI.RepositoryURL) - } - if mockAPI.Service != "my-service" { - t.Errorf("Expected service 'my-service', got '%s'", mockAPI.Service) - } - if len(result) != 1 { - t.Errorf("Expected 1 module, got %d", len(result)) - } - if !strings.Contains(logs.String(), "level=INFO") || - !strings.Contains(logs.String(), "Fetched test suite durations") || - !strings.Contains(logs.String(), "modulesCount=1") || - !strings.Contains(logs.String(), "testSuitesCount=1") || - !strings.Contains(logs.String(), "duration=") { - t.Errorf("Expected INFO log for non-empty durations response, got logs: %s", logs.String()) - } -} - -func TestDurationsClient_GetTestSuiteDurations_MissingRepositoryReturnsEmptyAndLogsError(t *testing.T) { - ciUtils.ResetCITags() - t.Cleanup(ciUtils.ResetCITags) - ciUtils.AddCITagsMap(map[string]string{constants.GitRepositoryURL: ""}) - logs := captureLogs(t) - mockAPI := &MockDurationsAPI{} - - client := NewDurationsClientWithDependencies(mockAPI) - result := client.GetTestSuiteDurations() - - if mockAPI.FetchCalled { - t.Error("GetTestSuiteDurations() should not fetch without a repository URL") - } - if len(result) != 0 { - t.Errorf("Expected empty durations on missing repository URL, got %v", result) - } - if !strings.Contains(logs.String(), "level=ERROR") || - !strings.Contains(logs.String(), "Test durations API errored") || - !strings.Contains(logs.String(), "repository URL is required") || - !strings.Contains(logs.String(), "duration=") { - t.Errorf("Expected ERROR log for missing repository URL, got logs: %s", logs.String()) - } -} - -func TestDurationsClient_GetTestSuiteDurations_APIErrorReturnsEmptyAndLogsError(t *testing.T) { - addRepositoryTag(t, "github.com/DataDog/foo") - t.Setenv("DD_SERVICE", "") - logs := captureLogs(t) - mockAPI := &MockDurationsAPI{ - ResponseErrors: []error{fmt.Errorf("connection refused")}, - } - - client := NewDurationsClientWithDependencies(mockAPI) - result := client.GetTestSuiteDurations() - - if len(result) != 0 { - t.Errorf("Expected empty durations on API error, got %v", result) - } - if !strings.Contains(logs.String(), "level=ERROR") || - !strings.Contains(logs.String(), "Test durations API errored") || - !strings.Contains(logs.String(), "repositoryURL=github.com/DataDog/foo") || - !strings.Contains(logs.String(), "service=foo") || - !strings.Contains(logs.String(), "connection refused") || - !strings.Contains(logs.String(), "duration=") { - t.Errorf("Expected ERROR log for durations API failure, got logs: %s", logs.String()) - } -} - -func TestDurationsClient_GetTestSuiteDurations_EmptyResponseReturnsEmptyAndLogsWarn(t *testing.T) { - addRepositoryTag(t, "github.com/DataDog/foo") - t.Setenv("DD_SERVICE", "") - logs := captureLogs(t) - mockAPI := &MockDurationsAPI{ - Responses: []*durationsResponseAttributes{ - { - TestSuites: map[string]map[string]TestSuiteDurationInfo{}, - }, - }, - } - - client := NewDurationsClientWithDependencies(mockAPI) - result := client.GetTestSuiteDurations() - - if len(result) != 0 { - t.Errorf("Expected empty durations on empty response, got %v", result) - } - if !strings.Contains(logs.String(), "level=WARN") || - !strings.Contains(logs.String(), "Test durations API returned no test suites") || - !strings.Contains(logs.String(), "modulesCount=0") || - !strings.Contains(logs.String(), "testSuitesCount=0") || - !strings.Contains(logs.String(), "duration=") { - t.Errorf("Expected WARN log for empty durations response, got logs: %s", logs.String()) - } -} - -func TestDurationsClient_FetchTestSuiteDurations_SinglePage(t *testing.T) { - mockAPI := &MockDurationsAPI{ - Responses: []*durationsResponseAttributes{ - { - TestSuites: map[string]map[string]TestSuiteDurationInfo{ - "module1": { - "suite1": { - SourceFile: "spec/user_spec.rb", - Duration: DurationPercentiles{P50: "280000000", P90: "350000000"}, - }, - "suite2": { - SourceFile: "spec/order_spec.rb", - Duration: DurationPercentiles{P50: "100000000", P90: "150000000"}, - }, - }, - "module2": { - "suite3": { - SourceFile: "spec/product_spec.rb", - Duration: DurationPercentiles{P50: "500000000", P90: "600000000"}, - }, - }, - }, - }, - }, - } - - client := NewDurationsClientWithDependencies(mockAPI) - result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") - - if err != nil { - t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) - } - - if !mockAPI.FetchCalled { - t.Error("fetchTestSuiteDurations() should call FetchTestSuiteDurations") - } - - if mockAPI.RepositoryURL != "github.com/DataDog/foo" { - t.Errorf("Expected repository URL 'github.com/DataDog/foo', got '%s'", mockAPI.RepositoryURL) - } - - if mockAPI.Service != "my-service" { - t.Errorf("Expected service 'my-service', got '%s'", mockAPI.Service) - } - - if len(result) != 2 { - t.Errorf("Expected 2 modules, got %d", len(result)) - } - - module1, exists := result["module1"] - if !exists { - t.Error("Expected module1 to exist") - return - } - - if len(module1) != 2 { - t.Errorf("Expected 2 suites in module1, got %d", len(module1)) - } - - suite1, exists := module1["suite1"] - if !exists { - t.Error("Expected suite1 to exist in module1") - return - } - - if suite1.SourceFile != "spec/user_spec.rb" { - t.Errorf("Expected source file 'spec/user_spec.rb', got '%s'", suite1.SourceFile) - } - if suite1.Duration.P50 != "280000000" { - t.Errorf("Expected P50 '280000000', got '%s'", suite1.Duration.P50) - } - if suite1.Duration.P90 != "350000000" { - t.Errorf("Expected P90 '350000000', got '%s'", suite1.Duration.P90) - } - - module2, exists := result["module2"] - if !exists { - t.Error("Expected module2 to exist") - return - } - - if len(module2) != 1 { - t.Errorf("Expected 1 suite in module2, got %d", len(module2)) - } -} - -func TestDurationsClient_FetchTestSuiteDurations_Pagination(t *testing.T) { - mockAPI := &MockDurationsAPI{ - Responses: []*durationsResponseAttributes{ - { - TestSuites: map[string]map[string]TestSuiteDurationInfo{ - "module1": { - "suite1": { - SourceFile: "spec/user_spec.rb", - Duration: DurationPercentiles{P50: "280000000", P90: "350000000"}, - }, - }, - }, - PageInfo: &durationsResponsePageInfo{ - Cursor: "abc123", - Size: 500, - HasNext: true, - }, - }, - { - TestSuites: map[string]map[string]TestSuiteDurationInfo{ - "module1": { - "suite2": { - SourceFile: "spec/order_spec.rb", - Duration: DurationPercentiles{P50: "100000000", P90: "150000000"}, - }, - }, - "module2": { - "suite3": { - SourceFile: "spec/product_spec.rb", - Duration: DurationPercentiles{P50: "500000000", P90: "600000000"}, - }, - }, - }, - PageInfo: &durationsResponsePageInfo{ - Cursor: "", - Size: 500, - HasNext: false, - }, - }, - }, - } - - client := NewDurationsClientWithDependencies(mockAPI) - result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") - - if err != nil { - t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) - } - - // Verify pagination cursors were passed correctly - if len(mockAPI.Cursors) != 2 { - t.Errorf("Expected 2 API calls, got %d", len(mockAPI.Cursors)) - } - - if mockAPI.Cursors[0] != "" { - t.Errorf("First call should have empty cursor, got '%s'", mockAPI.Cursors[0]) - } - - if mockAPI.Cursors[1] != "abc123" { - t.Errorf("Second call should have cursor 'abc123', got '%s'", mockAPI.Cursors[1]) - } - - // Verify merged results - if len(result) != 2 { - t.Errorf("Expected 2 modules, got %d", len(result)) - } - - module1, exists := result["module1"] - if !exists { - t.Error("Expected module1 to exist") - return - } - - if len(module1) != 2 { - t.Errorf("Expected 2 suites in module1 (merged from both pages), got %d", len(module1)) - } - - if _, exists := module1["suite1"]; !exists { - t.Error("Expected suite1 to exist in module1 (from page 1)") - } - if _, exists := module1["suite2"]; !exists { - t.Error("Expected suite2 to exist in module1 (from page 2)") - } - - module2, exists := result["module2"] - if !exists { - t.Error("Expected module2 to exist") - return - } - - if len(module2) != 1 { - t.Errorf("Expected 1 suite in module2, got %d", len(module2)) - } -} - -func TestDurationsClient_FetchTestSuiteDurations_EmptyResponse(t *testing.T) { - mockAPI := &MockDurationsAPI{ - Responses: []*durationsResponseAttributes{ - { - TestSuites: map[string]map[string]TestSuiteDurationInfo{}, - }, - }, - } - - client := NewDurationsClientWithDependencies(mockAPI) - result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") - - if err != nil { - t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) - } - - if result == nil { - t.Error("fetchTestSuiteDurations() should return non-nil map even with empty data") - } - - if len(result) != 0 { - t.Errorf("fetchTestSuiteDurations() should return empty map, got %d modules", len(result)) - } -} - -func TestDurationsClient_FetchTestSuiteDurations_NilTestSuites(t *testing.T) { - mockAPI := &MockDurationsAPI{ - Responses: []*durationsResponseAttributes{ - { - TestSuites: nil, - }, - }, - } - - client := NewDurationsClientWithDependencies(mockAPI) - result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") - - if err != nil { - t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) - } - - if result == nil { - t.Error("fetchTestSuiteDurations() should return non-nil map even with nil test suites") - } - - if len(result) != 0 { - t.Errorf("fetchTestSuiteDurations() should return empty map, got %d modules", len(result)) - } -} - -func TestDurationsClient_FetchTestSuiteDurations_APIError(t *testing.T) { - mockAPI := &MockDurationsAPI{ - ResponseErrors: []error{fmt.Errorf("connection refused")}, - } - - client := NewDurationsClientWithDependencies(mockAPI) - result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") - - if err == nil { - t.Error("fetchTestSuiteDurations() should return error when API fails") - } - - if result != nil { - t.Error("fetchTestSuiteDurations() should return nil result when API fails") - } -} - -func TestDurationsClient_FetchTestSuiteDurations_PaginationError(t *testing.T) { - mockAPI := &MockDurationsAPI{ - Responses: []*durationsResponseAttributes{ - { - TestSuites: map[string]map[string]TestSuiteDurationInfo{ - "module1": { - "suite1": { - SourceFile: "spec/user_spec.rb", - Duration: DurationPercentiles{P50: "280000000", P90: "350000000"}, - }, - }, - }, - PageInfo: &durationsResponsePageInfo{ - Cursor: "abc123", - Size: 500, - HasNext: true, - }, - }, - }, - ResponseErrors: []error{nil, fmt.Errorf("timeout on second page")}, - } - - client := NewDurationsClientWithDependencies(mockAPI) - result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") - - if err == nil { - t.Error("fetchTestSuiteDurations() should return error when pagination fails") - } - - if result != nil { - t.Error("fetchTestSuiteDurations() should return nil result when pagination fails") - } -} - -func TestDurationsClient_FetchTestSuiteDurations_NilPageInfo(t *testing.T) { - mockAPI := &MockDurationsAPI{ - Responses: []*durationsResponseAttributes{ - { - TestSuites: map[string]map[string]TestSuiteDurationInfo{ - "module1": { - "suite1": { - SourceFile: "spec/user_spec.rb", - Duration: DurationPercentiles{P50: "280000000", P90: "350000000"}, - }, - }, - }, - PageInfo: nil, - }, - }, - } - - client := NewDurationsClientWithDependencies(mockAPI) - result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") - - if err != nil { - t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) - } - - if len(result) != 1 { - t.Errorf("Expected 1 module, got %d", len(result)) - } - - // Should only make one API call (no pagination) - if len(mockAPI.Cursors) != 1 { - t.Errorf("Expected 1 API call when PageInfo is nil, got %d", len(mockAPI.Cursors)) - } -} - -func TestDurationsClient_FetchTestSuiteDurations_ThreePages(t *testing.T) { - mockAPI := &MockDurationsAPI{ - Responses: []*durationsResponseAttributes{ - { - TestSuites: map[string]map[string]TestSuiteDurationInfo{ - "module1": { - "suite1": { - SourceFile: "spec/a_spec.rb", - Duration: DurationPercentiles{P50: "100", P90: "200"}, - }, - }, - }, - PageInfo: &durationsResponsePageInfo{Cursor: "page2", HasNext: true}, - }, - { - TestSuites: map[string]map[string]TestSuiteDurationInfo{ - "module1": { - "suite2": { - SourceFile: "spec/b_spec.rb", - Duration: DurationPercentiles{P50: "300", P90: "400"}, - }, - }, - }, - PageInfo: &durationsResponsePageInfo{Cursor: "page3", HasNext: true}, - }, - { - TestSuites: map[string]map[string]TestSuiteDurationInfo{ - "module1": { - "suite3": { - SourceFile: "spec/c_spec.rb", - Duration: DurationPercentiles{P50: "500", P90: "600"}, - }, - }, - }, - PageInfo: &durationsResponsePageInfo{HasNext: false}, - }, - }, - } - - client := NewDurationsClientWithDependencies(mockAPI) - result, err := client.fetchTestSuiteDurations("github.com/DataDog/foo", "my-service") - - if err != nil { - t.Errorf("fetchTestSuiteDurations() should not return error, got: %v", err) - } - - if len(mockAPI.Cursors) != 3 { - t.Errorf("Expected 3 API calls, got %d", len(mockAPI.Cursors)) - } - - if mockAPI.Cursors[0] != "" { - t.Errorf("First cursor should be empty, got '%s'", mockAPI.Cursors[0]) - } - if mockAPI.Cursors[1] != "page2" { - t.Errorf("Second cursor should be 'page2', got '%s'", mockAPI.Cursors[1]) - } - if mockAPI.Cursors[2] != "page3" { - t.Errorf("Third cursor should be 'page3', got '%s'", mockAPI.Cursors[2]) - } - - module1 := result["module1"] - if len(module1) != 3 { - t.Errorf("Expected 3 suites merged in module1, got %d", len(module1)) - } -} - -func TestDatadogDurationsAPI_FetchTestSuiteDurations_EmptyRepositoryURL(t *testing.T) { - api := &DatadogDurationsAPI{ - baseURL: "https://api.datadoghq.com", - headers: map[string]string{"dd-api-key": "test-key"}, - } - - _, err := api.FetchTestSuiteDurations("", "my-service", "", 100) - - if err == nil { - t.Error("FetchTestSuiteDurations() should return error when repository URL is empty") - } -} - -func TestNewDatadogDurationsAPI_AgentlessMissingAPIKeyReturnsErroringClient(t *testing.T) { - t.Setenv(constants.CIVisibilityAgentlessEnabledEnvironmentVariable, "true") - t.Setenv(constants.APIKeyEnvironmentVariable, "") - - api := NewDatadogDurationsAPI() - if api == nil { - t.Fatal("NewDatadogDurationsAPI() should return an erroring client, not nil") - } - - _, err := api.FetchTestSuiteDurations("github.com/DataDog/foo", "my-service", "", 100) - if err == nil { - t.Fatal("FetchTestSuiteDurations() should return an error when API key is missing") - } - if !strings.Contains(err.Error(), constants.APIKeyEnvironmentVariable) { - t.Errorf("Expected error to mention missing API key, got %v", err) - } -} - -func TestNewDatadogDurationsAPI_AgentUnixSocketConfiguresHTTPTransport(t *testing.T) { - socketPath := "/tmp/ddtest-agent.sock" - t.Setenv(constants.CIVisibilityAgentlessEnabledEnvironmentVariable, "false") - t.Setenv("DD_TRACE_AGENT_URL", "unix://"+socketPath) - t.Setenv("DD_AGENT_HOST", "") - t.Setenv("DD_TRACE_AGENT_PORT", "") - t.Cleanup(func() { - _ = os.Unsetenv("DD_TRACE_AGENT_URL") - }) - - api := NewDatadogDurationsAPI() - if api == nil { - t.Fatal("NewDatadogDurationsAPI() should return a client") - } - if api.baseURL != "http://UDS__tmp_ddtest-agent.sock" { - t.Errorf("Expected UDS base URL host, got %q", api.baseURL) - } - if api.httpClient == nil { - t.Fatal("Expected HTTP client to be configured") - } - if _, ok := api.httpClient.Transport.(*http.Transport); !ok { - t.Fatalf("Expected Unix socket HTTP transport, got %T", api.httpClient.Transport) - } -} diff --git a/internal/testoptimization/test_management.go b/internal/testoptimization/test_management.go index b3694da..97d946a 100644 --- a/internal/testoptimization/test_management.go +++ b/internal/testoptimization/test_management.go @@ -1,8 +1,8 @@ package testoptimization -import "github.com/DataDog/ddtest/internal/utils/net" +import "github.com/DataDog/ddtest/internal/testoptimization/api" -func DisabledTestsFromTestManagementData(testManagementTests *net.TestManagementTestsResponseDataModules) map[string]bool { +func DisabledTestsFromTestManagementData(testManagementTests *api.TestManagementTestsResponseDataModules) map[string]bool { disabledTests := make(map[string]bool) if testManagementTests == nil { return disabledTests diff --git a/internal/testoptimization/testoptimization.go b/internal/testoptimization/testoptimization.go new file mode 100644 index 0000000..a031233 --- /dev/null +++ b/internal/testoptimization/testoptimization.go @@ -0,0 +1,521 @@ +package testoptimization + +import ( + "fmt" + "log/slog" + "os" + "os/signal" + "slices" + "sync" + "syscall" + "time" + + testoptimizationstate "github.com/DataDog/ddtest/civisibility" + ciConstants "github.com/DataDog/ddtest/civisibility/constants" + "github.com/DataDog/ddtest/internal/testoptimization/api" + "github.com/DataDog/ddtest/internal/utils" + "github.com/DataDog/ddtest/stableconfig" +) + +const autoDetectServiceName = "" + +type testOptimizationCloseAction func() + +type searchCommitsResponse struct { + LocalCommits []string + RemoteCommits []string + IsOk bool +} + +type TestOptimizationClient struct { + apiTransport api.Transport + newAPITransport func(serviceName string) api.Transport + cacheManager *CacheManager + repositoryChangesUploader func() (int64, error) + enableSignalHandler bool + + initializationOnce sync.Once + settingsOnce sync.Once + testOptimizationOnce sync.Once + closeActionsMutex sync.Mutex + closeActions []testOptimizationCloseAction + settings *api.SettingsResponseData + knownTests api.KnownTestsResponseData + skippableTests api.SkippableTests + testManagementTests api.TestManagementTestsResponseDataModules +} + +func NewTestOptimizationClient() *TestOptimizationClient { + return newTestOptimizationClient(nil, api.NewTransportWithServiceName, nil, true) +} + +func NewTestOptimizationClientWithDependencies(apiTransport api.Transport) *TestOptimizationClient { + return newTestOptimizationClient(apiTransport, nil, func() (int64, error) { return 0, nil }, false) +} + +func newTestOptimizationClient( + apiTransport api.Transport, + newAPITransport func(serviceName string) api.Transport, + repositoryChangesUploader func() (int64, error), + enableSignalHandler bool, +) *TestOptimizationClient { + if apiTransport == nil && newAPITransport == nil { + newAPITransport = api.NewTransportWithServiceName + } + + return &TestOptimizationClient{ + apiTransport: apiTransport, + newAPITransport: newAPITransport, + cacheManager: NewCacheManager(), + repositoryChangesUploader: repositoryChangesUploader, + enableSignalHandler: enableSignalHandler, + } +} + +func (c *TestOptimizationClient) Initialize(tags map[string]string) error { + utils.AddCITagsMap(tags) + + startTime := time.Now() + c.ensureTestOptimizationSessionInitialized() + + // Fetch and store settings. + c.settings = c.GetSettings() + + duration := time.Since(startTime) + slog.Debug("Finished Datadog Test Optimization initialization", "duration", duration) + + return nil +} + +func (c *TestOptimizationClient) GetSettings() *api.SettingsResponseData { + return c.ensureSettingsInitialization(autoDetectServiceName) +} + +func (c *TestOptimizationClient) GetSkippableTests() map[string]bool { + startTime := time.Now() + + slog.Debug("Fetching skippable tests...") + c.ensureTestOptimizationInitialized() + if c.skippableTests == nil { + c.skippableTests = api.SkippableTests{} + } + + if c.apiTransport != nil { + if err := c.cacheManager.StoreSkippableTestsCache(c.apiTransport.GetSkippableTestsRawResponse()); err != nil { + slog.Warn("Failed to store skippable tests cache", "error", err) + } + } + + duration := time.Since(startTime) + slog.Debug("Finished fetching skippable tests", "count", len(c.skippableTests), "duration", duration) + + return c.skippableTests +} + +func (c *TestOptimizationClient) GetKnownTests() *api.KnownTestsResponseData { + if c.settings == nil || !c.settings.KnownTestsEnabled { + return nil + } + c.ensureTestOptimizationInitialized() + return &c.knownTests +} + +func (c *TestOptimizationClient) GetTestManagementTestsData() *api.TestManagementTestsResponseDataModules { + if c.settings == nil || !c.settings.TestManagement.Enabled { + return nil + } + c.ensureTestOptimizationInitialized() + return &c.testManagementTests +} + +func (c *TestOptimizationClient) GetTestSuiteDurations() *api.TestSuiteDurationsResponseData { + testOptimizationTransport := c.ensureAPITransport(autoDetectServiceName) + if testOptimizationTransport == nil { + return &api.TestSuiteDurationsResponseData{ + TestSuites: map[string]map[string]api.TestSuiteDurationInfo{}, + } + } + return testOptimizationTransport.GetTestSuiteDurations() +} + +func (c *TestOptimizationClient) StoreCacheAndExit() { + repositorySettings := c.GetSettings() + if repositorySettings != nil { + slog.Debug("Repository settings", "itr_enabled", repositorySettings.ItrEnabled, "tests_skipping", repositorySettings.TestsSkipping) + } + + if c.apiTransport != nil { + if err := c.cacheManager.StoreRepositorySettings(c.apiTransport.GetSettingsRawResponse()); err != nil { + slog.Warn("Failed to store repository settings cache", "error", err) + } + + if err := c.cacheManager.StoreKnownTestsCache(c.apiTransport.GetKnownTestsRawResponse()); err != nil { + slog.Warn("Failed to store known tests cache", "error", err) + } + + if err := c.cacheManager.StoreTestManagementTestsCache(c.apiTransport.GetTestManagementTestsRawResponse()); err != nil { + slog.Warn("Failed to store test management tests cache", "error", err) + } + } + + c.exitTestOptimization() +} + +func (c *TestOptimizationClient) ensureTestOptimizationSessionInitialized() { + c.initializationOnce.Do(func() { + testoptimizationstate.SetState(testoptimizationstate.StateInitializing) + defer testoptimizationstate.SetState(testoptimizationstate.StateInitialized) + + slog.SetLogLoggerLevel(slog.LevelInfo) + if enabled, _ := stableconfig.Bool("DD_TRACE_DEBUG", false); enabled { + slog.SetLogLoggerLevel(slog.LevelDebug) + } + + slog.Debug("testoptimization: initializing") + + _ = os.Setenv(ciConstants.TestOptimizationEnabledEnvironmentVariable, "1") + _ = os.Setenv("DD_TRACE_SAMPLE_RATE", "1") + + ciTags := utils.GetCITags() + if _, ok := ciTags[ciConstants.GitRepositoryURL]; !ok { + slog.Debug("testoptimization: git repository URL tag was not detected") + } + + if c.enableSignalHandler { + c.registerSignalHandler() + } + }) +} + +func (c *TestOptimizationClient) ensureSettingsInitialization(serviceName string) *api.SettingsResponseData { + c.settingsOnce.Do(func() { + slog.Debug("testoptimization: initializing settings") + defer slog.Debug("testoptimization: settings initialization complete") + + testOptimizationTransport := c.ensureAPITransport(serviceName) + if testOptimizationTransport == nil { + slog.Error("testoptimization: error getting the test optimization API client") + return + } + + uploadChannel := c.uploadRepositoryChangesAsync() + waitUpload := func(timeout time.Duration) bool { + select { + case <-uploadChannel: + return true + case <-time.After(timeout): + slog.Warn("testoptimization: timeout waiting for upload repository changes") + return false + } + } + waitUploadFactory := func(timeout time.Duration) func() { + return func() { waitUpload(timeout) } + } + + ciSettings, err := testOptimizationTransport.GetSettings() + if err != nil || ciSettings == nil { + if err != nil { + slog.Error("testoptimization: error getting test optimization settings", "error", err.Error()) + } else { + slog.Error("testoptimization: error getting test optimization settings") + } + slog.Debug("testoptimization: no need to wait for the git upload to finish") + c.pushTestOptimizationCloseAction(waitUploadFactory(time.Minute)) + return + } + + if ciSettings.RequireGit { + slog.Debug("testoptimization: waiting for the git upload to finish and repeating the settings request") + if !waitUpload(time.Minute) { + slog.Error("testoptimization: error getting test optimization settings due to timeout") + return + } + ciSettings, err = testOptimizationTransport.GetSettings() + if err != nil || ciSettings == nil { + if err != nil { + slog.Error("testoptimization: error getting test optimization settings", "error", err.Error()) + } else { + slog.Error("testoptimization: error getting test optimization settings") + } + return + } + } + + applyEnvironmentOverrides(ciSettings) + + slog.Debug("testoptimization: no need to wait for the git upload to finish") + c.pushTestOptimizationCloseAction(waitUploadFactory(time.Minute)) + c.settings = ciSettings + }) + + return c.settings +} + +func (c *TestOptimizationClient) ensureAPITransport(serviceName string) api.Transport { + if c.apiTransport != nil { + return c.apiTransport + } + if c.newAPITransport == nil { + return nil + } + c.apiTransport = c.newAPITransport(serviceName) + return c.apiTransport +} + +func applyEnvironmentOverrides(ciSettings *api.SettingsResponseData) { + if !ciSettings.KnownTestsEnabled { + ciSettings.EarlyFlakeDetection.Enabled = false + } + + if ciSettings.FlakyTestRetriesEnabled && !testoptimizationstate.BoolEnv(ciConstants.TestOptimizationFlakyRetryEnabledEnvironmentVariable, true) { + slog.Warn("testoptimization: flaky test retries was disabled by the environment variable") + ciSettings.FlakyTestRetriesEnabled = false + } + + if ciSettings.TestManagement.Enabled && !testoptimizationstate.BoolEnv(ciConstants.TestOptimizationManagementEnabledEnvironmentVariable, true) { + slog.Warn("testoptimization: test management was disabled by the environment variable") + ciSettings.TestManagement.Enabled = false + } + + testManagementAttemptToFixRetriesEnv := testoptimizationstate.IntEnv(ciConstants.TestOptimizationAttemptToFixRetriesEnvironmentVariable, -1) + if testManagementAttemptToFixRetriesEnv != -1 { + ciSettings.TestManagement.AttemptToFixRetries = testManagementAttemptToFixRetriesEnv + } +} + +func (c *TestOptimizationClient) ensureTestOptimizationInitialized() { + c.testOptimizationOnce.Do(func() { + slog.Debug("testoptimization: initializing test optimization") + defer slog.Debug("testoptimization: test optimization initialization complete") + + currentSettings := c.GetSettings() + if currentSettings == nil || c.apiTransport == nil { + return + } + + additionalTags := map[string]string{ + ciConstants.LibraryCapabilitiesEarlyFlakeDetection: "1", + ciConstants.LibraryCapabilitiesAutoTestRetries: "1", + ciConstants.LibraryCapabilitiesTestImpactAnalysis: "1", + ciConstants.LibraryCapabilitiesTestManagementQuarantine: "1", + ciConstants.LibraryCapabilitiesTestManagementDisable: "1", + ciConstants.LibraryCapabilitiesTestManagementAttemptToFix: "5", + } + defer func() { + if len(additionalTags) > 0 { + slog.Debug("testoptimization: adding additional tags", "tags", additionalTags) //nolint:gocritic // Map structure logging for debugging + utils.AddCITagsMap(additionalTags) + } + }() + + var additionalTagsMutex sync.Mutex + setAdditionalTags := func(key string, value string) { + additionalTagsMutex.Lock() + defer additionalTagsMutex.Unlock() + additionalTags[key] = value + } + + var wg sync.WaitGroup + + if currentSettings.KnownTestsEnabled { + wg.Add(1) + go func() { + defer wg.Done() + knownTests, err := c.apiTransport.GetKnownTests() + if err != nil { + slog.Error("testoptimization: error getting test optimization known tests data", "err", err.Error()) + } else if knownTests != nil { + c.knownTests = *knownTests + slog.Debug("testoptimization: known tests data loaded.") + } + }() + } + + if currentSettings.TestsSkipping { + wg.Add(1) + go func() { + defer wg.Done() + correlationID, skippableTests, err := c.apiTransport.GetSkippableTests() + if err != nil { + slog.Error("testoptimization: error getting test optimization skippable tests", "err", err.Error()) + } else if skippableTests != nil { + slog.Debug("testoptimization: skippable tests loaded", "count", len(skippableTests)) + setAdditionalTags(ciConstants.ItrCorrelationIDTag, correlationID) + c.skippableTests = skippableTests + } + }() + } + + if currentSettings.TestManagement.Enabled { + wg.Add(1) + go func() { + defer wg.Done() + testManagementTests, err := c.apiTransport.GetTestManagementTests() + if err != nil { + slog.Error("testoptimization: error getting test optimization test management tests", "err", err.Error()) + } else if testManagementTests != nil { + c.testManagementTests = *testManagementTests + slog.Debug("testoptimization: test management loaded", "attemptToFixRetries", currentSettings.TestManagement.AttemptToFixRetries) + } + }() + } + + wg.Wait() + }) +} + +func (c *TestOptimizationClient) pushTestOptimizationCloseAction(action testOptimizationCloseAction) { + c.closeActionsMutex.Lock() + defer c.closeActionsMutex.Unlock() + c.closeActions = append([]testOptimizationCloseAction{action}, c.closeActions...) +} + +func (c *TestOptimizationClient) exitTestOptimization() { + if testoptimizationstate.GetState() != testoptimizationstate.StateInitialized { + slog.Debug("testoptimization: already closed or not initialized") + return + } + + testoptimizationstate.SetState(testoptimizationstate.StateExiting) + defer testoptimizationstate.SetState(testoptimizationstate.StateExited) + slog.Debug("testoptimization: exiting") + + c.closeActionsMutex.Lock() + defer c.closeActionsMutex.Unlock() + defer func() { + c.closeActions = []testOptimizationCloseAction{} + slog.Debug("testoptimization: done.") + }() + for _, action := range c.closeActions { + action() + } +} + +func (c *TestOptimizationClient) registerSignalHandler() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-signals + c.StoreCacheAndExit() + os.Exit(1) + }() +} + +func (c *TestOptimizationClient) uploadRepositoryChangesAsync() chan struct{} { + uploadChannel := make(chan struct{}) + go func() { + defer close(uploadChannel) + bytes, err := c.uploadRepositoryChanges() + if err != nil { + slog.Error("testoptimization: error uploading repository changes:", "error", err.Error()) + } else { + slog.Debug("testoptimization: uploaded bytes in pack files", "count", bytes) + } + }() + return uploadChannel +} + +func (c *TestOptimizationClient) uploadRepositoryChanges() (bytes int64, err error) { + if c.repositoryChangesUploader != nil { + return c.repositoryChangesUploader() + } + + return c.uploadRepositoryChangesFromGit() +} + +func (c *TestOptimizationClient) uploadRepositoryChangesFromGit() (bytes int64, err error) { + initialCommitData, err := c.getSearchCommits() + if err != nil { + return 0, fmt.Errorf("testoptimization: error getting the search commits response: %s", err) + } + + if !initialCommitData.IsOk { + return 0, nil + } + + if !initialCommitData.hasCommits() { + slog.Debug("testoptimization: no commits found") + return 0, nil + } + + if initialCommitData.hasCommits() && len(initialCommitData.missingCommits()) == 0 { + slog.Debug("testoptimization: initial commit data has everything already, we don't need to upload anything") + return 0, nil + } + + hasBeenUnshallowed, err := utils.UnshallowGitRepository() + if err != nil || !hasBeenUnshallowed { + if err != nil { + slog.Warn(err.Error()) + } + return c.sendObjectsPackFile(initialCommitData.LocalCommits[0], initialCommitData.missingCommits(), initialCommitData.RemoteCommits) + } + + commitsData, err := c.getSearchCommits() + if err != nil { + return 0, fmt.Errorf("testoptimization: error getting the search commits response: %s", err) + } + + if !commitsData.IsOk { + return 0, nil + } + + return c.sendObjectsPackFile(commitsData.LocalCommits[0], commitsData.missingCommits(), commitsData.RemoteCommits) +} + +func (c *TestOptimizationClient) getSearchCommits() (*searchCommitsResponse, error) { + localCommits := utils.GetLastLocalGitCommitShas() + if len(localCommits) == 0 { + slog.Debug("testoptimization: no local commits found") + return newSearchCommitsResponse(nil, nil, false), nil + } + + if c.apiTransport == nil { + return newSearchCommitsResponse(nil, nil, false), nil + } + + slog.Debug("testoptimization: local commits found", "count", len(localCommits)) + remoteCommits, err := c.apiTransport.GetCommits(localCommits) + return newSearchCommitsResponse(localCommits, remoteCommits, true), err +} + +func newSearchCommitsResponse(localCommits []string, remoteCommits []string, isOk bool) *searchCommitsResponse { + return &searchCommitsResponse{ + LocalCommits: localCommits, + RemoteCommits: remoteCommits, + IsOk: isOk, + } +} + +func (r *searchCommitsResponse) hasCommits() bool { + return len(r.LocalCommits) > 0 +} + +func (r *searchCommitsResponse) missingCommits() []string { + var missingCommits []string + for _, localCommit := range r.LocalCommits { + if !slices.Contains(r.RemoteCommits, localCommit) { + missingCommits = append(missingCommits, localCommit) + } + } + + return missingCommits +} + +func (c *TestOptimizationClient) sendObjectsPackFile(commitSha string, commitsToInclude []string, commitsToExclude []string) (bytes int64, err error) { + packFiles := utils.CreatePackFiles(commitsToInclude, commitsToExclude) + if len(packFiles) == 0 { + slog.Debug("testoptimization: no pack files to send") + return 0, nil + } + + slog.Debug("testoptimization: sending pack file with missing commits", "count", packFiles) //nolint:gocritic // File list logging for debugging + + defer func(files []string) { + for _, file := range files { + _ = os.Remove(file) + } + }(packFiles) + + return c.apiTransport.SendPackFiles(commitSha, packFiles) +} diff --git a/internal/testoptimization/testoptimization_lifecycle_test.go b/internal/testoptimization/testoptimization_lifecycle_test.go new file mode 100644 index 0000000..e678da1 --- /dev/null +++ b/internal/testoptimization/testoptimization_lifecycle_test.go @@ -0,0 +1,596 @@ +package testoptimization + +import ( + "encoding/json" + "errors" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + testoptimizationstate "github.com/DataDog/ddtest/civisibility" + ciConstants "github.com/DataDog/ddtest/civisibility/constants" + "github.com/DataDog/ddtest/internal/constants" + "github.com/DataDog/ddtest/internal/testoptimization/api" + "github.com/DataDog/ddtest/internal/utils" +) + +type settingsSequenceTransport struct { + MockAPIClient + settings []*api.SettingsResponseData + errors []error +} + +func (s *settingsSequenceTransport) GetSettings() (*api.SettingsResponseData, error) { + s.SettingsCalls++ + index := s.SettingsCalls - 1 + var err error + if index < len(s.errors) { + err = s.errors[index] + } + if index < len(s.settings) { + return s.settings[index], err + } + return s.Settings, err +} + +func TestTestOptimizationClientFeatureGetters(t *testing.T) { + settings := &api.SettingsResponseData{ + KnownTestsEnabled: true, + TestsSkipping: true, + } + settings.TestManagement.Enabled = true + + mockTransport := &MockAPIClient{ + Settings: settings, + KnownTests: &api.KnownTestsResponseData{ + Tests: api.KnownTestsResponseDataModules{ + "module": {"suite": {"test"}}, + }, + }, + SkippableCorrelationID: "correlation-id", + SkippableTests: api.SkippableTests{ + "module.suite.test.": true, + }, + TestManagementTestsData: &api.TestManagementTestsResponseDataModules{ + Modules: map[string]api.TestManagementTestsResponseDataSuites{ + "module": { + Suites: map[string]api.TestManagementTestsResponseDataTests{ + "suite": { + Tests: map[string]api.TestManagementTestsResponseDataTestProperties{ + "test": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, + }, + }, + }, + }, + }, + }, + } + client := newTestOptimizationClientForTest(t, mockTransport) + if err := client.Initialize(map[string]string{"custom": "tag"}); err != nil { + t.Fatalf("Initialize() returned error: %v", err) + } + + if known := client.GetKnownTests(); known == nil || len(known.Tests) != 1 { + t.Fatalf("expected known tests, got %#v", known) + } + if skippable := client.GetSkippableTests(); len(skippable) != 1 || !skippable["module.suite.test."] { + t.Fatalf("expected skippable tests, got %#v", skippable) + } + if managed := client.GetTestManagementTestsData(); managed == nil || len(managed.Modules) != 1 { + t.Fatalf("expected test management data, got %#v", managed) + } + if mockTransport.KnownTestsCalls != 1 || mockTransport.SkippableTestsCalls != 1 || mockTransport.TestManagementTestsCalls != 1 { + t.Fatalf("expected each feature endpoint once, got known=%d skippable=%d testManagement=%d", + mockTransport.KnownTestsCalls, mockTransport.SkippableTestsCalls, mockTransport.TestManagementTestsCalls) + } + + ciTags := utils.GetCITags() + if ciTags[ciConstants.ItrCorrelationIDTag] != "correlation-id" { + t.Fatalf("expected correlation id tag, got %#v", ciTags) + } +} + +func TestTestOptimizationClientFeatureGettersDisabled(t *testing.T) { + client := newTestOptimizationClientForTest(t, &MockAPIClient{ + Settings: &api.SettingsResponseData{}, + }) + if err := client.Initialize(map[string]string{}); err != nil { + t.Fatalf("Initialize() returned error: %v", err) + } + + if known := client.GetKnownTests(); known != nil { + t.Fatalf("expected nil known tests when disabled, got %#v", known) + } + if managed := client.GetTestManagementTestsData(); managed != nil { + t.Fatalf("expected nil test management tests when disabled, got %#v", managed) + } + if skippable := client.GetSkippableTests(); len(skippable) != 0 { + t.Fatalf("expected empty skippable map when disabled, got %#v", skippable) + } +} + +func TestEnsureSettingsInitializationRequireGitRetriesAfterUpload(t *testing.T) { + firstSettings := &api.SettingsResponseData{RequireGit: true} + secondSettings := &api.SettingsResponseData{KnownTestsEnabled: true} + transport := &settingsSequenceTransport{ + settings: []*api.SettingsResponseData{firstSettings, secondSettings}, + } + client := newTestOptimizationClient( + transport, + nil, + func() (int64, error) { return 42, nil }, + false, + ) + + settings := client.GetSettings() + if settings != secondSettings { + t.Fatalf("expected second settings response after git upload, got %#v", settings) + } + if transport.SettingsCalls != 2 { + t.Fatalf("expected settings to be requested twice, got %d", transport.SettingsCalls) + } +} + +func TestEnsureSettingsInitializationHandlesMissingTransportAndErrors(t *testing.T) { + client := newTestOptimizationClient(nil, func(string) api.Transport { return nil }, nil, false) + if settings := client.GetSettings(); settings != nil { + t.Fatalf("expected nil settings without transport, got %#v", settings) + } + + transport := &settingsSequenceTransport{ + errors: []error{errors.New("settings failed")}, + } + client = newTestOptimizationClient(transport, nil, func() (int64, error) { return 0, nil }, false) + if settings := client.GetSettings(); settings != nil { + t.Fatalf("expected nil settings on transport error, got %#v", settings) + } + if len(client.closeActions) != 1 { + t.Fatalf("expected close action to wait for upload, got %d", len(client.closeActions)) + } +} + +func TestEnsureSettingsInitializationHandlesNilResponsesAfterUpload(t *testing.T) { + transport := &settingsSequenceTransport{ + settings: []*api.SettingsResponseData{nil}, + } + client := newTestOptimizationClient(transport, nil, func() (int64, error) { return 0, nil }, false) + if settings := client.GetSettings(); settings != nil { + t.Fatalf("expected nil settings from nil response, got %#v", settings) + } + if len(client.closeActions) != 1 { + t.Fatalf("expected close action to wait for upload, got %d", len(client.closeActions)) + } + + transport = &settingsSequenceTransport{ + settings: []*api.SettingsResponseData{{RequireGit: true}, nil}, + } + client = newTestOptimizationClient(transport, nil, func() (int64, error) { return 0, nil }, false) + if settings := client.GetSettings(); settings != nil { + t.Fatalf("expected nil settings when require-git retry returns nil, got %#v", settings) + } + if transport.SettingsCalls != 2 { + t.Fatalf("expected retry after require-git upload, got %d calls", transport.SettingsCalls) + } + + secondErr := errors.New("second settings failed") + transport = &settingsSequenceTransport{ + settings: []*api.SettingsResponseData{{RequireGit: true}, nil}, + errors: []error{nil, secondErr}, + } + client = newTestOptimizationClient(transport, nil, func() (int64, error) { return 0, nil }, false) + if settings := client.GetSettings(); settings != nil { + t.Fatalf("expected nil settings when require-git retry fails, got %#v", settings) + } +} + +func TestEnsureAPITransportWithoutFactory(t *testing.T) { + client := &TestOptimizationClient{cacheManager: NewCacheManager()} + if transport := client.ensureAPITransport("service"); transport != nil { + t.Fatalf("expected nil transport without existing transport or factory, got %#v", transport) + } +} + +func TestEnsureTestOptimizationSessionInitializationBranches(t *testing.T) { + t.Setenv("DD_TRACE_DEBUG", "true") + t.Setenv(ciConstants.TestOptimizationEnabledEnvironmentVariable, "") + t.Setenv("DD_TRACE_SAMPLE_RATE", "") + utils.ResetCITags() + t.Cleanup(utils.ResetCITags) + t.Cleanup(func() { + signal.Reset(syscall.SIGINT, syscall.SIGTERM) + testoptimizationstate.SetState(testoptimizationstate.StateExited) + }) + + client := newTestOptimizationClient(&MockAPIClient{}, nil, func() (int64, error) { return 0, nil }, true) + client.ensureTestOptimizationSessionInitialized() + + if got := os.Getenv(ciConstants.TestOptimizationEnabledEnvironmentVariable); got != "1" { + t.Fatalf("%s = %q, want 1", ciConstants.TestOptimizationEnabledEnvironmentVariable, got) + } + if got := os.Getenv("DD_TRACE_SAMPLE_RATE"); got != "1" { + t.Fatalf("DD_TRACE_SAMPLE_RATE = %q, want 1", got) + } +} + +func TestEnsureTestOptimizationInitializedHandlesNilSettingsAndEndpointErrors(t *testing.T) { + client := newTestOptimizationClient(&MockAPIClient{}, nil, func() (int64, error) { return 0, nil }, false) + client.ensureTestOptimizationInitialized() + + settings := &api.SettingsResponseData{ + KnownTestsEnabled: true, + TestsSkipping: true, + } + settings.TestManagement.Enabled = true + mockTransport := &MockAPIClient{ + Settings: settings, + KnownTestsErr: errors.New("known tests failed"), + SkippableErr: errors.New("skippable tests failed"), + TestManagementTestsErr: errors.New("test management failed"), + } + client = newTestOptimizationClientForTest(t, mockTransport) + if err := client.Initialize(map[string]string{}); err != nil { + t.Fatalf("Initialize() returned error: %v", err) + } + + client.ensureTestOptimizationInitialized() + + if mockTransport.KnownTestsCalls != 1 || mockTransport.SkippableTestsCalls != 1 || mockTransport.TestManagementTestsCalls != 1 { + t.Fatalf("expected each feature endpoint once, got known=%d skippable=%d testManagement=%d", + mockTransport.KnownTestsCalls, mockTransport.SkippableTestsCalls, mockTransport.TestManagementTestsCalls) + } +} + +func TestApplyEnvironmentOverrides(t *testing.T) { + t.Setenv(ciConstants.TestOptimizationFlakyRetryEnabledEnvironmentVariable, "false") + t.Setenv(ciConstants.TestOptimizationManagementEnabledEnvironmentVariable, "false") + t.Setenv(ciConstants.TestOptimizationAttemptToFixRetriesEnvironmentVariable, "7") + + settings := &api.SettingsResponseData{ + KnownTestsEnabled: false, + FlakyTestRetriesEnabled: true, + } + settings.EarlyFlakeDetection.Enabled = true + settings.TestManagement.Enabled = true + + applyEnvironmentOverrides(settings) + + if settings.EarlyFlakeDetection.Enabled { + t.Fatal("expected early flake detection to be disabled when known tests are disabled") + } + if settings.FlakyTestRetriesEnabled { + t.Fatal("expected flaky retries env override") + } + if settings.TestManagement.Enabled { + t.Fatal("expected test management env override") + } + if settings.TestManagement.AttemptToFixRetries != 7 { + t.Fatalf("attempt-to-fix retries = %d, want 7", settings.TestManagement.AttemptToFixRetries) + } +} + +func TestRepositoryUploadHelpers(t *testing.T) { + uploadErr := errors.New("upload failed") + client := newTestOptimizationClient(nil, nil, func() (int64, error) { return 0, uploadErr }, false) + if bytes, err := client.uploadRepositoryChanges(); bytes != 0 || !errors.Is(err, uploadErr) { + t.Fatalf("uploadRepositoryChanges() = %d, %v", bytes, err) + } + + client = newTestOptimizationClient(nil, nil, func() (int64, error) { return 99, nil }, false) + if bytes, err := client.uploadRepositoryChanges(); bytes != 99 || err != nil { + t.Fatalf("uploadRepositoryChanges() = %d, %v", bytes, err) + } + + done := client.uploadRepositoryChangesAsync() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("uploadRepositoryChangesAsync() did not finish") + } + + client = newTestOptimizationClient(&MockAPIClient{}, nil, nil, false) + if bytes, err := client.sendObjectsPackFile("commit", nil, nil); bytes != 0 || err != nil { + t.Fatalf("sendObjectsPackFile() empty = %d, %v", bytes, err) + } +} + +func TestRepositoryUploadAsyncErrorAndGitFallback(t *testing.T) { + uploadErr := errors.New("upload failed") + client := newTestOptimizationClient(nil, nil, func() (int64, error) { return 0, uploadErr }, false) + done := client.uploadRepositoryChangesAsync() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("uploadRepositoryChangesAsync() with error did not finish") + } + + client = newTestOptimizationClient(nil, nil, nil, false) + bytes, err := client.uploadRepositoryChanges() + if err != nil || bytes != 0 { + t.Fatalf("expected git fallback noop without transport, got bytes=%d err=%v", bytes, err) + } +} + +func TestUploadRepositoryChangesFromGitAllCommitsKnown(t *testing.T) { + localCommits := utils.GetLastLocalGitCommitShas() + if len(localCommits) == 0 { + t.Skip("no local git commits available") + } + + mockTransport := &MockAPIClient{RemoteCommits: localCommits} + client := newTestOptimizationClient(mockTransport, nil, nil, false) + + bytes, err := client.uploadRepositoryChangesFromGit() + if err != nil { + t.Fatalf("uploadRepositoryChangesFromGit() returned error: %v", err) + } + if bytes != 0 { + t.Fatalf("expected no packfile upload when all commits are known, got %d bytes", bytes) + } + if mockTransport.GetCommitsCalls != 1 { + t.Fatalf("expected one search commits request, got %d", mockTransport.GetCommitsCalls) + } +} + +func TestUploadRepositoryChangesFromGitSearchError(t *testing.T) { + localCommits := utils.GetLastLocalGitCommitShas() + if len(localCommits) == 0 { + t.Skip("no local git commits available") + } + + searchErr := errors.New("search commits failed") + client := newTestOptimizationClient(&MockAPIClient{GetCommitsErr: searchErr}, nil, nil, false) + + bytes, err := client.uploadRepositoryChangesFromGit() + if bytes != 0 { + t.Fatalf("expected no bytes on search error, got %d", bytes) + } + if err == nil || !strings.Contains(err.Error(), searchErr.Error()) { + t.Fatalf("expected wrapped search error, got %v", err) + } +} + +func TestUploadRepositoryChangesFromGitUploadsMissingCommits(t *testing.T) { + localCommits := utils.GetLastLocalGitCommitShas() + if len(localCommits) < 1 { + t.Skip("no local git commits available") + } + + remoteCommits := append([]string(nil), localCommits[1:]...) + mockTransport := &MockAPIClient{ + RemoteCommits: remoteCommits, + SendPackFilesBytes: 123, + } + client := newTestOptimizationClient(mockTransport, nil, nil, false) + + bytes, err := client.uploadRepositoryChangesFromGit() + if err != nil { + t.Fatalf("uploadRepositoryChangesFromGit() returned error: %v", err) + } + if mockTransport.SendPackFilesCalls > 0 && bytes != 123 { + t.Fatalf("bytes = %d, want mock packfile bytes", bytes) + } + if mockTransport.GetCommitsCalls == 0 { + t.Fatal("expected search commits request") + } +} + +func TestUploadRepositoryChangesFromGitWithoutTransportIsNoop(t *testing.T) { + client := newTestOptimizationClient(nil, nil, nil, false) + bytes, err := client.uploadRepositoryChangesFromGit() + if err != nil || bytes != 0 { + t.Fatalf("expected noop without transport, got bytes=%d err=%v", bytes, err) + } +} + +func TestGetSearchCommitsBranches(t *testing.T) { + localCommits := utils.GetLastLocalGitCommitShas() + if len(localCommits) == 0 { + t.Skip("no local git commits available") + } + + client := newTestOptimizationClient(nil, nil, nil, false) + response, err := client.getSearchCommits() + if err != nil { + t.Fatalf("getSearchCommits() without transport returned error: %v", err) + } + if response.IsOk { + t.Fatalf("expected not-ok response without transport, got %#v", response) + } + + searchErr := errors.New("search failed") + mockTransport := &MockAPIClient{GetCommitsErr: searchErr} + client = newTestOptimizationClient(mockTransport, nil, nil, false) + response, err = client.getSearchCommits() + if !errors.Is(err, searchErr) { + t.Fatalf("expected search error, got response=%#v err=%v", response, err) + } + if response == nil || !response.IsOk || len(response.LocalCommits) == 0 { + t.Fatalf("expected local commits in ok response, got %#v", response) + } +} + +func TestGetSearchCommitsWithoutGitRepository(t *testing.T) { + originalWorkingDirectory, err := os.Getwd() + if err != nil { + t.Fatalf("get working directory: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(originalWorkingDirectory) + }) + if err := os.Chdir(t.TempDir()); err != nil { + t.Fatalf("chdir temp dir: %v", err) + } + + client := newTestOptimizationClient(&MockAPIClient{}, nil, nil, false) + response, err := client.getSearchCommits() + if err != nil { + t.Fatalf("getSearchCommits() returned error: %v", err) + } + if response.IsOk || response.hasCommits() { + t.Fatalf("expected no commits outside a git repository, got %#v", response) + } +} + +func TestCacheManagerAdditionalBranches(t *testing.T) { + cleanPlanDirectory(t) + manager := NewCacheManager() + + if err := manager.StoreTestOptimizationPlanCache(map[string]string{"key": "value"}); err != nil { + t.Fatalf("StoreTestOptimizationPlanCache() returned error: %v", err) + } + var decoded map[string]string + if err := manager.ReadTestOptimizationPlanCache(&decoded); err != nil { + t.Fatalf("ReadTestOptimizationPlanCache() returned error: %v", err) + } + if decoded["key"] != "value" { + t.Fatalf("decoded cache = %#v", decoded) + } + + nestedDir := filepath.Join(constants.PlanDirectory, "nested") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatalf("create nested dir: %v", err) + } + if err := manager.writeJSONToFile(map[string]string{"ok": "yes"}, filepath.Join(nestedDir, "data.json")); err != nil { + t.Fatalf("writeJSONToFile() returned error: %v", err) + } + + if err := manager.writeJSONBytesToFile([]byte("{"), filepath.Join(constants.PlanDirectory, "bad.json")); err != nil { + t.Fatalf("writeJSONBytesToFile() stores raw bytes and should not validate JSON: %v", err) + } + var invalid map[string]string + if err := manager.readJSONFromFile(filepath.Join(constants.PlanDirectory, "bad.json"), &invalid); err == nil { + t.Fatal("expected invalid JSON read error") + } + + if err := manager.readJSONFromFile(filepath.Join(constants.PlanDirectory, "missing.json"), &decoded); err == nil { + t.Fatal("expected missing cache read error") + } + + if err := manager.storeHTTPResponse(nil, "empty.json"); err != nil { + t.Fatalf("storeHTTPResponse(nil) should be a noop, got %v", err) + } + if err := manager.writeJSONBytesToFile(nil, filepath.Join(constants.PlanDirectory, "empty.json")); err != nil { + t.Fatalf("writeJSONBytesToFile(nil) should be a noop, got %v", err) + } + if err := manager.writeJSONToFile(make(chan int), filepath.Join(constants.PlanDirectory, "unsupported.json")); err == nil { + t.Fatal("expected marshal error for unsupported JSON value") + } + if err := manager.StoreTestOptimizationPlanCache(make(chan int)); err == nil { + t.Fatal("expected plan cache marshal error for unsupported JSON value") + } +} + +func TestCacheManagerWriteErrors(t *testing.T) { + blockingFile := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blockingFile, []byte("x"), 0o644); err != nil { + t.Fatalf("write blocking file: %v", err) + } + + manager := NewCacheManager() + if err := manager.writeJSONToFile(map[string]string{"x": "y"}, filepath.Join(blockingFile, "data.json")); err == nil { + t.Fatal("expected writeJSONToFile mkdir error") + } + if err := manager.writeJSONBytesToFile(json.RawMessage(`{}`), filepath.Join(blockingFile, "data.json")); err == nil { + t.Fatal("expected writeJSONBytesToFile mkdir error") + } + + originalHTTPDir := constants.HTTPCacheDir + t.Cleanup(func() { constants.HTTPCacheDir = originalHTTPDir }) + constants.HTTPCacheDir = t.TempDir() + if err := manager.storeHTTPResponse(json.RawMessage(`{"ok":true}`), "."); err == nil { + t.Fatal("expected HTTP response write error when target path is a directory") + } + + originalRunnerCacheDir := constants.RunnerCacheDir + t.Cleanup(func() { constants.RunnerCacheDir = originalRunnerCacheDir }) + constants.RunnerCacheDir = filepath.Join(t.TempDir(), "runner-cache-file") + if err := os.WriteFile(constants.RunnerCacheDir, []byte("x"), 0o644); err != nil { + t.Fatalf("write blocking runner cache file: %v", err) + } + if err := manager.StoreTestOptimizationPlanCache(map[string]string{"x": "y"}); err == nil { + t.Fatal("expected runner cache directory creation error") + } + + constants.RunnerCacheDir = t.TempDir() + if err := os.Mkdir(filepath.Join(constants.RunnerCacheDir, TestOptimizationPlanCacheFile), 0o755); err != nil { + t.Fatalf("create blocking plan cache directory: %v", err) + } + if err := manager.StoreTestOptimizationPlanCache(map[string]string{"x": "y"}); err == nil { + t.Fatal("expected plan cache write error when target path is a directory") + } +} + +func TestSearchCommitsResponseHelpers(t *testing.T) { + response := newSearchCommitsResponse([]string{"a", "b", "c"}, []string{"b"}, true) + if !response.IsOk || !response.hasCommits() { + t.Fatalf("unexpected response state: %#v", response) + } + missing := response.missingCommits() + if len(missing) != 2 || missing[0] != "a" || missing[1] != "c" { + t.Fatalf("missingCommits() = %#v", missing) + } + + empty := newSearchCommitsResponse(nil, nil, false) + if empty.hasCommits() { + t.Fatal("empty response should not have commits") + } +} + +func TestExitTestOptimizationRunsCloseActionsOnlyWhenInitialized(t *testing.T) { + testoptimizationstate.SetState(testoptimizationstate.StateInitialized) + t.Cleanup(func() { testoptimizationstate.SetState(testoptimizationstate.StateExited) }) + + var calls []string + client := newTestOptimizationClient(nil, nil, nil, false) + client.pushTestOptimizationCloseAction(func() { calls = append(calls, "first") }) + client.pushTestOptimizationCloseAction(func() { calls = append(calls, "second") }) + + client.exitTestOptimization() + + if len(calls) != 2 || calls[0] != "second" || calls[1] != "first" { + t.Fatalf("close actions executed in unexpected order: %#v", calls) + } + if len(client.closeActions) != 0 { + t.Fatalf("expected close actions to be cleared, got %d", len(client.closeActions)) + } + if state := testoptimizationstate.GetState(); state != testoptimizationstate.StateExited { + t.Fatalf("state = %v, want exited", state) + } + + client.exitTestOptimization() +} + +func TestStoreCacheAndExitLogsCacheWriteErrors(t *testing.T) { + cleanPlanDirectory(t) + originalHTTPDir := constants.HTTPCacheDir + t.Cleanup(func() { constants.HTTPCacheDir = originalHTTPDir }) + + constants.HTTPCacheDir = filepath.Join(t.TempDir(), "http-cache-file") + if err := os.WriteFile(constants.HTTPCacheDir, []byte("x"), 0o644); err != nil { + t.Fatalf("write blocking HTTP cache file: %v", err) + } + + mockTransport := &MockAPIClient{ + Settings: &api.SettingsResponseData{}, + SettingsRawResponse: json.RawMessage(`{"settings":true}`), + KnownTestsRawResponse: json.RawMessage(`{"known":true}`), + TestManagementTestsRawResponse: json.RawMessage(`{"managed":true}`), + } + client := newTestOptimizationClientForTest(t, mockTransport) + client.StoreCacheAndExit() + + if mockTransport.SettingsCalls != 1 { + t.Fatalf("expected settings request, got %d", mockTransport.SettingsCalls) + } +} + +func TestDisabledTestsFromNilTestManagementData(t *testing.T) { + disabled := DisabledTestsFromTestManagementData(nil) + if len(disabled) != 0 { + t.Fatalf("expected no disabled tests for nil data, got %#v", disabled) + } +} diff --git a/internal/testoptimization/client_test.go b/internal/testoptimization/testoptimization_test.go similarity index 52% rename from internal/testoptimization/client_test.go rename to internal/testoptimization/testoptimization_test.go index 5ed3770..c064555 100644 --- a/internal/testoptimization/client_test.go +++ b/internal/testoptimization/testoptimization_test.go @@ -9,7 +9,8 @@ import ( "time" "github.com/DataDog/ddtest/internal/constants" - "github.com/DataDog/ddtest/internal/utils/net" + "github.com/DataDog/ddtest/internal/testoptimization/api" + "github.com/DataDog/ddtest/internal/utils" ) // TestMain runs once for the entire package and handles global setup/teardown @@ -23,69 +24,87 @@ func TestMain(m *testing.M) { os.Exit(code) } -// Mock implementations for testing -type MockCIVisibilityIntegrations struct { - InitializationCalled bool - ShutdownCalled bool - Settings *net.SettingsResponseData +type MockAPIClient struct { + Settings *api.SettingsResponseData + SettingsErr error SettingsRawResponse json.RawMessage - SkippableTests net.SkippableTests + SettingsCalls int + SkippableTests api.SkippableTests + SkippableCorrelationID string + SkippableErr error SkippableTestsRawResponse json.RawMessage - KnownTests *net.KnownTestsResponseData + SkippableTestsCalls int + KnownTests *api.KnownTestsResponseData + KnownTestsErr error KnownTestsRawResponse json.RawMessage - TestManagementTestsData *net.TestManagementTestsResponseDataModules + KnownTestsCalls int + TestManagementTestsData *api.TestManagementTestsResponseDataModules + TestManagementTestsErr error TestManagementTestsRawResponse json.RawMessage + TestManagementTestsCalls int + TestSuiteDurations map[string]map[string]api.TestSuiteDurationInfo + TestSuiteDurationsCalls int + RemoteCommits []string + GetCommitsErr error + GetCommitsCalls int + SentCommitSha string + SentPackFiles []string + SendPackFilesBytes int64 + SendPackFilesErr error + SendPackFilesCalls int } -func (m *MockCIVisibilityIntegrations) EnsureCiVisibilityInitialization() { - m.InitializationCalled = true +func (m *MockAPIClient) GetSettings() (*api.SettingsResponseData, error) { + m.SettingsCalls++ + return m.Settings, m.SettingsErr } -func (m *MockCIVisibilityIntegrations) ExitCiVisibility() { - m.ShutdownCalled = true -} - -func (m *MockCIVisibilityIntegrations) GetSettings() *net.SettingsResponseData { - return m.Settings -} - -func (m *MockCIVisibilityIntegrations) GetSettingsRawResponse() json.RawMessage { +func (m *MockAPIClient) GetSettingsRawResponse() json.RawMessage { return m.SettingsRawResponse } -func (m *MockCIVisibilityIntegrations) GetSkippableTests() net.SkippableTests { - return m.SkippableTests +func (m *MockAPIClient) GetSkippableTests() (string, api.SkippableTests, error) { + m.SkippableTestsCalls++ + return m.SkippableCorrelationID, m.SkippableTests, m.SkippableErr } -func (m *MockCIVisibilityIntegrations) GetSkippableTestsRawResponse() json.RawMessage { +func (m *MockAPIClient) GetSkippableTestsRawResponse() json.RawMessage { return m.SkippableTestsRawResponse } -func (m *MockCIVisibilityIntegrations) GetKnownTests() *net.KnownTestsResponseData { - return m.KnownTests +func (m *MockAPIClient) GetKnownTests() (*api.KnownTestsResponseData, error) { + m.KnownTestsCalls++ + return m.KnownTests, m.KnownTestsErr +} + +func (m *MockAPIClient) GetTestSuiteDurations() *api.TestSuiteDurationsResponseData { + m.TestSuiteDurationsCalls++ + return &api.TestSuiteDurationsResponseData{TestSuites: m.TestSuiteDurations} } -func (m *MockCIVisibilityIntegrations) GetKnownTestsRawResponse() json.RawMessage { +func (m *MockAPIClient) GetKnownTestsRawResponse() json.RawMessage { return m.KnownTestsRawResponse } -func (m *MockCIVisibilityIntegrations) GetTestManagementTestsData() *net.TestManagementTestsResponseDataModules { - return m.TestManagementTestsData +func (m *MockAPIClient) GetTestManagementTests() (*api.TestManagementTestsResponseDataModules, error) { + m.TestManagementTestsCalls++ + return m.TestManagementTestsData, m.TestManagementTestsErr } -func (m *MockCIVisibilityIntegrations) GetTestManagementTestsRawResponse() json.RawMessage { +func (m *MockAPIClient) GetTestManagementTestsRawResponse() json.RawMessage { return m.TestManagementTestsRawResponse } -type MockUtils struct { - AddedTags map[string]string +func (m *MockAPIClient) GetCommits(_ []string) ([]string, error) { + m.GetCommitsCalls++ + return m.RemoteCommits, m.GetCommitsErr } -func (m *MockUtils) AddCITagsMap(tags map[string]string) { - if m.AddedTags == nil { - m.AddedTags = make(map[string]string) - } - maps.Copy(m.AddedTags, tags) +func (m *MockAPIClient) SendPackFiles(commitSha string, packFiles []string) (bytes int64, err error) { + m.SendPackFilesCalls++ + m.SentCommitSha = commitSha + m.SentPackFiles = append([]string(nil), packFiles...) + return m.SendPackFilesBytes, m.SendPackFilesErr } func cleanPlanDirectory(t *testing.T) { @@ -98,6 +117,13 @@ func cleanPlanDirectory(t *testing.T) { }) } +func newTestOptimizationClientForTest(t *testing.T, apiTransport api.Transport) *TestOptimizationClient { + t.Helper() + utils.ResetCITags() + t.Cleanup(utils.ResetCITags) + return NewTestOptimizationClientWithDependencies(apiTransport) +} + func assertFileDoesNotExist(t *testing.T, path string) { t.Helper() if _, err := os.Stat(path); !os.IsNotExist(err) { @@ -118,34 +144,54 @@ func assertJSONFile(t *testing.T, path string, expected json.RawMessage) { } } -func TestNewDatadogClient(t *testing.T) { - client := NewDatadogClient() +func TestNewTestOptimizationClient(t *testing.T) { + client := NewTestOptimizationClient() if client == nil { - t.Error("NewDatadogClient() should return non-nil client") + t.Error("NewTestOptimizationClient() should return non-nil client") } } -func TestNewDatadogClientWithDependencies(t *testing.T) { - mockIntegrations := &MockCIVisibilityIntegrations{} - mockUtils := &MockUtils{} +func TestNewTestOptimizationClientWithDependencies(t *testing.T) { + mockAPIClient := &MockAPIClient{} - client := NewDatadogClientWithDependencies(mockIntegrations, mockUtils) + client := newTestOptimizationClientForTest(t, mockAPIClient) if client == nil { - t.Error("NewDatadogClientWithDependencies() should return non-nil client") + t.Error("NewTestOptimizationClientWithDependencies() should return non-nil client") } } -func TestDatadogClient_Initialize(t *testing.T) { - mockIntegrations := &MockCIVisibilityIntegrations{ - Settings: &net.SettingsResponseData{ +func TestTestOptimizationClient_GetTestSuiteDurations(t *testing.T) { + durations := map[string]map[string]api.TestSuiteDurationInfo{ + "rspec": { + "Suite": { + SourceFile: "spec/suite_spec.rb", + Duration: api.DurationPercentiles{P50: "42000000"}, + }, + }, + } + mockAPIClient := &MockAPIClient{TestSuiteDurations: durations} + client := newTestOptimizationClientForTest(t, mockAPIClient) + + result := client.GetTestSuiteDurations() + + if mockAPIClient.TestSuiteDurationsCalls != 1 { + t.Fatalf("GetTestSuiteDurations() should fetch durations once, got %d calls", mockAPIClient.TestSuiteDurationsCalls) + } + if result.TestSuites["rspec"]["Suite"].SourceFile != "spec/suite_spec.rb" { + t.Fatalf("GetTestSuiteDurations() returned %#v, want %#v", result, durations) + } +} + +func TestTestOptimizationClient_Initialize(t *testing.T) { + mockAPIClient := &MockAPIClient{ + Settings: &api.SettingsResponseData{ ItrEnabled: true, TestsSkipping: false, }, } - mockUtils := &MockUtils{} - client := NewDatadogClientWithDependencies(mockIntegrations, mockUtils) + client := newTestOptimizationClientForTest(t, mockAPIClient) tags := map[string]string{ "test.key1": "value1", @@ -160,19 +206,16 @@ func TestDatadogClient_Initialize(t *testing.T) { t.Errorf("Initialize() should not return error, got: %v", err) } - if !mockIntegrations.InitializationCalled { - t.Error("Initialize() should call EnsureCiVisibilityInitialization") + if mockAPIClient.SettingsCalls != 1 { + t.Errorf("Initialize() should fetch settings once, got %d calls", mockAPIClient.SettingsCalls) } - if mockUtils.AddedTags == nil { - t.Error("Initialize() should call AddCITagsMap") - } else { - for key, expectedValue := range tags { - if actualValue, exists := mockUtils.AddedTags[key]; !exists { - t.Errorf("Expected tag %s to be added", key) - } else if actualValue != expectedValue { - t.Errorf("Expected tag %s to have value %s, got %s", key, expectedValue, actualValue) - } + ciTags := utils.GetCITags() + for key, expectedValue := range tags { + if actualValue, exists := ciTags[key]; !exists { + t.Errorf("Expected tag %s to be added", key) + } else if actualValue != expectedValue { + t.Errorf("Expected tag %s to have value %s, got %s", key, expectedValue, actualValue) } } @@ -181,15 +224,14 @@ func TestDatadogClient_Initialize(t *testing.T) { } } -func TestDatadogClient_GetSkippableTests_NilResponse(t *testing.T) { - mockIntegrations := &MockCIVisibilityIntegrations{ - Settings: &net.SettingsResponseData{ +func TestTestOptimizationClient_GetSkippableTests_NilResponse(t *testing.T) { + mockAPIClient := &MockAPIClient{ + Settings: &api.SettingsResponseData{ ItrEnabled: false, TestsSkipping: false, }, } - mockUtils := &MockUtils{} - client := NewDatadogClientWithDependencies(mockIntegrations, mockUtils) + client := newTestOptimizationClientForTest(t, mockAPIClient) // Initialize the client to set up settings err := client.Initialize(map[string]string{}) @@ -208,20 +250,19 @@ func TestDatadogClient_GetSkippableTests_NilResponse(t *testing.T) { } } -func TestDatadogClient_GetSkippableTests(t *testing.T) { - mockIntegrations := &MockCIVisibilityIntegrations{ - Settings: &net.SettingsResponseData{ +func TestTestOptimizationClient_GetSkippableTests(t *testing.T) { + mockAPIClient := &MockAPIClient{ + Settings: &api.SettingsResponseData{ ItrEnabled: true, TestsSkipping: true, }, - SkippableTests: net.SkippableTests{ + SkippableTests: api.SkippableTests{ "module1.TestSuite1.test_method_1.param1": true, "module1.TestSuite1.test_method_2.param2": true, "module2.TestSuite2.test_method_3.param3": true, }, } - mockUtils := &MockUtils{} - client := NewDatadogClientWithDependencies(mockIntegrations, mockUtils) + client := newTestOptimizationClientForTest(t, mockAPIClient) // Initialize the client to set up settings err := client.Initialize(map[string]string{}) @@ -253,42 +294,42 @@ func TestDatadogClient_GetSkippableTests(t *testing.T) { } } -func TestDatadogClient_StoreCacheAndExit(t *testing.T) { - mockIntegrations := &MockCIVisibilityIntegrations{} - mockUtils := &MockUtils{} - client := NewDatadogClientWithDependencies(mockIntegrations, mockUtils) +func TestTestOptimizationClient_StoreCacheAndExit(t *testing.T) { + mockAPIClient := &MockAPIClient{ + Settings: &api.SettingsResponseData{}, + } + client := newTestOptimizationClientForTest(t, mockAPIClient) client.StoreCacheAndExit() - if !mockIntegrations.ShutdownCalled { - t.Error("Shutdown() should call ExitCiVisibility") + if mockAPIClient.SettingsCalls != 1 { + t.Errorf("StoreCacheAndExit() should fetch settings once, got %d calls", mockAPIClient.SettingsCalls) } } -func TestDatadogClient_StoreCacheAndExit_WritesHTTPCaches(t *testing.T) { +func TestTestOptimizationClient_StoreCacheAndExit_WritesHTTPCaches(t *testing.T) { cleanPlanDirectory(t) settingsResponse := json.RawMessage(`{"data":{"id":"settings","type":"ci_app_test_service_settings","attributes":{"itr_enabled":true}}}`) knownTestsResponse := json.RawMessage(`{"data":{"id":"known-tests","type":"ci_app_libraries_tests","attributes":{"tests":{}}}}`) testManagementResponse := json.RawMessage(`{"data":{"id":"test-management","type":"test_management_tests","attributes":{"modules":{}}}}`) - mockIntegrations := &MockCIVisibilityIntegrations{ - Settings: &net.SettingsResponseData{ + mockAPIClient := &MockAPIClient{ + Settings: &api.SettingsResponseData{ ItrEnabled: true, TestsSkipping: false, }, SettingsRawResponse: settingsResponse, - KnownTests: &net.KnownTestsResponseData{ - Tests: net.KnownTestsResponseDataModules{}, + KnownTests: &api.KnownTestsResponseData{ + Tests: api.KnownTestsResponseDataModules{}, }, KnownTestsRawResponse: knownTestsResponse, - TestManagementTestsData: &net.TestManagementTestsResponseDataModules{ - Modules: map[string]net.TestManagementTestsResponseDataSuites{}, + TestManagementTestsData: &api.TestManagementTestsResponseDataModules{ + Modules: map[string]api.TestManagementTestsResponseDataSuites{}, }, TestManagementTestsRawResponse: testManagementResponse, } - mockUtils := &MockUtils{} - client := NewDatadogClientWithDependencies(mockIntegrations, mockUtils) + client := newTestOptimizationClientForTest(t, mockAPIClient) client.StoreCacheAndExit() @@ -297,17 +338,16 @@ func TestDatadogClient_StoreCacheAndExit_WritesHTTPCaches(t *testing.T) { assertJSONFile(t, filepath.Join(constants.HTTPCacheDir, "test_management.json"), testManagementResponse) } -func TestDatadogClient_StoreCacheAndExit_SkipsHTTPCacheWithoutResponse(t *testing.T) { +func TestTestOptimizationClient_StoreCacheAndExit_SkipsHTTPCacheWithoutResponse(t *testing.T) { cleanPlanDirectory(t) - mockIntegrations := &MockCIVisibilityIntegrations{ - Settings: &net.SettingsResponseData{ + mockAPIClient := &MockAPIClient{ + Settings: &api.SettingsResponseData{ ItrEnabled: true, TestsSkipping: false, }, } - mockUtils := &MockUtils{} - client := NewDatadogClientWithDependencies(mockIntegrations, mockUtils) + client := newTestOptimizationClientForTest(t, mockAPIClient) client.StoreCacheAndExit() @@ -371,23 +411,23 @@ func TestTest_FQN(t *testing.T) { } func TestDisabledTestsFromTestManagementData(t *testing.T) { - disabledTests := DisabledTestsFromTestManagementData(&net.TestManagementTestsResponseDataModules{ - Modules: map[string]net.TestManagementTestsResponseDataSuites{ + disabledTests := DisabledTestsFromTestManagementData(&api.TestManagementTestsResponseDataModules{ + Modules: map[string]api.TestManagementTestsResponseDataSuites{ "module-a": { - Suites: map[string]net.TestManagementTestsResponseDataTests{ + Suites: map[string]api.TestManagementTestsResponseDataTests{ "suite-a": { - Tests: map[string]net.TestManagementTestsResponseDataTestProperties{ - "disabled": {Properties: net.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, - "quarantined": {Properties: net.TestManagementTestsResponseDataTestPropertiesAttributes{Quarantined: true}}, + Tests: map[string]api.TestManagementTestsResponseDataTestProperties{ + "disabled": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, + "quarantined": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{Quarantined: true}}, }, }, }, }, "module-b": { - Suites: map[string]net.TestManagementTestsResponseDataTests{ + Suites: map[string]api.TestManagementTestsResponseDataTests{ "suite-b": { - Tests: map[string]net.TestManagementTestsResponseDataTestProperties{ - "also disabled": {Properties: net.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, + Tests: map[string]api.TestManagementTestsResponseDataTestProperties{ + "also disabled": {Properties: api.TestManagementTestsResponseDataTestPropertiesAttributes{Disabled: true}}, }, }, }, @@ -404,16 +444,15 @@ func TestDisabledTestsFromTestManagementData(t *testing.T) { } } -func TestDatadogClient_GetSkippableTests_EmptyData(t *testing.T) { - mockIntegrations := &MockCIVisibilityIntegrations{ - Settings: &net.SettingsResponseData{ +func TestTestOptimizationClient_GetSkippableTests_EmptyData(t *testing.T) { + mockAPIClient := &MockAPIClient{ + Settings: &api.SettingsResponseData{ ItrEnabled: true, TestsSkipping: true, }, - SkippableTests: net.SkippableTests{}, + SkippableTests: api.SkippableTests{}, } - mockUtils := &MockUtils{} - client := NewDatadogClientWithDependencies(mockIntegrations, mockUtils) + client := newTestOptimizationClientForTest(t, mockAPIClient) // Initialize the client to set up settings err := client.Initialize(map[string]string{}) @@ -432,23 +471,22 @@ func TestDatadogClient_GetSkippableTests_EmptyData(t *testing.T) { } } -func TestDatadogClient_GetSkippableTests_WritesHTTPCache(t *testing.T) { +func TestTestOptimizationClient_GetSkippableTests_WritesHTTPCache(t *testing.T) { cleanPlanDirectory(t) skippableTestsResponse := json.RawMessage(`{"data":[{"id":"test-id","type":"test","attributes":{"suite":"TestSuite1","name":"test_method_1"}}]}`) - mockIntegrations := &MockCIVisibilityIntegrations{ - Settings: &net.SettingsResponseData{ + mockAPIClient := &MockAPIClient{ + Settings: &api.SettingsResponseData{ ItrEnabled: true, TestsSkipping: true, }, - SkippableTests: net.SkippableTests{ + SkippableTests: api.SkippableTests{ "module1.TestSuite1.test_method_1.param1": true, }, SkippableTestsRawResponse: skippableTestsResponse, } - mockUtils := &MockUtils{} - client := NewDatadogClientWithDependencies(mockIntegrations, mockUtils) + client := newTestOptimizationClientForTest(t, mockAPIClient) err := client.Initialize(map[string]string{}) if err != nil { @@ -463,22 +501,21 @@ func TestDatadogClient_GetSkippableTests_WritesHTTPCache(t *testing.T) { assertJSONFile(t, filepath.Join(constants.HTTPCacheDir, "skippable_tests.json"), skippableTestsResponse) } -func TestDatadogClient_StoreCacheAndExit_NilTestManagementTests(t *testing.T) { +func TestTestOptimizationClient_StoreCacheAndExit_NilTestManagementTests(t *testing.T) { cleanPlanDirectory(t) - mockIntegrations := &MockCIVisibilityIntegrations{ - Settings: &net.SettingsResponseData{ + mockAPIClient := &MockAPIClient{ + Settings: &api.SettingsResponseData{ ItrEnabled: true, TestsSkipping: false, }, TestManagementTestsData: nil, } - mockUtils := &MockUtils{} - client := NewDatadogClientWithDependencies(mockIntegrations, mockUtils) + client := newTestOptimizationClientForTest(t, mockAPIClient) client.StoreCacheAndExit() - if !mockIntegrations.ShutdownCalled { - t.Error("StoreCacheAndExit should still call ExitCiVisibility even with nil test management tests") + if mockAPIClient.SettingsCalls != 1 { + t.Errorf("StoreCacheAndExit() should fetch settings once, got %d calls", mockAPIClient.SettingsCalls) } } diff --git a/internal/utils/codeowners.go b/internal/utils/codeowners.go deleted file mode 100644 index 4772102..0000000 --- a/internal/utils/codeowners.go +++ /dev/null @@ -1,320 +0,0 @@ -// 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 ( - "bufio" - "errors" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/DataDog/ddtest/civisibility/constants" -) - -// This is a port of https://github.com/DataDog/dd-trace-dotnet/blob/v2.53.0/tracer/src/Datadog.Trace/Ci/CodeOwners.cs - -type ( - // CodeOwners represents a structured data type that holds sections of code owners. - // Each section maps to a slice of entries, where each entry includes a pattern and a list of owners. - CodeOwners struct { - Sections []*Section - } - - // Section represents a block of structured data of multiple entries in a single section - Section struct { - Name string - Entries []Entry - } - - // Entry represents a single entry in a CODEOWNERS file. - // It includes the pattern for matching files, the list of owners, and the section to which it belongs. - Entry struct { - Pattern string - Owners []string - Section string - } -) - -var ( - // codeowners holds the parsed CODEOWNERS file data. - codeowners *CodeOwners - codeownersMutex sync.Mutex -) - -// GetCodeOwners retrieves and caches the CODEOWNERS data. -// It looks for the CODEOWNERS file in various standard locations within the CI workspace. -// This function is thread-safe due to the use of a mutex. -// -// Returns: -// -// A pointer to a CodeOwners struct containing the parsed CODEOWNERS data, or nil if not found. -func GetCodeOwners() *CodeOwners { - codeownersMutex.Lock() - defer codeownersMutex.Unlock() - - if codeowners != nil { - return codeowners - } - - tags := GetCITags() - if v, ok := tags[constants.CIWorkspacePath]; ok { - paths := []string{ - filepath.Join(v, "CODEOWNERS"), - filepath.Join(v, ".github", "CODEOWNERS"), - filepath.Join(v, ".gitlab", "CODEOWNERS"), - filepath.Join(v, ".docs", "CODEOWNERS"), - } - for _, path := range paths { - if cow, err := parseCodeOwners(path); err == nil { - codeowners = cow - return codeowners - } - } - } - - // If the codeowners file is not found, let's try a last resort by looking in the current directory (for standalone test binaries) - for _, path := range []string{"CODEOWNERS", filepath.Join(filepath.Dir(os.Args[0]), "CODEOWNERS")} { - if cow, err := parseCodeOwners(path); err == nil { - codeowners = cow - return codeowners - } - } - - return nil -} - -// parseCodeOwners reads and parses the CODEOWNERS file located at the given filePath. -func parseCodeOwners(filePath string) (*CodeOwners, error) { - if _, err := os.Stat(filePath); err != nil { - return nil, err - } - cow, err := NewCodeOwners(filePath) - if err == nil { - slog.Debug("civisibility: codeowner file was loaded successfully", "filepath", filePath) - return cow, nil - } - slog.Debug("Error parsing codeowners", "error", err.Error()) - return nil, err -} - -// NewCodeOwners creates a new instance of CodeOwners by parsing a CODEOWNERS file located at the given filePath. -// It returns an error if the file cannot be read or parsed properly. -func NewCodeOwners(filePath string) (*CodeOwners, error) { - if filePath == "" { - return nil, fmt.Errorf("filePath cannot be empty") - } - - file, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer func() { - err = file.Close() - if err != nil && !errors.Is(err, os.ErrClosed) { - slog.Warn("Error closing codeowners file", "error", err.Error()) - } - }() - - var entriesList []Entry - var sectionsList []string - var currentSectionName string - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - if len(line) == 0 || line[0] == '#' { - continue - } - - // Identify section headers, which are lines enclosed in square brackets - if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { - currentSectionName = line[1 : len(line)-1] - foundSectionName := findSectionIgnoreCase(sectionsList, currentSectionName) - if foundSectionName == "" { - sectionsList = append(sectionsList, currentSectionName) - } else { - currentSectionName = foundSectionName - } - continue - } - - finalLine := line - var ownersList []string - terms := strings.Fields(line) - for _, term := range terms { - if len(term) == 0 { - continue - } - - // Identify owners by their prefixes (either @ for usernames or containing @ for emails) - if term[0] == '@' || strings.Contains(term, "@") { - ownersList = append(ownersList, term) - pos := strings.Index(finalLine, term) - if pos > 0 { - finalLine = finalLine[:pos] + finalLine[pos+len(term):] - } - } - } - - finalLine = strings.TrimSpace(finalLine) - if len(finalLine) == 0 { - continue - } - - entriesList = append(entriesList, Entry{Pattern: finalLine, Owners: ownersList, Section: currentSectionName}) - } - - if err := scanner.Err(); err != nil { - return nil, err - } - - // Reverse the entries list to maintain the order of appearance in the file - for i, j := 0, len(entriesList)-1; i < j; i, j = i+1, j-1 { - entriesList[i], entriesList[j] = entriesList[j], entriesList[i] - } - - codeOwners := &CodeOwners{} - for _, entry := range entriesList { - var section *Section - for _, val := range codeOwners.Sections { - if val.Name == entry.Section { - section = val - break - } - } - - if section == nil { - section = &Section{Name: entry.Section, Entries: []Entry{}} - codeOwners.Sections = append(codeOwners.Sections, section) - } - - section.Entries = append(section.Entries, entry) - } - - return codeOwners, nil -} - -// findSectionIgnoreCase searches for a section name in a case-insensitive manner. -// It returns the section name if found, otherwise returns an empty string. -func findSectionIgnoreCase(sections []string, section string) string { - sectionLower := strings.ToLower(section) - for _, s := range sections { - if strings.ToLower(s) == sectionLower { - return s - } - } - return "" -} - -// GetSection gets the first Section entry in the CodeOwners that matches the section name. -// It returns a pointer to the matched entry, or nil if no match is found -func (co *CodeOwners) GetSection(section string) *Section { - for _, value := range co.Sections { - if value.Name == section { - return value - } - } - - return nil -} - -// Match finds the first entry in the CodeOwners that matches the given value. -// It returns a pointer to the matched entry, or nil if no match is found. -func (co *CodeOwners) Match(value string) (*Entry, bool) { - var matchedEntries []Entry - - for _, section := range co.Sections { - for _, entry := range section.Entries { - pattern := entry.Pattern - finalPattern := pattern - - var includeAnythingBefore, includeAnythingAfter bool - - if strings.HasPrefix(pattern, "/") { - includeAnythingBefore = false - } else { - finalPattern = strings.TrimPrefix(finalPattern, "*") - includeAnythingBefore = true - } - - if strings.HasSuffix(pattern, "/") { - includeAnythingAfter = true - } else if strings.HasSuffix(pattern, "/*") { - includeAnythingAfter = true - finalPattern = finalPattern[:len(finalPattern)-1] - } else { - includeAnythingAfter = false - } - - if includeAnythingAfter { - found := includeAnythingBefore && strings.Contains(value, finalPattern) || strings.HasPrefix(value, finalPattern) - if !found { - continue - } - - if !strings.HasSuffix(pattern, "/*") { - matchedEntries = append(matchedEntries, entry) - break - } - - patternEnd := strings.Index(value, finalPattern) - if patternEnd != -1 { - patternEnd += len(finalPattern) - remainingString := value[patternEnd:] - if !strings.Contains(remainingString, "/") { - matchedEntries = append(matchedEntries, entry) - break - } - } - } else { - if includeAnythingBefore { - if strings.HasSuffix(value, finalPattern) { - matchedEntries = append(matchedEntries, entry) - break - } - } else if value == finalPattern { - matchedEntries = append(matchedEntries, entry) - break - } - } - } - } - - switch len(matchedEntries) { - case 0: - return nil, false - case 1: - return &matchedEntries[0], true - default: - patterns := make([]string, 0) - owners := make([]string, 0) - sections := make([]string, 0) - for _, entry := range matchedEntries { - patterns = append(patterns, entry.Pattern) - owners = append(owners, entry.Owners...) - sections = append(sections, entry.Section) - } - return &Entry{ - Pattern: strings.Join(patterns, " | "), - Owners: owners, - Section: strings.Join(sections, " | "), - }, true - } -} - -// GetOwnersString returns a formatted string of the owners list in an Entry. -// It returns an empty string if there are no owners. -func (e *Entry) GetOwnersString() string { - if len(e.Owners) == 0 { - return "" - } - - return "[\"" + strings.Join(e.Owners, "\",\"") + "\"]" -} diff --git a/internal/utils/environmentTags.go b/internal/utils/environmentTags.go index 93a8182..705d096 100644 --- a/internal/utils/environmentTags.go +++ b/internal/utils/environmentTags.go @@ -26,11 +26,6 @@ var ( originalCiTags map[string]string // originalCiTags holds the original CI/CD tags after all the CMDs addedTags map[string]string // addedTags holds the tags added by the user ciTagsMutex sync.Mutex - - // ciMetrics holds the CI/CD environment numeric variable information - currentCiMetrics map[string]float64 // currentCiMetrics holds the cached CI/CD metrics - originalCiMetrics map[string]float64 // originalCiMetrics holds the original CI/CD metrics after all the CMDs - ciMetricsMutex sync.Mutex ) // GetCITags retrieves and caches the CI/CD tags from environment variables. @@ -96,32 +91,6 @@ func ResetCITags() { addedTags = nil } -// GetCIMetrics retrieves and caches the CI/CD metrics from environment variables. -// It initializes the ciMetrics map if it is not already initialized. -// This function is thread-safe due to the use of a mutex. -// -// Returns: -// -// A map[string]float64 containing the CI/CD metrics. -func GetCIMetrics() map[string]float64 { - ciMetricsMutex.Lock() - defer ciMetricsMutex.Unlock() - - // Return the current metrics if they are already initialized - if currentCiMetrics != nil { - return currentCiMetrics - } - - if originalCiMetrics == nil { - // If the original metrics are not initialized, create them - originalCiMetrics = createCIMetricsMap() - } - - // Create a new map with the added metrics - currentCiMetrics = maps.Clone(originalCiMetrics) - return currentCiMetrics -} - // createCITagsMap creates a map of CI/CD tags by extracting information from environment variables and the local Git repository. // It also adds OS and runtime information to the tags. // @@ -158,7 +127,7 @@ func createCITagsMap() map[string]string { slog.Debug("civisibility: test command", "command", cmd) // Populate the test session name - if testSessionName, ok := os.LookupEnv(constants.CIVisibilityTestSessionNameEnvironmentVariable); ok { + if testSessionName, ok := os.LookupEnv(constants.TestOptimizationTestSessionNameEnvironmentVariable); ok { localTags[constants.TestSessionName] = testSessionName } else if jobName, ok := localTags[constants.CIJobName]; ok { localTags[constants.TestSessionName] = fmt.Sprintf("%s-%s", jobName, cmd) @@ -240,16 +209,3 @@ func createCITagsMap() map[string]string { slog.Debug("civisibility: common tags created", "count", len(localTags)) return localTags } - -// createCIMetricsMap creates a map of CI/CD tags by extracting information from environment variables and runtime information. -// -// Returns: -// -// A map[string]float64 containing the metrics extracted -func createCIMetricsMap() map[string]float64 { - localMetrics := make(map[string]float64) - localMetrics[constants.LogicalCPUCores] = float64(runtime.NumCPU()) - - slog.Debug("civisibility: common metrics created with", "items", len(localMetrics)) - return localMetrics -} diff --git a/internal/utils/file_environmental_data.go b/internal/utils/file_environmental_data.go index 80f5038..c07d275 100644 --- a/internal/utils/file_environmental_data.go +++ b/internal/utils/file_environmental_data.go @@ -103,7 +103,7 @@ func getEnvironmentalData() *fileEnvironmentalData { // //go:linkname getEnvDataFileName func getEnvDataFileName() string { - envDataFileName := strings.TrimSpace(os.Getenv(constants.CIVisibilityEnvironmentDataFilePath)) + envDataFileName := strings.TrimSpace(os.Getenv(constants.TestOptimizationEnvironmentDataFilePath)) if envDataFileName != "" { return envDataFileName } diff --git a/internal/utils/filebitmap/filebitmap.go b/internal/utils/filebitmap/filebitmap.go deleted file mode 100644 index 2e29e9a..0000000 --- a/internal/utils/filebitmap/filebitmap.go +++ /dev/null @@ -1,76 +0,0 @@ -// 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 2025 Datadog, Inc. - -package filebitmap - -// FileBitmap represents a memory-efficient, modifiable bitmap. -type FileBitmap struct { - data []byte -} - -// NewFileBitmapFromBytes creates a new FileBitmap from a given byte slice without modifying it. -func NewFileBitmapFromBytes(data []byte) *FileBitmap { - if data == nil { - panic("bitmap array source is nil") - } - return &FileBitmap{data: data} -} - -// FromLineCount creates a new FileBitmap that can hold the specified number of lines (bits). -func FromLineCount(lines int) *FileBitmap { - size := getSize(lines) - return &FileBitmap{data: make([]byte, size)} -} - -// FromActiveRange creates a FileBitmap with enough space for 'toLine' lines, -// and sets all bits in the range [fromLine, toLine] (1-indexed). -func FromActiveRange(fromLine, toLine int) *FileBitmap { - if fromLine <= 0 || toLine < fromLine { - panic("Invalid range") - } - fb := FromLineCount(toLine) - for i := fromLine; i <= toLine; i++ { - fb.Set(i) - } - return fb -} - -// getSize returns the number of bytes needed for numOfLines bits. -func getSize(numOfLines int) int { - return (numOfLines + 7) / 8 -} - -// Set sets the bit at the given line (1-indexed) to 1. -func (fb *FileBitmap) Set(line int) { - if fb.data == nil { - return - } - idx := line - 1 // adjust for zero-based index - byteIndex := idx / 8 // each byte holds 8 bits - if byteIndex >= len(fb.data) { - panic("line out of range") - } - bitMask := byte(128 >> (idx % 8)) // 128 >> (idx mod 8) creates the proper mask - fb.data[byteIndex] |= bitMask -} - -// IntersectsWith returns true if this bitmap has at least one common set bit with the other bitmap. -func (fb *FileBitmap) IntersectsWith(other *FileBitmap) bool { - minSize := len(fb.data) - if len(other.data) < minSize { - minSize = len(other.data) - } - for i := 0; i < minSize; i++ { - if (fb.data[i] & other.data[i]) != 0 { - return true - } - } - return false -} - -// GetBuffer returns the internal byte buffer of the bitmap. -func (fb *FileBitmap) GetBuffer() []byte { - return fb.data -} diff --git a/internal/utils/git.go b/internal/utils/git.go index b0b7d70..dce1f2b 100644 --- a/internal/utils/git.go +++ b/internal/utils/git.go @@ -18,8 +18,6 @@ import ( "sync" "sync/atomic" "time" - - "github.com/DataDog/ddtest/civisibility/constants" ) // MaxPackFileSizeInMb is the maximum size of a pack file in megabytes. @@ -61,12 +59,6 @@ var ( // regexpSensitiveInfo is a regular expression used to match and filter out sensitive information from URLs. regexpSensitiveInfo = regexp.MustCompile("(https?://|ssh?://)[^/]*@") - // Constants for base branch detection algorithm - possibleBaseBranches = []string{"main", "master", "preprod", "prod", "dev", "development", "trunk"} - - // BASE_LIKE_BRANCH_FILTER - regex to check if the branch name is similar to a possible base branch - baseLikeBranchFilter = regexp.MustCompile(`^(main|master|preprod|prod|dev|development|trunk|release\/.*|hotfix\/.*)$`) - // Cached data // isGitFoundValue is a boolean flag indicating whether the Git executable is available on the system. @@ -94,13 +86,6 @@ var ( safeDirectoryValue string ) -// branchMetrics holds metrics for evaluating base branch candidates -type branchMetrics struct { - behind int - ahead int - baseSha string -} - // isGitFound checks if the Git executable is available on the system. func isGitFound() bool { gitFinderOnce.Do(func() { @@ -493,44 +478,6 @@ func UnshallowGitRepository() (bool, error) { return true, nil } -// GetGitDiff retrieves the diff between two Git commits using the `git diff` command. -func GetGitDiff(baseCommit, headCommit string) (string, error) { - // git diff -U0 --word-diff=porcelain {baseCommit} {headCommit} - if len(baseCommit) != 40 { - // not a commit sha - var re = regexp.MustCompile(`(?i)^[a-f0-9]{40}$`) - if !re.MatchString(baseCommit) { - // first let's get the remote - remoteOut, err := execGitString("remote", "show") - if err != nil { - slog.Debug("civisibility.git: error on git remote show origin", "remoteOut", remoteOut, "error", err.Error()) - } - if remoteOut == "" { - remoteOut = "origin" - } - - // let's ensure we have all the branch names from the remote - fetchOut, err := execGitString("fetch", remoteOut, baseCommit, "--depth=1") - if err != nil { - slog.Debug("civisibility.git: error fetching", "remoteOut", remoteOut, "baseCommit", baseCommit, "fetchOut", fetchOut, "error", err.Error()) - } - - // then let's get the remote branch name - baseCommit = fmt.Sprintf("%s/%s", remoteOut, baseCommit) - } - } - - slog.Debug("civisibility.git: getting the diff between", "baseCommit", baseCommit, "headCommit", headCommit) - out, err := execGitString("diff", "-U0", "--word-diff=porcelain", baseCommit, headCommit) - if err != nil { - return "", fmt.Errorf("civisibility.git: error getting the diff from %s to %s: %s | %s", baseCommit, headCommit, err, out) - } - if out == "" { - return "", fmt.Errorf("civisibility.git: error getting the diff from %s to %s: empty output", baseCommit, headCommit) - } - return out, nil -} - // filterSensitiveInfo removes sensitive information from a given URL using a regular expression. // It replaces the user credentials part of the URL (if present) with an empty string. // @@ -691,143 +638,7 @@ func getParentGitFolder(innerFolder string) (string, error) { return "", nil } -// isDefaultBranch checks if a branch is the default branch -func isDefaultBranch(branch, defaultBranch, remoteName string) bool { - return branch == defaultBranch || branch == remoteName+"/"+defaultBranch -} - -// detectDefaultBranch detects the default branch using git symbolic-ref -func detectDefaultBranch(remoteName string) (string, error) { - // Try to get the default branch using symbolic-ref - defaultRef, err := execGitString("symbolic-ref", "--quiet", "--short", "refs/remotes/"+remoteName+"/HEAD") - if err == nil && defaultRef != "" { - // Remove the remote prefix to get just the branch name - defaultBranch := removeRemotePrefix(defaultRef, remoteName) - if defaultBranch != "" { - slog.Debug("civisibility.git: detected default branch from symbolic-ref", "defaultBranch", defaultBranch) - return defaultBranch, nil - } - } - - slog.Debug("civisibility.git: could not get symbolic-ref, trying to find a fallback (main, master)...") - - // Fallback to checking for main/master - fallbackBranch := findFallbackDefaultBranch(remoteName) - if fallbackBranch != "" { - return fallbackBranch, nil - } - - return "", errors.New("could not detect default branch") -} - -// findFallbackDefaultBranch tries to find main or master as fallback default branches -func findFallbackDefaultBranch(remoteName string) string { - fallbackBranches := []string{"main", "master"} - - for _, fallback := range fallbackBranches { - // Check if the remote branch exists - _, err := execGitString("show-ref", "--verify", "--quiet", "refs/remotes/"+remoteName+"/"+fallback) - if err == nil { - slog.Debug("civisibility.git: found fallback default branch", "fallback", fallback) - return fallback - } - } - - return "" -} - -// GetBaseBranchSha detects the base branch SHA using the algorithm -func GetBaseBranchSha(defaultBranch string) (string, error) { - if !isGitFound() { - return "", errors.New("git executable not found") - } - - // Step 1 - collect info we'll need later - - // Step 1a - remote_name - remoteName, err := getRemoteName() - if err != nil { - return "", fmt.Errorf("failed to get remote name: %w", err) - } - - // Step 1b - source_branch - sourceBranch, err := getSourceBranch() - if err != nil { - return "", fmt.Errorf("failed to get source branch: %w", err) - } - - // Step 1c - Detect default branch automatically - detectedDefaultBranch, err := detectDefaultBranch(remoteName) - if err != nil { - // Fallback to the provided parameter if detection fails - if defaultBranch == "" { - defaultBranch = "main" // ultimate fallback - } - slog.Debug("civisibility.git: failed to detect default branch, using fallback", "defaultBranch", defaultBranch) - detectedDefaultBranch = defaultBranch - } - - // Step 2 - build candidate branches list and fetch them from remote - var candidateBranches []string - - // Check if we have git.pull_request.base_branch from CI provider environment variables - ciTags := GetCITags() - gitPrBaseBranch := ciTags[constants.GitPrBaseBranch] - - if gitPrBaseBranch != "" { - // Step 2b - we have git.pull_request.base_branch - slog.Debug("civisibility.git: using git.pull_request.base_branch from CI", "gitPrBaseBranch", gitPrBaseBranch) - checkAndFetchBranch(gitPrBaseBranch, remoteName) - candidateBranches = []string{gitPrBaseBranch} - } else { - // Step 2a - we don't have git.pull_request.base_branch - // Fetch all possible base branches from remote - for _, branch := range possibleBaseBranches { - checkAndFetchBranch(branch, remoteName) - } - - // Get the list of remote branches present in local repo and see which ones are base-like - remoteBranches, err := getRemoteBranches(remoteName) - if err != nil { - return "", fmt.Errorf("failed to get remote branches: %w", err) - } - - for _, branch := range remoteBranches { - if branch != sourceBranch && isMainLikeBranch(branch, remoteName) { - candidateBranches = append(candidateBranches, branch) - } - } - } - - if len(candidateBranches) == 0 { - return "", errors.New("no candidate base branches found") - } - - // Step 3 - find the best base branch - if len(candidateBranches) == 1 { - // Step 3a - single candidate - baseSha, err := execGitString("merge-base", candidateBranches[0], sourceBranch) - if err != nil { - return "", fmt.Errorf("failed to find merge base for %s and %s: %w", candidateBranches[0], sourceBranch, err) - } - return baseSha, nil - } - - // Step 3b - multiple candidates - metrics, err := computeBranchMetrics(candidateBranches, sourceBranch) - if err != nil { - return "", fmt.Errorf("failed to compute branch metrics: %w", err) - } - - baseSha := findBestBranch(metrics, detectedDefaultBranch, remoteName) - if baseSha == "" { - return "", errors.New("failed to find best base branch") - } - - return baseSha, nil -} - -// getRemoteName determines the remote name using the algorithm from algorithm.md +// getRemoteName determines the remote name. func getRemoteName() (string, error) { // Try to find remote from upstream tracking upstream, err := execGitString("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") @@ -851,143 +662,3 @@ func getRemoteName() (string, error) { return "origin", nil } - -// getSourceBranch gets the current branch name -func getSourceBranch() (string, error) { - return execGitString("rev-parse", "--abbrev-ref", "HEAD") -} - -// isMainLikeBranch checks if a branch name matches the base-like branch pattern -func isMainLikeBranch(branchName, remoteName string) bool { - shortBranchName := removeRemotePrefix(branchName, remoteName) - return baseLikeBranchFilter.MatchString(shortBranchName) -} - -// removeRemotePrefix removes the remote prefix from a branch name -func removeRemotePrefix(branchName, remoteName string) string { - prefix := remoteName + "/" - if strings.HasPrefix(branchName, prefix) { - return strings.TrimPrefix(branchName, prefix) - } - return branchName -} - -// checkAndFetchBranch checks if a branch exists and fetches it if needed -func checkAndFetchBranch(branch, remoteName string) { - // Check if branch exists locally (as remote ref) - _, err := execGitString("show-ref", "--verify", "--quiet", "refs/remotes/"+remoteName+"/"+branch) - if err == nil { - return // branch exists locally - } - - // Check if branch exists in remote - remoteHeads, err := execGitString("ls-remote", "--heads", remoteName, branch) - if err != nil || remoteHeads == "" { - return // branch doesn't exist in remote - } - - // Fetch the latest commit for this branch from remote (without creating local branch) - _, err = execGitString("fetch", "--depth", "1", remoteName, branch) - if err != nil { - slog.Debug("civisibility.git: failed to fetch branch", "branch", branch, "error", err.Error()) - } -} - -// getRemoteBranches gets list of remote tracking branches only (for Step 2a in algorithm) -func getRemoteBranches(remoteName string) ([]string, error) { - // Get remote tracking branches as per algorithm update - remoteOut, err := execGitString("for-each-ref", "--format=%(refname:short)", "refs/remotes/"+remoteName) - if err != nil { - return nil, fmt.Errorf("failed to get remote branches: %w", err) - } - - var branches []string - if remoteOut != "" { - remoteBranches := strings.Split(strings.TrimSpace(remoteOut), "\n") - for _, branch := range remoteBranches { - if strings.TrimSpace(branch) != "" { - branches = append(branches, strings.TrimSpace(branch)) - } - } - } - - return branches, nil -} - -// computeBranchMetrics calculates metrics for candidate branches -func computeBranchMetrics(candidates []string, sourceBranch string) (map[string]branchMetrics, error) { - metrics := make(map[string]branchMetrics) - - for _, candidate := range candidates { - // Find common ancestor - baseSha, err := execGitString("merge-base", candidate, sourceBranch) - if err != nil || baseSha == "" { - continue - } - - // Count commits ahead/behind - counts, err := execGitString("rev-list", "--left-right", "--count", candidate+"..."+sourceBranch) - if err != nil { - continue - } - - parts := strings.Fields(counts) - if len(parts) != 2 { - continue - } - - behind, err1 := strconv.Atoi(parts[0]) - ahead, err2 := strconv.Atoi(parts[1]) - if err1 != nil || err2 != nil { - continue - } - - metrics[candidate] = branchMetrics{ - behind: behind, - ahead: ahead, - baseSha: baseSha, - } - } - - return metrics, nil -} - -// findBestBranch finds the best branch from metrics, preferring default branch on tie -func findBestBranch(metrics map[string]branchMetrics, defaultBranch, remoteName string) string { - if len(metrics) == 0 { - return "" - } - - var bestBranch string - bestScore := []int{int(^uint(0) >> 1), 1, 1} // [ahead, is_not_default, is_remote_prefixed] - max int, not default, remote prefixed - - for branch, data := range metrics { - isDefault := 0 - if isDefaultBranch(branch, defaultBranch, remoteName) { - isDefault = 0 - } else { - isDefault = 1 - } - - // Check if this branch is remote-prefixed (prefer exact branch names) - isRemotePrefixed := 0 - if strings.HasPrefix(branch, remoteName+"/") { - isRemotePrefixed = 1 - } - - score := []int{data.ahead, isDefault, isRemotePrefixed} - - // Compare scores: prefer smaller ahead count, then prefer default branch, then prefer exact branch names - if score[0] < bestScore[0] || - (score[0] == bestScore[0] && score[1] < bestScore[1]) || - (score[0] == bestScore[0] && score[1] == bestScore[1] && score[2] < bestScore[2]) { - bestScore = score - bestBranch = branch - } - } - - if bestBranch != "" { - return metrics[bestBranch].baseSha - } - return "" -} diff --git a/internal/utils/impactedtests/impacted_tests.go b/internal/utils/impactedtests/impacted_tests.go deleted file mode 100644 index 40e84c7..0000000 --- a/internal/utils/impactedtests/impacted_tests.go +++ /dev/null @@ -1,295 +0,0 @@ -// 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 2025 Datadog, Inc. - -package impactedtests - -import ( - "fmt" - "log/slog" - "regexp" - "slices" - "strconv" - "strings" - - "github.com/DataDog/ddtest/civisibility/constants" - "github.com/DataDog/ddtest/internal/utils" - "github.com/DataDog/ddtest/internal/utils/filebitmap" -) - -type ( - // fileWithBitmap represents a file with its coverage bitmap. - fileWithBitmap struct { - file string // file path - bitmap []byte // coverage bitmap - } - - // ImpactedTestAnalyzer is a struct that holds information about impacted tests. - ImpactedTestAnalyzer struct { - modifiedFiles []fileWithBitmap - currentCommitSha string - baseCommitSha string - } - - // lineRange represents a tuple of start and end line numbers. - lineRange struct { - start int - end int - } -) - -// Precompiled regex for diff header and line changes. -// Adjust these patterns to match the actual output of "git diff". -// Example: diff --git a/file.txt b/file.txt -var diffHeaderRegex = regexp.MustCompile(`^diff --git a\/(?P.+) b\/(?P.+)`) - -// Example: @@ -1,2 +3,4 @@ -// This regex captures "start" and "count" (if available) from the new file's diff. -var lineChangeRegex = regexp.MustCompile(`^@@ -\d+(?:,\d+)? \+(?P\d+)(?:,(?P\d+))? @@`) - -// NewImpactedTestAnalyzer creates a new instance of ImpactedTestAnalyzer. -func NewImpactedTestAnalyzer() (*ImpactedTestAnalyzer, error) { - ciTags := utils.GetCITags() - - // Get the current commit SHA - currentCommitSha := ciTags[constants.GitHeadCommit] - if currentCommitSha == "" { - currentCommitSha = ciTags[constants.GitCommitSHA] - } - if currentCommitSha == "" { - return nil, fmt.Errorf("civisibility.ImpactedTests: current commit is empty") - } - - // Get the base commit SHA - baseCommitSha := ciTags[constants.GitPrBaseCommit] - if baseCommitSha == "" { - baseCommitSha = ciTags[constants.GitPrBaseBranch] - } - - // If we don't have the base commit from the tags, then let's try to calculate it using the git CLI - if baseCommitSha == "" { - var err error - baseCommitSha, err = utils.GetBaseBranchSha("") // empty string triggers auto-detection - if err != nil { - slog.Debug("civisibility.ImpactedTests: Failed to get base commit SHA from git CLI", "err", err.Error()) - // Don't fail here - we might be on a base branch or in a scenario where - // base branch detection isn't possible. Return an analyzer with no modified files. - } - } - - // Extract the modified files - var modifiedFiles []fileWithBitmap - - // Check if the base commit SHA is available - if len(baseCommitSha) > 0 { - slog.Debug("civisibility.ImpactedTests: PR detected. Retrieving diff lines from Git CLI from BaseCommit", "sha", baseCommitSha) - modifiedFiles = getGitDiffFrom(baseCommitSha, currentCommitSha) - } - - // If we still don't have modified files, initialize with empty slice instead of failing - if modifiedFiles == nil { - slog.Debug("civisibility.ImpactedTests: No modified files found - initializing with empty list") - modifiedFiles = []fileWithBitmap{} - } - - slog.Debug("civisibility.ImpactedTests: loaded", "baseCommitSha", baseCommitSha, "currentCommitSha", currentCommitSha, "modifiedFiles", modifiedFiles) //nolint:gocritic // File list debug logging - return &ImpactedTestAnalyzer{ - modifiedFiles: modifiedFiles, - currentCommitSha: currentCommitSha, - baseCommitSha: baseCommitSha, - }, nil -} - -// IsImpacted checks if a test is impacted based on the modified files and their line ranges. -func (a *ImpactedTestAnalyzer) IsImpacted(testName string, sourceFile string, startLine int, endLine int) bool { - if len(a.modifiedFiles) == 0 { - return false - } - - // Has the test been modified? - modified := false - - // Get the test impact information - testFiles := getTestImpactInfo(sourceFile, startLine, endLine) - if len(testFiles) == 0 { - return false - } - - for _, testFile := range testFiles { - if testFile == nil || testFile.file == "" { - continue - } - - modifiedFileIndex := slices.IndexFunc(a.modifiedFiles, func(file fileWithBitmap) bool { - if file.file == "" { - return false - } - return strings.HasSuffix(testFile.file, file.file) - }) - if modifiedFileIndex >= 0 { - modifiedFile := a.modifiedFiles[modifiedFileIndex] - slog.Debug("civisibility.ImpactedTests: DiffFile found:", "diff", modifiedFile.file) - if testFile.bitmap == nil || modifiedFile.bitmap == nil { - slog.Debug("civisibility.ImpactedTests: No line info found") - modified = true - break - } - - testFileBitmap := filebitmap.NewFileBitmapFromBytes(testFile.bitmap) - modifiedFileBitmap := filebitmap.NewFileBitmapFromBytes(modifiedFile.bitmap) - - if testFileBitmap.IntersectsWith(modifiedFileBitmap) { - slog.Debug("civisibility.ImpactedTests: Intersecting lines. Marking test as modified.", "name", testName) - modified = true - break - } - } - } - - return modified -} - -// getGitDiffFrom retrieves the diff files and lines from the Git CLI. -func getGitDiffFrom(baseCommitSha string, currentCommitSha string) []fileWithBitmap { - var modifiedFiles []fileWithBitmap - - // Milestone 1.5 : Retrieve diff files and lines from Git Diff CLI - output, err := utils.GetGitDiff(baseCommitSha, currentCommitSha) - if err != nil { - slog.Debug("civisibility.ImpactedTests: Failed to get diff files from Git CLI", "err", err.Error()) - } else if output != "" { - modifiedFiles = parseGitDiffOutput(output) - } else { - slog.Debug("civisibility.ImpactedTests: No diff files found from Git CLI") - } - return modifiedFiles -} - -// getTestImpactInfo returns the test impact information based on the tags. -func getTestImpactInfo(sourceFile string, startLine int, endLine int) []*fileWithBitmap { - result := make([]*fileWithBitmap, 0) - if sourceFile == "" { - return result - } - - // Milestone 1: Return only the test definition file - file := &fileWithBitmap{file: sourceFile} - result = append(result, file) - - // Milestone 1.5: Return the test definition lines - if startLine == 0 || endLine == 0 { - return result - } - - bitmap := filebitmap.FromActiveRange(startLine, endLine) - file.bitmap = bitmap.GetBuffer() - - return result -} - -// parseGitDiffOutput parses the git diff output to extract modified files and their changed lines. -func parseGitDiffOutput(output string) []fileWithBitmap { - var fileChanges []fileWithBitmap - var currentFile *fileWithBitmap - var modifiedLines []lineRange - - // Split output into lines (ignoring empty lines) - lines := splitLines(output) - for _, line := range lines { - - // Check for the start of a new file diff - if headerMatch := diffHeaderRegex.FindStringSubmatch(line); headerMatch != nil { - // If there's a file in process, finalize it before iniciar uno nuevo - if currentFile != nil { - currentFile.bitmap = toFileBitmap(modifiedLines) - fileChanges = append(fileChanges, *currentFile) - // Clear the modified lines for the new file - modifiedLines = modifiedLines[:0] - } - - // Extract file path from the named group "file" - filePath := "" - for i, name := range diffHeaderRegex.SubexpNames() { - if name == "fileB" { - filePath = headerMatch[i] - break - } - } - currentFile = &fileWithBitmap{file: filePath} - continue - } - - // Check for the line change marker (e.g., @@ -1,2 +3,4 @@) - if lineChangeMatch := lineChangeRegex.FindStringSubmatch(line); lineChangeMatch != nil { - startLineStr := "" - countStr := "" - for i, name := range lineChangeRegex.SubexpNames() { - if name == "start" { - startLineStr = lineChangeMatch[i] - } - if name == "count" { - countStr = lineChangeMatch[i] - } - } - startLine, err := strconv.Atoi(startLineStr) - if err != nil { - // In case of error, we skip the line - continue - } - lineCount := 0 - if countStr != "" { - lineCount, err = strconv.Atoi(countStr) - if err != nil { - lineCount = 0 - } - if lineCount > 0 { - // Adjust the line count to account for the start line - lineCount = lineCount - 1 - } - } - - // Add the range - if startLine > 0 { - modifiedLines = append(modifiedLines, lineRange{start: startLine, end: startLine + lineCount}) - } - continue - } - } - if currentFile != nil { - currentFile.bitmap = toFileBitmap(modifiedLines) - fileChanges = append(fileChanges, *currentFile) - } - - return fileChanges -} - -// splitLines splits the text into lines, ignoring empty lines. -func splitLines(text string) []string { - rawLines := strings.Split(text, "\n") - var lines []string - for _, line := range rawLines { - if strings.TrimSpace(line) != "" { - lines = append(lines, line) - } - } - return lines -} - -// toFileBitmap converts a slice of modified line ranges into a bitmap (as a byte slice). -func toFileBitmap(modifiedLines []lineRange) []byte { - if len(modifiedLines) == 0 { - return nil - } - // Get the maximum count from the last range's end value. - maxCount := modifiedLines[len(modifiedLines)-1].end - bitmap := filebitmap.FromLineCount(maxCount) - // Mark all lines in the ranges as modified. - for _, r := range modifiedLines { - // Note: This marks lines from r.start to r.end inclusive. - for i := r.start; i <= r.end; i++ { - bitmap.Set(i) - } - } - return bitmap.GetBuffer() -} diff --git a/osinfo/osinfo.go b/osinfo/osinfo.go index 32ddb76..ab84959 100644 --- a/osinfo/osinfo.go +++ b/osinfo/osinfo.go @@ -5,50 +5,14 @@ package osinfo -import ( - "runtime" -) - // Modified in init functions to provide OS-specific information var ( - osName = runtime.GOOS - osVersion = "unknown" - arch = runtime.GOARCH - kernelName = "unknown" - kernelRelease = "unknown" - kernelVersion = "unknown" + osVersion = "unknown" ) -// OSName returns the name of the operating system, including the distribution -// for Linux when possible. -func OSName() string { - // call out to OS-specific implementation - return osName -} - // OSVersion returns the operating system release, e.g. major/minor version // number and build ID. func OSVersion() string { // call out to OS-specific implementation return osVersion } - -// Architecture returns the architecture of the operating system. -func Architecture() string { - return arch -} - -// KernelName returns the name of the kernel. -func KernelName() string { - return kernelName -} - -// KernelRelease returns the release of the kernel. -func KernelRelease() string { - return kernelRelease -} - -// KernelVersion returns the version of the kernel. -func KernelVersion() string { - return kernelVersion -} diff --git a/osinfo/osinfo_unix.go b/osinfo/osinfo_unix.go index b4bdb30..bc8a7d0 100644 --- a/osinfo/osinfo_unix.go +++ b/osinfo/osinfo_unix.go @@ -19,14 +19,7 @@ import ( ) func init() { - // Change the default values for backwards compatibility on scenarios - if runtime.GOOS == "linux" { - osName = "Linux (Unknown Distribution)" - kernelName = "Linux" - } - if runtime.GOOS == "darwin" { - kernelName = "Darwin" out, err := exec.Command("sw_vers", "-productVersion").Output() if err != nil { return @@ -37,9 +30,7 @@ func init() { var uts unix.Utsname if err := unix.Uname(&uts); err == nil { - kernelName = string(bytes.TrimRight(uts.Sysname[:], "\x00")) - kernelVersion = string(bytes.TrimRight(uts.Version[:], "\x00")) - kernelRelease = strings.SplitN(strings.TrimRight(string(uts.Release[:]), "\x00"), "-", 2)[0] + kernelRelease := strings.SplitN(strings.TrimRight(string(uts.Release[:]), "\x00"), "-", 2)[0] // Backwards compatibility on how data is reported for freebsd if runtime.GOOS == "freebsd" { @@ -59,8 +50,6 @@ func init() { for scanner.Scan() { parts := strings.SplitN(scanner.Text(), "=", 2) switch parts[0] { - case "NAME": - osName = strings.Trim(parts[1], "\"") case "VERSION": osVersion = strings.Trim(parts[1], "\"") case "VERSION_ID": diff --git a/stableconfig/api.go b/stableconfig/api.go index b368822..2aac774 100644 --- a/stableconfig/api.go +++ b/stableconfig/api.go @@ -28,15 +28,6 @@ func Bool(env string, def bool) (value bool, err error) { return def, err } -// String returns a string config value from managed file-based config, environment variable, -// or local file-based config, in that order. If none are set, it returns the default value and origin. -func String(env string, def string) string { - for value := range stableConfigByPriority(env) { - return value - } - return def -} - func stableConfigByPriority(env string) iter.Seq[string] { return func(yield func(string) bool) { if v, ok := os.LookupEnv(env); ok && !yield(v) {