datadog-ci 1.25.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -2
  3. data/ext/datadog_ci_native/ci.c +5 -3
  4. data/ext/datadog_ci_native/datadog_common.c +64 -0
  5. data/ext/datadog_ci_native/datadog_common.h +60 -0
  6. data/ext/datadog_ci_native/datadog_cov.c +13 -65
  7. data/ext/datadog_ci_native/datadog_method_inspect.c +22 -0
  8. data/ext/datadog_ci_native/datadog_method_inspect.h +4 -0
  9. data/ext/datadog_ci_native/imemo_helpers.c +16 -0
  10. data/ext/datadog_ci_native/imemo_helpers.h +32 -0
  11. data/ext/datadog_ci_native/iseq_collector.c +65 -0
  12. data/ext/datadog_ci_native/iseq_collector.h +6 -0
  13. data/ext/datadog_ci_native/ruby_internal.h +48 -0
  14. data/lib/datadog/ci/code_coverage/component.rb +55 -0
  15. data/lib/datadog/ci/code_coverage/null_component.rb +24 -0
  16. data/lib/datadog/ci/code_coverage/transport.rb +66 -0
  17. data/lib/datadog/ci/configuration/components.rb +19 -2
  18. data/lib/datadog/ci/configuration/settings.rb +26 -2
  19. data/lib/datadog/ci/contrib/minitest/helpers.rb +3 -3
  20. data/lib/datadog/ci/contrib/minitest/parallel_executor_minitest_6.rb +0 -7
  21. data/lib/datadog/ci/contrib/minitest/test.rb +3 -3
  22. data/lib/datadog/ci/contrib/rspec/example.rb +50 -10
  23. data/lib/datadog/ci/contrib/rspec/example_group.rb +63 -31
  24. data/lib/datadog/ci/contrib/simplecov/ext.rb +2 -0
  25. data/lib/datadog/ci/contrib/simplecov/patcher.rb +2 -0
  26. data/lib/datadog/ci/contrib/simplecov/report_uploader.rb +59 -0
  27. data/lib/datadog/ci/ext/environment/providers/github_actions.rb +65 -2
  28. data/lib/datadog/ci/ext/environment.rb +10 -0
  29. data/lib/datadog/ci/ext/settings.rb +3 -0
  30. data/lib/datadog/ci/ext/telemetry.rb +5 -0
  31. data/lib/datadog/ci/ext/test.rb +0 -5
  32. data/lib/datadog/ci/ext/transport.rb +4 -0
  33. data/lib/datadog/ci/git/cli.rb +59 -1
  34. data/lib/datadog/ci/remote/component.rb +6 -1
  35. data/lib/datadog/ci/remote/library_settings.rb +8 -0
  36. data/lib/datadog/ci/source_code/constant_resolver.rb +43 -0
  37. data/lib/datadog/ci/{utils/source_code.rb → source_code/method_inspect.rb} +3 -3
  38. data/lib/datadog/ci/source_code/path_filter.rb +33 -0
  39. data/lib/datadog/ci/source_code/static_dependencies.rb +71 -0
  40. data/lib/datadog/ci/source_code/static_dependencies_extractor.rb +237 -0
  41. data/lib/datadog/ci/test.rb +27 -18
  42. data/lib/datadog/ci/test_management/component.rb +9 -0
  43. data/lib/datadog/ci/test_management/null_component.rb +8 -0
  44. data/lib/datadog/ci/test_optimisation/component.rb +202 -20
  45. data/lib/datadog/ci/test_optimisation/null_component.rb +19 -5
  46. data/lib/datadog/ci/test_suite.rb +16 -21
  47. data/lib/datadog/ci/test_visibility/component.rb +1 -2
  48. data/lib/datadog/ci/test_visibility/known_tests.rb +59 -6
  49. data/lib/datadog/ci/transport/api/agentless.rb +8 -1
  50. data/lib/datadog/ci/transport/api/base.rb +21 -0
  51. data/lib/datadog/ci/transport/api/builder.rb +5 -1
  52. data/lib/datadog/ci/transport/api/evp_proxy.rb +8 -0
  53. data/lib/datadog/ci/version.rb +1 -1
  54. metadata +19 -4
  55. data/ext/datadog_ci_native/datadog_source_code.c +0 -28
  56. data/ext/datadog_ci_native/datadog_source_code.h +0 -3
