datadog-ci 1.3.0 → 1.4.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -2
  3. data/README.md +1 -0
  4. data/ext/datadog_cov/datadog_cov.c +1 -1
  5. data/lib/datadog/ci/configuration/components.rb +25 -11
  6. data/lib/datadog/ci/configuration/settings.rb +19 -1
  7. data/lib/datadog/ci/contrib/cucumber/configuration_override.rb +37 -0
  8. data/lib/datadog/ci/contrib/cucumber/formatter.rb +5 -5
  9. data/lib/datadog/ci/contrib/cucumber/instrumentation.rb +9 -1
  10. data/lib/datadog/ci/contrib/cucumber/patcher.rb +3 -3
  11. data/lib/datadog/ci/contrib/minitest/runner.rb +16 -0
  12. data/lib/datadog/ci/contrib/minitest/test.rb +1 -0
  13. data/lib/datadog/ci/contrib/rspec/example.rb +67 -39
  14. data/lib/datadog/ci/contrib/rspec/example_group.rb +1 -1
  15. data/lib/datadog/ci/ext/settings.rb +3 -0
  16. data/lib/datadog/ci/ext/telemetry.rb +1 -0
  17. data/lib/datadog/ci/ext/test.rb +11 -11
  18. data/lib/datadog/ci/ext/transport.rb +1 -0
  19. data/lib/datadog/ci/git/local_repository.rb +32 -13
  20. data/lib/datadog/ci/remote/component.rb +50 -0
  21. data/lib/datadog/ci/remote/library_settings.rb +91 -0
  22. data/lib/datadog/ci/{transport/remote_settings_api.rb → remote/library_settings_client.rb} +11 -56
  23. data/lib/datadog/ci/test.rb +8 -1
  24. data/lib/datadog/ci/test_optimisation/component.rb +12 -16
  25. data/lib/datadog/ci/test_retries/component.rb +84 -0
  26. data/lib/datadog/ci/test_retries/null_component.rb +28 -0
  27. data/lib/datadog/ci/test_retries/strategy/base.rb +19 -0
  28. data/lib/datadog/ci/test_retries/strategy/no_retry.rb +16 -0
  29. data/lib/datadog/ci/test_retries/strategy/retry_failed.rb +37 -0
  30. data/lib/datadog/ci/test_suite.rb +39 -18
  31. data/lib/datadog/ci/test_visibility/component.rb +45 -47
  32. data/lib/datadog/ci/test_visibility/null_component.rb +6 -0
  33. data/lib/datadog/ci/test_visibility/telemetry.rb +3 -0
  34. data/lib/datadog/ci/version.rb +2 -2
  35. metadata +13 -6
  36. data/lib/datadog/ci/contrib/cucumber/step.rb +0 -27
@@ -238,23 +238,42 @@ module Datadog
238
238
  Telemetry.git_command(Ext::Telemetry::Command::UNSHALLOW)
239
239
  res = nil
240
240
 
241
+ unshallow_command =
242
+ "git fetch " \
243
+ "--shallow-since=\"1 month ago\" " \
244
+ "--update-shallow " \
245
+ "--filter=\"blob:none\" " \
246
+ "--recurse-submodules=no " \
247
+ "$(git config --default origin --get clone.defaultRemoteName)"
248
+
249
+ unshallow_remotes = [
250
+ "$(git rev-parse HEAD)",
251
+ "$(git rev-parse --abbrev-ref --symbolic-full-name @{upstream})",
252
+ nil
253
+ ]
254
+
241
255
  duration_ms = Core::Utils::Time.measure(:float_millisecond) do
242
- res = exec_git_command(
243
- "git fetch " \
244
- "--shallow-since=\"1 month ago\" " \
245
- "--update-shallow " \
246
- "--filter=\"blob:none\" " \
247
- "--recurse-submodules=no " \
248
- "$(git config --default origin --get clone.defaultRemoteName) $(git rev-parse HEAD)"
249
- )
256
+ unshallow_remotes.each do |remote|
257
+ unshallowing_errored = false
258
+
259
+ res =
260
+ begin
261
+ exec_git_command(
262
+ "#{unshallow_command} #{remote}"
263
+ )
264
+ rescue => e
265
+ log_failure(e, "git unshallow")
266
+ telemetry_track_error(e, Ext::Telemetry::Command::UNSHALLOW)
267
+ unshallowing_errored = true
268
+ nil
269
+ end
270
+
271
+ break unless unshallowing_errored
272
+ end
250
273
  end
