datadog-ci 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -2
  3. data/lib/datadog/ci/code_coverage/component.rb +55 -0
  4. data/lib/datadog/ci/code_coverage/null_component.rb +24 -0
  5. data/lib/datadog/ci/code_coverage/transport.rb +66 -0
  6. data/lib/datadog/ci/configuration/components.rb +17 -1
  7. data/lib/datadog/ci/configuration/settings.rb +20 -2
  8. data/lib/datadog/ci/contrib/minitest/test.rb +1 -1
  9. data/lib/datadog/ci/contrib/rspec/example.rb +48 -8
  10. data/lib/datadog/ci/contrib/rspec/example_group.rb +63 -31
  11. data/lib/datadog/ci/contrib/simplecov/ext.rb +2 -0
  12. data/lib/datadog/ci/contrib/simplecov/patcher.rb +2 -0
  13. data/lib/datadog/ci/contrib/simplecov/report_uploader.rb +59 -0
  14. data/lib/datadog/ci/ext/environment/providers/github_actions.rb +65 -2
  15. data/lib/datadog/ci/ext/environment.rb +10 -0
  16. data/lib/datadog/ci/ext/settings.rb +2 -0
  17. data/lib/datadog/ci/ext/telemetry.rb +5 -0
  18. data/lib/datadog/ci/ext/test.rb +0 -5
  19. data/lib/datadog/ci/ext/transport.rb +4 -0
  20. data/lib/datadog/ci/git/cli.rb +59 -1
  21. data/lib/datadog/ci/remote/component.rb +6 -1
  22. data/lib/datadog/ci/remote/library_settings.rb +8 -0
  23. data/lib/datadog/ci/test.rb +27 -18
  24. data/lib/datadog/ci/test_management/component.rb +9 -0
  25. data/lib/datadog/ci/test_management/null_component.rb +8 -0
  26. data/lib/datadog/ci/test_optimisation/component.rb +168 -16
  27. data/lib/datadog/ci/test_optimisation/null_component.rb +19 -5
  28. data/lib/datadog/ci/test_suite.rb +16 -21
  29. data/lib/datadog/ci/test_visibility/component.rb +1 -2
  30. data/lib/datadog/ci/test_visibility/known_tests.rb +59 -6
  31. data/lib/datadog/ci/transport/api/agentless.rb +8 -1
  32. data/lib/datadog/ci/transport/api/base.rb +21 -0
  33. data/lib/datadog/ci/transport/api/builder.rb +5 -1
  34. data/lib/datadog/ci/transport/api/evp_proxy.rb +8 -0
  35. data/lib/datadog/ci/version.rb +1 -1
  36. metadata +5 -1
@@ -15,6 +15,13 @@ module Datadog
15
15
  # Github Actions: https://github.com/features/actions
16
16
  # Environment variables docs: https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables
17
17
  class GithubActions < Base
18
+ # Paths to GitHub Actions runner diagnostics folder
19
+ # GitHub-hosted (SaaS) runners use the cached path, self-hosted runners use the non-cached path
20
+ GITHUB_RUNNER_DIAG_PATHS = [
21
+ "/home/runner/actions-runner/cached/_diag", # GitHub-hosted (SaaS) runners
22
+ "/home/runner/actions-runner/_diag" # Self-hosted runners
23
+ ].freeze
24
+
18
25
  def self.handles?(env)
19
26
  env.key?("GITHUB_SHA")
20
27
  end
@@ -28,11 +35,16 @@ module Datadog
28
35
  end
29
36
 
30
37
  def job_url
31
- "#{github_server_url}/#{env["GITHUB_REPOSITORY"]}/commit/#{env["GITHUB_SHA"]}/checks"
38
+ numeric_id = numeric_job_id
39
+ if numeric_id
40
+ "#{github_server_url}/#{env["GITHUB_REPOSITORY"]}/actions/runs/#{env["GITHUB_RUN_ID"]}/job/#{numeric_id}"
41
+ else
42
+ "#{github_server_url}/#{env["GITHUB_REPOSITORY"]}/commit/#{env["GITHUB_SHA"]}/checks"
43
+ end
32
44
  end