@@ -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"
@@ -29,6 +30,8 @@ module Datadog
29
30
  ENV_TEST_DISCOVERY_MODE_ENABLED = "DD_TEST_OPTIMIZATION_DISCOVERY_ENABLED"
30
31
  ENV_TEST_DISCOVERY_OUTPUT_PATH = "DD_TEST_OPTIMIZATION_DISCOVERY_FILE"
31
32
  ENV_AUTO_INSTRUMENTATION_PROVIDER = "DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER"
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"
32
35
 
33
36
  # Source: https://docs.datadoghq.com/getting_started/site/
34
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
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module CI
5
+ module SourceCode
6
+ # ConstantResolver resolves Ruby constant names to their source file locations.
7
+ #
8
+ # This module uses Object.const_source_location to find where a constant is defined.
9
+ # Constants defined in C extensions or built-in Ruby classes have no source location.
10
+ #
11
+ # This module mirrors the C implementation in datadog_common.c (dd_ci_resolve_const_to_file).
12
+ module ConstantResolver
13
+ # Resolve a constant name to its source file path.
14
+ #
15
+ # @param constant_name [String] The fully qualified constant name (e.g., "Foo::Bar::Baz")
16
+ # @return [String, nil] The absolute file path where the constant is defined, or nil if not found
17
+ def self.resolve_path(constant_name)
18
+ return nil unless constant_name.is_a?(String)
19
+ return nil if constant_name.empty?
20
+
21
+ source_location = safely_get_const_source_location(constant_name)
22
+ return nil unless source_location.is_a?(Array) && !source_location.empty?
23
+
24
+ filename = source_location[0]
25
+ return nil unless filename.is_a?(String)
26
+
27
+ filename
28
+ end
29
+
30
+ # Safely get source location for a constant, returning nil on any exception.
31
+ # This handles cases like anonymous classes, C-defined constants, etc.
32
+ #
33
+ # @param constant_name [String] The constant name to look up
34
+ # @return [Array, nil] The [filename, lineno] array or nil
35
+ def self.safely_get_const_source_location(constant_name)
36
+ Object.const_source_location(constant_name)
37
+ rescue
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Datadog
4
4
  module CI
5
- module Utils
6
- module SourceCode
5
+ module SourceCode
6
+ module MethodInspect
7
7
  begin
8
8
  require "datadog_ci_native.#{RUBY_VERSION}_#{RUBY_PLATFORM}"
9
9
 
@@ -22,7 +22,7 @@ module Datadog
22
22
  return nil unless iseq.is_a?(RubyVM::InstructionSequence)
23
23
  # steep:ignore:end
24
24
 
25
- # this function is implemented in ext/datadog_ci_native/datadog_source_code.c
25
+ # this function is implemented in ext/datadog_ci_native/datadog_method_inspect.c
26
26
  _native_last_line_from_iseq(iseq)
27
27
  end