251
- Telemetry.git_command_ms(Ext::Telemetry::Command::UNSHALLOW, duration_ms)
252
274
 
275
+ Telemetry.git_command_ms(Ext::Telemetry::Command::UNSHALLOW, duration_ms)
253
276
  res
254
- rescue => e
255
- log_failure(e, "git unshallow")
256
- telemetry_track_error(e, Ext::Telemetry::Command::UNSHALLOW)
257
- nil
258
277
  end
259
278
 
260
279
  # makes .exec_git_command private to make sure that this method
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module CI
5
+ module Remote
6
+ # Remote configuration component.
7
+ # Responsible for fetching library settings and configuring the library accordingly.
8
+ class Component
9
+ def initialize(library_settings_client:)
10
+ @library_settings_client = library_settings_client
11
+ end
12
+
13
+ # called on test session start, uses test session info to send configuration request to the backend
14
+ def configure(test_session)
15
+ library_configuration = @library_settings_client.fetch(test_session)
16
+ # sometimes we can skip code coverage for default branch if there are no changes in the repository
17
+ # backend needs git metadata uploaded for this test session to check if we can skip code coverage
18
+ if library_configuration.require_git?
19
+ Datadog.logger.debug { "Library configuration endpoint requires git upload to be finished, waiting..." }
20
+ git_tree_upload_worker.wait_until_done
21
+
22
+ Datadog.logger.debug { "Requesting library configuration again..." }
23
+ library_configuration = @library_settings_client.fetch(test_session)
24
+
25
+ if library_configuration.require_git?
26
+ Datadog.logger.debug { "git metadata upload did not complete in time when configuring library" }
27
+ end
28
+ end
29
+
30
+ test_optimisation.configure(library_configuration, test_session)
31
+ test_retries.configure(library_configuration)
32
+ end
33
+
34
+ private
35
+
36
+ def test_optimisation
37
+ Datadog.send(:components).test_optimisation
38
+ end
39
+
40
+ def test_retries
41
+ Datadog.send(:components).test_retries
42
+ end
43
+
44
+ def git_tree_upload_worker
45
+ Datadog.send(:components).git_tree_upload_worker
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../ext/telemetry"
6
+ require_relative "../ext/transport"
7
+ require_relative "../transport/telemetry"
8
+ require_relative "../utils/parsing"
9
+
10
+ module Datadog
11
+ module CI
12
+ module Remote
13
+ # Wrapper around the settings HTTP response
14
+ class LibrarySettings
15
+ def initialize(http_response)
16
+ @http_response = http_response
17
+ @json = nil
18
+ end
19
+
20
+ def ok?
21
+ resp = @http_response
22
+ !resp.nil? && resp.ok?
23
+ end
24
+
25
+ def payload
26
+ cached = @json
27
+ return cached unless cached.nil?
28
+
29
+ resp = @http_response
30
+ return @json = default_payload if resp.nil? || !ok?
31
+
32
+ begin
33
+ @json = JSON.parse(resp.payload).dig(*Ext::Transport::DD_API_SETTINGS_RESPONSE_DIG_KEYS) ||
34
+ default_payload
35
+ rescue JSON::ParserError => e
36
+ Datadog.logger.error("Failed to parse settings response payload: #{e}. Payload was: #{resp.payload}")
37
+
38
+ Transport::Telemetry.api_requests_errors(
39
+ Ext::Telemetry::METRIC_GIT_REQUESTS_SETTINGS_ERRORS,
40
+ 1,
41
+ error_type: "invalid_json",
42
+ status_code: nil
43
+ )
44
+
45
+ @json = default_payload
46
+ end
47
+ end
48
+
49
+ def require_git?
50
+ return @require_git if defined?(@require_git)
51
+
52
+ @require_git = bool(Ext::Transport::DD_API_SETTINGS_RESPONSE_REQUIRE_GIT_KEY)
53
+ end
54
+
55
+ def itr_enabled?
56
+ return @itr_enabled if defined?(@itr_enabled)
57
+
58
+ @itr_enabled = bool(Ext::Transport::DD_API_SETTINGS_RESPONSE_ITR_ENABLED_KEY)
59
+ end
60
+
61
+ def code_coverage_enabled?
62
+ return @code_coverage_enabled if defined?(@code_coverage_enabled)
63
+
64
+ @code_coverage_enabled = bool(Ext::Transport::DD_API_SETTINGS_RESPONSE_CODE_COVERAGE_KEY)
65
+ end
66
+
67
+ def tests_skipping_enabled?
68
+ return @tests_skipping_enabled if defined?(@tests_skipping_enabled)
69
+
70
+ @tests_skipping_enabled = bool(Ext::Transport::DD_API_SETTINGS_RESPONSE_TESTS_SKIPPING_KEY)
71
+ end
72
+
73
+ def flaky_test_retries_enabled?
74
+ return @flaky_test_retries_enabled if defined?(@flaky_test_retries_enabled)
75
+
76
+ @flaky_test_retries_enabled = bool(Ext::Transport::DD_API_SETTINGS_RESPONSE_FLAKY_TEST_RETRIES_KEY)
77
+ end
78
+
79
+ private
80
+
81
+ def bool(key)
82
+ Utils::Parsing.convert_to_bool(payload.fetch(key, false))
83
+ end
84
+
85
+ def default_payload
86
+ Ext::Transport::DD_API_SETTINGS_RESPONSE_DEFAULT
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -4,73 +4,28 @@ require "json"
4
4
 