33
45
 
34
46
  def job_id
35
- env["GITHUB_JOB"]
47
+ numeric_job_id || env["GITHUB_JOB"]
36
48
  end
37
49
 
38
50
  def pipeline_id
@@ -141,6 +153,57 @@ module Datadog
141
153
 
142
154
  @github_server_url ||= Datadog::Core::Utils::Url.filter_basic_auth(env["GITHUB_SERVER_URL"])
143
155
  end
156
+
157
+ # Returns numeric job ID from environment variable or runner diagnostics.
158
+ # Priority:
159
+ # 1. JOB_CHECK_RUN_ID environment variable (GitHub Actions feature pending)
160
+ # 2. Worker_*.log files in the runner's _diag folder (fallback)
161
+ def numeric_job_id
162
+ return @numeric_job_id if defined?(@numeric_job_id)
163
+
164
+ @numeric_job_id = env["JOB_CHECK_RUN_ID"] || extract_numeric_job_id_from_diag_files
165
+ end
166
+
167
+ def extract_numeric_job_id_from_diag_files
168
+ GITHUB_RUNNER_DIAG_PATHS.each do |diag_path|
169
+ next unless Dir.exist?(diag_path)
170
+
171
+ worker_files = Dir.glob(File.join(diag_path, "Worker_*.log")).sort
172
+ next if worker_files.empty?
173
+
174
+ # Use the most recent worker file (last in sorted order)
175
+ worker_file = worker_files.last
176
+ check_run_id = extract_check_run_id_from_worker_file(worker_file)
177
+ return check_run_id if check_run_id
178
+ end
179
+
180
+ nil
181
+ rescue => e
182
+ Datadog.logger.debug("Failed to extract numeric job ID from GitHub Actions runner diagnostics: #{e}")
183
+ nil
184
+ end
185
+
186
+ # Regex to extract check_run_id value from Worker log files.
187
+ # The log format varies between GitHub-hosted and self-hosted runners,
188
+ # so we use regex instead of JSON parsing for robustness.
189
+ # Matches patterns like: "k": "check_run_id" ... "v": 12345 or "v": 12345.0
190
+ CHECK_RUN_ID_REGEX = /"k":\s*"check_run_id"[^}]*"v":\s*(\d+)(?:\.\d+)?/
191
+
192
+ def extract_check_run_id_from_worker_file(file_path)
193
+ return nil unless File.exist?(file_path)
194
+
195
+ content = File.read(file_path)
196
+
197
+ # Find all check_run_id values in the file.
198
+ # On self-hosted runners, Worker_*.log files can be appended across multiple jobs,
199
+ # so we use the last match to get the current job's ID.
200
+ # flatten because scan with capture groups returns Array[Array[String]]
201
+ matches = content.scan(CHECK_RUN_ID_REGEX).flatten
202
+ matches.last
203
+ rescue => e
204
+ Datadog.logger.debug("Failed to parse Worker log file #{file_path}: #{e}")
205
+ nil
206
+ end
144
207
  end
145
208
  end
146
209
  end
@@ -53,6 +53,14 @@ module Datadog
53
53
  module_function
54
54
 
55
55
  def tags(env)
56
+ @tags ||= extract_tags(env).freeze
57
+ end
58
+
59
+ def reset!
60
+ @tags = nil
61
+ end
62
+
63
+ def extract_tags(env)
56
64
  # Extract metadata from CI provider environment variables
57
65
  tags = Environment::Extractor.new(env).tags
58
66
 
@@ -91,6 +99,8 @@ module Datadog
91
99
  tags
92
100
  end
93
101
 
102
+ private_class_method :extract_tags
103
+
94
104
  def ensure_post_conditions(tags)
95
105
  validate_repository_url(tags[Git::TAG_REPOSITORY_URL])
96
106
  validate_git_sha(tags[Git::TAG_COMMIT_SHA])
@@ -6,6 +6,7 @@ module Datadog
6
6
  # Defines constants for test tags
7
7
  module Settings
