diff --git a/.github/workflows/add-milestone-to-pull-requests.yml b/.github/workflows/add-milestone-to-pull-requests.yml index 2ff00b17..a6a09980 100644 --- a/.github/workflows/add-milestone-to-pull-requests.yml +++ b/.github/workflows/add-milestone-to-pull-requests.yml @@ -5,6 +5,9 @@ on: jobs: add_milestone: + permissions: + contents: read + pull-requests: write runs-on: ubuntu-latest if: github.event.pull_request.merged == true && github.event.pull_request.milestone == null steps: diff --git a/lib/datadog/ci.rb b/lib/datadog/ci.rb index cc0bfc7b..a5efc9f8 100644 --- a/lib/datadog/ci.rb +++ b/lib/datadog/ci.rb @@ -27,7 +27,8 @@ class << self # ``` # Datadog::CI.start_test_session( # service: "my-web-site-tests", - # tags: { Datadog::CI::Ext::Test::TAG_FRAMEWORK => "my-test-framework" } + # tags: { Datadog::CI::Ext::Test::TAG_FRAMEWORK => "my-test-framework" }, + # total_tests_count: 100 # ) # # # Somewhere else after test run has ended @@ -38,15 +39,16 @@ class << self # # @param [String] service the service name for this session (optional, defaults to DD_SERVICE or repository name) # @param [Hash] tags extra tags which should be added to the test session. + # @param [Integer] total_tests_count the total number of tests in the test session (optional, defaults to 0) - it is used to limit the number of new tests retried within session if early flake detection is enabled # @return [Datadog::CI::TestSession] the active, running {Datadog::CI::TestSession}. # @return [nil] if test suite level visibility is disabled or CI mode is disabled. - def start_test_session(service: Utils::Configuration.fetch_service_name("test"), tags: {}) + def start_test_session(service: Utils::Configuration.fetch_service_name("test"), tags: {}, total_tests_count: 0) Utils::Telemetry.inc( Ext::Telemetry::METRIC_MANUAL_API_EVENTS, 1, {Ext::Telemetry::TAG_EVENT_TYPE => Ext::Telemetry::EventType::SESSION} ) - test_visibility.start_test_session(service: service, tags: tags) + test_visibility.start_test_session(service: service, tags: tags, total_tests_count: total_tests_count) end # The active, unfinished test session. diff --git a/lib/datadog/ci/contrib/minitest/runner.rb b/lib/datadog/ci/contrib/minitest/runner.rb index b99beff6..e8ecae2b 100644 --- a/lib/datadog/ci/contrib/minitest/runner.rb +++ b/lib/datadog/ci/contrib/minitest/runner.rb @@ -8,6 +8,8 @@ module CI module Contrib module Minitest module Runner + DD_ESTIMATED_TESTS_PER_SUITE = 5 + def self.included(base) base.singleton_class.prepend(ClassMethods) end @@ -18,12 +20,15 @@ def init_plugins(*args) return unless datadog_configuration[:enabled] + # minitest does not store the total number of tests, so we can't pass it to the test session + # instead, we use the number of test suites * DD_ESTIMATED_TESTS_PER_SUITE as a rough estimate test_visibility_component.start_test_session( tags: { CI::Ext::Test::TAG_FRAMEWORK => Ext::FRAMEWORK, CI::Ext::Test::TAG_FRAMEWORK_VERSION => CI::Contrib::Minitest::Integration.version.to_s }, - service: datadog_configuration[:service_name] + service: datadog_configuration[:service_name], + total_tests_count: (DD_ESTIMATED_TESTS_PER_SUITE * ::Minitest::Runnable.runnables.size).to_i ) test_visibility_component.start_test_module(Ext::FRAMEWORK) end diff --git a/lib/datadog/ci/contrib/minitest/test.rb b/lib/datadog/ci/contrib/minitest/test.rb index 58a498e4..213825bb 100644 --- a/lib/datadog/ci/contrib/minitest/test.rb +++ b/lib/datadog/ci/contrib/minitest/test.rb @@ -51,6 +51,10 @@ def after_teardown return super unless test_span finish_with_result(test_span, result_code) + + # remove failures if test passed at least once on retries + self.failures = [] if test_span.any_retry_passed? + if Helpers.parallel?(self.class) finish_with_result(test_span.test_suite, result_code) end diff --git a/lib/datadog/ci/contrib/rspec/example.rb b/lib/datadog/ci/contrib/rspec/example.rb index 0f85064f..bac1f27a 100644 --- a/lib/datadog/ci/contrib/rspec/example.rb +++ b/lib/datadog/ci/contrib/rspec/example.rb @@ -64,9 +64,9 @@ def run(*args) result = super - # In case when test job is canceled and RSpec is quitting we don't want to report the last test + # When test job is canceled and RSpec is quitting we don't want to report the last test # before RSpec context unwinds. This test might have some unrelated errors that we don't want to - # report. + # see in Datadog. return result if ::RSpec.world.wants_to_quit case execution_result.status @@ -74,6 +74,8 @@ def run(*args) test_span&.passed! when :failed test_span&.failed!(exception: execution_result.exception) + # if any of the retries passed, we don't fail the test run + @exception = nil if test_span&.any_retry_passed? else # :pending or nil test_span&.skipped!( @@ -84,21 +86,20 @@ def run(*args) end end - # after retries are done, we can report the test to RSpec - @skip_reporting = false - # this is a special case for ci-queue, we need to finish the test suite span ci_queue_test_span&.finish - # Finish spec with latest retry's result - # TODO: when implementing new test retries make sure to clean @exception before calling this method - # if test passed at least once + # after retries are done, we can finally report the test to RSpec + @skip_reporting = false finish(reporter) end def finish(reporter) - # by default finish test but do not report it to RSpec::Core::Reporter - # it is going to be reported once after retries are done + # By default finish test but do not report it to RSpec::Core::Reporter + # it is going to be reported once after retries are done. + # + # We need to do this because RSpec breaks when we try to report the same example multiple times with different + # results. return super unless @skip_reporting super(::RSpec::Core::NullReporter) diff --git a/lib/datadog/ci/contrib/rspec/runner.rb b/lib/datadog/ci/contrib/rspec/runner.rb index d4600a1d..eb27d402 100644 --- a/lib/datadog/ci/contrib/rspec/runner.rb +++ b/lib/datadog/ci/contrib/rspec/runner.rb @@ -23,7 +23,8 @@ def run_specs(*args) CI::Ext::Test::TAG_FRAMEWORK => Ext::FRAMEWORK, CI::Ext::Test::TAG_FRAMEWORK_VERSION => CI::Contrib::RSpec::Integration.version.to_s }, - service: datadog_configuration[:service_name] + service: datadog_configuration[:service_name], + total_tests_count: ::RSpec.world.example_count ) test_module = test_visibility_component.start_test_module(Ext::FRAMEWORK) diff --git a/lib/datadog/ci/ext/telemetry.rb b/lib/datadog/ci/ext/telemetry.rb index d0feb603..60419006 100644 --- a/lib/datadog/ci/ext/telemetry.rb +++ b/lib/datadog/ci/ext/telemetry.rb @@ -69,6 +69,7 @@ module Telemetry TAG_BROWSER_DRIVER = "browser_driver" TAG_IS_RUM = "is_rum" TAG_IS_RETRY = "is_retry" + TAG_IS_NEW = "is_new" TAG_LIBRARY = "library" TAG_ENDPOINT = "endpoint" TAG_ERROR_TYPE = "error_type" @@ -80,6 +81,7 @@ module Telemetry TAG_COVERAGE_ENABLED = "coverage_enabled" TAG_ITR_SKIP_ENABLED = "itrskip_enabled" TAG_EARLY_FLAKE_DETECTION_ENABLED = "early_flake_detection_enabled" + TAG_EARLY_FLAKE_DETECTION_ABORT_REASON = "early_flake_detection_abort_reason" TAG_PROVIDER = "provider" TAG_AUTO_INJECTED = "auto_injected" diff --git a/lib/datadog/ci/ext/test.rb b/lib/datadog/ci/ext/test.rb index bee08038..cc6c64b4 100644 --- a/lib/datadog/ci/ext/test.rb +++ b/lib/datadog/ci/ext/test.rb @@ -58,8 +58,11 @@ module Test # version of the browser, if multiple browsers or multiple versions then this tag is empty TAG_BROWSER_VERSION = "test.browser.version" - # Tags for test retries + # Tags for retries TAG_IS_RETRY = "test.is_retry" # true if test was retried by datadog-ci library + TAG_IS_NEW = "test.is_new" # true if test was marked as new by new test retries (early flake detection) + TAG_EARLY_FLAKE_ENABLED = "test.early_flake.enabled" # true if early flake detection is enabled + TAG_EARLY_FLAKE_ABORT_REASON = "test.early_flake.abort_reason" # reason why early flake detection was aborted # internal APM tag to mark a span as a test span TAG_SPAN_KIND = "span.kind" @@ -73,6 +76,8 @@ module Test ITR_TEST_SKIP_REASON = "Skipped by Datadog's intelligent test runner" ITR_UNSKIPPABLE_OPTION = :datadog_itr_unskippable + EARLY_FLAKE_FAULTY = "faulty" + # test status as recognized by Datadog module Status PASS = "pass" diff --git a/lib/datadog/ci/test.rb b/lib/datadog/ci/test.rb index 71ced4da..3d6e2b7c 100644 --- a/lib/datadog/ci/test.rb +++ b/lib/datadog/ci/test.rb @@ -145,11 +145,18 @@ def parameters get_tag(Ext::Test::TAG_PARAMETERS) end + # @internal + def any_retry_passed? + !!test_suite&.any_test_retry_passed?(test_id) + end + private - def record_test_result(datadog_status) - test_id = Utils::TestRun.datadog_test_id(name, test_suite_name, parameters) + def test_id + @test_id ||= Utils::TestRun.datadog_test_id(name, test_suite_name, parameters) + end + def record_test_result(datadog_status) # if this test was already executed in this test suite, mark it as retried if test_suite&.test_executed?(test_id) set_tag(Ext::Test::TAG_IS_RETRY, "true") diff --git a/lib/datadog/ci/test_retries/component.rb b/lib/datadog/ci/test_retries/component.rb index 0406ace6..2466db39 100644 --- a/lib/datadog/ci/test_retries/component.rb +++ b/lib/datadog/ci/test_retries/component.rb @@ -2,6 +2,7 @@ require_relative "strategy/no_retry" require_relative "strategy/retry_failed" +require_relative "strategy/retry_new" require_relative "../ext/telemetry" require_relative "../utils/telemetry" @@ -13,10 +14,16 @@ module TestRetries # - retrying failed tests - improve success rate of CI pipelines # - retrying new tests - detect flaky tests as early as possible to prevent them from being merged class Component + FIBER_LOCAL_CURRENT_RETRY_STRATEGY_KEY = :__dd_current_retry_strategy + + DEFAULT_TOTAL_TESTS_COUNT = 100 + + # there are clearly 2 different concepts mixed here, we should split them into separate components + # (high level strategies?) in the subsequent PR attr_reader :retry_failed_tests_enabled, :retry_failed_tests_max_attempts, :retry_failed_tests_total_limit, :retry_failed_tests_count, - :retry_new_tests_enabled, :retry_new_tests_duration_thresholds, :retry_new_tests_percentage_limit, - :retry_new_tests_unique_tests_set, :retry_new_tests_fault_reason + :retry_new_tests_enabled, :retry_new_tests_duration_thresholds, :retry_new_tests_unique_tests_set, + :retry_new_tests_total_limit, :retry_new_tests_count def initialize( retry_failed_tests_enabled:, @@ -33,12 +40,12 @@ def initialize( @retry_new_tests_enabled = retry_new_tests_enabled @retry_new_tests_duration_thresholds = nil - @retry_new_tests_percentage_limit = 0 @retry_new_tests_unique_tests_set = Set.new - # indicates that retrying new tests failed and was disabled - @retry_new_tests_fault_reason = nil - @unique_tests_client = unique_tests_client + # total maximum number of new tests to retry (will be set based on the total number of tests in the session) + @retry_new_tests_total_limit = 0 + # counter thate stores the current number of new tests retried + @retry_new_tests_count = 0 @mutex = Mutex.new end @@ -49,53 +56,75 @@ def configure(library_settings, test_session) return unless @retry_new_tests_enabled + # mark early flake detection enabled for test session + test_session.set_tag(Ext::Test::TAG_EARLY_FLAKE_ENABLED, "true") + # configure retrying new tests @retry_new_tests_duration_thresholds = library_settings.slow_test_retries - @retry_new_tests_percentage_limit = library_settings.faulty_session_threshold + Datadog.logger.debug do + "Slow test retries thresholds: #{@retry_new_tests_duration_thresholds.entries}" + end + @retry_new_tests_unique_tests_set = @unique_tests_client.fetch_unique_tests(test_session) + percentage_limit = library_settings.faulty_session_threshold + tests_count = test_session.total_tests_count.to_i + if tests_count.zero? + Datadog.logger.debug do + "Total tests count is zero, using default value for the total number of tests: [#{DEFAULT_TOTAL_TESTS_COUNT}]" + end + + tests_count = DEFAULT_TOTAL_TESTS_COUNT + end + + @retry_new_tests_total_limit = (tests_count * percentage_limit / 100.0).ceil + Datadog.logger.debug do + "Retry new tests total limit is [#{@retry_new_tests_total_limit}] (#{percentage_limit}%) of #{tests_count}" + end + if @retry_new_tests_unique_tests_set.empty? @retry_new_tests_enabled = false - @retry_new_tests_fault_reason = "unique tests set is empty" + mark_test_session_faulty(test_session) - Datadog.logger.debug("Unique tests set is empty, retrying new tests disabled") - else - Utils::Telemetry.distribution( - Ext::Telemetry::METRIC_EFD_UNIQUE_TESTS_RESPONSE_TESTS, - @retry_new_tests_unique_tests_set.size.to_f + Datadog.logger.warn( + "Disabling early flake detection because there is no known tests (possible reason: no test runs in default branch)" ) end - end - - def with_retries(&block) - # @type var retry_strategy: Strategy::Base - retry_strategy = nil - test_finished_callback = lambda do |test_span| - if retry_strategy.nil? - # we always run test at least once and after first pass create a correct retry strategy - retry_strategy = build_strategy(test_span) - else - # after each retry we record the result, strategy will decide if we should retry again - retry_strategy&.record_retry(test_span) - end + Datadog.logger.debug do + "Found [#{@retry_new_tests_unique_tests_set.size}] known unique tests" end + Utils::Telemetry.distribution( + Ext::Telemetry::METRIC_EFD_UNIQUE_TESTS_RESPONSE_TESTS, + @retry_new_tests_unique_tests_set.size.to_f + ) + end - test_visibility_component.set_test_finished_callback(test_finished_callback) + def with_retries(&block) + self.current_retry_strategy = nil loop do yield - break unless retry_strategy&.should_retry? + break unless current_retry_strategy&.should_retry? end ensure - test_visibility_component.remove_test_finished_callback + self.current_retry_strategy = nil end def build_strategy(test_span) @mutex.synchronize do - if should_retry_failed_test?(test_span) - Datadog.logger.debug("Failed test retry starts") + if should_retry_new_test?(test_span) + Datadog.logger.debug do + "#{test_span.name} is new, will be retried" + end + @retry_new_tests_count += 1 + + Strategy::RetryNew.new(test_span, duration_thresholds: @retry_new_tests_duration_thresholds) + elsif should_retry_failed_test?(test_span) + Datadog.logger.debug do + "#{test_span.name} failed, will be retried" + end @retry_failed_tests_count += 1 Strategy::RetryFailed.new(max_attempts: @retry_failed_tests_max_attempts) @@ -105,15 +134,74 @@ def build_strategy(test_span) end end + def record_test_finished(test_span) + if current_retry_strategy.nil? + # we always run test at least once and after the first pass create a correct retry strategy + self.current_retry_strategy = build_strategy(test_span) + else + # after each retry we record the result, strategy will decide if we should retry again + current_retry_strategy&.record_retry(test_span) + end + end + + def record_test_span_duration(tracer_span) + current_retry_strategy&.record_duration(tracer_span.duration) + end + private + def current_retry_strategy + Thread.current[FIBER_LOCAL_CURRENT_RETRY_STRATEGY_KEY] + end + + def current_retry_strategy=(strategy) + Thread.current[FIBER_LOCAL_CURRENT_RETRY_STRATEGY_KEY] = strategy + end + def should_retry_failed_test?(test_span) - @retry_failed_tests_enabled && !!test_span&.failed? && @retry_failed_tests_count < @retry_failed_tests_total_limit + if @retry_failed_tests_count >= @retry_failed_tests_total_limit + Datadog.logger.debug do + "Retry failed tests limit reached: [#{@retry_failed_tests_count}] out of [#{@retry_new_tests_total_limit}]" + end + @retry_failed_tests_enabled = false + end + + @retry_failed_tests_enabled && !!test_span&.failed? + end + + def should_retry_new_test?(test_span) + if @retry_new_tests_count >= @retry_new_tests_total_limit + Datadog.logger.debug do + "Retry new tests limit reached: [#{@retry_new_tests_count}] out of [#{@retry_new_tests_total_limit}]" + end + @retry_new_tests_enabled = false + mark_test_session_faulty(Datadog::CI.active_test_session) + end + + @retry_new_tests_enabled && !test_span.skipped? && is_new_test?(test_span) end def test_visibility_component Datadog.send(:components).test_visibility end + + def is_new_test?(test_span) + test_id = Utils::TestRun.datadog_test_id(test_span.name, test_span.test_suite_name) + + result = !@retry_new_tests_unique_tests_set.include?(test_id) + + if result + Datadog.logger.debug do + "#{test_id} is not found in the unique tests set, it is a new test" + end + end + + result + end + + def mark_test_session_faulty(test_session) + test_session&.set_tag(Ext::Test::TAG_EARLY_FLAKE_ABORT_REASON, Ext::Test::EARLY_FLAKE_FAULTY) + end end end end diff --git a/lib/datadog/ci/test_retries/strategy/base.rb b/lib/datadog/ci/test_retries/strategy/base.rb index 3c69fd84..e91c6115 100644 --- a/lib/datadog/ci/test_retries/strategy/base.rb +++ b/lib/datadog/ci/test_retries/strategy/base.rb @@ -12,6 +12,10 @@ def should_retry? def record_retry(test_span) test_span&.set_tag(Ext::Test::TAG_IS_RETRY, "true") end + + # duration in float seconds + def record_duration(duration) + end end end end diff --git a/lib/datadog/ci/test_retries/strategy/retry_new.rb b/lib/datadog/ci/test_retries/strategy/retry_new.rb new file mode 100644 index 00000000..c3876b9f --- /dev/null +++ b/lib/datadog/ci/test_retries/strategy/retry_new.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative "base" + +require_relative "../../ext/test" + +module Datadog + module CI + module TestRetries + module Strategy + # retry every new test up to 10 times (early flake detection) + class RetryNew < Base + def initialize(test_span, duration_thresholds:) + @duration_thresholds = duration_thresholds + @attempts = 0 + # will be changed based on test span duration + @max_attempts = 10 + + mark_new_test(test_span) + end + + def should_retry? + @attempts < @max_attempts + end + + def record_retry(test_span) + super + + @attempts += 1 + mark_new_test(test_span) + + Datadog.logger.debug { "Retry Attempts [#{@attempts} / #{@max_attempts}]" } + end + + def record_duration(duration) + @max_attempts = @duration_thresholds.max_attempts_for_duration(duration) + + Datadog.logger.debug { "Recorded test duration of [#{duration}], new Max Attempts value is [#{@max_attempts}]" } + end + + private + + def mark_new_test(test_span) + test_span.set_tag(Ext::Test::TAG_IS_NEW, "true") + end + end + end + end + end +end diff --git a/lib/datadog/ci/test_session.rb b/lib/datadog/ci/test_session.rb index 720b9cfc..713d195b 100644 --- a/lib/datadog/ci/test_session.rb +++ b/lib/datadog/ci/test_session.rb @@ -12,6 +12,8 @@ module CI # # @public_api class TestSession < ConcurrentSpan + attr_accessor :total_tests_count + # Finishes the current test session. # @return [void] def finish diff --git a/lib/datadog/ci/test_suite.rb b/lib/datadog/ci/test_suite.rb index f4073015..78a70fa2 100644 --- a/lib/datadog/ci/test_suite.rb +++ b/lib/datadog/ci/test_suite.rb @@ -51,6 +51,14 @@ def any_passed? end end + # @internal + def any_test_retry_passed?(test_id) + synchronize do + stats = @execution_stats_per_test[test_id] + stats && stats[Ext::Test::Status::PASS] > 0 + end + end + # @internal def test_executed?(test_id) synchronize do diff --git a/lib/datadog/ci/test_visibility/component.rb b/lib/datadog/ci/test_visibility/component.rb index f30678e8..572ce78b 100644 --- a/lib/datadog/ci/test_visibility/component.rb +++ b/lib/datadog/ci/test_visibility/component.rb @@ -19,8 +19,6 @@ module TestVisibility class Component attr_reader :test_suite_level_visibility_enabled - FIBER_LOCAL_TEST_FINISHED_CALLBACK_KEY = :__dd_test_finished_callback - def initialize( test_suite_level_visibility_enabled: false, codeowners: Codeowners::Parser.new(Git::LocalRepository.root).parse @@ -30,10 +28,12 @@ def initialize( @codeowners = codeowners end - def start_test_session(service: nil, tags: {}) + def start_test_session(service: nil, tags: {}, total_tests_count: 0) return skip_tracing unless test_suite_level_visibility_enabled test_session = @context.start_test_session(service: service, tags: tags) + test_session.total_tests_count = total_tests_count + on_test_session_started(test_session) test_session end @@ -57,6 +57,8 @@ def start_test_suite(test_suite_name, service: nil, tags: {}) def trace_test(test_name, test_suite_name, service: nil, tags: {}, &block) if block @context.trace_test(test_name, test_suite_name, service: service, tags: tags) do |test| + subscribe_to_after_stop_event(test.tracer_span) + on_test_started(test) res = block.call(test) on_test_finished(test) @@ -64,6 +66,7 @@ def trace_test(test_name, test_suite_name, service: nil, tags: {}, &block) end else test = @context.trace_test(test_name, test_suite_name, service: service, tags: tags) + subscribe_to_after_stop_event(test.tracer_span) on_test_started(test) test end @@ -127,15 +130,6 @@ def deactivate_test_suite(test_suite_name) @context.deactivate_test_suite(test_suite_name) end - # sets fiber-local callback to be called when test is finished - def set_test_finished_callback(callback) - Thread.current[FIBER_LOCAL_TEST_FINISHED_CALLBACK_KEY] = callback - end - - def remove_test_finished_callback - Thread.current[FIBER_LOCAL_TEST_FINISHED_CALLBACK_KEY] = nil - end - def itr_enabled? test_optimisation.enabled? end @@ -204,7 +198,11 @@ def on_test_finished(test) Telemetry.event_finished(test) - Thread.current[FIBER_LOCAL_TEST_FINISHED_CALLBACK_KEY]&.call(test) + test_retries.record_test_finished(test) + end + + def on_after_test_span_finished(tracer_span) + test_retries.record_test_span_duration(tracer_span) end # HELPERS @@ -212,6 +210,14 @@ def skip_tracing(block = nil) block&.call(nil) end + def subscribe_to_after_stop_event(tracer_span) + events = tracer_span.send(:events) + + events.after_stop.subscribe do |span| + on_after_test_span_finished(span) + end + end + def set_codeowners(test) source = test.source_file owners = @codeowners.list_owners(source) if source @@ -267,6 +273,10 @@ def test_optimisation Datadog.send(:components).test_optimisation end + def test_retries + Datadog.send(:components).test_retries + end + def git_tree_upload_worker Datadog.send(:components).git_tree_upload_worker end diff --git a/lib/datadog/ci/test_visibility/null_component.rb b/lib/datadog/ci/test_visibility/null_component.rb index 0ab8bfa4..817313ba 100644 --- a/lib/datadog/ci/test_visibility/null_component.rb +++ b/lib/datadog/ci/test_visibility/null_component.rb @@ -5,7 +5,7 @@ module CI module TestVisibility # Special test visibility component that does not record anything class NullComponent - def start_test_session(service: nil, tags: {}) + def start_test_session(service: nil, tags: {}, total_tests_count: 0) skip_tracing end diff --git a/lib/datadog/ci/test_visibility/telemetry.rb b/lib/datadog/ci/test_visibility/telemetry.rb index 86d48e5e..bcc9a753 100644 --- a/lib/datadog/ci/test_visibility/telemetry.rb +++ b/lib/datadog/ci/test_visibility/telemetry.rb @@ -58,6 +58,15 @@ def self.event_tags_from_span(span) # set is_retry tag if span represents a retried test tags[Ext::Telemetry::TAG_IS_RETRY] = "true" if span.get_tag(Ext::Test::TAG_IS_RETRY) + # is_new + tags[Ext::Telemetry::TAG_IS_NEW] = "true" if span.get_tag(Ext::Test::TAG_IS_NEW) + + # session-level tag - early_flake_detection_abort_reason + early_flake_detection_abort_reason = span.get_tag(Ext::Test::TAG_EARLY_FLAKE_ABORT_REASON) + if early_flake_detection_abort_reason + tags[Ext::Telemetry::TAG_EARLY_FLAKE_DETECTION_ABORT_REASON] = early_flake_detection_abort_reason + end + tags end diff --git a/sig/datadog/ci.rbs b/sig/datadog/ci.rbs index 3cd1624c..6d85db27 100644 --- a/sig/datadog/ci.rbs +++ b/sig/datadog/ci.rbs @@ -7,7 +7,7 @@ module Datadog def self.start_test: (String test_name, String test_suite_name, ?service: String?, ?tags: Hash[untyped, untyped]) -> Datadog::CI::Test? - def self.start_test_session: (?service: String, ?tags: Hash[untyped, untyped]) -> Datadog::CI::TestSession? + def self.start_test_session: (?service: String, ?tags: Hash[untyped, untyped], total_tests_count: Integer) -> Datadog::CI::TestSession? def self.start_test_module: (String test_module_name, ?service: String?, ?tags: Hash[untyped, untyped]) -> Datadog::CI::TestModule? diff --git a/sig/datadog/ci/contrib/minitest/runner.rbs b/sig/datadog/ci/contrib/minitest/runner.rbs index 73cd5f73..80100aac 100644 --- a/sig/datadog/ci/contrib/minitest/runner.rbs +++ b/sig/datadog/ci/contrib/minitest/runner.rbs @@ -3,6 +3,8 @@ module Datadog module Contrib module Minitest module Runner + DD_ESTIMATED_TESTS_PER_SUITE: Integer + def self.included: (untyped base) -> untyped module ClassMethods diff --git a/sig/datadog/ci/ext/telemetry.rbs b/sig/datadog/ci/ext/telemetry.rbs index 89908ab7..94b7c1df 100644 --- a/sig/datadog/ci/ext/telemetry.rbs +++ b/sig/datadog/ci/ext/telemetry.rbs @@ -106,6 +106,8 @@ module Datadog TAG_IS_RETRY: "is_retry" + TAG_IS_NEW: "is_new" + TAG_LIBRARY: "library" TAG_ENDPOINT: "endpoint" @@ -128,6 +130,8 @@ module Datadog TAG_EARLY_FLAKE_DETECTION_ENABLED: "early_flake_detection_enabled" + TAG_EARLY_FLAKE_DETECTION_ABORT_REASON: "early_flake_detection_abort_reason" + TAG_PROVIDER: "provider" TAG_AUTO_INJECTED: "auto_injected" diff --git a/sig/datadog/ci/ext/test.rbs b/sig/datadog/ci/ext/test.rbs index 86a7c7e4..95147249 100644 --- a/sig/datadog/ci/ext/test.rbs +++ b/sig/datadog/ci/ext/test.rbs @@ -88,6 +88,14 @@ module Datadog TAG_IS_RETRY: "test.is_retry" + TAG_IS_NEW: "test.is_new" + + TAG_EARLY_FLAKE_ENABLED: "test.early_flake.enabled" + + TAG_EARLY_FLAKE_ABORT_REASON: "test.early_flake.abort_reason" + + EARLY_FLAKE_FAULTY: "faulty" + module Status PASS: "pass" diff --git a/sig/datadog/ci/test.rbs b/sig/datadog/ci/test.rbs index d264178c..2ed61b3d 100644 --- a/sig/datadog/ci/test.rbs +++ b/sig/datadog/ci/test.rbs @@ -1,6 +1,8 @@ module Datadog module CI class Test < Span + @test_id: String + def finish: () -> void def test_suite: () -> Datadog::CI::TestSuite? def test_suite_id: () -> String? @@ -12,9 +14,11 @@ module Datadog def source_file: () -> String? def parameters: () -> String? def is_retry?: () -> bool + def any_retry_passed?: () -> bool private + def test_id: () -> String def record_test_result: (String datadog_status) -> void end end diff --git a/sig/datadog/ci/test_retries/component.rbs b/sig/datadog/ci/test_retries/component.rbs index 0ae6d246..5a096485 100644 --- a/sig/datadog/ci/test_retries/component.rbs +++ b/sig/datadog/ci/test_retries/component.rbs @@ -2,6 +2,10 @@ module Datadog module CI module TestRetries class Component + FIBER_LOCAL_CURRENT_RETRY_STRATEGY_KEY: Symbol + + DEFAULT_TOTAL_TESTS_COUNT: 100 + attr_reader retry_failed_tests_enabled: bool attr_reader retry_failed_tests_max_attempts: Integer @@ -12,13 +16,13 @@ module Datadog attr_reader retry_new_tests_enabled: bool - attr_reader retry_new_tests_duration_thresholds: Datadog::CI::Remote::SlowTestRetries? - - attr_reader retry_new_tests_percentage_limit: Integer + attr_reader retry_new_tests_duration_thresholds: Datadog::CI::Remote::SlowTestRetries attr_reader retry_new_tests_unique_tests_set: Set[String] - attr_reader retry_new_tests_fault_reason: String? + attr_reader retry_new_tests_total_limit: Integer + + attr_reader retry_new_tests_count: Integer @mutex: Thread::Mutex @@ -32,11 +36,25 @@ module Datadog def build_strategy: (Datadog::CI::Test test) -> Datadog::CI::TestRetries::Strategy::Base + def record_test_finished: (Datadog::CI::Test test) -> void + + def record_test_span_duration: (Datadog::Tracing::SpanOperation span) -> void + private + def current_retry_strategy: () -> Datadog::CI::TestRetries::Strategy::Base? + + def current_retry_strategy=: (Datadog::CI::TestRetries::Strategy::Base? strategy) -> void + def should_retry_failed_test?: (Datadog::CI::Test test) -> bool + def should_retry_new_test?: (Datadog::CI::Test test) -> bool + + def is_new_test?: (Datadog::CI::Test test) -> bool + def test_visibility_component: () -> Datadog::CI::TestVisibility::Component + + def mark_test_session_faulty: (Datadog::CI::TestSession? test_session) -> void end end end diff --git a/sig/datadog/ci/test_retries/strategy/base.rbs b/sig/datadog/ci/test_retries/strategy/base.rbs index 8440b3e3..af71436a 100644 --- a/sig/datadog/ci/test_retries/strategy/base.rbs +++ b/sig/datadog/ci/test_retries/strategy/base.rbs @@ -6,6 +6,8 @@ module Datadog def should_retry?: () -> bool def record_retry: (Datadog::CI::Test test_span) -> void + + def record_duration: (Float duration) -> void end end end diff --git a/sig/datadog/ci/test_retries/strategy/retry_new.rbs b/sig/datadog/ci/test_retries/strategy/retry_new.rbs new file mode 100644 index 00000000..4221c7ba --- /dev/null +++ b/sig/datadog/ci/test_retries/strategy/retry_new.rbs @@ -0,0 +1,20 @@ +module Datadog + module CI + module TestRetries + module Strategy + class RetryNew < Base + @duration_thresholds: Datadog::CI::Remote::SlowTestRetries + + @attempts: Integer + @max_attempts: Integer + + def initialize: (Datadog::CI::Test test_span, duration_thresholds: Datadog::CI::Remote::SlowTestRetries) -> void + + private + + def mark_new_test: (Datadog::CI::Test test_span) -> void + end + end + end + end +end diff --git a/sig/datadog/ci/test_session.rbs b/sig/datadog/ci/test_session.rbs index 3d369772..01433d41 100644 --- a/sig/datadog/ci/test_session.rbs +++ b/sig/datadog/ci/test_session.rbs @@ -1,6 +1,7 @@ module Datadog module CI class TestSession < ConcurrentSpan + attr_accessor total_tests_count: Integer @inheritable_tags: Hash[untyped, untyped] def inheritable_tags: () -> Hash[untyped, untyped] diff --git a/sig/datadog/ci/test_suite.rbs b/sig/datadog/ci/test_suite.rbs index 6bc63894..9f495ed7 100644 --- a/sig/datadog/ci/test_suite.rbs +++ b/sig/datadog/ci/test_suite.rbs @@ -9,6 +9,8 @@ module Datadog def test_executed?: (String test_id) -> bool + def any_test_retry_passed?: (String) -> bool + private def set_status_from_stats!: () -> void diff --git a/sig/datadog/ci/test_visibility/component.rbs b/sig/datadog/ci/test_visibility/component.rbs index 9738bd04..29aabee0 100644 --- a/sig/datadog/ci/test_visibility/component.rbs +++ b/sig/datadog/ci/test_visibility/component.rbs @@ -7,8 +7,6 @@ module Datadog @codeowners: Datadog::CI::Codeowners::Matcher @context: Datadog::CI::TestVisibility::Context - FIBER_LOCAL_TEST_FINISHED_CALLBACK_KEY: Symbol - attr_reader test_suite_level_visibility_enabled: bool def initialize: (?test_suite_level_visibility_enabled: bool, ?codeowners: Datadog::CI::Codeowners::Matcher) -> void @@ -17,7 +15,7 @@ module Datadog def trace: (String span_name, ?type: String, ?tags: Hash[untyped, untyped]) ?{ (Datadog::CI::Span span) -> untyped } -> untyped - def start_test_session: (?service: String?, ?tags: Hash[untyped, untyped]) -> Datadog::CI::TestSession + def start_test_session: (?service: String?, ?tags: Hash[untyped, untyped], ?total_tests_count: Integer) -> Datadog::CI::TestSession def start_test_module: (String test_module_name, ?service: String?, ?tags: Hash[untyped, untyped]) -> Datadog::CI::TestModule @@ -41,10 +39,6 @@ module Datadog def deactivate_test_suite: (String test_suite_name) -> void - def set_test_finished_callback: (Proc callback) -> void - - def remove_test_finished_callback: () -> void - def itr_enabled?: () -> bool def shutdown!: () -> void @@ -79,8 +73,14 @@ module Datadog def on_test_finished: (Datadog::CI::Test test) -> void + def on_after_test_span_finished: (Datadog::Tracing::SpanOperation span) -> void + + def subscribe_to_after_stop_event: (Datadog::Tracing::SpanOperation span) -> void + def test_optimisation: () -> Datadog::CI::TestOptimisation::Component + def test_retries: () -> Datadog::CI::TestRetries::Component + def git_tree_upload_worker: () -> Datadog::CI::Worker def remote: () -> Datadog::CI::Remote::Component diff --git a/sig/datadog/ci/test_visibility/null_component.rbs b/sig/datadog/ci/test_visibility/null_component.rbs index 920effe4..e0321cb2 100644 --- a/sig/datadog/ci/test_visibility/null_component.rbs +++ b/sig/datadog/ci/test_visibility/null_component.rbs @@ -8,7 +8,7 @@ module Datadog def trace: (String span_name, ?type: String, ?tags: Hash[untyped, untyped]) ?{ (Datadog::CI::Span span) -> untyped } -> untyped - def start_test_session: (?service: String?, ?tags: Hash[untyped, untyped]) -> nil + def start_test_session: (?service: String?, ?tags: Hash[untyped, untyped], ?total_tests_count: Integer) -> nil def start_test_module: (String test_module_name, ?service: String?, ?tags: Hash[untyped, untyped]) -> nil diff --git a/spec/datadog/ci/contrib/minitest/instrumentation_spec.rb b/spec/datadog/ci/contrib/minitest/instrumentation_spec.rb index d2d2e4a6..a559795b 100644 --- a/spec/datadog/ci/contrib/minitest/instrumentation_spec.rb +++ b/spec/datadog/ci/contrib/minitest/instrumentation_spec.rb @@ -1190,4 +1190,165 @@ def test_failed expect(test_session_span).to have_fail_status end end + + context "with one new test and new test retries enabled" do + include_context "CI mode activated" do + let(:integration_name) { :minitest } + + let(:early_flake_detection_enabled) { true } + let(:unique_tests_set) { Set.new(["TestSuiteWithNewTest at spec/datadog/ci/contrib/minitest/instrumentation_spec.rb.test_passed."]) } + end + + before do + Minitest.run([]) + end + + before(:context) do + Minitest::Runnable.reset + + class TestSuiteWithNewTest < Minitest::Test + def test_passed + assert true + end + + def test_passed_second + assert true + end + end + end + + it "retries new test" do + # 1 initial run of test_passed + 1 run of test_passed_second + 10 retries = 12 spans + expect(test_spans).to have(12).items + + test_spans_by_test_name = test_spans.group_by { |span| span.get_tag("test.name") } + expect(test_spans_by_test_name["test_passed"]).to have(1).item + expect(test_spans_by_test_name["test_passed_second"]).to have(11).items + + # count how many spans were marked as retries + retries_count = test_spans.count { |span| span.get_tag("test.is_retry") == "true" } + expect(retries_count).to eq(10) + + # count how many tests were marked as new + new_tests_count = test_spans.count { |span| span.get_tag("test.is_new") == "true" } + expect(new_tests_count).to eq(11) + + expect(test_suite_spans).to have(1).item + expect(test_suite_spans.first).to have_pass_status + + expect(test_session_span).to have_pass_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + expect(test_session_span).to_not have_test_tag(:early_flake_abort_reason) + end + end + + context "when all tests are new" do + include_context "CI mode activated" do + let(:integration_name) { :minitest } + + let(:early_flake_detection_enabled) { true } + let(:unique_tests_set) { Set.new(["TestSuiteWithNewTest at spec/datadog/ci/contrib/minitest/instrumentation_spec.rb.no_such_test."]) } + let(:faulty_session_threshold) { 10 } + end + + before do + Minitest.run([]) + end + + before(:context) do + Minitest::Runnable.reset + + class TestSuiteWithFaultyEFDTest < Minitest::Test + def test_passed + assert true + end + + def test_passed_second + assert true + end + end + end + + it "bails out of retrying new tests and marks EFD as faulty" do + # 1 initial run of a test + 10 retries + 1 run of another test = 12 spans + expect(test_spans).to have(12).items + + # count how many spans were marked as retries + retries_count = test_spans.count { |span| span.get_tag("test.is_retry") == "true" } + expect(retries_count).to eq(10) + + # count how many tests were marked as new + new_tests_count = test_spans.count { |span| span.get_tag("test.is_new") == "true" } + expect(new_tests_count).to eq(11) + + expect(test_suite_spans).to have(1).item + expect(test_suite_spans.first).to have_pass_status + + expect(test_session_span).to have_pass_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + expect(test_session_span).to have_test_tag(:early_flake_abort_reason, "faulty") + end + end + + context "with new test retries enabled and there is a test that fails once on the last retry" do + include_context "CI mode activated" do + let(:integration_name) { :minitest } + + let(:early_flake_detection_enabled) { true } + let(:unique_tests_set) { Set.new(["FlakyTestThatFailsOnceSuite at spec/datadog/ci/contrib/minitest/instrumentation_spec.rb.test_passed."]) } + end + + before do + Minitest.run([]) + end + + before(:context) do + Minitest::Runnable.reset + + class FlakyTestThatFailsOnceSuite < Minitest::Test + @@max_flaky_test_passes = 10 + @@flaky_test_passes = 0 + + def test_passed + assert true + end + + def test_flaky + if @@flaky_test_passes < @@max_flaky_test_passes + @@flaky_test_passes += 1 + assert 1 + 1 == 2 + else + assert 1 + 1 == 3 + end + end + end + end + + it "does not fail the build" do + # 1 initial run of new test + 10 retries + 1 passing test = 12 spans + expect(test_spans).to have(12).items + + failed_spans, passed_spans = test_spans.partition { |span| span.get_tag("test.status") == "fail" } + expect(failed_spans).to have(1).items + expect(passed_spans).to have(11).items + + test_spans_by_test_name = test_spans.group_by { |span| span.get_tag("test.name") } + expect(test_spans_by_test_name["test_flaky"]).to have(11).item + expect(test_spans_by_test_name["test_passed"]).to have(1).item + + # count how many spans were marked as retries + retries_count = test_spans.count { |span| span.get_tag("test.is_retry") == "true" } + expect(retries_count).to eq(10) + + # count how many tests were marked as new + new_tests_count = test_spans.count { |span| span.get_tag("test.is_new") == "true" } + expect(new_tests_count).to eq(11) + + expect(test_suite_spans).to have(1).item + expect(test_suite_spans.first).to have_pass_status + + expect(test_session_span).to have_pass_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + end + end end diff --git a/spec/datadog/ci/contrib/rspec/instrumentation_spec.rb b/spec/datadog/ci/contrib/rspec/instrumentation_spec.rb index f02a23c9..3e9779af 100644 --- a/spec/datadog/ci/contrib/rspec/instrumentation_spec.rb +++ b/spec/datadog/ci/contrib/rspec/instrumentation_spec.rb @@ -33,6 +33,7 @@ def rspec_session_run( with_shared_context: false, with_flaky_test: false, with_canceled_test: false, + with_flaky_test_that_fails_once: false, unskippable: { test: false, context: false, @@ -47,6 +48,9 @@ def rspec_session_run( max_flaky_test_failures = 4 flaky_test_failures = 0 + max_flaky_test_that_fails_once_passes = 10 + flaky_test_that_fails_once_passes = 0 + current_let_value = 0 with_new_rspec_environment do @@ -87,6 +91,17 @@ def rspec_session_run( end end + if with_flaky_test_that_fails_once + it "flaky that fails once" do + if flaky_test_that_fails_once_passes < max_flaky_test_that_fails_once_passes + flaky_test_that_fails_once_passes += 1 + expect(1 + 1).to eq(2) + else + expect(1 + 1).to eq(3) + end + end + end + if with_canceled_test it "canceled during execution" do RSpec.world.wants_to_quit = true @@ -114,6 +129,10 @@ def rspec_session_run( let(:integration_options) { {service_name: "lspec"} } end + before do + Datadog.send(:components).test_visibility.start_test_session + end + it "creates span for example" do spec = with_new_rspec_environment do RSpec.describe "some test" do @@ -147,7 +166,7 @@ def rspec_session_run( :source_file, "spec/datadog/ci/contrib/rspec/instrumentation_spec.rb" ) - expect(first_test_span).to have_test_tag(:source_start, "120") + expect(first_test_span).to have_test_tag(:source_start, "139") expect(first_test_span).to have_test_tag( :codeowners, "[\"@DataDog/ruby-guild\", \"@DataDog/ci-app-libraries\"]" @@ -155,7 +174,7 @@ def rspec_session_run( end it "creates spans for several examples" do - expect(Datadog::CI::Ext::Environment).to receive(:tags).once.and_call_original + expect(Datadog::CI::Ext::Environment).to receive(:tags).never num_examples = 20 with_new_rspec_environment do @@ -958,4 +977,349 @@ def rspec_skipped_session_run end end end + + context "session with early flake detection enabled" do + include_context "CI mode activated" do + let(:integration_name) { :rspec } + + let(:early_flake_detection_enabled) { true } + let(:unique_tests_set) { Set.new(["SomeTest at ./spec/datadog/ci/contrib/rspec/instrumentation_spec.rb.nested fails."]) } + end + + it "retries the new test 10 times" do + rspec_session_run(with_failed_test: true) + + # 1 passing test + 10 new test retries + 1 failed test run = 12 spans + expect(test_spans).to have(12).items + + failed_spans, passed_spans = test_spans.partition { |span| span.get_tag("test.status") == "fail" } + expect(failed_spans).to have(1).items + expect(passed_spans).to have(11).items + + test_spans_by_test_name = test_spans.group_by { |span| span.get_tag("test.name") } + + # it retried the new test 10 times + expect(test_spans_by_test_name["nested foo"]).to have(11).item + + # count how many tests were marked as retries + retries_count = test_spans.count { |span| span.get_tag("test.is_retry") == "true" } + expect(retries_count).to eq(10) + + # count how many tests were marked as new + new_tests_count = test_spans.count { |span| span.get_tag("test.is_new") == "true" } + expect(new_tests_count).to eq(11) + + expect(test_suite_spans).to have(1).item + expect(test_suite_spans.first).to have_fail_status + + expect(test_session_span).to have_fail_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + end + + context "when test is slower than 5 seconds" do + before do + allow_any_instance_of(Datadog::Tracing::SpanOperation).to receive(:duration).and_return(6.0) + end + + it "retries the new test 5 times" do + rspec_session_run(with_failed_test: true) + + # 1 passing test + 5 new test retries + 1 failed test run = 7 spans + expect(test_spans).to have(7).items + + test_spans_by_test_name = test_spans.group_by { |span| span.get_tag("test.name") } + # it retried the new test 5 times + expect(test_spans_by_test_name["nested foo"]).to have(6).item + + # count how many spans were marked as retries + retries_count = test_spans.count { |span| span.get_tag("test.is_retry") == "true" } + expect(retries_count).to eq(5) + + # count how many tests were marked as new + new_tests_count = test_spans.count { |span| span.get_tag("test.is_new") == "true" } + expect(new_tests_count).to eq(6) + + expect(test_suite_spans).to have(1).item + expect(test_session_span).to have_fail_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + end + end + + context "when test is slower than 10 minutes" do + before do + allow_any_instance_of(Datadog::Tracing::SpanOperation).to receive(:duration).and_return(601.0) + end + + it "doesn't retry the new test" do + rspec_session_run(with_failed_test: true) + + # 1 passing test + 1 failed test run = 2 spans + expect(test_spans).to have(2).items + + test_spans_by_test_name = test_spans.group_by { |span| span.get_tag("test.name") } + # it retried the new test 0 times + expect(test_spans_by_test_name["nested foo"]).to have(1).item + + # count how many spans were marked as retries + retries_count = test_spans.count { |span| span.get_tag("test.is_retry") == "true" } + expect(retries_count).to eq(0) + + # count how many tests were marked as new + new_tests_count = test_spans.count { |span| span.get_tag("test.is_new") == "true" } + expect(new_tests_count).to eq(1) + + expect(test_suite_spans).to have(1).item + expect(test_session_span).to have_fail_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + end + end + end + + context "session with early flake detection enabled but unique tests set is empty" do + include_context "CI mode activated" do + let(:integration_name) { :rspec } + + let(:early_flake_detection_enabled) { true } + end + + it "retries the new test 10 times and the flaky test until it passes" do + rspec_session_run + + expect(test_spans).to have(1).item + + # count how many spans were marked as retries + retries_count = test_spans.count { |span| span.get_tag("test.is_retry") == "true" } + expect(retries_count).to eq(0) + + # count how many tests were marked as new + new_tests_count = test_spans.count { |span| span.get_tag("test.is_new") == "true" } + expect(new_tests_count).to eq(0) + + expect(test_suite_spans).to have(1).item + expect(test_suite_spans.first).to have_pass_status + + expect(test_session_span).to have_pass_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + expect(test_session_span).to have_test_tag(:early_flake_abort_reason, "faulty") + end + end + + context "session with early flake detection enabled and retrying failed tests enabled" do + include_context "CI mode activated" do + let(:integration_name) { :rspec } + + let(:early_flake_detection_enabled) { true } + let(:unique_tests_set) { Set.new(["SomeTest at ./spec/datadog/ci/contrib/rspec/instrumentation_spec.rb.nested flaky."]) } + + let(:flaky_test_retries_enabled) { true } + end + + it "retries the new test 10 times and the flaky test until it passes" do + rspec_session_run(with_flaky_test: true) + + # 1 initial run of flaky test + 4 retries until pass + 1 passing new test + 10 new test retries = 16 spans + expect(test_spans).to have(16).items + + failed_spans, passed_spans = test_spans.partition { |span| span.get_tag("test.status") == "fail" } + expect(failed_spans).to have(4).items + expect(passed_spans).to have(12).items + + test_spans_by_test_name = test_spans.group_by { |span| span.get_tag("test.name") } + expect(test_spans_by_test_name["nested flaky"]).to have(5).items + expect(test_spans_by_test_name["nested foo"]).to have(11).item + + # count how many spans were marked as retries + retries_count = test_spans.count { |span| span.get_tag("test.is_retry") == "true" } + expect(retries_count).to eq(14) + + # count how many tests were marked as new + new_tests_count = test_spans.count { |span| span.get_tag("test.is_new") == "true" } + expect(new_tests_count).to eq(11) + + expect(test_suite_spans).to have(1).item + expect(test_suite_spans.first).to have_pass_status + + expect(test_session_span).to have_pass_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + end + end + + context "session with early flake detection enabled and retrying failed tests enabled and both tests are new" do + include_context "CI mode activated" do + let(:integration_name) { :rspec } + + let(:early_flake_detection_enabled) { true } + # avoid bailing out of EFD + let(:faulty_session_threshold) { 75 } + let(:unique_tests_set) do + Set.new( + [ + "SomeTest at ./spec/datadog/ci/contrib/rspec/instrumentation_spec.rb.nested x." + ] + ) + end + + let(:flaky_test_retries_enabled) { true } + end + + it "retries both tests 10 times" do + rspec_session_run(with_flaky_test: true) + + # 1 initial run of flaky test + 10 retries + 1 passing new test + 10 new test retries = 22 spans + expect(test_spans).to have(22).items + + failed_spans, passed_spans = test_spans.partition { |span| span.get_tag("test.status") == "fail" } + expect(failed_spans).to have(4).items + expect(passed_spans).to have(18).items + + test_spans_by_test_name = test_spans.group_by { |span| span.get_tag("test.name") } + expect(test_spans_by_test_name["nested flaky"]).to have(11).items + expect(test_spans_by_test_name["nested foo"]).to have(11).item + + # count how many spans were marked as retries + retries_count = test_spans.count { |span| span.get_tag("test.is_retry") == "true" } + expect(retries_count).to eq(20) + + # count how many tests were marked as new + new_tests_count = test_spans.count { |span| span.get_tag("test.is_new") == "true" } + expect(new_tests_count).to eq(22) + + expect(test_suite_spans).to have(1).item + expect(test_suite_spans.first).to have_pass_status + + expect(test_session_span).to have_pass_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + end + end + + context "session with early flake detection enabled and both tests are new and faulty percentage is reached" do + include_context "CI mode activated" do + let(:integration_name) { :rspec } + + let(:early_flake_detection_enabled) { true } + let(:faulty_session_threshold) { 30 } + let(:unique_tests_set) do + Set.new( + [ + "SomeTest at ./spec/datadog/ci/contrib/rspec/instrumentation_spec.rb.nested x." + ] + ) + end + end + + it "retries first test only and then bails out of retrying new tests" do + rspec_session_run(with_flaky_test: true) + + # 1 initial run of passing test + 10 retries + 1 flaky test = 12 spans + expect(test_spans).to have(12).items + + failed_spans, passed_spans = test_spans.partition { |span| span.get_tag("test.status") == "fail" } + expect(failed_spans).to have(1).items + expect(passed_spans).to have(11).items + + test_spans_by_test_name = test_spans.group_by { |span| span.get_tag("test.name") } + expect(test_spans_by_test_name["nested flaky"]).to have(1).item + expect(test_spans_by_test_name["nested foo"]).to have(11).items + + # count how many spans were marked as retries + retries_count = test_spans.count { |span| span.get_tag("test.is_retry") == "true" } + expect(retries_count).to eq(10) + + # count how many tests were marked as new + new_tests_count = test_spans.count { |span| span.get_tag("test.is_new") == "true" } + expect(new_tests_count).to eq(11) + + expect(test_suite_spans).to have(1).item + expect(test_suite_spans.first).to have_fail_status + + expect(test_session_span).to have_fail_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + expect(test_session_span).to have_test_tag(:early_flake_abort_reason, "faulty") + end + end + + context "session with early flake detection enabled and test fails on last retry" do + include_context "CI mode activated" do + let(:integration_name) { :rspec } + + let(:early_flake_detection_enabled) { true } + let(:unique_tests_set) { Set.new(["SomeTest at ./spec/datadog/ci/contrib/rspec/instrumentation_spec.rb.nested foo."]) } + + let(:itr_enabled) { true } + let(:code_coverage_enabled) { true } + end + + it "retries the new test 10 times" do + rspec_session_run(with_flaky_test_that_fails_once: true) + + # 1 passing test + 1 flaky test run + 10 new test retries = 12 spans + expect(test_spans).to have(12).items + + failed_spans, passed_spans = test_spans.partition { |span| span.get_tag("test.status") == "fail" } + expect(failed_spans).to have(1).items + expect(passed_spans).to have(11).items + + test_spans_by_test_name = test_spans.group_by { |span| span.get_tag("test.name") } + expect(test_spans_by_test_name["nested foo"]).to have(1).item + expect(test_spans_by_test_name["nested flaky that fails once"]).to have(11).items + + # count how many tests were marked as retries + retries_count = test_spans.count { |span| span.get_tag("test.is_retry") == "true" } + expect(retries_count).to eq(10) + + # count how many tests were marked as new + new_tests_count = test_spans.count { |span| span.get_tag("test.is_new") == "true" } + expect(new_tests_count).to eq(11) + + expect(test_suite_spans).to have(1).item + expect(test_suite_spans.first).to have_pass_status + + expect(test_session_span).to have_pass_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + end + end + + context "session with early flake detection and ITR enabled" do + include_context "CI mode activated" do + let(:integration_name) { :rspec } + + let(:early_flake_detection_enabled) { true } + let(:faulty_session_threshold) { 30 } + let(:unique_tests_set) do + Set.new( + [ + "SomeTest at ./spec/datadog/ci/contrib/rspec/instrumentation_spec.rb.nested x." + ] + ) + end + + let(:itr_enabled) { true } + let(:code_coverage_enabled) { true } + let(:tests_skipping_enabled) { true } + let(:itr_skippable_tests) do + Set.new([ + 'SomeTest at ./spec/datadog/ci/contrib/rspec/instrumentation_spec.rb.nested foo.{"arguments":{},"metadata":{"scoped_id":"1:1:1"}}' + ]) + end + end + + it "retries first test only and then bails out of retrying new tests" do + rspec_session_run + + # 1 test skipped by ITR + expect(test_spans).to have(1).items + test_span = test_spans.first + + expect(test_span).to have_skip_status + expect(test_span).not_to have_test_tag(:is_retry) + # skipped test is not marked as new + expect(test_span).not_to have_test_tag(:is_new) + + expect(test_suite_spans).to have(1).item + expect(test_suite_spans.first).to have_skip_status + + expect(test_session_span).to have_pass_status + expect(test_session_span).to have_test_tag(:early_flake_enabled, "true") + end + end end diff --git a/spec/datadog/ci/test_retries/component_spec.rb b/spec/datadog/ci/test_retries/component_spec.rb index 3a346eba..24277877 100644 --- a/spec/datadog/ci/test_retries/component_spec.rb +++ b/spec/datadog/ci/test_retries/component_spec.rb @@ -21,6 +21,9 @@ let(:retry_failed_tests_total_limit) { 12 } let(:retry_new_tests_enabled) { true } let(:retry_new_tests_percentage_limit) { 30 } + let(:retry_new_tests_max_attempts) { 5 } + + let(:session_total_tests_count) { 30 } let(:remote_flaky_test_retries_enabled) { false } let(:remote_early_flake_detection_enabled) { false } @@ -36,12 +39,16 @@ let(:slow_test_retries) do instance_double( Datadog::CI::Remote::SlowTestRetries, - max_attempts_for_duration: 10 + max_attempts_for_duration: retry_new_tests_max_attempts ) end let(:tracer_span) { Datadog::Tracing::SpanOperation.new("session") } - let(:test_session) { Datadog::CI::TestSession.new(tracer_span) } + let(:test_session) do + Datadog::CI::TestSession.new(tracer_span).tap do |test_session| + test_session.total_tests_count = session_total_tests_count + end + end subject(:component) do described_class.new( @@ -93,12 +100,14 @@ context "when unique tests set is empty" do let(:unique_tests_set) { Set.new } - it "disables retrying new tests and adds fault reason" do + it "disables retrying new tests and adds fault reason to the test session" do subject expect(component.retry_new_tests_enabled).to be false - expect(component.retry_new_tests_fault_reason).to eq("unique tests set is empty") + expect(test_session.get_tag("test.early_flake.abort_reason")).to eq("faulty") end + + it_behaves_like "emits telemetry metric", :distribution, "early_flake_detection.response_tests", 0 end context "when unique tests set is not empty" do @@ -106,8 +115,9 @@ subject expect(component.retry_new_tests_enabled).to be true - expect(component.retry_new_tests_duration_thresholds.max_attempts_for_duration(1.2)).to eq(10) - expect(component.retry_new_tests_percentage_limit).to eq(retry_new_tests_percentage_limit) + expect(component.retry_new_tests_duration_thresholds.max_attempts_for_duration(1.2)).to eq(retry_new_tests_max_attempts) + # 30% of 30 tests = 9 + expect(component.retry_new_tests_total_limit).to eq(9) end it_behaves_like "emits telemetry metric", :distribution, "early_flake_detection.response_tests", 2 @@ -152,7 +162,7 @@ subject { component.build_strategy(test_span) } let(:test_failed) { false } - let(:test_span) { instance_double(Datadog::CI::Test, failed?: test_failed) } + let(:test_span) { instance_double(Datadog::CI::Test, failed?: test_failed, name: "test", test_suite_name: "suite") } before do component.configure(library_settings, test_session) @@ -218,26 +228,36 @@ let(:flaky_test_retries_enabled) { true } end - let(:test_failed) { false } + let(:component) do + Datadog.send(:components).test_retries + end + + let(:tracer_span) do + instance_double(Datadog::Tracing::SpanOperation, duration: 1.2, set_tag: true) + end let(:test_span) do instance_double( Datadog::CI::Test, failed?: test_failed, - passed?: false, + passed?: !test_failed, set_tag: true, get_tag: true, skipped?: false, - type: "test" + type: "test", + name: "mytest", + test_suite_name: "mysuite" ) end + let(:test_failed) { false } subject(:runs_count) do runs_count = 0 component.with_retries do runs_count += 1 - # run callback manually + # run callbacks manually Datadog.send(:components).test_visibility.send(:on_test_finished, test_span) + Datadog.send(:components).test_visibility.send(:on_after_test_span_finished, tracer_span) end runs_count @@ -251,7 +271,7 @@ it { is_expected.to eq(1) } end - context "when retried failed tests strategy is used" do + context "when retry failed tests strategy is used" do let(:remote_flaky_test_retries_enabled) { true } context "when test span is failed" do @@ -267,5 +287,22 @@ it { is_expected.to eq(1) } end end + + context "when retry new test strategy is used" do + let(:remote_early_flake_detection_enabled) { true } + let(:unique_tests_set) { Set.new(["mysuite.mytest2."]) } + + it { is_expected.to eq(11) } + + context "when test duration increases" do + let(:tracer_span) { instance_double(Datadog::Tracing::SpanOperation, set_tag: true) } + before do + allow(tracer_span).to receive(:duration).and_return(5.1, 10.1, 30.1, 600.1) + end + + # 5.1s (5 retries) -> 10.1s (3 retries) -> 30.1s (2 retries) -> done => 3 executions in total + it { is_expected.to eq(3) } + end + end end end diff --git a/spec/datadog/ci/test_retries/strategy/retry_new_spec.rb b/spec/datadog/ci/test_retries/strategy/retry_new_spec.rb new file mode 100644 index 00000000..0736b8a1 --- /dev/null +++ b/spec/datadog/ci/test_retries/strategy/retry_new_spec.rb @@ -0,0 +1,40 @@ +require_relative "../../../../../lib/datadog/ci/test_retries/strategy/retry_new" + +RSpec.describe Datadog::CI::TestRetries::Strategy::RetryNew do + let(:max_attempts) { 10 } + let(:duration_thresholds) { + Datadog::CI::Remote::SlowTestRetries.new({ + "5s" => 10, + "10s" => 5, + "30s" => 3, + "10m" => 2 + }) + } + let(:test_span) { double(:test_span, set_tag: true) } + + subject(:strategy) { described_class.new(test_span, duration_thresholds: duration_thresholds) } + + describe "#should_retry?" do + subject { strategy.should_retry? } + + context "when max attempts haven't been reached yet" do + it { is_expected.to be true } + end + + context "when the max attempts have been reached" do + before { max_attempts.times { strategy.record_retry(test_span) } } + + it { is_expected.to be false } + end + end + + describe "#record_duration" do + subject { strategy.record_duration(duration) } + + let(:duration) { 5 } + + it "updates the max attempts based on the duration" do + expect { subject }.to change { strategy.instance_variable_get(:@max_attempts) }.from(10).to(5) + end + end +end diff --git a/spec/datadog/ci/test_spec.rb b/spec/datadog/ci/test_spec.rb index 98c16a70..4b1a1e5f 100644 --- a/spec/datadog/ci/test_spec.rb +++ b/spec/datadog/ci/test_spec.rb @@ -169,9 +169,9 @@ describe "#passed!" do before do allow(ci_test).to receive(:test_suite).and_return(test_suite) - expect(tracer_span).to receive(:get_tag).with("test.name").and_return("test name") - expect(tracer_span).to receive(:get_tag).with("test.suite").and_return("test suite name") - expect(tracer_span).to receive(:get_tag).with("test.parameters").and_return(nil) + allow(tracer_span).to receive(:get_tag).with("test.name").and_return("test name") + allow(tracer_span).to receive(:get_tag).with("test.suite").and_return("test suite name") + allow(tracer_span).to receive(:get_tag).with("test.parameters").and_return(nil) end context "when test suite is set" do @@ -216,9 +216,9 @@ describe "#skipped!" do before do allow(ci_test).to receive(:test_suite).and_return(test_suite) - expect(tracer_span).to receive(:get_tag).with("test.name").and_return("test name") - expect(tracer_span).to receive(:get_tag).with("test.suite").and_return("test suite name") - expect(tracer_span).to receive(:get_tag).with("test.parameters").and_return(nil) + allow(tracer_span).to receive(:get_tag).with("test.name").and_return("test name") + allow(tracer_span).to receive(:get_tag).with("test.suite").and_return("test suite name") + allow(tracer_span).to receive(:get_tag).with("test.parameters").and_return(nil) end context "when test suite is set" do @@ -264,9 +264,9 @@ before do allow(ci_test).to receive(:test_suite).and_return(test_suite) - expect(tracer_span).to receive(:get_tag).with("test.name").and_return("test name") - expect(tracer_span).to receive(:get_tag).with("test.suite").and_return("test suite name") - expect(tracer_span).to receive(:get_tag).with("test.parameters").and_return(nil) + allow(tracer_span).to receive(:get_tag).with("test.name").and_return("test name") + allow(tracer_span).to receive(:get_tag).with("test.suite").and_return("test suite name") + allow(tracer_span).to receive(:get_tag).with("test.parameters").and_return(nil) end context "when test suite is set" do diff --git a/spec/datadog/ci/test_visibility/component_spec.rb b/spec/datadog/ci/test_visibility/component_spec.rb index 6a43bd8f..c7379fbd 100644 --- a/spec/datadog/ci/test_visibility/component_spec.rb +++ b/spec/datadog/ci/test_visibility/component_spec.rb @@ -797,51 +797,4 @@ end end end - - describe "#set_test_finished_callback" do - include_context "CI mode activated" - - let(:callback) { spy(:callback) } - - it "sets the test finished callback which will be executed when any test is finished" do - test_visibility.set_test_finished_callback(callback) - - ci_test = test_visibility.trace_test("my test", "my suite") - test_visibility.deactivate_test - - expect(callback).to have_received(:call).with(ci_test) - end - - it "only fires callback on the same thread where it was set" do - test_visibility.set_test_finished_callback(callback) - - t = Thread.new do - test_visibility.trace_test("my test", "my suite") - test_visibility.deactivate_test - end - t.join - - expect(callback).to_not have_received(:call) - end - end - - describe "#remove_test_finished_callback" do - include_context "CI mode activated" - - let(:callback) { spy(:callback) } - - it "removes the callback" do - test_visibility.set_test_finished_callback(callback) - - test_visibility.trace_test("my test", "my suite") - test_visibility.deactivate_test - - test_visibility.remove_test_finished_callback - - test_visibility.trace_test("my test", "my suite") - test_visibility.deactivate_test - - expect(callback).to have_received(:call).once - end - end end diff --git a/spec/datadog/ci/test_visibility/telemetry_spec.rb b/spec/datadog/ci/test_visibility/telemetry_spec.rb index 7bcff7f7..3eeb853b 100644 --- a/spec/datadog/ci/test_visibility/telemetry_spec.rb +++ b/spec/datadog/ci/test_visibility/telemetry_spec.rb @@ -18,7 +18,8 @@ type: Datadog::CI::Ext::AppTypes::TYPE_TEST_SESSION, tags: { Datadog::CI::Ext::Test::TAG_FRAMEWORK => "rspec", - Datadog::CI::Ext::Environment::TAG_PROVIDER_NAME => "gha" + Datadog::CI::Ext::Environment::TAG_PROVIDER_NAME => "gha", + Datadog::CI::Ext::Test::TAG_EARLY_FLAKE_ENABLED => "true" } ) end @@ -33,6 +34,31 @@ it { event_created } end + context "test session span with faulty EFD" do + let(:span) do + Datadog::Tracing::SpanOperation.new( + "test_session", + type: Datadog::CI::Ext::AppTypes::TYPE_TEST_SESSION, + tags: { + Datadog::CI::Ext::Test::TAG_FRAMEWORK => "rspec", + Datadog::CI::Ext::Environment::TAG_PROVIDER_NAME => "gha", + Datadog::CI::Ext::Test::TAG_EARLY_FLAKE_ABORT_REASON => "faulty", + Datadog::CI::Ext::Test::TAG_EARLY_FLAKE_ENABLED => "true" + } + ) + end + + let(:expected_tags) do + { + Datadog::CI::Ext::Telemetry::TAG_EVENT_TYPE => Datadog::CI::Ext::Telemetry::EventType::SESSION, + Datadog::CI::Ext::Telemetry::TAG_TEST_FRAMEWORK => "rspec", + Datadog::CI::Ext::Telemetry::TAG_EARLY_FLAKE_DETECTION_ABORT_REASON => "faulty" + } + end + + it { event_created } + end + context "test module span without CI provider" do let(:span) do Datadog::Tracing::SpanOperation.new( @@ -159,7 +185,7 @@ context "test suite span" do let(:span) do Datadog::Tracing::SpanOperation.new( - "test_session", + "test_suite", type: Datadog::CI::Ext::AppTypes::TYPE_TEST_SUITE, tags: { Datadog::CI::Ext::Test::TAG_FRAMEWORK => "rspec", @@ -181,7 +207,7 @@ context "test span with codeowners" do let(:span) do Datadog::Tracing::SpanOperation.new( - "test_session", + "test", type: Datadog::CI::Ext::AppTypes::TYPE_TEST, tags: { Datadog::CI::Ext::Test::TAG_FRAMEWORK => "rspec", @@ -206,10 +232,10 @@ it { event_finished } end - context "test span with retry" do + context "test span with retry and new test" do let(:span) do Datadog::Tracing::SpanOperation.new( - "test_session", + "test", type: Datadog::CI::Ext::AppTypes::TYPE_TEST, tags: { Datadog::CI::Ext::Test::TAG_FRAMEWORK => "rspec", @@ -217,7 +243,8 @@ Datadog::CI::Ext::Test::TAG_CODEOWNERS => "@owner", Datadog::CI::Ext::Test::TAG_IS_RUM_ACTIVE => "true", Datadog::CI::Ext::Test::TAG_BROWSER_DRIVER => "selenium", - Datadog::CI::Ext::Test::TAG_IS_RETRY => "true" + Datadog::CI::Ext::Test::TAG_IS_RETRY => "true", + Datadog::CI::Ext::Test::TAG_IS_NEW => "true" } ) end @@ -229,7 +256,8 @@ Datadog::CI::Ext::Telemetry::TAG_HAS_CODEOWNER => "true", Datadog::CI::Ext::Telemetry::TAG_IS_RUM => "true", Datadog::CI::Ext::Telemetry::TAG_BROWSER_DRIVER => "selenium", - Datadog::CI::Ext::Telemetry::TAG_IS_RETRY => "true" + Datadog::CI::Ext::Telemetry::TAG_IS_RETRY => "true", + Datadog::CI::Ext::Telemetry::TAG_IS_NEW => "true" } end diff --git a/spec/datadog/ci_spec.rb b/spec/datadog/ci_spec.rb index e144e8dd..47c0df79 100644 --- a/spec/datadog/ci_spec.rb +++ b/spec/datadog/ci_spec.rb @@ -121,7 +121,9 @@ subject(:start_test_session) { described_class.start_test_session(service: service) } before do - allow(test_visibility).to receive(:start_test_session).with(service: service, tags: {}).and_return(ci_test_session) + allow(test_visibility).to receive(:start_test_session).with( + service: service, tags: {}, total_tests_count: 0 + ).and_return(ci_test_session) end it { is_expected.to be(ci_test_session) } @@ -136,12 +138,38 @@ before do allow(Datadog.configuration).to receive(:service_without_fallback).and_return("configured-service") allow(test_visibility).to receive(:start_test_session).with( - service: "configured-service", tags: {} + service: "configured-service", tags: {}, total_tests_count: 0 ).and_return(ci_test_session) end it { is_expected.to be(ci_test_session) } end + + context "when service is not configured on library level" do + before do + allow(Datadog.configuration).to receive(:service_without_fallback).and_return(nil) + allow(test_visibility).to receive(:start_test_session).with( + service: "datadog-ci-rb", tags: {}, total_tests_count: 0 + ).and_return(ci_test_session) + end + + it { is_expected.to be(ci_test_session) } + end + end + + context "when total_tests_count is provided" do + let(:total_tests_count) { 42 } + subject(:start_test_session) { described_class.start_test_session(total_tests_count: total_tests_count) } + + before do + allow(test_visibility).to receive(:start_test_session).with( + service: "datadog-ci-rb", tags: {}, total_tests_count: total_tests_count + ).and_return(ci_test_session) + end + + it { is_expected.to be(ci_test_session) } + + it_behaves_like "emits telemetry metric", :inc, "manual_api_events", 1 end end diff --git a/vendor/rbs/rspec/0/rspec.rbs b/vendor/rbs/rspec/0/rspec.rbs index 399049a6..82fe6d6d 100644 --- a/vendor/rbs/rspec/0/rspec.rbs +++ b/vendor/rbs/rspec/0/rspec.rbs @@ -50,4 +50,5 @@ end class RSpec::Core::World def wants_to_quit: () -> bool + def example_count: () -> Integer end