5
5
  require "datadog/core/environment/identity"
6
6
 
7
+ require_relative "library_settings"
8
+
9
+ require_relative "../ext/test"
7
10
  require_relative "../ext/telemetry"
8
11
  require_relative "../ext/transport"
9
12
  require_relative "../transport/telemetry"
10
- require_relative "../utils/parsing"
11
13
  require_relative "../utils/telemetry"
12
14
 
13
15
  module Datadog
14
16
  module CI
15
- module Transport
16
- # Datadog API client
17
+ module Remote
17
18
  # Calls settings endpoint to fetch library settings for given service and env
18
- class RemoteSettingsApi
19
- class Response
20
- def initialize(http_response)
21
- @http_response = http_response
22
- @json = nil
23
- end
24
-
25
- def ok?
26
- resp = @http_response
27
- !resp.nil? && resp.ok?
28
- end
29
-
30
- def payload
31
- cached = @json
32
- return cached unless cached.nil?
33
-
34
- resp = @http_response
35
- return @json = default_payload if resp.nil? || !ok?
36
-
37
- begin
38
- @json = JSON.parse(resp.payload).dig(*Ext::Transport::DD_API_SETTINGS_RESPONSE_DIG_KEYS) ||
39
- default_payload
40
- rescue JSON::ParserError => e
41
- Datadog.logger.error("Failed to parse settings response payload: #{e}. Payload was: #{resp.payload}")
42
-
43
- Transport::Telemetry.api_requests_errors(
44
- Ext::Telemetry::METRIC_GIT_REQUESTS_SETTINGS_ERRORS,
45
- 1,
46
- error_type: "invalid_json",
47
- status_code: nil
48
- )
49
-
50
- @json = default_payload
51
- end
52
- end
53
-
54
- def require_git?
55
- Utils::Parsing.convert_to_bool(payload[Ext::Transport::DD_API_SETTINGS_RESPONSE_REQUIRE_GIT_KEY])
56
- end
57
-
58
- private
59
-
60
- def default_payload
61
- Ext::Transport::DD_API_SETTINGS_RESPONSE_DEFAULT
62
- end
63
- end
64
-
19
+ class LibrarySettingsClient
65
20
  def initialize(dd_env:, api: nil, config_tags: {})
66
21
  @api = api
67
22
  @dd_env = dd_env
68
23
  @config_tags = config_tags || {}
69
24
  end
70
25
 
71
- def fetch_library_settings(test_session)
26
+ def fetch(test_session)
72
27
  api = @api
73
- return Response.new(nil) unless api
28
+ return LibrarySettings.new(nil) unless api
74
29
 
75
30
  request_payload = payload(test_session)
76
31
  Datadog.logger.debug("Fetching library settings with request: #{request_payload}")
@@ -96,18 +51,18 @@ module Datadog
96
51
  )
97
52
  end
98
53
 
99
- response = Response.new(http_response)
54
+ library_settings = LibrarySettings.new(http_response)
100
55
 