8
8
  ENV_MODE_ENABLED = "DD_TRACE_CI_ENABLED"
9
+ ENV_ENABLED = "DD_CIVISIBILITY_ENABLED"
9
10
  ENV_AGENTLESS_MODE_ENABLED = "DD_CIVISIBILITY_AGENTLESS_ENABLED"
10
11
  ENV_AGENTLESS_URL = "DD_CIVISIBILITY_AGENTLESS_URL"
11
12
  ENV_EXPERIMENTAL_TEST_SUITE_LEVEL_VISIBILITY_ENABLED = "DD_CIVISIBILITY_EXPERIMENTAL_TEST_SUITE_LEVEL_VISIBILITY_ENABLED"
@@ -30,6 +31,7 @@ module Datadog
30
31
  ENV_TEST_DISCOVERY_OUTPUT_PATH = "DD_TEST_OPTIMIZATION_DISCOVERY_FILE"
31
32
  ENV_AUTO_INSTRUMENTATION_PROVIDER = "DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER"
32
33
  ENV_TIA_STATIC_DEPENDENCIES_TRACKING_ENABLED = "DD_TEST_OPTIMIZATION_TIA_STATIC_DEPS_COVERAGE_ENABLED"
34
+ ENV_CODE_COVERAGE_REPORT_UPLOAD_ENABLED = "DD_CIVISIBILITY_CODE_COVERAGE_REPORT_UPLOAD_ENABLED"
33
35
 
34
36
  # Source: https://docs.datadoghq.com/getting_started/site/
