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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -2
- data/ext/datadog_ci_native/ci.c +5 -3
- data/ext/datadog_ci_native/datadog_common.c +64 -0
- data/ext/datadog_ci_native/datadog_common.h +60 -0
- data/ext/datadog_ci_native/datadog_cov.c +13 -65
- data/ext/datadog_ci_native/datadog_method_inspect.c +22 -0
- data/ext/datadog_ci_native/datadog_method_inspect.h +4 -0
- data/ext/datadog_ci_native/imemo_helpers.c +16 -0
- data/ext/datadog_ci_native/imemo_helpers.h +32 -0
- data/ext/datadog_ci_native/iseq_collector.c +65 -0
- data/ext/datadog_ci_native/iseq_collector.h +6 -0
- data/ext/datadog_ci_native/ruby_internal.h +48 -0
- data/lib/datadog/ci/code_coverage/component.rb +55 -0
- data/lib/datadog/ci/code_coverage/null_component.rb +24 -0
- data/lib/datadog/ci/code_coverage/transport.rb +66 -0
- data/lib/datadog/ci/configuration/components.rb +19 -2
- data/lib/datadog/ci/configuration/settings.rb +26 -2
- data/lib/datadog/ci/contrib/minitest/helpers.rb +3 -3
- data/lib/datadog/ci/contrib/minitest/parallel_executor_minitest_6.rb +0 -7
- data/lib/datadog/ci/contrib/minitest/test.rb +3 -3
- data/lib/datadog/ci/contrib/rspec/example.rb +50 -10
- data/lib/datadog/ci/contrib/rspec/example_group.rb +63 -31
- data/lib/datadog/ci/contrib/simplecov/ext.rb +2 -0
- data/lib/datadog/ci/contrib/simplecov/patcher.rb +2 -0
- data/lib/datadog/ci/contrib/simplecov/report_uploader.rb +59 -0
- data/lib/datadog/ci/ext/environment/providers/github_actions.rb +65 -2
- data/lib/datadog/ci/ext/environment.rb +10 -0
- data/lib/datadog/ci/ext/settings.rb +3 -0
- data/lib/datadog/ci/ext/telemetry.rb +5 -0
- data/lib/datadog/ci/ext/test.rb +0 -5
- data/lib/datadog/ci/ext/transport.rb +4 -0
- data/lib/datadog/ci/git/cli.rb +59 -1
- data/lib/datadog/ci/remote/component.rb +6 -1
- data/lib/datadog/ci/remote/library_settings.rb +8 -0
- data/lib/datadog/ci/source_code/constant_resolver.rb +43 -0
- data/lib/datadog/ci/{utils/source_code.rb → source_code/method_inspect.rb} +3 -3
- data/lib/datadog/ci/source_code/path_filter.rb +33 -0
- data/lib/datadog/ci/source_code/static_dependencies.rb +71 -0
- data/lib/datadog/ci/source_code/static_dependencies_extractor.rb +237 -0
- data/lib/datadog/ci/test.rb +27 -18
- data/lib/datadog/ci/test_management/component.rb +9 -0
- data/lib/datadog/ci/test_management/null_component.rb +8 -0
- data/lib/datadog/ci/test_optimisation/component.rb +202 -20
- data/lib/datadog/ci/test_optimisation/null_component.rb +19 -5
- data/lib/datadog/ci/test_suite.rb +16 -21
- data/lib/datadog/ci/test_visibility/component.rb +1 -2
- data/lib/datadog/ci/test_visibility/known_tests.rb +59 -6
- data/lib/datadog/ci/transport/api/agentless.rb +8 -1
- data/lib/datadog/ci/transport/api/base.rb +21 -0
- data/lib/datadog/ci/transport/api/builder.rb +5 -1
- data/lib/datadog/ci/transport/api/evp_proxy.rb +8 -0
- data/lib/datadog/ci/version.rb +1 -1
- metadata +19 -4
- data/ext/datadog_ci_native/datadog_source_code.c +0 -28
- 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"
|
data/lib/datadog/ci/ext/test.rb
CHANGED
|
@@ -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
|
|
data/lib/datadog/ci/git/cli.rb
CHANGED
|
@@ -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(
|
|
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
|
|
6
|
-
module
|
|
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/
|
|
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
|
data/lib/datadog/ci/test.rb
CHANGED
|
@@ -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
|
-
|
|
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?
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|