101
56
  Utils::Telemetry.inc(
102
57
  Ext::Telemetry::METRIC_GIT_REQUESTS_SETTINGS_RESPONSE,
103
58
  1,
104
59
  {
105
- Ext::Telemetry::TAG_COVERAGE_ENABLED => response.payload[Ext::Transport::DD_API_SETTINGS_RESPONSE_CODE_COVERAGE_KEY],
106
- Ext::Telemetry::TAG_ITR_SKIP_ENABLED => response.payload[Ext::Transport::DD_API_SETTINGS_RESPONSE_TESTS_SKIPPING_KEY]
60
+ Ext::Telemetry::TAG_COVERAGE_ENABLED => library_settings.code_coverage_enabled?.to_s,
61
+ Ext::Telemetry::TAG_ITR_SKIP_ENABLED => library_settings.tests_skipping_enabled?.to_s
107
62
  }
108
63
  )
109
64
 
110
- response
65
+ library_settings
111
66
  end
112
67
 
113
68
  private
@@ -142,7 +142,14 @@ module Datadog
142
142
  private
143
143
 
144
144
  def record_test_result(datadog_status)
145
- test_suite&.record_test_result(datadog_status)
145
+ test_id = Utils::TestRun.skippable_test_id(name, test_suite_name, parameters)
146
+
147
+ # if this test was already executed in this test suite, mark it as retried
148
+ if test_suite&.test_executed?(test_id)
149
+ set_tag(Ext::Test::TAG_IS_RETRY, "true")
150
+ end
151
+
152
+ test_suite&.record_test_result(test_id, datadog_status)
146
153
  end
147
154
  end
148
155
  end
@@ -6,7 +6,6 @@ require "datadog/core/utils/forking"
6
6
 
7
7
  require_relative "../ext/test"
8
8
  require_relative "../ext/telemetry"
9
- require_relative "../ext/transport"
10
9
 
11
10
  require_relative "../git/local_repository"
12
11
 
@@ -65,24 +64,17 @@ module Datadog
65
64
  Datadog.logger.debug("TestOptimisation initialized with enabled: #{@enabled}")
66
65
  end
67
66
 
68
- def configure(remote_configuration, test_session:, git_tree_upload_worker:)
67
+ def configure(remote_configuration, test_session)
68
+ return unless enabled?
69
+
69
70
  Datadog.logger.debug("Configuring TestOptimisation with remote configuration: #{remote_configuration}")
70
71
 
71
- @enabled = Utils::Parsing.convert_to_bool(
72
- remote_configuration.fetch(Ext::Transport::DD_API_SETTINGS_RESPONSE_ITR_ENABLED_KEY, false)
73
- )
74
- @test_skipping_enabled = @enabled && Utils::Parsing.convert_to_bool(
75
- remote_configuration.fetch(Ext::Transport::DD_API_SETTINGS_RESPONSE_TESTS_SKIPPING_KEY, false)
76
- )
77
- @code_coverage_enabled = @enabled && Utils::Parsing.convert_to_bool(
78
- remote_configuration.fetch(Ext::Transport::DD_API_SETTINGS_RESPONSE_CODE_COVERAGE_KEY, false)
79
- )
72
+ @enabled = remote_configuration.itr_enabled?
73
+ @test_skipping_enabled = @enabled && remote_configuration.tests_skipping_enabled?
74
+ @code_coverage_enabled = @enabled && remote_configuration.code_coverage_enabled?
80
75
 
81
76
  test_session.set_tag(Ext::Test::TAG_ITR_TEST_SKIPPING_ENABLED, @test_skipping_enabled)
82
- # currently we set this tag when ITR requires collecting code coverage
83
- # this will change as soon as we implement total code coverage support in this library
84
77
  test_session.set_tag(Ext::Test::TAG_CODE_COVERAGE_ENABLED, @code_coverage_enabled)
85
-
86
78
  # we skip tests, not suites
87
79
  test_session.set_tag(Ext::Test::TAG_ITR_TEST_SKIPPING_TYPE, Ext::Test::ITR_TEST_SKIPPING_MODE)
88
80
 
@@ -90,7 +82,7 @@ module Datadog
90
82
 
91
83
  Datadog.logger.debug("Configured TestOptimisation with enabled: #{@enabled}, skipping_tests: #{@test_skipping_enabled}, code_coverage: #{@code_coverage_enabled}")
92
84
 
93
- fetch_skippable_tests(test_session: test_session, git_tree_upload_worker: git_tree_upload_worker)
85
+ fetch_skippable_tests(test_session)
94
86
  end
95
87
 
96
88
  def enabled?
@@ -225,7 +217,7 @@ module Datadog
225
217
  coverage[absolute_test_source_file_path] = true