35
37
  DD_SITE_ALLOWLIST = %w[
@@ -54,6 +54,11 @@ module Datadog
54
54
  METRIC_CODE_COVERAGE_IS_EMPTY = "code_coverage.is_empty"
55
55
  METRIC_CODE_COVERAGE_FILES = "code_coverage.files"
56
56
 
57
+ METRIC_COVERAGE_UPLOAD_REQUEST = "coverage_upload.request"
58
+ METRIC_COVERAGE_UPLOAD_REQUEST_ERRORS = "coverage_upload.request_errors"
59
+ METRIC_COVERAGE_UPLOAD_REQUEST_MS = "coverage_upload.request_ms"
60
+ METRIC_COVERAGE_UPLOAD_REQUEST_BYTES = "coverage_upload.request_bytes"
61
+
57
62
  METRIC_KNOWN_TESTS_REQUEST = "known_tests.request"
58
63
  METRIC_KNOWN_TESTS_REQUEST_MS = "known_tests.request_ms"
59
64
  METRIC_KNOWN_TESTS_REQUEST_ERRORS = "known_tests.request_errors"
@@ -155,11 +155,6 @@ module Datadog
155
155
  SKIP = "skip"
156
156
  end
157
157
 
158
- # test statuses that we use for execution stats but don't report to Datadog (e.g. fail_ignored)
159
- module ExecutionStatsStatus
160
- FAIL_IGNORED = "fail_ignored"
161
- end
162
-
163
158
  # test types (e.g. test, benchmark, browser)
164
159
  module Type
165
160
  TEST = "test"
@@ -28,6 +28,9 @@ module Datadog
28
28
  TEST_COVERAGE_INTAKE_HOST_PREFIX = "citestcov-intake"
29
29
  TEST_COVERAGE_INTAKE_PATH = "/api/v2/citestcov"
30
30
 
31
+ CODE_COVERAGE_REPORT_INTAKE_HOST_PREFIX = "ci-intake"
32
+ CODE_COVERAGE_REPORT_INTAKE_PATH = "/api/v2/cicovreprt"
33
+
31
34
  LOGS_INTAKE_HOST_PREFIX = "http-intake.logs"
32
35
 
33
36
  DD_API_HOST_PREFIX = "api"
@@ -49,6 +52,7 @@ module Datadog
49
52
  DD_API_SETTINGS_RESPONSE_ATTEMPT_TO_FIX_RETRIES_KEY = "attempt_to_fix_retries"
50
53
  DD_API_SETTINGS_RESPONSE_DEFAULT = {DD_API_SETTINGS_RESPONSE_ITR_ENABLED_KEY => false}.freeze
51
54
  DD_API_SETTINGS_RESPONSE_IMPACTED_TESTS_ENABLED_KEY = "impacted_tests_enabled"
55
+ DD_API_SETTINGS_RESPONSE_COVERAGE_REPORT_UPLOAD_KEY = "coverage_report_upload_enabled"
52
56
 
53
57
  DD_API_GIT_SEARCH_COMMITS_PATH = "/api/v2/git/repository/search_commits"
54
58
 
@@ -25,6 +25,10 @@ module Datadog
25
25
 
26
26
  # Execute a git command with optional stdin input and timeout
27
27
  #
28
+ # All git commands are executed with the `-c safe.directory` option
29
+ # to handle cases where the repository is owned by a different user
30
+ # (common in CI environments with containerized builds).
31
+ #
28
32
  # @param cmd [Array<String>] The git command as an array of strings
29
33
  # @param stdin [String, nil] Optional stdin data to pass to the command
30
34
  # @param timeout [Integer] Timeout in seconds for the command execution
@@ -33,7 +37,11 @@ module Datadog
33
37
  def self.exec_git_command(cmd, stdin: nil, timeout: SHORT_TIMEOUT)
34
38
  # @type var out: String
35
39
  # @type var status: Process::Status?
36
- out, status = Utils::Command.exec_command(["git"] + cmd, stdin_data: stdin, timeout: timeout)
40
+ out, status = Utils::Command.exec_command(
41
+ ["git", "-c", "safe.directory=#{safe_directory}"] + cmd,
42
+ stdin_data: stdin,
43
+ timeout: timeout
44
+ )
37
45
 
38
46
  if status.nil? || !status.success?
39
47
  # Convert command to string representation for error message
@@ -50,6 +58,56 @@ module Datadog
50
58
 
51
59
  out
52
60
  end
61
+
62
+ # Returns the directory to use for git's safe.directory config.
63
+ # This is cached to avoid repeated filesystem lookups.
64
+ #
65
+ # Traverses up from current directory to find the nearest .git folder
66
+ # and returns its parent (the repository root). Falls back to current
67
+ # working directory if no .git folder is found.
68
+ #
69
+ # @return [String] The safe directory path
70
+ def self.safe_directory
71
+ return @safe_directory if defined?(@safe_directory)
72
+
73
+ @safe_directory = find_git_directory(Dir.pwd)
74
+ Datadog.logger.debug { "Git safe.directory configured to: #{@safe_directory}" }
75
+ @safe_directory
76
+ end
77
+
78
+ # Traverses up from the given directory to find the nearest .git folder or file.
79
+ # Returns the repository root (parent of .git) if found, otherwise the original directory.
80
+ #
81
+ # Note: .git can be either a directory (regular repos) or a file (worktrees/submodules).
82
+ # In worktrees and submodules, .git is a file containing a pointer to the actual git directory.
83
+ #
84
+ # @param start_dir [String] The directory to start searching from
85
+ # @return [String] The repository root path or the start directory if not found
86
+ def self.find_git_directory(start_dir)
87
+ Datadog.logger.debug { "Searching for .git starting from: #{start_dir}" }
88
+ current_dir = File.expand_path(start_dir)
89
+
90
+ loop do
91
+ git_path = File.join(current_dir, ".git")
92
+
93
+ # Check for both directory (.git in regular repos) and file (.git in worktrees/submodules)
94
+ if File.exist?(git_path)
95
+ Datadog.logger.debug { "Found .git at: #{git_path} (#{File.directory?(git_path) ? "directory" : "file"})" }
96
+ return current_dir
97
+ end
98
+
99
+ parent_dir = File.dirname(current_dir)
100
+
101
+ # Reached the root directory
102
+ break if parent_dir == current_dir
103
+
104
+ current_dir = parent_dir
105
+ end
106
+
107
+ # Fallback to original directory if no .git found
108
+ Datadog.logger.debug { "No .git found, using fallback: #{start_dir}" }
109
+ start_dir
110
+ end
53
111
  end
54
112
  end
55
113
  end
@@ -29,7 +29,8 @@ module Datadog
29
29
  Worker.new { test_retries.configure(@library_configuration, test_session) },
30
30
  Worker.new { test_visibility.configure(@library_configuration, test_session) },
31
31
  Worker.new { test_management.configure(@library_configuration, test_session) },
32
- Worker.new { impacted_tests_detection.configure(@library_configuration, test_session) }
32
+ Worker.new { impacted_tests_detection.configure(@library_configuration, test_session) },
33
+ Worker.new { code_coverage.configure(@library_configuration) }
33
34
  ]
