mutineer 1.4.0 → 1.6.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 +475 -0
- data/README.md +224 -28
- data/lib/mutineer/changed_lines.rb +24 -1
- data/lib/mutineer/cli.rb +117 -20
- data/lib/mutineer/config.rb +64 -13
- data/lib/mutineer/coverage_map.rb +533 -100
- data/lib/mutineer/daemon_backend.rb +51 -33
- data/lib/mutineer/daemon_client.rb +212 -22
- data/lib/mutineer/daemon_server.rb +74 -27
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/isolation.rb +92 -6
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
- data/lib/mutineer/minitest_integration.rb +75 -11
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +0 -7
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +0 -7
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +344 -21
- data/lib/mutineer/rails_worker_db.rb +83 -9
- data/lib/mutineer/reporter.rb +306 -53
- data/lib/mutineer/result.rb +73 -5
- data/lib/mutineer/runner.rb +123 -242
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +6 -2
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +282 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +2 -0
- metadata +10 -3
|
@@ -9,7 +9,9 @@ require "set"
|
|
|
9
9
|
require_relative "minitest_integration"
|
|
10
10
|
require_relative "test_runners"
|
|
11
11
|
require_relative "child_stdout"
|
|
12
|
+
require_relative "orphan_guard"
|
|
12
13
|
require_relative "project_path"
|
|
14
|
+
require_relative "pairing"
|
|
13
15
|
|
|
14
16
|
module Mutineer
|
|
15
17
|
# Maps `(source_file, line) -> [test_files]` so each mutant runs only against
|
|
@@ -27,8 +29,36 @@ module Mutineer
|
|
|
27
29
|
# the parent. Stdout stays free for test output, which goes to File::NULL.
|
|
28
30
|
RESULT_FD = 3
|
|
29
31
|
|
|
32
|
+
# Seconds a capture waits for its result after the child exits in time.
|
|
33
|
+
# The result is already in the pipe then; the wait only lets the reader
|
|
34
|
+
# thread run, even when the capture deadline has passed (#129).
|
|
35
|
+
RESULT_GRACE = 1
|
|
36
|
+
|
|
30
37
|
attr_reader :project_root, :failed_test_files, :failed_clean_tests, :phase_a_ran, :map
|
|
31
38
|
|
|
39
|
+
# The source lines that ran while the app booted or the sources and the
|
|
40
|
+
# `--require` files loaded, before any test ran, as a Set of "file:line"
|
|
41
|
+
# keys like {#map}'s (#187, #217).
|
|
42
|
+
# A mutant on such a line can change a value computed before the mutant was
|
|
43
|
+
# applied, so its verdict is not trusted (`ran_at_load`).
|
|
44
|
+
#
|
|
45
|
+
# @return [Set<String>]
|
|
46
|
+
attr_reader :load_lines
|
|
47
|
+
|
|
48
|
+
# The source methods that were called while the app booted or the sources
|
|
49
|
+
# and the `--require` files loaded, as a Set of "file:line:column" keys of
|
|
50
|
+
# their `def` (#209). Line coverage cannot tell this for a one-line or
|
|
51
|
+
# endless method: its `def` line counts when the method is defined.
|
|
52
|
+
#
|
|
53
|
+
# @return [Set<String>]
|
|
54
|
+
attr_reader :load_methods
|
|
55
|
+
|
|
56
|
+
# Seconds each test file took to capture, keyed by project-relative path
|
|
57
|
+
# (#203). {#order_tests} runs the cheaper files first.
|
|
58
|
+
#
|
|
59
|
+
# @return [Hash{String => Float}]
|
|
60
|
+
attr_reader :timings
|
|
61
|
+
|
|
32
62
|
# Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
|
|
33
63
|
# map app-side and ships `map` + `failed_test_files` over IPC; the tool
|
|
34
64
|
# reconstructs it here for per-mutant selection). Skips the capture machinery
|
|
@@ -39,10 +69,18 @@ module Mutineer
|
|
|
39
69
|
# @param failed_test_files [Array<String>] test files whose capture failed.
|
|
40
70
|
# @param project_root [String] project root (for path relativization).
|
|
41
71
|
# @param failed_clean_tests [Array<String>] test files whose unmutated run failed.
|
|
72
|
+
# @param load_lines [Array<String>, Set<String>] "file:line" keys that ran at load ({#load_lines}).
|
|
73
|
+
# @param load_methods [Array<String>, Set<String>] "file:line:column" keys of methods called at load
|
|
74
|
+
# ({#load_methods}).
|
|
75
|
+
# @param timings [Hash{String => Float}] capture seconds per test file ({#timings}).
|
|
42
76
|
# @return [Mutineer::CoverageMap] a query-only map.
|
|
43
|
-
def self.from_data(map:, failed_test_files:, project_root:, failed_clean_tests: []
|
|
77
|
+
def self.from_data(map:, failed_test_files:, project_root:, failed_clean_tests: [], load_lines: [],
|
|
78
|
+
load_methods: [], timings: {})
|
|
44
79
|
instance = allocate
|
|
45
80
|
instance.instance_variable_set(:@map, map || {})
|
|
81
|
+
instance.instance_variable_set(:@timings, timings || {})
|
|
82
|
+
instance.instance_variable_set(:@load_lines, Set.new(load_lines || []))
|
|
83
|
+
instance.instance_variable_set(:@load_methods, Set.new(load_methods || []))
|
|
46
84
|
instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
|
|
47
85
|
instance.instance_variable_set(:@failed_clean_tests, failed_clean_tests || [])
|
|
48
86
|
instance.instance_variable_set(:@project_root, project_root)
|
|
@@ -52,8 +90,9 @@ module Mutineer
|
|
|
52
90
|
def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
|
|
53
91
|
load_paths: ["lib"], project_root: Dir.pwd,
|
|
54
92
|
capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil,
|
|
55
|
-
framework: "minitest", verbose: false)
|
|
93
|
+
framework: "minitest", verbose: false, require_paths: [])
|
|
56
94
|
@source_paths = Array(source_paths)
|
|
95
|
+
@require_paths = Array(require_paths)
|
|
57
96
|
@test_paths = Array(test_paths)
|
|
58
97
|
@cache_dir = cache_dir
|
|
59
98
|
@load_paths = Array(load_paths)
|
|
@@ -65,6 +104,9 @@ module Mutineer
|
|
|
65
104
|
@map = {}
|
|
66
105
|
@failed_test_files = []
|
|
67
106
|
@failed_clean_tests = []
|
|
107
|
+
@load_lines = Set.new
|
|
108
|
+
@load_methods = Set.new
|
|
109
|
+
@timings = {}
|
|
68
110
|
@loaded_dependencies = {}
|
|
69
111
|
@phase_a_ran = false
|
|
70
112
|
end
|
|
@@ -82,8 +124,15 @@ module Mutineer
|
|
|
82
124
|
# the booted parent instead. Inverts into the same map #tests_for reads, and
|
|
83
125
|
# reuses the digest cache (the digest mixes in the boot file so a boot cache
|
|
84
126
|
# never collides with a standalone one).
|
|
127
|
+
#
|
|
128
|
+
# The lines that ran during boot are read here on every run, cache or not:
|
|
129
|
+
# the boot runs every time, and no test is credited with those lines.
|
|
85
130
|
def build_via_fork(after_fork: nil)
|
|
86
131
|
warn_external_sources
|
|
132
|
+
# Matched by real path in #record_load: a Coverage key is the path as
|
|
133
|
+
# required, which can differ from the configured one (/var, /private/var).
|
|
134
|
+
booted = Coverage.running? ? Coverage.peek_result : {}
|
|
135
|
+
record_load(booted.transform_values { |v| v.is_a?(Hash) ? load_entry(v[:lines], v[:methods]) : v })
|
|
87
136
|
cached_or(after_fork: after_fork) { run_phase_a_via_fork(after_fork: after_fork) }
|
|
88
137
|
end
|
|
89
138
|
|
|
@@ -94,12 +143,63 @@ module Mutineer
|
|
|
94
143
|
@map["#{relativize(file)}:#{line}"] || []
|
|
95
144
|
end
|
|
96
145
|
|
|
146
|
+
# The covering `tests` of a mutant in `file`, in the order a mutant run
|
|
147
|
+
# loads them (#203): the files {Pairing.infer_tests} pairs with `file`
|
|
148
|
+
# first, then the cheapest by {#timings}. A file with no timing comes
|
|
149
|
+
# after the timed ones, and the path breaks a tie, so the same cache gives
|
|
150
|
+
# the same order on every run. Costs compare in doubling buckets
|
|
151
|
+
# (`log2(seconds - fastest + 1)`), so files of close cost keep their path
|
|
152
|
+
# order when a rebuild measures them again. Taking off the fastest
|
|
153
|
+
# captured file's time removes the startup cost every standalone capture
|
|
154
|
+
# pays; a failed capture is timed again on each run, so it does not count.
|
|
155
|
+
# A run that stops at the first failure then reaches a fast killing test
|
|
156
|
+
# before a slow file uses up the timeout.
|
|
157
|
+
#
|
|
158
|
+
# @param file [String] the mutated source file path.
|
|
159
|
+
# @param tests [Array<String>] project-relative test paths from {#tests_for}.
|
|
160
|
+
# @return [Array<String>] the same paths, reordered.
|
|
161
|
+
def order_tests(file, tests)
|
|
162
|
+
rel = relativize(absolute(file))
|
|
163
|
+
@paired ||= {}
|
|
164
|
+
paired = @paired[rel] ||= Pairing.infer_tests(rel, project_root: @project_root,
|
|
165
|
+
prefer: @framework || "minitest")
|
|
166
|
+
base = @timings.except(*@failed_test_files).values.min || 0
|
|
167
|
+
tests.sort_by do |t|
|
|
168
|
+
cost = @timings[t]
|
|
169
|
+
[paired.include?(t) ? 0 : 1, cost ? Math.log2(cost - base + 1).floor : Float::INFINITY, t]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# True when `file:line` ran while the app booted or the sources loaded
|
|
174
|
+
# (see {#load_lines}).
|
|
175
|
+
#
|
|
176
|
+
# @param file [String] source file path.
|
|
177
|
+
# @param line [Integer] 1-based line.
|
|
178
|
+
# @return [Boolean]
|
|
179
|
+
def ran_at_load?(file, line)
|
|
180
|
+
@load_lines.include?("#{relativize(file)}:#{line}")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# True when the method whose `def` starts at `line` and `column` of `file`
|
|
184
|
+
# was called while the app booted or the sources loaded (see {#load_methods}).
|
|
185
|
+
#
|
|
186
|
+
# @param file [String] source file path.
|
|
187
|
+
# @param line [Integer] 1-based line of the `def`.
|
|
188
|
+
# @param column [Integer] 0-based byte column of the `def`.
|
|
189
|
+
# @return [Boolean]
|
|
190
|
+
def method_ran_at_load?(file, line, column)
|
|
191
|
+
@load_methods.include?("#{relativize(file)}:#{line}:#{column}")
|
|
192
|
+
end
|
|
193
|
+
|
|
97
194
|
# Is this source file's empty coverage the result of an *errored* capture
|
|
98
195
|
# rather than a genuine coverage gap? True iff some capture failed this run
|
|
99
196
|
# AND this file got zero coverage from any successful capture AND a failed
|
|
100
197
|
# test file maps to it by the _test/_spec/test_ naming convention. Derived
|
|
101
|
-
#
|
|
102
|
-
#
|
|
198
|
+
# from already-persisted state (@map keys + @failed_test_files). A split
|
|
199
|
+
# name checks the longer source files that existed when that check first
|
|
200
|
+
# ran for this map. Adding or deleting one of those files changes the
|
|
201
|
+
# coverage digest, so the next run captures again instead of retargeting
|
|
202
|
+
# a stored failure.
|
|
103
203
|
#
|
|
104
204
|
# File-level, convention-based attribution. A line covered only by a failed
|
|
105
205
|
# test in an otherwise-covered file stays no_coverage (condition 2), and a
|
|
@@ -112,7 +212,7 @@ module Mutineer
|
|
|
112
212
|
rel = relativize(absolute(file))
|
|
113
213
|
return false if covered_source_files.include?(rel)
|
|
114
214
|
|
|
115
|
-
|
|
215
|
+
failed_test_blames?(rel)
|
|
116
216
|
end
|
|
117
217
|
|
|
118
218
|
# Per-method taint. A mutant on a line whose enclosing method got zero
|
|
@@ -131,7 +231,7 @@ module Mutineer
|
|
|
131
231
|
return false if @failed_test_files.empty?
|
|
132
232
|
|
|
133
233
|
rel = relativize(absolute(file))
|
|
134
|
-
return false unless
|
|
234
|
+
return false unless failed_test_blames?(rel)
|
|
135
235
|
|
|
136
236
|
line_range.none? { |ln| @map.key?("#{rel}:#{ln}") }
|
|
137
237
|
end
|
|
@@ -143,17 +243,119 @@ module Mutineer
|
|
|
143
243
|
@map.keys.map { |k| k.rpartition(":").first }.to_set
|
|
144
244
|
end
|
|
145
245
|
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
246
|
+
# True when a failed test file pairs with `source_rel` by convention.
|
|
247
|
+
# Exact `_test` / `_spec` / `test_` names match by basename, as before.
|
|
248
|
+
# A split `<name>_*_test.rb` matches only in the mirrored test directory,
|
|
249
|
+
# and only when no longer source file owns that name (#87). The answer
|
|
250
|
+
# is kept for this map, so a later delete does not move the failure.
|
|
251
|
+
#
|
|
252
|
+
# @param source_rel [String] project-relative source path.
|
|
253
|
+
# @return [Boolean]
|
|
254
|
+
def failed_test_blames?(source_rel)
|
|
255
|
+
@blame_for ||= {}
|
|
256
|
+
return @blame_for[source_rel] if @blame_for.key?(source_rel)
|
|
257
|
+
|
|
258
|
+
@blame_for[source_rel] = exact_failed_test?(source_rel) || split_failed_test?(source_rel)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# True when a failed exact `_test` / `_spec` / `test_` file pairs with
|
|
262
|
+
# this source. An `app/` or `lib/` source only accepts a `test/` or
|
|
263
|
+
# `spec/` file from its mirrored directory, so
|
|
264
|
+
# `test/foo/bar_upsert_test.rb` does not taint `app/other/bar_upsert.rb`.
|
|
265
|
+
# Any other source still matches by basename. A fixture under
|
|
266
|
+
# `test/fixtures/` and an explicit test both name their source that way.
|
|
267
|
+
#
|
|
268
|
+
# @param source_rel [String] project-relative source path.
|
|
269
|
+
# @return [Boolean]
|
|
270
|
+
def exact_failed_test?(source_rel)
|
|
271
|
+
name = File.basename(source_rel, ".rb")
|
|
272
|
+
base, lib = Pairing.logical_path(source_rel)
|
|
273
|
+
@failed_test_files.any? do |test_path|
|
|
274
|
+
next false unless failed_test_target_name(test_path) == name
|
|
275
|
+
|
|
276
|
+
rel = relativize(test_path)
|
|
277
|
+
dir = File.dirname(rel)
|
|
278
|
+
if app_or_lib_source?(source_rel) && conventional_test_dir?(dir)
|
|
279
|
+
mirrored_test_dir?(rel, base, lib) || spec_mirror?(rel, base, lib)
|
|
280
|
+
else
|
|
281
|
+
true
|
|
155
282
|
end
|
|
156
|
-
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# True when pairing gives this source a mirrored test directory.
|
|
287
|
+
#
|
|
288
|
+
# @param source_rel [String] project-relative source path.
|
|
289
|
+
# @return [Boolean]
|
|
290
|
+
def app_or_lib_source?(source_rel)
|
|
291
|
+
source_rel.start_with?("app/", "lib/")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Basename a failed test file would pair with, before the directory check.
|
|
295
|
+
#
|
|
296
|
+
# @param test_path [String]
|
|
297
|
+
# @return [String]
|
|
298
|
+
def failed_test_target_name(test_path)
|
|
299
|
+
name = File.basename(test_path, ".rb")
|
|
300
|
+
case name
|
|
301
|
+
when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
|
|
302
|
+
when "test_helper" then name
|
|
303
|
+
else name.delete_prefix("test_")
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# True when `dir` is `test`, `spec`, or a directory under one of them.
|
|
308
|
+
#
|
|
309
|
+
# @param dir [String] project-relative directory.
|
|
310
|
+
# @return [Boolean]
|
|
311
|
+
def conventional_test_dir?(dir)
|
|
312
|
+
dir == "test" || dir == "spec" || dir.start_with?("test/", "spec/")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# True when a failed split test file pairs with this source. Directory and
|
|
316
|
+
# the longer-source check match {Pairing}, so a test outside the mirror, or
|
|
317
|
+
# one owned by `user_session.rb`, does not taint `user.rb`.
|
|
318
|
+
#
|
|
319
|
+
# @param source_rel [String] project-relative source path.
|
|
320
|
+
# @return [Boolean]
|
|
321
|
+
def split_failed_test?(source_rel)
|
|
322
|
+
base, lib = Pairing.logical_path(source_rel)
|
|
323
|
+
name = File.basename(base)
|
|
324
|
+
@failed_test_files.any? do |t|
|
|
325
|
+
test_rel = relativize(t)
|
|
326
|
+
entry = File.basename(test_rel)
|
|
327
|
+
next false unless Pairing.split_entry?(entry, name)
|
|
328
|
+
next false unless mirrored_test_dir?(test_rel, base, lib)
|
|
329
|
+
next false if Pairing.claimed_by_longer_source?(@project_root, base, entry, File.dirname(test_rel))
|
|
330
|
+
|
|
331
|
+
true
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# True when `test_rel` sits in a mirrored test directory for `base`.
|
|
336
|
+
#
|
|
337
|
+
# @param test_rel [String] project-relative test path.
|
|
338
|
+
# @param base [String] logical source path without extension.
|
|
339
|
+
# @param lib [Boolean] whether the source originated from lib/.
|
|
340
|
+
# @return [Boolean]
|
|
341
|
+
def mirrored_test_dir?(test_rel, base, lib)
|
|
342
|
+
dirs = [Pairing.mirror_dir("test", base)]
|
|
343
|
+
dirs << Pairing.mirror_dir("test/lib", base) if lib
|
|
344
|
+
dirs.include?(File.dirname(test_rel))
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# True when `test_rel` sits in a mirrored spec directory for `base`.
|
|
348
|
+
# Split Minitest names stay in {#mirrored_test_dir?}. An exact `_spec`
|
|
349
|
+
# file uses this directory.
|
|
350
|
+
#
|
|
351
|
+
# @param test_rel [String] project-relative test path.
|
|
352
|
+
# @param base [String] logical source path without extension.
|
|
353
|
+
# @param lib [Boolean] whether the source originated from lib/.
|
|
354
|
+
# @return [Boolean]
|
|
355
|
+
def spec_mirror?(test_rel, base, lib)
|
|
356
|
+
dirs = [Pairing.mirror_dir("spec", base)]
|
|
357
|
+
dirs << Pairing.mirror_dir("spec/lib", base) if lib
|
|
358
|
+
dirs.include?(File.dirname(test_rel))
|
|
157
359
|
end
|
|
158
360
|
|
|
159
361
|
# Shared cache dance for both build paths: hit the digest-keyed cache, else
|
|
@@ -166,8 +368,18 @@ module Mutineer
|
|
|
166
368
|
def cached_or(after_fork: nil)
|
|
167
369
|
@digest = compute_digest
|
|
168
370
|
cached = read_cache
|
|
169
|
-
|
|
371
|
+
# A cache from before load methods (#209) or test timings (#203) were
|
|
372
|
+
# saved rebuilds once. Boot mode reads its load lines and methods from
|
|
373
|
+
# the live boot instead (#build_via_fork), but its map from before #209
|
|
374
|
+
# lacks the `def` lines of the methods each test called.
|
|
375
|
+
if cached && cached["digest"] == @digest && dependencies_match?(cached) &&
|
|
376
|
+
cached.key?("load_methods") && cached.key?("timings")
|
|
170
377
|
@map = cached["map"] || {}
|
|
378
|
+
@timings = cached["timings"] || {}
|
|
379
|
+
unless @boot_path
|
|
380
|
+
@load_lines = Set.new(cached["load_lines"])
|
|
381
|
+
@load_methods = Set.new(cached["load_methods"])
|
|
382
|
+
end
|
|
171
383
|
@failed_test_files = cached["failed_test_files"] || []
|
|
172
384
|
@failed_clean_tests = []
|
|
173
385
|
@loaded_dependencies = cached["dependencies"] || {}
|
|
@@ -191,12 +403,15 @@ module Mutineer
|
|
|
191
403
|
def run_phase_a
|
|
192
404
|
@phase_a_ran = true
|
|
193
405
|
@map = {}
|
|
406
|
+
@load_lines = Set.new
|
|
407
|
+
@load_methods = Set.new
|
|
408
|
+
@timings = {}
|
|
194
409
|
@failed_test_files = []
|
|
195
410
|
@failed_clean_tests = []
|
|
196
411
|
@loaded_dependencies = {}
|
|
197
412
|
|
|
198
413
|
@test_paths.each do |test_path|
|
|
199
|
-
payload = capture(test_path)
|
|
414
|
+
payload = timed(test_path) { capture(test_path) }
|
|
200
415
|
next unless payload
|
|
201
416
|
|
|
202
417
|
accept_capture_payload(test_path, payload)
|
|
@@ -211,51 +426,83 @@ module Mutineer
|
|
|
211
426
|
def run_phase_a_via_fork(after_fork:)
|
|
212
427
|
@phase_a_ran = true
|
|
213
428
|
@map = {}
|
|
429
|
+
@timings = {}
|
|
214
430
|
@failed_test_files = []
|
|
215
431
|
@failed_clean_tests = []
|
|
216
432
|
@loaded_dependencies = {}
|
|
217
433
|
abs_sources = abs_source_paths
|
|
218
434
|
|
|
219
435
|
@test_paths.each do |test_path|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
436
|
+
accept_fork_payload(test_path, timed(test_path) { fork_capture(absolute(test_path), abs_sources, after_fork) })
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Runs the block, a capture of `test_path`, and records its wall-clock
|
|
441
|
+
# seconds in {#timings}. The parent times the whole capture, so the
|
|
442
|
+
# numbers compare files, not exact test cost.
|
|
443
|
+
#
|
|
444
|
+
# @api private
|
|
445
|
+
# @param test_path [String] test file path.
|
|
446
|
+
# @yieldreturn [Object] the capture result.
|
|
447
|
+
# @return [Object] the block's value.
|
|
448
|
+
def timed(test_path)
|
|
449
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
450
|
+
value = yield
|
|
451
|
+
@timings[relativize(test_path)] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
|
|
452
|
+
value
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Records a {#fork_capture} result. Hash = capture result, String = error
|
|
456
|
+
# diagnostic from the child, :timeout = the capture deadline passed, nil =
|
|
457
|
+
# pipe gone / empty. The String diagnostic is what becomes an
|
|
458
|
+
# :uncapturable status.
|
|
459
|
+
#
|
|
460
|
+
# @api private
|
|
461
|
+
# @param test_path [String] test file path.
|
|
462
|
+
# @param payload [Hash, String, Symbol, nil] what {#fork_capture} returned.
|
|
463
|
+
# @return [void]
|
|
464
|
+
def accept_fork_payload(test_path, payload)
|
|
465
|
+
case payload
|
|
466
|
+
when Hash then accept_capture_payload(test_path, payload)
|
|
467
|
+
when :timeout then fail_test(test_path, "timed out after #{@capture_timeout}s")
|
|
468
|
+
when String
|
|
469
|
+
fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
|
|
470
|
+
"fork capture produced no result (re-run with --verbose for the error)")
|
|
471
|
+
else fail_test(test_path, "fork capture produced no result")
|
|
230
472
|
end
|
|
231
473
|
end
|
|
232
474
|
|
|
233
475
|
# Fork the booted parent, run one test under the inherited Coverage, and
|
|
234
|
-
# return its per-source counts hash
|
|
476
|
+
# return its per-source counts hash, a String diagnostic on failure, or
|
|
477
|
+
# :timeout after `@capture_timeout` (see {#await_child}). Reuses the same
|
|
235
478
|
# fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
|
|
236
479
|
def fork_capture(abs_test, abs_sources, after_fork)
|
|
480
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
|
|
237
481
|
rd, wr = IO.pipe
|
|
238
482
|
# Marshal output is binary: an un-binmoded pipe can raise
|
|
239
483
|
# Encoding::UndefinedConversionError on write, which the child's rescue then
|
|
240
484
|
# swallows, losing the real error and yielding a bare "no result".
|
|
241
485
|
rd.binmode
|
|
242
486
|
wr.binmode
|
|
487
|
+
parent = Process.pid
|
|
243
488
|
pid = fork do
|
|
244
489
|
rd.close
|
|
490
|
+
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
491
|
+
OrphanGuard.start(parent)
|
|
245
492
|
payload =
|
|
246
493
|
begin
|
|
247
494
|
ChildStdout.silence
|
|
248
495
|
# Fork-safety hook: the in-process path reconnects AR; the daemon
|
|
249
|
-
# routes to its worker DB. Nil
|
|
250
|
-
# file needs neither Runner (Prism) nor Rails.
|
|
496
|
+
# drops its protocol channel and routes to its worker DB. Nil =
|
|
497
|
+
# no-op. Injected so this file needs neither Runner (Prism) nor Rails.
|
|
251
498
|
after_fork&.call
|
|
252
499
|
Coverage.result(clear: true, stop: false) # discard pre-test delta
|
|
253
500
|
passed = TestRunners.for(@framework).run([abs_test]).zero?
|
|
254
|
-
#
|
|
501
|
+
# {file => {lines: [...], methods: {...}}}; reduce to the counts
|
|
255
502
|
# array record() expects, keeping only our source files.
|
|
256
503
|
coverage = Coverage.result(stop: false)
|
|
257
504
|
.select { |f, _| abs_sources.include?(f) }
|
|
258
|
-
.transform_values { |v| v.is_a?(Hash) ? v
|
|
505
|
+
.transform_values { |v| v.is_a?(Hash) ? lines_with_called_defs(v) : v }
|
|
259
506
|
{ "passed" => passed, "coverage" => coverage,
|
|
260
507
|
"loaded_files" => capture_loaded_files }
|
|
261
508
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
@@ -273,18 +520,22 @@ module Mutineer
|
|
|
273
520
|
end
|
|
274
521
|
end
|
|
275
522
|
wr.close
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
523
|
+
# Marshal.load reads exactly one object, not until EOF: a process that the
|
|
524
|
+
# test leaves running (even outside the group) can hold the pipe open.
|
|
525
|
+
status, payload = await_child(pid, deadline) { read_marshal(rd) }
|
|
526
|
+
return :timeout unless status
|
|
527
|
+
|
|
279
528
|
# An empty pipe means the child died before writing (e.g. a hard crash,
|
|
280
529
|
# OOM, or a signal from the test's own subprocess handling). Report HOW it
|
|
281
530
|
# died (exit status / signal) as a diagnostic string so --verbose has
|
|
282
531
|
# something actionable instead of a silent "no result".
|
|
283
|
-
return "child wrote no result (#{describe_status(status)})" if
|
|
532
|
+
return "child wrote no result (#{describe_status(status)})" if payload.nil?
|
|
284
533
|
|
|
285
|
-
|
|
534
|
+
payload
|
|
286
535
|
rescue StandardError => e
|
|
287
536
|
"parent could not read capture result: #{e.class}: #{e.message}"
|
|
537
|
+
ensure
|
|
538
|
+
[rd, wr].compact.each { |io| io.close unless io.closed? }
|
|
288
539
|
end
|
|
289
540
|
|
|
290
541
|
# Human description of a child Process::Status for capture diagnostics.
|
|
@@ -356,6 +607,7 @@ module Mutineer
|
|
|
356
607
|
end
|
|
357
608
|
|
|
358
609
|
record_loaded(payload["loaded_files"])
|
|
610
|
+
record_load(payload["load_coverage"])
|
|
359
611
|
|
|
360
612
|
unless payload["passed"]
|
|
361
613
|
@failed_clean_tests << relativize(test_path)
|
|
@@ -401,16 +653,9 @@ module Mutineer
|
|
|
401
653
|
pending.each do |rel|
|
|
402
654
|
test_path = @test_paths.find { |t| relativize(t) == rel } || rel
|
|
403
655
|
if @boot_path
|
|
404
|
-
|
|
405
|
-
case payload
|
|
406
|
-
when Hash then accept_capture_payload(test_path, payload)
|
|
407
|
-
when String
|
|
408
|
-
fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
|
|
409
|
-
"fork capture produced no result (re-run with --verbose for the error)")
|
|
410
|
-
else fail_test(test_path, "fork capture produced no result")
|
|
411
|
-
end
|
|
656
|
+
accept_fork_payload(test_path, timed(test_path) { fork_capture(absolute(test_path), abs_sources, after_fork) })
|
|
412
657
|
else
|
|
413
|
-
payload = capture(test_path)
|
|
658
|
+
payload = timed(test_path) { capture(test_path) }
|
|
414
659
|
accept_capture_payload(test_path, payload) if payload
|
|
415
660
|
end
|
|
416
661
|
end
|
|
@@ -450,8 +695,8 @@ module Mutineer
|
|
|
450
695
|
# or the result. With `result: true`, the child writes its result as one
|
|
451
696
|
# line to fd {RESULT_FD}, a pipe that only the script uses. The child's
|
|
452
697
|
# stderr is the parent's stderr, so warnings from the script reach the
|
|
453
|
-
# user. A wall clock of `@capture_timeout` bounds the whole call
|
|
454
|
-
# test cannot wedge the run.
|
|
698
|
+
# user. A wall clock of `@capture_timeout` bounds the whole call (see
|
|
699
|
+
# {#await_child}), so a hung test cannot wedge the run.
|
|
455
700
|
#
|
|
456
701
|
# The parent reads one line, not until EOF: a process that a test leaves
|
|
457
702
|
# running can inherit fd {RESULT_FD} (a `fork` without `exec` keeps it
|
|
@@ -468,27 +713,72 @@ module Mutineer
|
|
|
468
713
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
|
|
469
714
|
script_rd, script_wr = IO.pipe
|
|
470
715
|
result_rd, result_wr = IO.pipe if result
|
|
471
|
-
options = { in: script_rd, out: File::NULL }
|
|
716
|
+
options = { in: script_rd, out: File::NULL, pgroup: true }
|
|
472
717
|
options[RESULT_FD] = result_wr if result
|
|
473
718
|
pid = Process.spawn(RbConfig.ruby, "-", **options)
|
|
474
|
-
waiter = Process.detach(pid)
|
|
475
719
|
script_rd.close
|
|
476
720
|
result_wr&.close
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
482
|
-
waiter.join
|
|
483
|
-
reader&.kill
|
|
484
|
-
return [nil, ""]
|
|
721
|
+
begin
|
|
722
|
+
script_wr.write(script)
|
|
723
|
+
rescue Errno::EPIPE
|
|
724
|
+
# The child exited before reading its script; await_child still reaps it.
|
|
485
725
|
end
|
|
486
|
-
|
|
726
|
+
script_wr.close
|
|
727
|
+
status, out = await_child(pid, deadline) { read_result_line(result_rd) if result }
|
|
728
|
+
[status, out.to_s]
|
|
487
729
|
ensure
|
|
488
|
-
reader&.kill
|
|
489
730
|
[script_rd, script_wr, result_rd, result_wr].compact.each { |io| io.close unless io.closed? }
|
|
490
731
|
end
|
|
491
732
|
|
|
733
|
+
# Reads the one result line a {#spawn_script} child writes to fd
|
|
734
|
+
# {RESULT_FD}.
|
|
735
|
+
#
|
|
736
|
+
# @api private
|
|
737
|
+
# @param io [IO] the parent end of the result pipe.
|
|
738
|
+
# @return [String] the line, or `""` at end of file.
|
|
739
|
+
def read_result_line(io) = io.gets.to_s
|
|
740
|
+
|
|
741
|
+
# Reads the one Marshal object a forked capture child writes, without
|
|
742
|
+
# waiting for end of file.
|
|
743
|
+
#
|
|
744
|
+
# @api private
|
|
745
|
+
# @param io [IO] the parent end of the binary result pipe.
|
|
746
|
+
# @return [Object, nil] the object, or nil when the child wrote none.
|
|
747
|
+
def read_marshal(io)
|
|
748
|
+
Marshal.load(io)
|
|
749
|
+
rescue EOFError
|
|
750
|
+
nil
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Waits for capture child `pid`, which leads its own process group, while
|
|
754
|
+
# a thread runs the block to read the child's result. One `deadline`
|
|
755
|
+
# bounds the run, the read, and the reap. Past it, the whole group gets
|
|
756
|
+
# SIGKILL, so a process the test started does not outlive a hung capture.
|
|
757
|
+
# Process.detach owns reaping. The same deadline and group kill as
|
|
758
|
+
# DaemonServer#wait_verdict, plus a reader, because the child reports
|
|
759
|
+
# through a pipe.
|
|
760
|
+
#
|
|
761
|
+
# A child that exits before the deadline has written its result, so the
|
|
762
|
+
# reader gets the time left, and at least {RESULT_GRACE} seconds, to finish.
|
|
763
|
+
#
|
|
764
|
+
# @api private
|
|
765
|
+
# @param pid [Integer] child pid, also its process group id.
|
|
766
|
+
# @param deadline [Float] a CLOCK_MONOTONIC time.
|
|
767
|
+
# @yieldreturn [Object] what the child wrote.
|
|
768
|
+
# @return [Array(Process::Status, Object)] the exit status and the block's
|
|
769
|
+
# value (nil when the reader did not finish); `[nil, nil]` after a timeout.
|
|
770
|
+
def await_child(pid, deadline, &read)
|
|
771
|
+
waiter = Process.detach(pid)
|
|
772
|
+
reader = Thread.new(&read)
|
|
773
|
+
reader.report_on_exception = false
|
|
774
|
+
return [nil, nil] unless waiter.join(remaining(deadline))
|
|
775
|
+
|
|
776
|
+
[waiter.value, reader.join([remaining(deadline), RESULT_GRACE].max)&.value]
|
|
777
|
+
ensure
|
|
778
|
+
kill_group(pid, waiter) if waiter&.alive?
|
|
779
|
+
reader&.kill
|
|
780
|
+
end
|
|
781
|
+
|
|
492
782
|
# Seconds left before `deadline`, never negative.
|
|
493
783
|
#
|
|
494
784
|
# @api private
|
|
@@ -517,12 +807,15 @@ module Mutineer
|
|
|
517
807
|
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
518
808
|
# @return [Boolean]
|
|
519
809
|
def fork_clean_pass?(abs_tests, after_fork)
|
|
810
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
|
|
520
811
|
rd, wr = IO.pipe
|
|
521
812
|
rd.binmode
|
|
522
813
|
wr.binmode
|
|
814
|
+
parent = Process.pid
|
|
523
815
|
pid = fork do
|
|
524
816
|
rd.close
|
|
525
817
|
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
818
|
+
OrphanGuard.start(parent)
|
|
526
819
|
begin
|
|
527
820
|
ChildStdout.silence
|
|
528
821
|
after_fork&.call
|
|
@@ -536,34 +829,32 @@ module Mutineer
|
|
|
536
829
|
end
|
|
537
830
|
end
|
|
538
831
|
wr.close
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
kill_fork_clean(pid)
|
|
542
|
-
rd.close
|
|
543
|
-
return false
|
|
544
|
-
end
|
|
545
|
-
data = rd.read
|
|
546
|
-
rd.close
|
|
547
|
-
Process.waitpid2(pid)
|
|
548
|
-
return false if data.empty?
|
|
549
|
-
|
|
550
|
-
Marshal.load(data)
|
|
832
|
+
_, passed = await_child(pid, deadline) { read_marshal(rd) }
|
|
833
|
+
passed == true
|
|
551
834
|
rescue StandardError
|
|
552
835
|
false
|
|
836
|
+
ensure
|
|
837
|
+
[rd, wr].compact.each { |io| io.close unless io.closed? }
|
|
553
838
|
end
|
|
554
839
|
|
|
555
|
-
# SIGKILLs
|
|
840
|
+
# SIGKILLs capture child `pid` and its process group, then waits for
|
|
841
|
+
# `waiter` to reap the child. Falls back to the pid alone when the group
|
|
842
|
+
# does not exist yet.
|
|
556
843
|
#
|
|
557
844
|
# @api private
|
|
558
|
-
# @param pid [Integer] child pid.
|
|
845
|
+
# @param pid [Integer] child pid, also its process group id.
|
|
846
|
+
# @param waiter [Thread] the Process.detach thread for `pid`.
|
|
559
847
|
# @return [void]
|
|
560
|
-
def
|
|
848
|
+
def kill_group(pid, waiter)
|
|
561
849
|
begin
|
|
562
850
|
Process.kill(:KILL, -pid)
|
|
563
851
|
rescue Errno::ESRCH, Errno::EPERM
|
|
564
|
-
|
|
852
|
+
# No group yet: the child has not run setpgid. Never signal a reaped pid.
|
|
853
|
+
if waiter.alive?
|
|
854
|
+
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
855
|
+
end
|
|
565
856
|
end
|
|
566
|
-
|
|
857
|
+
waiter.join
|
|
567
858
|
end
|
|
568
859
|
|
|
569
860
|
# Builds a pass/fail-only subprocess script (no coverage instrumentation).
|
|
@@ -575,8 +866,9 @@ module Mutineer
|
|
|
575
866
|
@framework == "rspec" ? rspec_clean_check_script(test_paths) : minitest_clean_check_script(test_paths)
|
|
576
867
|
end
|
|
577
868
|
|
|
578
|
-
# Minitest clean-suite check. Preloads configured sources
|
|
579
|
-
# standalone {Runner.execute}, so tests that rely on
|
|
869
|
+
# Minitest clean-suite check. Preloads configured sources and `--require`
|
|
870
|
+
# files like capture and standalone {Runner.execute}, so tests that rely on
|
|
871
|
+
# that preload stay green.
|
|
580
872
|
#
|
|
581
873
|
# @api private
|
|
582
874
|
# @param test_paths [Array<String>] test file paths.
|
|
@@ -591,7 +883,7 @@ module Mutineer
|
|
|
591
883
|
Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
|
|
592
884
|
Minitest.extensions << "mutineer_report"
|
|
593
885
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
594
|
-
#{
|
|
886
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
595
887
|
#{loads}
|
|
596
888
|
_passed = Minitest.run([])
|
|
597
889
|
$stderr.write(_report.string) unless _passed
|
|
@@ -599,7 +891,8 @@ module Mutineer
|
|
|
599
891
|
RUBY
|
|
600
892
|
end
|
|
601
893
|
|
|
602
|
-
# RSpec clean-suite check. Preloads configured sources
|
|
894
|
+
# RSpec clean-suite check. Preloads configured sources and `--require`
|
|
895
|
+
# files like capture.
|
|
603
896
|
#
|
|
604
897
|
# @api private
|
|
605
898
|
# @param test_paths [Array<String>] spec file paths.
|
|
@@ -615,7 +908,7 @@ module Mutineer
|
|
|
615
908
|
end
|
|
616
909
|
RSpec::Core::Runner.disable_autorun!
|
|
617
910
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
618
|
-
#{
|
|
911
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
619
912
|
_sink = StringIO.new
|
|
620
913
|
status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
|
|
621
914
|
$stderr.write(_sink.string) unless status.zero?
|
|
@@ -648,13 +941,16 @@ module Mutineer
|
|
|
648
941
|
_report = StringIO.new
|
|
649
942
|
Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
|
|
650
943
|
Minitest.extensions << "mutineer_report"
|
|
651
|
-
Coverage.start(lines: true)
|
|
944
|
+
Coverage.start(lines: true, methods: true)
|
|
652
945
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
653
|
-
#{
|
|
946
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
947
|
+
_load = #{load_coverage_expression}
|
|
654
948
|
load #{absolute(test_path).inspect}
|
|
655
949
|
_passed = Minitest.run([])
|
|
656
950
|
$stderr.write(_report.string) unless _passed
|
|
657
|
-
|
|
951
|
+
_coverage = Coverage.result.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
|
|
952
|
+
_result.puts JSON.generate("passed" => _passed == true, "coverage" => _coverage,
|
|
953
|
+
"load_coverage" => _load,
|
|
658
954
|
"loaded_files" => #{loaded_files_expression})
|
|
659
955
|
_result.close
|
|
660
956
|
RUBY
|
|
@@ -679,13 +975,16 @@ module Mutineer
|
|
|
679
975
|
exit 3
|
|
680
976
|
end
|
|
681
977
|
RSpec::Core::Runner.disable_autorun!
|
|
682
|
-
Coverage.start(lines: true)
|
|
978
|
+
Coverage.start(lines: true, methods: true)
|
|
683
979
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
684
|
-
#{
|
|
980
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
981
|
+
_load = #{load_coverage_expression}
|
|
685
982
|
_sink = StringIO.new
|
|
686
983
|
_status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
|
|
687
984
|
$stderr.write(_sink.string) unless _status.zero?
|
|
688
|
-
|
|
985
|
+
_coverage = Coverage.result.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
|
|
986
|
+
_result.puts JSON.generate("passed" => _status.zero?, "coverage" => _coverage,
|
|
987
|
+
"load_coverage" => _load,
|
|
689
988
|
"loaded_files" => #{loaded_files_expression})
|
|
690
989
|
_result.close
|
|
691
990
|
RUBY
|
|
@@ -696,16 +995,95 @@ module Mutineer
|
|
|
696
995
|
# path outside the project (stdlib/gem files).
|
|
697
996
|
def record(coverage, test_path)
|
|
698
997
|
rel_test = relativize(test_path)
|
|
998
|
+
each_covered_key(coverage) { |key| (@map[key] ||= []) << rel_test }
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
# Adds the configured source lines that ran at load to {#load_lines}, and
|
|
1002
|
+
# the methods called at load to {#load_methods}. Lines of other files
|
|
1003
|
+
# (tests, helpers, gems) are dropped.
|
|
1004
|
+
#
|
|
1005
|
+
# @api private
|
|
1006
|
+
# @param coverage [Hash, nil] counts per absolute file, as {#record} reads
|
|
1007
|
+
# them; a Hash entry can also hold "methods", as {#load_entry} builds it.
|
|
1008
|
+
# @return [void]
|
|
1009
|
+
def record_load(coverage)
|
|
1010
|
+
return unless coverage.is_a?(Hash)
|
|
1011
|
+
|
|
1012
|
+
@source_rels ||= @source_paths.to_set { |p| relativize(absolute(p)) }
|
|
1013
|
+
ours = coverage.select { |abs_file, _| @source_rels.include?(relativize(abs_file)) }
|
|
1014
|
+
each_covered_key(ours) { |key| @load_lines << key }
|
|
1015
|
+
ours.each do |abs_file, data|
|
|
1016
|
+
next unless data.is_a?(Hash) && data["methods"].is_a?(Array)
|
|
1017
|
+
|
|
1018
|
+
data["methods"].each { |line, column| @load_methods << "#{relativize(abs_file)}:#{line}:#{column}" }
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
# One file's line counts, with the `def` line of each method the test
|
|
1023
|
+
# called counted too (#209). A forked capture does not see the `def` lines
|
|
1024
|
+
# run, since the boot defined the methods before the test. Ruby counts no
|
|
1025
|
+
# line when an endless method runs, so without this its mutants would be
|
|
1026
|
+
# `no_coverage` even when a test calls it. A line where more than one
|
|
1027
|
+
# method starts is not counted: the map keys tests by line, so the test
|
|
1028
|
+
# would also be credited with the methods it did not call.
|
|
1029
|
+
#
|
|
1030
|
+
# @api private
|
|
1031
|
+
# @param data [Hash] `Coverage` result for one file, with `:lines` and `:methods`.
|
|
1032
|
+
# @return [Array, nil] line counts.
|
|
1033
|
+
def lines_with_called_defs(data)
|
|
1034
|
+
lines = data[:lines]
|
|
1035
|
+
return lines unless lines && data[:methods]
|
|
1036
|
+
|
|
1037
|
+
lines = lines.dup
|
|
1038
|
+
starts = data[:methods].keys.map { |key| key[2] }.tally
|
|
1039
|
+
data[:methods].each do |key, count|
|
|
1040
|
+
line = key[2]
|
|
1041
|
+
lines[line - 1] = [lines[line - 1].to_i, count].max if count.positive? && starts[line] == 1
|
|
1042
|
+
end
|
|
1043
|
+
lines
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
# One file's load-time coverage in the shape {#record_load} reads: the line
|
|
1047
|
+
# counts, and the `[line, column]` of each method called at least once.
|
|
1048
|
+
# {#load_coverage_expression} builds the same shape in a capture child.
|
|
1049
|
+
#
|
|
1050
|
+
# @api private
|
|
1051
|
+
# @param lines [Array, nil] line counts.
|
|
1052
|
+
# @param methods [Hash, nil] `Coverage` method counts, keyed by
|
|
1053
|
+
# `[owner, name, start_line, start_column, end_line, end_column]`.
|
|
1054
|
+
# @return [Hash{String => Array}]
|
|
1055
|
+
def load_entry(lines, methods)
|
|
1056
|
+
{ "lines" => lines, "methods" => (methods || {}).filter_map { |key, count| key[2, 2] if count.positive? } }
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
# Ruby source of the capture child's load-time coverage: `Coverage.peek_result`
|
|
1060
|
+
# for the configured sources only, matched by real path (a Coverage key is
|
|
1061
|
+
# the path as required), so the payload does not carry every loaded gem.
|
|
1062
|
+
# Each entry has the shape {#load_entry} builds.
|
|
1063
|
+
#
|
|
1064
|
+
# @api private
|
|
1065
|
+
# @return [String] expression to embed in a capture subprocess script.
|
|
1066
|
+
def load_coverage_expression
|
|
1067
|
+
"Coverage.peek_result.select { |f, _| #{abs_source_paths.inspect}.include?((File.realpath(f) rescue f)) }" \
|
|
1068
|
+
".transform_values { |v| { \"lines\" => v[:lines], " \
|
|
1069
|
+
"\"methods\" => v[:methods].filter_map { |k, n| k[2, 2] if n.positive? } } }"
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
# Yields a "file:line" key for every project line with a non-zero count.
|
|
1073
|
+
#
|
|
1074
|
+
# @api private
|
|
1075
|
+
# @param coverage [Hash] counts per absolute file: an Array, or a Hash with "lines".
|
|
1076
|
+
# @yieldparam key [String] the "file:line" key.
|
|
1077
|
+
# @return [void]
|
|
1078
|
+
def each_covered_key(coverage)
|
|
699
1079
|
coverage.each do |abs_file, data|
|
|
700
1080
|
rel = relativize(abs_file)
|
|
701
1081
|
next if rel.start_with?("/") # outside project_root: not our source
|
|
702
1082
|
|
|
703
|
-
counts = data.is_a?(
|
|
704
|
-
counts.
|
|
705
|
-
next unless count&.positive?
|
|
1083
|
+
counts = data.is_a?(Hash) ? data["lines"] : data
|
|
1084
|
+
next unless counts.is_a?(Array) # a Coverage mode without line counts
|
|
706
1085
|
|
|
707
|
-
|
|
708
|
-
end
|
|
1086
|
+
counts.each_with_index { |count, idx| yield "#{rel}:#{idx + 1}" if count&.positive? }
|
|
709
1087
|
end
|
|
710
1088
|
end
|
|
711
1089
|
|
|
@@ -808,16 +1186,62 @@ module Mutineer
|
|
|
808
1186
|
d = Digest::SHA256.new
|
|
809
1187
|
digest_group(d, "source", @source_paths)
|
|
810
1188
|
digest_group(d, "test", @test_paths)
|
|
811
|
-
digest_group
|
|
1189
|
+
# One group per file: digest_group sorts, and the load order matters.
|
|
1190
|
+
@require_paths.each { |p| digest_group(d, "require", [digest_path(p)]) }
|
|
1191
|
+
digest_group(d, "boot", [digest_path(@boot_path)]) if @boot_path
|
|
1192
|
+
ownership_paths.each do |rel|
|
|
1193
|
+
d.update("owner\0")
|
|
1194
|
+
d.update(rel)
|
|
1195
|
+
d.update("\0")
|
|
1196
|
+
end
|
|
812
1197
|
@load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
|
|
813
1198
|
d.update("framework\0#{@framework}\0")
|
|
814
1199
|
d.hexdigest
|
|
815
1200
|
end
|
|
816
1201
|
|
|
817
|
-
#
|
|
818
|
-
#
|
|
819
|
-
|
|
820
|
-
|
|
1202
|
+
# Longer source files that can take a split test from a configured source.
|
|
1203
|
+
# Only their presence is digested. A content edit of a sibling does not
|
|
1204
|
+
# change pairing, and it does not rebuild coverage. Each directory is
|
|
1205
|
+
# listed once, even when many sources share it.
|
|
1206
|
+
#
|
|
1207
|
+
# @return [Array<String>] project-relative paths, sorted.
|
|
1208
|
+
def ownership_paths
|
|
1209
|
+
root = File.expand_path(@project_root)
|
|
1210
|
+
listings = {}
|
|
1211
|
+
paths = @source_paths.flat_map do |source|
|
|
1212
|
+
rel = relativize(absolute(source))
|
|
1213
|
+
next [] if rel.start_with?("/")
|
|
1214
|
+
|
|
1215
|
+
base, = Pairing.logical_path(rel)
|
|
1216
|
+
name = File.basename(base)
|
|
1217
|
+
dir = File.dirname(base)
|
|
1218
|
+
folders = [dir == "." ? nil : dir]
|
|
1219
|
+
%w[app lib].each { |prefix| folders << (dir == "." ? prefix : File.join(prefix, dir)) }
|
|
1220
|
+
folders.uniq.flat_map do |folder|
|
|
1221
|
+
abs = folder ? File.join(root, folder) : root
|
|
1222
|
+
entries = listings.fetch(abs) do
|
|
1223
|
+
listings[abs] = File.directory?(abs) ? Dir.children(abs) : []
|
|
1224
|
+
end
|
|
1225
|
+
entries.filter_map do |entry|
|
|
1226
|
+
next unless entry.end_with?(".rb")
|
|
1227
|
+
|
|
1228
|
+
stem = entry.delete_suffix(".rb")
|
|
1229
|
+
next unless stem.start_with?("#{name}_") && stem.length > name.length
|
|
1230
|
+
|
|
1231
|
+
folder ? File.join(folder, entry) : entry
|
|
1232
|
+
end
|
|
1233
|
+
end
|
|
1234
|
+
end
|
|
1235
|
+
paths.uniq.sort
|
|
1236
|
+
end
|
|
1237
|
+
|
|
1238
|
+
# boot_path and require_paths are require-style paths (e.g. "config/environment",
|
|
1239
|
+
# no extension); resolve one to the file `require` loads: the path itself, then
|
|
1240
|
+
# ".rb", then a native extension. A folder of the same name (lib/my_gem/ next to
|
|
1241
|
+
# lib/my_gem.rb) is not the file.
|
|
1242
|
+
def digest_path(path)
|
|
1243
|
+
["", ".rb", ".#{RbConfig::CONFIG['DLEXT']}"].map { |ext| "#{path}#{ext}" }
|
|
1244
|
+
.find { |p| File.file?(absolute(p)) } || "#{path}.rb"
|
|
821
1245
|
end
|
|
822
1246
|
|
|
823
1247
|
# Groups a digest with its role and paths.
|
|
@@ -879,7 +1303,9 @@ module Mutineer
|
|
|
879
1303
|
|
|
880
1304
|
FileUtils.mkdir_p(@cache_dir)
|
|
881
1305
|
data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
|
|
882
|
-
"dependencies" => @loaded_dependencies, "map" => @map
|
|
1306
|
+
"dependencies" => @loaded_dependencies, "map" => @map,
|
|
1307
|
+
"load_lines" => @load_lines.to_a.sort, "load_methods" => @load_methods.to_a.sort,
|
|
1308
|
+
"timings" => @timings }
|
|
883
1309
|
tmp = "#{cache_path}.tmp"
|
|
884
1310
|
File.write(tmp, JSON.generate(data))
|
|
885
1311
|
File.rename(tmp, cache_path) # atomic swap
|
|
@@ -899,6 +1325,13 @@ module Mutineer
|
|
|
899
1325
|
# @return [Array<String>] absolute source paths.
|
|
900
1326
|
def abs_source_paths = @source_paths.map { |p| absolute(p) }
|
|
901
1327
|
|
|
1328
|
+
# The sources, then the `--require` files, in the order standalone
|
|
1329
|
+
# {Runner.execute} requires them before it forks the mutants (#217).
|
|
1330
|
+
#
|
|
1331
|
+
# @api private
|
|
1332
|
+
# @return [Array<String>] absolute paths.
|
|
1333
|
+
def abs_preload_paths = abs_source_paths + @require_paths.map { |p| absolute(p) }
|
|
1334
|
+
|
|
902
1335
|
# Returns absolute load paths.
|
|
903
1336
|
#
|
|
904
1337
|
# @return [Array<String>] absolute load paths.
|