226
218
  end
227
219
 
228
- def fetch_skippable_tests(test_session:, git_tree_upload_worker:)
220
+ def fetch_skippable_tests(test_session)
229
221
  return unless skipping_tests?
230
222
 
231
223
  # we can only request skippable tests if git metadata is already uploaded
@@ -248,6 +240,10 @@ module Datadog
248
240
  def code_coverage_mode
249
241
  @use_single_threaded_coverage ? :single : :multi
250
242
  end
243
+
244
+ def git_tree_upload_worker
245
+ Datadog.send(:components).git_tree_upload_worker
246
+ end
251
247
  end
252
248
  end
253
249
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "strategy/no_retry"
4
+ require_relative "strategy/retry_failed"
5
+
6
+ module Datadog
7
+ module CI
8
+ module TestRetries
9
+ # Encapsulates the logic to enable test retries, including:
10
+ # - retrying failed tests - improve success rate of CI pipelines
11
+ # - retrying new tests - detect flaky tests as early as possible to prevent them from being merged
12
+ class Component
13
+ attr_reader :retry_failed_tests_enabled, :retry_failed_tests_max_attempts,
14
+ :retry_failed_tests_total_limit, :retry_failed_tests_count
15
+
16
+ def initialize(
17
+ retry_failed_tests_enabled:,
18
+ retry_failed_tests_max_attempts:,
19
+ retry_failed_tests_total_limit:
20
+ )
21
+ @retry_failed_tests_enabled = retry_failed_tests_enabled
22
+ @retry_failed_tests_max_attempts = retry_failed_tests_max_attempts
23
+ @retry_failed_tests_total_limit = retry_failed_tests_total_limit
24
+ # counter that store the current number of failed tests retried
25
+ @retry_failed_tests_count = 0
26
+
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ def configure(library_settings)
31
+ @retry_failed_tests_enabled &&= library_settings.flaky_test_retries_enabled?
32
+ end
33
+
34
+ def with_retries(&block)
35
+ # @type var retry_strategy: Strategy::Base
36
+ retry_strategy = nil
37
+
38
+ test_finished_callback = lambda do |test_span|
39
+ if retry_strategy.nil?
40
+ # we always run test at least once and after first pass create a correct retry strategy
41
+ retry_strategy = build_strategy(test_span)
42
+ else
43
+ # after each retry we record the result, strategy will decide if we should retry again
44
+ retry_strategy&.record_retry(test_span)
45
+ end
46
+ end
47
+
48
+ test_visibility_component.set_test_finished_callback(test_finished_callback)
49
+
50
+ loop do
51
+ yield
52
+
53
+ break unless retry_strategy&.should_retry?
54
+ end
55
+ ensure
56
+ test_visibility_component.remove_test_finished_callback
57
+ end
58
+
59
+ def build_strategy(test_span)
60
+ @mutex.synchronize do
61
+ if should_retry_failed_test?(test_span)
62
+ Datadog.logger.debug("Failed test retry starts")
63
+ @retry_failed_tests_count += 1
64
+
65
+ Strategy::RetryFailed.new(max_attempts: @retry_failed_tests_max_attempts)
66
+ else
67
+ Strategy::NoRetry.new
68
+ end
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def should_retry_failed_test?(test_span)
75
+ @retry_failed_tests_enabled && !!test_span&.failed? && @retry_failed_tests_count < @retry_failed_tests_total_limit
76
+ end
77
+
78
+ def test_visibility_component
79
+ Datadog.send(:components).test_visibility
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "component"
4
+
5
+ module Datadog
6
+ module CI
7
+ module TestRetries
8
+ class NullComponent < Component
9
+ attr_reader :retry_failed_tests_enabled, :retry_failed_tests_max_attempts, :retry_failed_tests_total_limit
10
+
11
+ def initialize
12
+ # enabled only by remote settings
13
+ @retry_failed_tests_enabled = false
14
+ @retry_failed_tests_max_attempts = 0
15
+ @retry_failed_tests_total_limit = 0
16
+ end
17
+
18
+ def configure(library_settings)
19
+ end
20
+
21
+ def with_retries(&block)
22
+ no_action = proc {}
23
+ yield no_action
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module CI
5
+ module TestRetries
6
+ module Strategy
7
+ class Base
8
+ def should_retry?
9
+ false
10
+ end
11
+
12
+ def record_retry(test_span)
13
+ test_span&.set_tag(Ext::Test::TAG_IS_RETRY, "true")
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Datadog
6
+ module CI
7
+ module TestRetries
8
+ module Strategy
9
+ class NoRetry < Base
10
+ def record_retry(test_span)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ require_relative "../../ext/test"
6
+
7
+ module Datadog
8
+ module CI
9
+ module TestRetries
10
+ module Strategy
11
+ class RetryFailed < Base
12
+ attr_reader :max_attempts
13
+
14
+ def initialize(max_attempts:)
15
+ @max_attempts = max_attempts
16
+
17
+ @attempts = 0
18
+ @passed_once = false
19
+ end
20
+
21
+ def should_retry?
22
+ @attempts < @max_attempts && !@passed_once
23
+ end
24
+
25
+ def record_retry(test_span)
26
+ super
27
+
28
+ @attempts += 1
29
+ @passed_once = true if test_span&.passed?
30
+
31
+ Datadog.logger.debug { "Retry Attempts [#{@attempts} / #{@max_attempts}], Passed: [#{@passed_once}]" }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -16,7 +16,9 @@ module Datadog
16
16
  def initialize(tracer_span)