34
35
 
35
36
  # launch configuration workers
@@ -123,6 +124,10 @@ module Datadog
123
124
  def git_tree_upload_worker
124
125
  Datadog.send(:components).git_tree_upload_worker
125
126
  end
127
+
128
+ def code_coverage
129
+ Datadog.send(:components).code_coverage
130
+ end
126
131
  end
127
132
  end
128
133
  end
@@ -117,6 +117,14 @@ module Datadog
117
117
  )
118
118
  end
119
119
 
120
+ def coverage_report_upload_enabled?
121
+ return @coverage_report_upload_enabled if defined?(@coverage_report_upload_enabled)
122
+
123
+ @coverage_report_upload_enabled = Utils::Parsing.convert_to_bool(
124
+ payload.fetch(Ext::Transport::DD_API_SETTINGS_RESPONSE_COVERAGE_REPORT_UPLOAD_KEY, false)
125
+ )
126
+ end
127
+
120
128
  def slow_test_retries
121
129
  return @slow_test_retries if defined?(@slow_test_retries)
122
130
 
@@ -12,6 +12,11 @@ module Datadog
12
12
  #
13
13
  # @public_api
14
14
  class Test < Span
15
+ # Context IDs for this test (used for TIA context coverage merging).
16
+ # Contains list of context identifiers from outermost to innermost.
17
+ # @return [Array<String>] list of context IDs
18
+ attr_accessor :context_ids
19
+
15
20
  # @return [String] the name of the test.
16
21
  def name
17
22
  get_tag(Ext::Test::TAG_NAME)
@@ -166,13 +171,7 @@ module Datadog
166
171
  def failed!(exception: nil)
167
172
  super
168
173
 
169
- # if we should ignore failures, we consider this test to be passed
170
- if should_ignore_failures?
171
- # use a special "fail_ignored" status to mark this test as failed but ignored
172
- record_test_result(Ext::Test::ExecutionStatsStatus::FAIL_IGNORED)
173
- else
174
- record_test_result(Ext::Test::Status::FAIL)
175
- end
174
+ record_test_result(Ext::Test::Status::FAIL)
176
175
  end
177
176
 
178
177
  # Sets the status of the span to "skip".
@@ -236,7 +235,10 @@ module Datadog
236
235
 
237
236
  # @internal
238
237
  def should_ignore_failures?
239
- quarantined? || disabled? || any_retry_passed?
238
+ return true if quarantined? || disabled?
239
+ return false if attempt_to_fix?
240
+
241
+ any_retry_passed?
240
242
  end
241
243
 
242
244
  # @internal
@@ -249,16 +251,9 @@ module Datadog
249
251
  status = get_tag(Ext::Test::TAG_STATUS)
250
252
  return if status.nil?
251
253
 
252
- if [Ext::Test::Status::PASS, Ext::Test::Status::SKIP].include?(status)
253
- set_tag(Ext::Test::TAG_FINAL_STATUS, status)
254
- return
255
- end
256
-
257
- if should_ignore_failures?
258
- set_tag(Ext::Test::TAG_FINAL_STATUS, Ext::Test::Status::PASS)
259
- else
260
- set_tag(Ext::Test::TAG_FINAL_STATUS, Ext::Test::Status::FAIL)
261
- end
254
+ final_status = compute_final_status(status)
255
+ set_tag(Ext::Test::TAG_FINAL_STATUS, final_status)
256
+ test_suite&.record_test_final_status(datadog_test_id, final_status)
262
257
  end
