selective-ruby-core 0.2.8-aarch64-linux → 0.2.10-aarch64-linux
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/LICENSE +0 -0
- data/Rakefile +20 -0
- data/ext/selective_tracer/extconf.rb +41 -0
- data/ext/selective_tracer/selective_tracer.c +336 -0
- data/lib/bin/build_env.sh +115 -2
- data/lib/selective/ruby/core/controller.rb +77 -7
- data/lib/selective/ruby/core/file_correlator.rb +0 -0
- data/lib/selective/ruby/core/helper.rb +0 -0
- data/lib/selective/ruby/core/named_pipe.rb +0 -0
- data/lib/selective/ruby/core/test_map/recorder.rb +61 -0
- data/lib/selective/ruby/core/test_map/tracer.rb +120 -0
- data/lib/selective/ruby/core/test_map/tree.rb +125 -0
- data/lib/selective/ruby/core/test_map/uploader.rb +108 -0
- data/lib/selective/ruby/core/test_map.rb +89 -0
- data/lib/selective/ruby/core/version.rb +1 -1
- data/lib/selective-ruby-core.rb +0 -0
- data/selective-ruby-core.gemspec +53 -0
- metadata +29 -6
|
@@ -16,15 +16,17 @@ module Selective
|
|
|
16
16
|
"platform" => "SELECTIVE_PLATFORM",
|
|
17
17
|
"run_id" => "SELECTIVE_RUN_ID",
|
|
18
18
|
"run_attempt" => "SELECTIVE_RUN_ATTEMPT",
|
|
19
|
-
"branch" => "SELECTIVE_BRANCH"
|
|
19
|
+
"branch" => "SELECTIVE_BRANCH",
|
|
20
|
+
"sha" => "SELECTIVE_SHA",
|
|
21
|
+
"git_repo_full_name" => "SELECTIVE_GIT_REPO",
|
|
22
|
+
"git_provider" => "SELECTIVE_GIT_PROVIDER"
|
|
20
23
|
}.freeze
|
|
21
24
|
|
|
22
25
|
def initialize(runner_class, runner_args, debug: false, log: false)
|
|
23
26
|
@debug = debug
|
|
27
|
+
@log = log
|
|
24
28
|
@runner = runner_class.new(runner_args, method(:test_case_callback))
|
|
25
29
|
@retries = 0
|
|
26
|
-
@runner_id = safe_filename(get_runner_id)
|
|
27
|
-
@logger = init_logger(log)
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
def start(reconnect: false)
|
|
@@ -64,7 +66,19 @@ module Selective
|
|
|
64
66
|
|
|
65
67
|
private
|
|
66
68
|
|
|
67
|
-
attr_reader :runner, :pipe, :transport_pid, :retries, :
|
|
69
|
+
attr_reader :runner, :pipe, :transport_pid, :retries, :diff
|
|
70
|
+
|
|
71
|
+
# Resolved on first use rather than in the constructor: `selective
|
|
72
|
+
# exec` (the plain framework run behind the manifest dry-run) never
|
|
73
|
+
# connects, so it must not require the CI environment that naming a
|
|
74
|
+
# runner validates.
|
|
75
|
+
def runner_id
|
|
76
|
+
@runner_id ||= safe_filename(get_runner_id)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def logger
|
|
80
|
+
@logger ||= init_logger(@log)
|
|
81
|
+
end
|
|
68
82
|
|
|
69
83
|
def get_runner_id
|
|
70
84
|
runner_id = build_env["runner_id"]
|
|
@@ -87,7 +101,7 @@ module Selective
|
|
|
87
101
|
message = pipe.read
|
|
88
102
|
response = JSON.parse(message, symbolize_names: true)
|
|
89
103
|
|
|
90
|
-
|
|
104
|
+
logger.info("Received Command: #{response}")
|
|
91
105
|
break if handle_command(response) == :break
|
|
92
106
|
end
|
|
93
107
|
end
|
|
@@ -137,6 +151,13 @@ module Selective
|
|
|
137
151
|
"framework" => runner.framework,
|
|
138
152
|
"framework_version" => runner.framework_version,
|
|
139
153
|
"framework_wrapper_version" => runner.wrapper_version,
|
|
154
|
+
"runtime_version" => RUBY_VERSION,
|
|
155
|
+
# "record", "select" or "0": whether this runner can record a
|
|
156
|
+
# test map, only run selected subsets, or neither.
|
|
157
|
+
"test_map" => TestMap.capability,
|
|
158
|
+
# Forces this run to record (e.g. a nightly full run), however
|
|
159
|
+
# fresh the current map is.
|
|
160
|
+
"test_map_record" => ENV["SELECTIVE_TEST_MAP_RECORD"].to_s
|
|
140
161
|
}.merge(metadata: metadata.to_json)
|
|
141
162
|
end
|
|
142
163
|
end
|
|
@@ -268,8 +289,11 @@ module Selective
|
|
|
268
289
|
|
|
269
290
|
def handle_test_manifest(_data)
|
|
270
291
|
self.class.restore_reporting!
|
|
271
|
-
|
|
292
|
+
logger.info("Sending Response: test_manifest")
|
|
272
293
|
data = {test_cases: runner.manifest["examples"] || runner.manifest["test_cases"]}
|
|
294
|
+
if TestMap.tree_requested? && (tree = test_map_tree)
|
|
295
|
+
data[:test_map_tree] = tree
|
|
296
|
+
end
|
|
273
297
|
num_commits = build_env["num_commits"] || 1000
|
|
274
298
|
if (diff = get_diff(num_commits))
|
|
275
299
|
data[:modified_test_files] = modified_test_files(diff)
|
|
@@ -278,6 +302,22 @@ module Selective
|
|
|
278
302
|
write({type: "test_manifest", data: data})
|
|
279
303
|
end
|
|
280
304
|
|
|
305
|
+
# Sent by servers that support test maps, before any tests are
|
|
306
|
+
# assigned. `tree: true` asks for a file-hash snapshot with the
|
|
307
|
+
# manifest; `record: true` turns on tracing for every test this runner
|
|
308
|
+
# executes.
|
|
309
|
+
def handle_configure_test_map(data)
|
|
310
|
+
recorder = TestMap.configure(
|
|
311
|
+
record: data[:record] == true,
|
|
312
|
+
tree: data[:tree] == true,
|
|
313
|
+
allocations: data[:allocations] != false
|
|
314
|
+
)
|
|
315
|
+
logger.info("Test map configured: record=#{!recorder.nil?} tree=#{TestMap.tree_requested?}")
|
|
316
|
+
if data[:record] == true && recorder.nil?
|
|
317
|
+
print_warning("Selective asked this runner to record a test map, but the native tracer isn't available. Tests will run normally.")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
281
321
|
def handle_run_test_cases(data)
|
|
282
322
|
runner.run_test_cases(data[:test_case_ids])
|
|
283
323
|
end
|
|
@@ -304,6 +344,8 @@ module Selective
|
|
|
304
344
|
runner.finish unless exit_status.is_a?(Integer)
|
|
305
345
|
end
|
|
306
346
|
|
|
347
|
+
upload_test_map
|
|
348
|
+
|
|
307
349
|
kill_transport
|
|
308
350
|
pipe.delete_pipes
|
|
309
351
|
exit(exit_status || runner.exit_status)
|
|
@@ -312,12 +354,40 @@ module Selective
|
|
|
312
354
|
:break
|
|
313
355
|
end
|
|
314
356
|
|
|
357
|
+
def test_map_tree
|
|
358
|
+
TestMap::Tree.snapshot
|
|
359
|
+
rescue => e
|
|
360
|
+
logger.warn("Could not snapshot the repository for test selection: #{e.message}")
|
|
361
|
+
nil
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def upload_test_map
|
|
365
|
+
recorder = TestMap.recorder
|
|
366
|
+
return if recorder.nil? || recorder.empty?
|
|
367
|
+
|
|
368
|
+
uploader = TestMap::Uploader.new(
|
|
369
|
+
host: build_env["host"],
|
|
370
|
+
api_key: build_env["api_key"],
|
|
371
|
+
run_id: build_env["run_id"],
|
|
372
|
+
run_attempt: build_env["run_attempt"],
|
|
373
|
+
runner_id: runner_id,
|
|
374
|
+
logger: logger
|
|
375
|
+
)
|
|
376
|
+
if uploader.upload(recorder.to_h)
|
|
377
|
+
logger.info("Uploaded test map fragment: #{recorder.test_count} tests")
|
|
378
|
+
else
|
|
379
|
+
print_warning("Selective could not upload this runner's test map (#{uploader.last_error&.message}). Its tests will run on the next build rather than be skipped.")
|
|
380
|
+
end
|
|
381
|
+
rescue => e
|
|
382
|
+
logger.warn("Test map upload failed: #{e.class}: #{e.message}")
|
|
383
|
+
end
|
|
384
|
+
|
|
315
385
|
def correlated_files(diff, num_commits)
|
|
316
386
|
Selective::Ruby::Core::FileCorrelator.new(diff, num_commits, build_env["target_branch"]).correlate
|
|
317
387
|
end
|
|
318
388
|
|
|
319
389
|
def test_case_callback(test_case)
|
|
320
|
-
|
|
390
|
+
logger.info("Sending Response: test_case_result: #{test_case[:id]}")
|
|
321
391
|
write({type: "test_case_result", data: test_case})
|
|
322
392
|
end
|
|
323
393
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Selective
|
|
6
|
+
module Ruby
|
|
7
|
+
module Core
|
|
8
|
+
module TestMap
|
|
9
|
+
# Accumulates "test id => files" for every test this runner executes and
|
|
10
|
+
# serialises it as a fragment. Paths are interned into a table so a
|
|
11
|
+
# fragment carries each path once, not once per test that touched it.
|
|
12
|
+
class Recorder
|
|
13
|
+
FORMAT_VERSION = 1
|
|
14
|
+
|
|
15
|
+
attr_reader :tracer
|
|
16
|
+
|
|
17
|
+
def initialize(tracer:)
|
|
18
|
+
@tracer = tracer
|
|
19
|
+
@paths = []
|
|
20
|
+
@path_index = {}
|
|
21
|
+
@tests = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Retries and reruns can run a test twice in one process. Keep the
|
|
25
|
+
# union: a dependency seen on any attempt is a real dependency.
|
|
26
|
+
def record(test_id)
|
|
27
|
+
result, files = tracer.trace { yield }
|
|
28
|
+
indexes = (@tests[test_id] ||= Set.new)
|
|
29
|
+
files.each { |path| indexes << intern(path) }
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_count
|
|
34
|
+
@tests.size
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def empty?
|
|
38
|
+
@tests.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
version: FORMAT_VERSION,
|
|
44
|
+
files: @paths,
|
|
45
|
+
tests: @tests.map { |id, indexes| {id: id, deps: indexes.to_a.sort} }
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def intern(path)
|
|
52
|
+
@path_index[path] ||= begin
|
|
53
|
+
@paths << path
|
|
54
|
+
@paths.size - 1
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Selective
|
|
6
|
+
module Ruby
|
|
7
|
+
module Core
|
|
8
|
+
module TestMap
|
|
9
|
+
# Ruby face of the native tracer: decides which paths count as the
|
|
10
|
+
# project's own code, and turns what the tracer saw into sorted,
|
|
11
|
+
# repo-relative paths that line up with `git ls-tree`.
|
|
12
|
+
class Tracer
|
|
13
|
+
attr_reader :repo_root
|
|
14
|
+
|
|
15
|
+
def initialize(allocations: true, repo_root: Tree.repo_root, pwd: Dir.pwd)
|
|
16
|
+
@repo_root = repo_root || File.realpath(pwd)
|
|
17
|
+
@roots = unique_dirs([@repo_root, pwd])
|
|
18
|
+
# Only directories *inside* a root need ignoring (everything else is
|
|
19
|
+
# outside the roots already). An ancestor would ignore the whole
|
|
20
|
+
# project, e.g. GEM_HOME=/work with the repo at /work/app.
|
|
21
|
+
@ignored = unique_dirs(ignored_dirs).select do |dir|
|
|
22
|
+
@roots.any? { |root| dir.start_with?("#{root}/") }
|
|
23
|
+
end
|
|
24
|
+
@native = NativeTracer.new(roots: @roots, ignored: @ignored, allocations: allocations)
|
|
25
|
+
@relative_paths = {}
|
|
26
|
+
@class_files = {}.compare_by_identity
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Runs the block with tracing on and returns [result, files], where
|
|
30
|
+
# files is a Set of repo-relative paths.
|
|
31
|
+
def trace
|
|
32
|
+
@native.start
|
|
33
|
+
begin
|
|
34
|
+
result = yield
|
|
35
|
+
ensure
|
|
36
|
+
files, klasses = @native.stop
|
|
37
|
+
end
|
|
38
|
+
[result, collect(files, klasses)]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def collect(files, klasses)
|
|
44
|
+
paths = Set.new
|
|
45
|
+
files.each_key do |path|
|
|
46
|
+
rel = relative(path)
|
|
47
|
+
paths << rel if rel
|
|
48
|
+
end
|
|
49
|
+
klasses.each do |klass|
|
|
50
|
+
files_for_class(klass).each { |rel| paths << rel }
|
|
51
|
+
end
|
|
52
|
+
paths
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A class's own file, plus those of its project-defined ancestors: a
|
|
56
|
+
# test that builds a `User` depends on `ApplicationRecord` too.
|
|
57
|
+
def files_for_class(klass)
|
|
58
|
+
@class_files[klass] ||= klass.ancestors.filter_map do |mod|
|
|
59
|
+
name = mod.name
|
|
60
|
+
next if name.nil?
|
|
61
|
+
|
|
62
|
+
location = Object.const_source_location(name)&.first
|
|
63
|
+
location && relative(location)
|
|
64
|
+
rescue NameError, ArgumentError, TypeError
|
|
65
|
+
nil
|
|
66
|
+
end.uniq.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def relative(path)
|
|
70
|
+
return @relative_paths[path] if @relative_paths.key?(path)
|
|
71
|
+
|
|
72
|
+
@relative_paths[path] = compute_relative(path)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def compute_relative(path)
|
|
76
|
+
abs = File.expand_path(path)
|
|
77
|
+
return nil if ignored?(abs)
|
|
78
|
+
|
|
79
|
+
rel = strip_root(abs)
|
|
80
|
+
if rel.nil? && File.exist?(abs)
|
|
81
|
+
real = File.realpath(abs)
|
|
82
|
+
rel = strip_root(real) unless ignored?(real)
|
|
83
|
+
end
|
|
84
|
+
rel
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def strip_root(abs)
|
|
88
|
+
prefix = "#{@repo_root}/"
|
|
89
|
+
abs.start_with?(prefix) ? abs.delete_prefix(prefix) : nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ignored?(abs)
|
|
93
|
+
@ignored.any? { |dir| abs == dir || abs.start_with?("#{dir}/") }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Code the project runs but doesn't own: installed gems (including a
|
|
97
|
+
# vendored bundle inside the repo) and Selective's own gems, which
|
|
98
|
+
# live inside the repo when developed through path/symlinks.
|
|
99
|
+
def ignored_dirs
|
|
100
|
+
dirs = [Gem.dir, *Gem.path]
|
|
101
|
+
dirs << Bundler.bundle_path.to_s if defined?(Bundler) && Bundler.respond_to?(:bundle_path)
|
|
102
|
+
Gem.loaded_specs.each do |name, spec|
|
|
103
|
+
dirs << spec.full_gem_path if name.start_with?("selective-ruby-")
|
|
104
|
+
end
|
|
105
|
+
extra = ENV["SELECTIVE_TEST_MAP_IGNORE"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
106
|
+
dirs.concat(extra.map { |d| File.expand_path(d) })
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def unique_dirs(dirs)
|
|
110
|
+
dirs.compact.flat_map do |dir|
|
|
111
|
+
expanded = File.expand_path(dir.to_s)
|
|
112
|
+
real = File.exist?(expanded) ? File.realpath(expanded) : expanded
|
|
113
|
+
[expanded, real]
|
|
114
|
+
end.map { |d| d.chomp("/") }.reject(&:empty?).uniq
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Selective
|
|
6
|
+
module Ruby
|
|
7
|
+
module Core
|
|
8
|
+
module TestMap
|
|
9
|
+
# A content-hash snapshot of the repository: every file's git blob id.
|
|
10
|
+
#
|
|
11
|
+
# This is what makes selection independent of diffs. The server
|
|
12
|
+
# compares these hashes with the ones stored alongside the map, so there
|
|
13
|
+
# is no base commit to pick, no merge-base to compute and no fetch depth
|
|
14
|
+
# to get right. It works in a depth-1 clone, and for uncommitted edits
|
|
15
|
+
# (a developer or an agent running locally) it hashes what is actually
|
|
16
|
+
# on disk rather than what HEAD says.
|
|
17
|
+
module Tree
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def repo_root
|
|
21
|
+
out, status = Open3.capture2e("git", "rev-parse", "--show-toplevel")
|
|
22
|
+
status.success? ? out.strip : nil
|
|
23
|
+
rescue SystemCallError
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# => {sha:, prefix:, files: {path => blob}, dirty: [paths]} or nil
|
|
28
|
+
# when the working directory isn't a git checkout.
|
|
29
|
+
def snapshot(pwd: Dir.pwd)
|
|
30
|
+
root = repo_root
|
|
31
|
+
return nil if root.nil?
|
|
32
|
+
|
|
33
|
+
files = committed_files(root)
|
|
34
|
+
return nil if files.nil?
|
|
35
|
+
|
|
36
|
+
dirty = apply_working_tree_changes(root, files)
|
|
37
|
+
{
|
|
38
|
+
sha: head_sha(root),
|
|
39
|
+
prefix: prefix_for(root, pwd),
|
|
40
|
+
files: files,
|
|
41
|
+
dirty: dirty
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Where the test command runs, relative to the repository root, e.g.
|
|
46
|
+
# "services/api/" in a monorepo. The server uses it to line manifest
|
|
47
|
+
# paths (relative to the command) up with tree paths (relative to the
|
|
48
|
+
# root).
|
|
49
|
+
def prefix_for(root, pwd)
|
|
50
|
+
real = File.realpath(pwd)
|
|
51
|
+
return "" if real == root
|
|
52
|
+
|
|
53
|
+
real.start_with?("#{root}/") ? "#{real.delete_prefix("#{root}/")}/" : ""
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def head_sha(root)
|
|
57
|
+
git(root, "rev-parse", "HEAD")&.strip
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def committed_files(root)
|
|
61
|
+
out = git(root, "ls-tree", "-r", "-z", "--full-tree", "HEAD")
|
|
62
|
+
return nil if out.nil?
|
|
63
|
+
|
|
64
|
+
out.split("\0").each_with_object({}) do |entry, files|
|
|
65
|
+
meta, path = entry.split("\t", 2)
|
|
66
|
+
next if path.nil?
|
|
67
|
+
|
|
68
|
+
_mode, _type, blob = meta.split(" ")
|
|
69
|
+
files[path] = blob
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Folds uncommitted edits to tracked files into the snapshot. Returns
|
|
74
|
+
# the dirty paths so the server can report that the run used a
|
|
75
|
+
# working tree.
|
|
76
|
+
#
|
|
77
|
+
# Untracked files are deliberately left out. They add nothing — a new
|
|
78
|
+
# test file is "new" to the map through the manifest, and a new app
|
|
79
|
+
# file can't be a recorded dependency — and they churn: runner logs,
|
|
80
|
+
# tmp/, coverage output. Hashing them made a local log file look like
|
|
81
|
+
# a changed file no test uses, which runs the whole suite.
|
|
82
|
+
def apply_working_tree_changes(root, files)
|
|
83
|
+
out = git(root, "status", "--porcelain=v1", "-z", "--untracked-files=no", "--no-renames")
|
|
84
|
+
return [] if out.nil? || out.empty?
|
|
85
|
+
|
|
86
|
+
changed = []
|
|
87
|
+
deleted = []
|
|
88
|
+
out.split("\0").each do |entry|
|
|
89
|
+
next if entry.length < 4
|
|
90
|
+
|
|
91
|
+
status = entry[0, 2]
|
|
92
|
+
path = entry[3..]
|
|
93
|
+
if status.include?("D") && !File.exist?(File.join(root, path))
|
|
94
|
+
files.delete(path)
|
|
95
|
+
deleted << path
|
|
96
|
+
else
|
|
97
|
+
changed << path
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
hash_objects(root, changed).each { |path, blob| files[path] = blob }
|
|
102
|
+
changed + deleted
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def hash_objects(root, paths)
|
|
106
|
+
existing = paths.select { |p| File.file?(File.join(root, p)) }
|
|
107
|
+
return {} if existing.empty?
|
|
108
|
+
|
|
109
|
+
out, status = Open3.capture2("git", "-C", root, "hash-object", "--stdin-paths", stdin_data: existing.join("\n") + "\n")
|
|
110
|
+
return {} unless status.success?
|
|
111
|
+
|
|
112
|
+
existing.zip(out.split("\n")).to_h
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def git(root, *args)
|
|
116
|
+
out, status = Open3.capture2("git", "-C", root, *args)
|
|
117
|
+
status.success? ? out : nil
|
|
118
|
+
rescue SystemCallError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require "uri"
|
|
9
|
+
require "zlib"
|
|
10
|
+
|
|
11
|
+
module Selective
|
|
12
|
+
module Ruby
|
|
13
|
+
module Core
|
|
14
|
+
module TestMap
|
|
15
|
+
# Sends this runner's recorded fragment to the server over HTTPS.
|
|
16
|
+
#
|
|
17
|
+
# A map is far larger than anything else a runner sends (tests × files),
|
|
18
|
+
# so it doesn't go over the WebSocket. Upload failure never fails the
|
|
19
|
+
# build: the worst outcome is a map missing this runner's tests, and a
|
|
20
|
+
# test missing from the map is always run.
|
|
21
|
+
#
|
|
22
|
+
# The POST runs in a child Ruby process. The upload happens inside the
|
|
23
|
+
# test process, where suites commonly load WebMock or VCR, and those
|
|
24
|
+
# intercept every Net::HTTP request, this one included. A child started
|
|
25
|
+
# without Bundler's environment loads neither.
|
|
26
|
+
class Uploader
|
|
27
|
+
PATH = "/test_maps/fragments"
|
|
28
|
+
ATTEMPTS = 3
|
|
29
|
+
|
|
30
|
+
def initialize(host:, api_key:, run_id:, run_attempt:, runner_id:, logger: nil)
|
|
31
|
+
@uri = build_uri(host, run_id: run_id, run_attempt: run_attempt, runner_id: runner_id)
|
|
32
|
+
@api_key = api_key
|
|
33
|
+
@logger = logger
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns true on success.
|
|
37
|
+
def upload(fragment)
|
|
38
|
+
body = Zlib.gzip(JSON.generate(fragment))
|
|
39
|
+
attempt = 0
|
|
40
|
+
begin
|
|
41
|
+
attempt += 1
|
|
42
|
+
true if post(body)
|
|
43
|
+
rescue => e
|
|
44
|
+
@logger&.warn("Test map upload attempt #{attempt} failed: #{e.message}")
|
|
45
|
+
if attempt < ATTEMPTS
|
|
46
|
+
sleep(attempt)
|
|
47
|
+
retry
|
|
48
|
+
end
|
|
49
|
+
@last_error = e
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :last_error, :uri
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# The request, as a standalone script for the child process. The API key
|
|
59
|
+
# arrives in the environment, never on the command line.
|
|
60
|
+
CHILD_SCRIPT = <<~RUBY
|
|
61
|
+
require "net/http"
|
|
62
|
+
require "uri"
|
|
63
|
+
uri = URI(ARGV.fetch(0))
|
|
64
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
65
|
+
http.use_ssl = uri.scheme == "https"
|
|
66
|
+
http.open_timeout = 10
|
|
67
|
+
http.read_timeout = 120
|
|
68
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
69
|
+
request["authorization"] = ENV.fetch("SELECTIVE_TEST_MAP_UPLOAD_KEY")
|
|
70
|
+
request["content-type"] = "application/gzip"
|
|
71
|
+
request.body = File.binread(ARGV.fetch(1))
|
|
72
|
+
response = http.request(request)
|
|
73
|
+
puts response.code
|
|
74
|
+
warn response.body.to_s[0, 200] unless response.is_a?(Net::HTTPSuccess)
|
|
75
|
+
exit(response.is_a?(Net::HTTPSuccess) ? 0 : 1)
|
|
76
|
+
RUBY
|
|
77
|
+
|
|
78
|
+
# Keeps Bundler (and whatever it would require) out of the child.
|
|
79
|
+
CLEAN_ENV = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_SETUP BUNDLER_VERSION GEM_HOME
|
|
80
|
+
GEM_PATH].to_h do |k|
|
|
81
|
+
[k, nil]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Raises unless the server accepted the upload.
|
|
85
|
+
def post(body)
|
|
86
|
+
Tempfile.create(["selective-test-map", ".json.gz"], binmode: true) do |file|
|
|
87
|
+
file.write(body)
|
|
88
|
+
file.flush
|
|
89
|
+
|
|
90
|
+
env = CLEAN_ENV.merge("SELECTIVE_TEST_MAP_UPLOAD_KEY" => @api_key.to_s)
|
|
91
|
+
out, err, status = Open3.capture3(env, RbConfig.ruby, "-e", CHILD_SCRIPT, @uri.to_s, file.path)
|
|
92
|
+
raise "HTTP #{out.strip}: #{err.strip}" unless status.success?
|
|
93
|
+
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The runner is configured with the WebSocket host (ws:// or wss://);
|
|
99
|
+
# the upload goes to the same server over HTTP(S).
|
|
100
|
+
def build_uri(host, **params)
|
|
101
|
+
base = host.sub(%r{\Aws(s?)://}) { "http#{::Regexp.last_match(1)}://" }.chomp("/")
|
|
102
|
+
URI("#{base}#{PATH}?#{URI.encode_www_form(params)}")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Selective
|
|
4
|
+
module Ruby
|
|
5
|
+
module Core
|
|
6
|
+
# Test maps let Selective skip tests a change can't affect.
|
|
7
|
+
#
|
|
8
|
+
# A *recording* run traces every test with the native tracer and uploads
|
|
9
|
+
# "test id => repo files it executed" when the runner finishes. A later
|
|
10
|
+
# run sends a snapshot of the repository's file hashes with its manifest,
|
|
11
|
+
# and the server skips a test only when every file it depends on is
|
|
12
|
+
# byte-for-byte what it was when the map was recorded.
|
|
13
|
+
#
|
|
14
|
+
# Everything here is inert unless the server asks for it
|
|
15
|
+
# (`configure_test_map`), and recording additionally needs the optional
|
|
16
|
+
# native extension. Without either, the suite runs exactly as before.
|
|
17
|
+
module TestMap
|
|
18
|
+
class << self
|
|
19
|
+
# True when the native tracer compiled and loads on this Ruby.
|
|
20
|
+
def available?
|
|
21
|
+
return @available if defined?(@available)
|
|
22
|
+
|
|
23
|
+
@available = if disabled?
|
|
24
|
+
false
|
|
25
|
+
else
|
|
26
|
+
begin
|
|
27
|
+
require "selective_tracer"
|
|
28
|
+
true
|
|
29
|
+
rescue LoadError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# SELECTIVE_TEST_MAP_DISABLE=1 is the escape hatch: the runner then
|
|
36
|
+
# never records and never takes part in selection.
|
|
37
|
+
def disabled?
|
|
38
|
+
ENV["SELECTIVE_TEST_MAP_DISABLE"].to_s.match?(/\A(1|true|yes)\z/i)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# What this runner can do, as advertised to the server. Selection
|
|
42
|
+
# needs only a git snapshot, so a runner without the native tracer
|
|
43
|
+
# (unsupported Ruby, failed compile) still runs selected subsets
|
|
44
|
+
# from maps recorded elsewhere; it just can't record one.
|
|
45
|
+
def capability
|
|
46
|
+
return "0" if disabled?
|
|
47
|
+
|
|
48
|
+
available? ? "record" : "select"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Recording is one-way for the life of the process: a reconnect can
|
|
52
|
+
# make the server re-send its configuration, and dropping a recorder
|
|
53
|
+
# would throw away every test traced so far.
|
|
54
|
+
def configure(record: false, tree: false, allocations: true)
|
|
55
|
+
@tree_requested = tree
|
|
56
|
+
if record && available?
|
|
57
|
+
@recorder ||= Recorder.new(tracer: Tracer.new(allocations: allocations))
|
|
58
|
+
end
|
|
59
|
+
@recorder
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def recording?
|
|
63
|
+
!@recorder.nil?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def tree_requested?
|
|
67
|
+
@tree_requested == true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wraps one test. Adapters call this around the framework's own
|
|
71
|
+
# "run this test" so setup, the test body and teardown are all traced.
|
|
72
|
+
def around(test_id)
|
|
73
|
+
recorder = @recorder
|
|
74
|
+
return yield if recorder.nil?
|
|
75
|
+
|
|
76
|
+
recorder.record(test_id) { yield }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
attr_reader :recorder
|
|
80
|
+
|
|
81
|
+
def reset!
|
|
82
|
+
@recorder = nil
|
|
83
|
+
@tree_requested = false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/selective-ruby-core.rb
CHANGED
|
File without changes
|