17
17
  super
18
18
 
19
- @test_suite_stats = Hash.new(0)
19
+ # counts how many times every test in this suite was executed with each status:
20
+ # { "MySuite.mytest.a:1" => { "pass" => 3, "fail" => 2 } }
21
+ @execution_stats_per_test = {}
20
22
  end
21
23
 
22
24
  # Finishes this test suite.
@@ -33,42 +35,61 @@ module Datadog
33
35
  end
34
36
 
35
37
  # @internal
36
- def record_test_result(datadog_test_status)
38
+ def record_test_result(test_id, datadog_test_status)
37
39
  synchronize do
38
- @test_suite_stats[datadog_test_status] += 1
40
+ @execution_stats_per_test[test_id] ||= Hash.new(0)
41
+ @execution_stats_per_test[test_id][datadog_test_status] += 1
39
42
  end
40
43
  end
41
44
 
42
45
  # @internal
43
- def passed_tests_count
46
+ def any_passed?
44
47
  synchronize do
45
- @test_suite_stats[Ext::Test::Status::PASS]
48
+ @execution_stats_per_test.any? do |_, stats|
49
+ stats[Ext::Test::Status::PASS] > 0
50
+ end
46
51
  end
47
52
  end
48
53
 
49
54
  # @internal
50
- def skipped_tests_count
55
+ def test_executed?(test_id)
51
56
  synchronize do
52
- @test_suite_stats[Ext::Test::Status::SKIP]
57
+ @execution_stats_per_test.key?(test_id)
53
58
  end
54
59
  end
55
60
 
56
- # @internal
57
- def failed_tests_count
61
+ private
62
+
63
+ def set_status_from_stats!
58
64
  synchronize do
59
- @test_suite_stats[Ext::Test::Status::FAIL]
65
+ # count how many tests passed, failed and skipped
66
+ test_suite_stats = @execution_stats_per_test.each_with_object(Hash.new(0)) do |(_test_id, stats), acc|
67
+ acc[derive_test_status_from_execution_stats(stats)] += 1
68
+ end
69
+
70
+ # test suite is considered failed if at least one test failed
71
+ if test_suite_stats[Ext::Test::Status::FAIL] > 0
72
+ failed!
73
+ # if there are no failures and no passes, it is skipped
74
+ elsif test_suite_stats[Ext::Test::Status::PASS] == 0
75
+ skipped!
76
+ # otherwise we consider it passed
77
+ else
78
+ passed!
79
+ end
60
80
  end
61
81
  end
62
82
 
63
- private
64
-
65
- def set_status_from_stats!
66
- if failed_tests_count > 0
67
- failed!
68
- elsif passed_tests_count == 0
69
- skipped!
83
+ def derive_test_status_from_execution_stats(test_execution_stats)
84
+ # test is passed if it passed at least once
85
+ if test_execution_stats[Ext::Test::Status::PASS] > 0
86
+ Ext::Test::Status::PASS
87
+ # if test was never passed, it is failed if it failed at least once
88
+ elsif test_execution_stats[Ext::Test::Status::FAIL] > 0
89
+ Ext::Test::Status::FAIL
90
+ # otherwise it is skipped
70
91
  else
71
- passed!
92
+ Ext::Test::Status::SKIP
72
93
  end
73
94
  end
74
95
  end