263
258
 
264
259
  # @internal
@@ -272,6 +267,20 @@ module Datadog
272
267
 
273
268
  private
274
269
 
270
+ def compute_final_status(status)
271
+ # Skip status is always preserved
272
+ return status if status == Ext::Test::Status::SKIP
273
+
274
+ # For attempt_to_fix tests (not quarantined/disabled), any failure means the fix didn't work
275
+ if attempt_to_fix? && !quarantined? && !disabled?
276
+ return all_executions_passed? ? Ext::Test::Status::PASS : Ext::Test::Status::FAIL
277
+ end
278
+
279
+ return status if status == Ext::Test::Status::PASS
280
+
281
+ should_ignore_failures? ? Ext::Test::Status::PASS : Ext::Test::Status::FAIL
282
+ end
283
+
275
284
  def record_test_result(datadog_status)
276
285
  # if this test was already executed in this test suite, mark it as retried
277
286
  if test_suite&.test_executed?(datadog_test_id)
@@ -75,6 +75,15 @@ module Datadog
75
75
  test_properties.fetch("attempt_to_fix", false)
76
76
  end
77
77
 
78
+ def disabled?(datadog_fqn_test_id)
79
+ return false unless @enabled
80
+
81
+ test_properties = @tests_properties[datadog_fqn_test_id]
82
+ return false if test_properties.nil?
83
+
84
+ test_properties.fetch("disabled", false)
85
+ end
86
+
78
87
  def restore_state_from_datadog_test_runner
79
88
  Datadog.logger.debug { "Restoring test management tests from DDTest cache" }
80
89
 
@@ -19,6 +19,14 @@ module Datadog
19
19
 
20
20
  def tag_test_from_properties(_)
21
21
  end
22
+
23
+ def attempt_to_fix?(_datadog_fqn_test_id)
24
+ false
25
+ end
26
+
27
+ def disabled?(_datadog_fqn_test_id)
28
+ false
29
+ end
22
30
  end
23
31
  end
24
32
  end
@@ -69,6 +69,16 @@ module Datadog
69
69
 
70
70
  @mutex = Mutex.new
71
71
 
72
+ # Context coverage: stores coverage collected during before(:context)/before(:all) hooks
73
+ # keyed by context_id (e.g., RSpec scoped_id for example groups)
74
+ # Only used when use_single_threaded_coverage is false (multi-threaded mode)
75
+ @context_coverages = {}
76
+ @context_coverages_mutex = Mutex.new
77
+
78
+ # Currently active context ID for context coverage collection
79
+ @current_context_id = nil
80
+ @current_context_id_mutex = Mutex.new
81
+
72
82
  Datadog.logger.debug("TestOptimisation initialized with enabled: #{@enabled}")
73
83
  end
74
84
 
@@ -117,24 +127,105 @@ module Datadog
117
127
  @code_coverage_enabled
118
128
  end
119
129
 
120
- def start_coverage(test)
130
+ # Starts coverage collection.
131
+ # This is a low-level method that only starts the collector.
132
+ #
133
+ # @return [void]
134
+ def start_coverage
121
135
  return if !enabled? || !code_coverage?
122
136
 
123
- Telemetry.code_coverage_started(test)
124
137
  coverage_collector&.start
125
138
  end
126
139
 