28
28
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module CI
5
+ module SourceCode
6
+ # PathFilter determines whether a file path should be included in test impact analysis.
7
+ #
8
+ # A path is included if:
9
+ # - It starts with root_path (prefix match)
10
+ # - It does NOT start with ignored_path (when ignored_path is set)
11
+ #
12
+ # This module mirrors the C implementation in datadog_common.c (dd_ci_is_path_included).
13
+ module PathFilter
14
+ # Check if a file path should be included in analysis.
15
+ #
16
+ # @param path [String] The file path to check
17
+ # @param root_path [String] The root path prefix (required)
18
+ # @param ignored_path [String, nil] Path prefix to exclude (optional)
19
+ # @return [Boolean] true if the path should be included
20
+ def self.included?(path, root_path, ignored_path = nil)
21
+ return false unless path.is_a?(String) && root_path.is_a?(String)
22
+ return false unless path.start_with?(root_path)
23
+
24
+ if ignored_path.is_a?(String) && !ignored_path.empty?
25
+ return false if path.start_with?(ignored_path)
26
+ end
27
+
28
+ true
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "static_dependencies_extractor"
4
+
5
+ module Datadog
6
+ module CI
7
+ module SourceCode
8
+ # ISeqCollector provides native access to Ruby's object space
9
+ # for collecting instruction sequences (ISeqs).
10
+ #
11
+ # @api private
12
+ module ISeqCollector
13
+ STATIC_DEPENDENCIES_EXTRACTION_AVAILABLE = begin
14
+ # We support Ruby >= 3.2 even though technically it is possible to support 3.1
15
+ # The issue is that Ruby 3.1 and earlier doesn't have opt_getconstant_path YARV instruction
16
+ # which makes it a lot harder to parse fully qualified constant access.
17
+ #
18
+ # See the PR https://github.com/DataDog/datadog-ci-rb/pull/442 for more context
19
+ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2")
20
+ require "datadog_ci_native.#{RUBY_VERSION}_#{RUBY_PLATFORM}"
21
+ true
22
+ else
23
+ false
24
+ end
25
+ rescue LoadError
26
+ false
27
+ end
28
+
29
+ # Collect all live ISeqs from the Ruby object space.
30
+ # Falls back to empty array if native extension is not available.
31
+ #
32
+ # @return [Array<RubyVM::InstructionSequence>] Array of all live ISeqs
33
+ def self.collect
34
+ return [] unless STATIC_DEPENDENCIES_EXTRACTION_AVAILABLE
35
+
36
+ collect_iseqs
37
+ end
38
+ end
39
+
40
+ module StaticDependencies
41
+ # Populate the static dependencies map by scanning all live ISeqs.
42
+ #
43
+ # @param root_path [String] Only process files under this path
44
+ # @param ignored_path [String, nil] Exclude files under this path
45
+ # @return [Hash{String => Hash{String => Boolean}}] The dependencies map
46
+ def self.populate!(root_path, ignored_path = nil)
47
+ raise ArgumentError, "root_path must be a String and not nil" if root_path.nil? || !root_path.is_a?(String)
48
+
49
+ extractor = StaticDependenciesExtractor.new(root_path, ignored_path)
50
+
51
+ ISeqCollector.collect.each do |iseq|
52
+ extractor.extract(iseq)
53
+ end
54
+
55
+ @dependencies_map = extractor.dependencies_map
56
+ end
57
+
58
+ # Fetch static dependencies for a given file.
59
+ #
60
+ # @param file [String, nil] The file path to look up
61
+ # @return [Hash{String => Boolean}] Dependencies hash or empty hash
62
+ def self.fetch_static_dependencies(file)
63
+ return {} unless @dependencies_map
64
+ return {} if file.nil?
65
+
66
+ @dependencies_map.fetch(file, {})
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_filter"
4
+ require_relative "constant_resolver"
5
+
6
+ module Datadog
7
+ module CI
8
+ module SourceCode
9
+ # StaticDependenciesExtractor extracts static constant dependencies from Ruby bytecode.
10
+ #
11
+ # For each ISeq (compiled Ruby code), it:
12
+ # 1. Extracts the source file path
13
+ # 2. Filters by root_path and ignored_path
14
+ # 3. Scans bytecode for constant references
15
+ # 4. Resolves constants to their source file locations
16
+ # 5. Filters dependency paths by root_path and ignored_path
17
+ #
18
+ # @example
19
+ # extractor = StaticDependenciesExtractor.new("/app", "/app/vendor")
20
+ # iseq = RubyVM::InstructionSequence.of(some_method)
21
+ # extractor.extract(iseq)
22
+ # deps = extractor.dependencies_map
23
+ # # => { "/app/foo.rb" => { "/app/bar.rb" => true } }
24
+ #
25
+ class StaticDependenciesExtractor
26
+ # BytecodeScanner scans Ruby bytecode instructions for constant references.
27
+ #
28
+ # This class traverses the ISeq#to_a representation to find:
29
+ # - :getconstant instructions - simple constant references
30
+ # - :opt_getconstant_path instructions - optimized qualified constant paths
31
+ #
32
+ # @api private
33
+ class BytecodeScanner
34
+ # Scan an ISeq body for constant references.
35
+ #
36
+ # @param body [Array] The ISeq body array (last element of ISeq#to_a)
37
+ # @return [Array<String>] Array of constant name strings found in the bytecode
38
+ def scan(body)
39
+ return [] unless body.is_a?(Array)
40
+
41
+ constants = []
42
+ scan_value(body, constants)
43
+ constants
44
+ end
45
+
46
+ # Build a qualified constant name from an array of symbols.
47
+ # e.g., [:Foo, :Bar, :Baz] -> "Foo::Bar::Baz"
48
+ #
49
+ # @param symbol_array [Array<Symbol>] Array of constant name symbols
50
+ # @return [String] The qualified constant path string
51
+ def build_constant_path(symbol_array)
52
+ symbol_array
53
+ .select { |part| part.is_a?(Symbol) }
54
+ .map(&:to_s)
55
+ .join("::")
56
+ end
57
+
58
+ private
59
+
60
+ # Recursively scan a Ruby value for constant references.
61
+ #
62
+ # @param value [Object] Any Ruby value from the ISeq representation
63
+ # @param constants [Array<String>] Accumulator for found constants
64
+ def scan_value(value, constants)
65
+ case value
66
+ when Array
67
+ scan_array(value, constants)
68
+ when Hash
69
+ scan_hash(value, constants)
70
+ end
71
+ end
72
+
73
+ # Scan an array for instructions and nested values.
74
+ #
75
+ # @param arr [Array] Array to scan
76
+ # @param constants [Array<String>] Accumulator for found constants
77
+ def scan_array(arr, constants)
78
+ handle_instruction(arr, constants)
79
+
80
+ arr.each do |elem|
81
+ scan_value(elem, constants)
82
+ end
83
+ end
84
+
85
+ # Scan a hash for constant references in keys and values.
86
+ #
87
+ # @param hash [Hash] Hash to scan
88
+ # @param constants [Array<String>] Accumulator for found constants
89
+ def scan_hash(hash, constants)
90
+ hash.each do |key, val|
91
+ scan_value(key, constants)
92
+ scan_value(val, constants)
93
+ end
94
+ end
95
+
96
+ # Check if an array is a bytecode instruction and handle it.
97
+ # Instructions have the form [:instruction_name, ...args].
98
+ #
99
+ # @param arr [Array] Potential instruction array
100
+ # @param constants [Array<String>] Accumulator for found constants
101
+ def handle_instruction(arr, constants)
102
+ return if arr.size < 2
103
+ return unless arr[0].is_a?(Symbol)
104
+
105
+ case arr[0]
106
+ when :getconstant
107
+ handle_getconstant(arr, constants)
108
+ when :opt_getconstant_path
109
+ handle_opt_getconstant_path(arr, constants)
110
+ end
111
+ end
112
+
113
+ # Handle [:getconstant, :CONST_NAME, ...] instruction.
114
+ #
115
+ # @param instruction [Array] The instruction array
116
+ # @param constants [Array<String>] Accumulator for found constants
117
+ def handle_getconstant(instruction, constants)
118
+ const_name = instruction[1]
119
+ return unless const_name.is_a?(Symbol)
120
+
121
+ constants << const_name.to_s
122
+ end
123
+
124
+ # Handle [:opt_getconstant_path, cache_entry] instruction.
125
+ # The cache entry is an array of symbols: [:Foo, :Bar, :Baz]
126
+ #
127
+ # @param instruction [Array] The instruction array
128
+ # @param constants [Array<String>] Accumulator for found constants
129
+ def handle_opt_getconstant_path(instruction, constants)
130
+ cache_entry = instruction[1]
131
+ return unless cache_entry.is_a?(Array) && !cache_entry.empty?
132
+
133
+ path = build_constant_path(cache_entry)
134
+ constants << path unless path.empty?
135
+ end
136
+ end
137
+
138
+ # @return [Hash{String => Hash{String => Boolean}}] Map of source file to dependencies
139
+ attr_reader :dependencies_map
140
+
141
+ # @return [String] Root path prefix for filtering
142
+ attr_reader :root_path
143
+
144
+ # @return [String, nil] Ignored path prefix for exclusion
145
+ attr_reader :ignored_path
146
+
147
+ # Initialize a new StaticDependenciesExtractor.
148
+ #
149
+ # @param root_path [String] Only process files under this path
150
+ # @param ignored_path [String, nil] Exclude files under this path
151
+ def initialize(root_path, ignored_path = nil)
152
+ @root_path = root_path
153
+ @ignored_path = ignored_path
154
+ @dependencies_map = {}
155
+ @bytecode_scanner = BytecodeScanner.new
156
+ end
157
+
158
+ # Extract constant dependencies from an ISeq.
159
+ #
160
+ # @param iseq [RubyVM::InstructionSequence] The instruction sequence to process
161
+ # @return [void]
162
+ def extract(iseq)
163
+ path = extract_absolute_path(iseq)
164
+ return if path.nil?
165
+ return unless PathFilter.included?(path, root_path, ignored_path)
166
+
167
+ body = extract_body(iseq)
168
+ return if body.nil?
169
+
170
+ deps = get_or_create_deps(path)
171
+ constant_names = @bytecode_scanner.scan(body)
172
+
173
+ constant_names.each do |const_name|
174
+ resolve_and_store_dependency(const_name, deps)
175
+ end
176
+ end
177
+
178
+ # Reset the dependencies map.
179
+ #
180
+ # @return [void]
181
+ def reset
182
+ @dependencies_map = {}
183
+ end
184
+
185
+ private
186
+
187
+ # Extract the absolute path from an ISeq.
188
+ # Returns nil for eval'd code (which has no file).
189
+ #
190
+ # @param iseq [RubyVM::InstructionSequence]
191
+ # @return [String, nil]
192
+ def extract_absolute_path(iseq)
193
+ path = iseq.absolute_path
194
+ return nil unless path.is_a?(String)
195
+
196
+ path
197
+ end
198
+
199
+ # Extract the body array from an ISeq's SimpleDataFormat.
200
+ # The body is the last element of ISeq#to_a.
201
+ #
202
+ # @param iseq [RubyVM::InstructionSequence]
203
+ # @return [Array, nil]
204
+ def extract_body(iseq)
205
+ arr = iseq.to_a
206
+ return nil unless arr.is_a?(Array) && !arr.empty?
207
+
208
+ body = arr[-1]
209
+ return nil unless body.is_a?(Array)
210
+
211
+ body
212
+ end
213
+
214
+ # Get or create dependencies hash for a given path.
215
+ #
216
+ # @param path [String]
217
+ # @return [Hash{String => Boolean}]
218
+ def get_or_create_deps(path)
219
+ @dependencies_map[path] ||= {}
220
+ end
221
+
222
+ # Resolve a constant name to its file and store in dependencies.
223
+ #
224
+ # @param constant_name [String]
225
+ # @param deps [Hash{String => Boolean}]
226
+ # @return [void]
227
+ def resolve_and_store_dependency(constant_name, deps)
228
+ file_path = ConstantResolver.resolve_path(constant_name)
229
+ return if file_path.nil?
230
+ return unless PathFilter.included?(file_path, root_path, ignored_path)
231
+
232
+ deps[file_path] = true
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -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