127
- def stop_coverage(test)
140
+ # Stops coverage collection and returns raw coverage data.
141
+ # This is a low-level method that only stops the collector.
142
+ #
143
+ # @return [Hash, nil] Raw coverage data or nil
144
+ def stop_coverage
145
+ return if !enabled? || !code_coverage?
146
+
147
+ coverage_collector&.stop
148
+ end
149
+
150
+ # Called when a test context (e.g., RSpec example group with before(:context)) starts.
151
+ # Starts collecting coverage that will be merged into all tests within this context.
152
+ #
153
+ # @param context_id [String] A stable identifier for the context (e.g., RSpec scoped_id)
154
+ # @return [void]
155
+ def on_test_context_started(context_id)
156
+ return unless context_coverage_enabled?
157
+
158
+ # Stop and store any existing context coverage before starting new one.
159
+ # This ensures that outer context coverage is preserved when nested contexts start.
160
+ stop_context_coverage_and_store
161
+
162
+ Datadog.logger.debug { "Starting context coverage collection for context [#{context_id}]" }
163
+
164
+ # Store the context_id we're collecting for
165
+ @current_context_id_mutex.synchronize do
166
+ @current_context_id = context_id
167
+ end
168
+
169
+ coverage_collector&.start
170
+ end
171
+
172
+ # Called when a test starts within a context. This method:
173
+ # 1. Stops any in-progress context coverage collection and stores it
174
+ # 2. Starts coverage collection for the test itself
175
+ #
176
+ # @param test [Datadog::CI::Test] The test that is starting
177
+ # @return [void]
178
+ def on_test_started(test)
128
179
  return if !enabled? || !code_coverage?
129
180
 
181
+ # Stop any in-progress context coverage and store it
182
+ stop_context_coverage_and_store
183
+
184
+ Telemetry.code_coverage_started(test)
185
+
186
+ context_ids = test.context_ids || []
187
+
188
+ Datadog.logger.debug do
189
+ "Starting test coverage for [#{test.name}] with context chain: #{context_ids.inspect}"
190
+ end
191
+
192
+ coverage_collector&.start
193
+ end
194
+
195
+ # Called when a test finishes. This method:
196
+ # 1. Stops test coverage collection
197
+ # 2. Merges context coverage from all relevant contexts
198
+ # 3. Writes the combined coverage event
199
+ # 4. Records ITR statistics if test was skipped by TIA
200
+ #
201
+ # @param test [Datadog::CI::Test] The test that finished
202
+ # @param context [Datadog::CI::TestVisibility::Context] The test visibility context for ITR stats
203
+ # @return [Datadog::CI::TestOptimisation::Coverage::Event, nil] The coverage event or nil
204
+ def on_test_finished(test, context)
205
+ return unless enabled?
206
+
207
+ # Handle ITR statistics
208
+ if test.skipped_by_test_impact_analysis?
209
+ Telemetry.itr_skipped
210
+
211
+ context.incr_tests_skipped_by_tia_count
212
+ end
213
+
214
+ # Handle code coverage
215
+ return unless code_coverage?
130
216
  Telemetry.code_coverage_finished(test)
131
217
 
132
218
  coverage = coverage_collector&.stop
133
219
 
134
220
  # if test was skipped, we discard coverage data
135
221
  return if test.skipped?
222
+ coverage ||= {}
223
+
224
+ # Merge context coverage from all relevant contexts
225
+ context_ids = test.context_ids || []
226
+ merge_context_coverages_into_test(coverage, context_ids)
136
227
 
137
- if coverage.nil? || coverage.empty?
228
+ if coverage.empty?
138
229
  Telemetry.code_coverage_is_empty
139
230
  return
140
231
  end
@@ -149,18 +240,41 @@ module Datadog
149
240
 
150
241
  Telemetry.code_coverage_files(coverage.size)
151
242
 
152
- event = Coverage::Event.new(
243
+ coverage_event = Coverage::Event.new(
153
244
  test_id: test.id.to_s,
154
245
  test_suite_id: test.test_suite_id.to_s,
155
246
  test_session_id: test.test_session_id.to_s,
156
247
  coverage: coverage
157
248
  )
158
249
 
159
- Datadog.logger.debug { "Writing coverage event \n #{event.pretty_inspect}" }
250
+ Datadog.logger.debug { "Writing coverage event \n #{coverage_event.pretty_inspect}" }
160
251
 
161
- write(event)
252
+ write(coverage_event)
162
253
 
163
- event
254
+ coverage_event
255
+ end
256
+
257
+ # Clears stored context coverage for a specific context.
258
+ # Should be called when a context finishes (e.g., after(:context) completes).
259
+ #
260
+ # @param context_id [String] The context ID to clear
261
+ # @return [void]
262
+ def clear_context_coverage(context_id)
263
+ return unless context_coverage_enabled?
264
+
265
+ @context_coverages_mutex.synchronize do
266
+ @context_coverages.delete(context_id)
267
+
268
+ Datadog.logger.debug { "Cleared context coverage for [#{context_id}]" }
269
+ end
270
+ end
271
+
272
+ # Returns whether context coverage collection is enabled.
273
+ # Context coverage is disabled in single-threaded mode.
274
+ #
275
+ # @return [Boolean]
276
+ def context_coverage_enabled?
277
+ enabled? && code_coverage? && !@use_single_threaded_coverage
164
278
  end
165
279
 
166
280
  def skippable?(datadog_test_id)
@@ -181,14 +295,6 @@ module Datadog
181
295
  end
182
296
  end
183
297
 
184
- def on_test_finished(test, context)
185
- return if !test.skipped? || !test.skipped_by_test_impact_analysis?
186
-
187
- Telemetry.itr_skipped
188
-
189
- context.incr_tests_skipped_by_tia_count
190
- end
191
-
192
298
  def write_test_session_tags(test_session, skipped_tests_count)
193
299
  return if !enabled?
194
300
 
@@ -390,6 +496,52 @@ module Datadog
390
496
  def git_tree_upload_worker
391
497
  Datadog.send(:components).git_tree_upload_worker
392
498
  end
499
+
500
+ # Stops any in-progress context coverage collection and stores it.
501
+ # Called when a test starts to capture coverage from before(:context) hooks.
502
+ def stop_context_coverage_and_store
503
+ return unless context_coverage_enabled?
504
+
505
+ context_id = @current_context_id_mutex.synchronize do
506
+ id = @current_context_id
507
+ @current_context_id = nil
508
+ id
509
+ end
510
+ return if context_id.nil?
511
+
512
+ coverage = coverage_collector&.stop
513
+ return if coverage.nil? || coverage.empty?
514
+
515
+ @context_coverages_mutex.synchronize do
516
+ @context_coverages[context_id] = coverage
517
+ end
518
+
519
+ Datadog.logger.debug do
520
+ "Stored context coverage for [#{context_id}] with #{coverage.size} files"
521
+ end
522
+ end
523
+
524
+ # Merges context coverage from all relevant contexts into the test's coverage.
525
+ #
526
+ # @param coverage [Hash] The test's coverage hash to merge into
527
+ # @param context_ids [Array<String>] List of context IDs to merge coverage from
528
+ def merge_context_coverages_into_test(coverage, context_ids)
529
+ return if @use_single_threaded_coverage
530
+ return if context_ids.empty?
531
+
532
+ @context_coverages_mutex.synchronize do
533
+ context_ids.each do |context_id|
534
+ context_coverage = @context_coverages[context_id]
535
+ next unless context_coverage
536
+
537
+ coverage.merge!(context_coverage)
538
+ end
539
+ end
540
+
541
+ Datadog.logger.debug do
542
+ "Merged context coverage for contexts: #{context_ids.inspect} into test coverage"
543
+ end
544
+ end
393
545
  end
394
546
  end
395
547
  end
@@ -34,21 +34,35 @@ module Datadog
34
34
  false
35
35
  end
36
36
 
37
- def start_coverage(_test)
37
+ def start_coverage
38
38
  end
39
39
 
40
- def stop_coverage(_test)
40
+ def stop_coverage
41
41
  nil
42
42
  end
43
43
 
44
- def mark_if_skippable(_test)
44
+ def on_test_context_started(_context_id)
45
45
  end
46
46
 
47
- def skippable?(_datadog_test_id)
48
- false
47
+ def on_test_started(_test)
49
48
  end
50
49
 
51
50
  def on_test_finished(_test, _context)
51
+ nil
52
+ end
53
+
54
+ def clear_context_coverage(_context_id)
55
+ end
56
+
57
+ def context_coverage_enabled?
58
+ false
59
+ end
60
+
61
+ def mark_if_skippable(_test)
62
+ end
63
+
64
+ def skippable?(_datadog_test_id)
65
+ false
52
66
  end
53
67
 
54
68
  def write_test_session_tags(_test_session, _skipped_tests_count)