mutineer 1.5.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 +225 -0
- data/README.md +28 -3
- data/lib/mutineer/cli.rb +5 -4
- data/lib/mutineer/coverage_map.rb +372 -86
- data/lib/mutineer/daemon_backend.rb +48 -33
- data/lib/mutineer/daemon_client.rb +42 -9
- data/lib/mutineer/daemon_server.rb +73 -27
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/isolation.rb +1 -0
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/minitest_integration.rb +48 -1
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/project.rb +337 -21
- data/lib/mutineer/rails_worker_db.rb +82 -8
- data/lib/mutineer/reporter.rb +20 -3
- data/lib/mutineer/result.rb +36 -4
- data/lib/mutineer/runner.rb +48 -284
- data/lib/mutineer/statement_lines.rb +40 -0
- data/lib/mutineer/subject.rb +6 -2
- data/lib/mutineer/test_runners/rspec.rb +39 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +1 -0
- metadata +3 -1
|
@@ -9,6 +9,7 @@ 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"
|
|
13
14
|
require_relative "pairing"
|
|
14
15
|
|
|
@@ -28,8 +29,36 @@ module Mutineer
|
|
|
28
29
|
# the parent. Stdout stays free for test output, which goes to File::NULL.
|
|
29
30
|
RESULT_FD = 3
|
|
30
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
|
+
|
|
31
37
|
attr_reader :project_root, :failed_test_files, :failed_clean_tests, :phase_a_ran, :map
|
|
32
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
|
+
|
|
33
62
|
# Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
|
|
34
63
|
# map app-side and ships `map` + `failed_test_files` over IPC; the tool
|
|
35
64
|
# reconstructs it here for per-mutant selection). Skips the capture machinery
|
|
@@ -40,10 +69,18 @@ module Mutineer
|
|
|
40
69
|
# @param failed_test_files [Array<String>] test files whose capture failed.
|
|
41
70
|
# @param project_root [String] project root (for path relativization).
|
|
42
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}).
|
|
43
76
|
# @return [Mutineer::CoverageMap] a query-only map.
|
|
44
|
-
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: {})
|
|
45
79
|
instance = allocate
|
|
46
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 || []))
|
|
47
84
|
instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
|
|
48
85
|
instance.instance_variable_set(:@failed_clean_tests, failed_clean_tests || [])
|
|
49
86
|
instance.instance_variable_set(:@project_root, project_root)
|
|
@@ -53,8 +90,9 @@ module Mutineer
|
|
|
53
90
|
def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
|
|
54
91
|
load_paths: ["lib"], project_root: Dir.pwd,
|
|
55
92
|
capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil,
|
|
56
|
-
framework: "minitest", verbose: false)
|
|
93
|
+
framework: "minitest", verbose: false, require_paths: [])
|
|
57
94
|
@source_paths = Array(source_paths)
|
|
95
|
+
@require_paths = Array(require_paths)
|
|
58
96
|
@test_paths = Array(test_paths)
|
|
59
97
|
@cache_dir = cache_dir
|
|
60
98
|
@load_paths = Array(load_paths)
|
|
@@ -66,6 +104,9 @@ module Mutineer
|
|
|
66
104
|
@map = {}
|
|
67
105
|
@failed_test_files = []
|
|
68
106
|
@failed_clean_tests = []
|
|
107
|
+
@load_lines = Set.new
|
|
108
|
+
@load_methods = Set.new
|
|
109
|
+
@timings = {}
|
|
69
110
|
@loaded_dependencies = {}
|
|
70
111
|
@phase_a_ran = false
|
|
71
112
|
end
|
|
@@ -83,8 +124,15 @@ module Mutineer
|
|
|
83
124
|
# the booted parent instead. Inverts into the same map #tests_for reads, and
|
|
84
125
|
# reuses the digest cache (the digest mixes in the boot file so a boot cache
|
|
85
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.
|
|
86
130
|
def build_via_fork(after_fork: nil)
|
|
87
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 })
|
|
88
136
|
cached_or(after_fork: after_fork) { run_phase_a_via_fork(after_fork: after_fork) }
|
|
89
137
|
end
|
|
90
138
|
|
|
@@ -95,6 +143,54 @@ module Mutineer
|
|
|
95
143
|
@map["#{relativize(file)}:#{line}"] || []
|
|
96
144
|
end
|
|
97
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
|
+
|
|
98
194
|
# Is this source file's empty coverage the result of an *errored* capture
|
|
99
195
|
# rather than a genuine coverage gap? True iff some capture failed this run
|
|
100
196
|
# AND this file got zero coverage from any successful capture AND a failed
|
|
@@ -272,8 +368,18 @@ module Mutineer
|
|
|
272
368
|
def cached_or(after_fork: nil)
|
|
273
369
|
@digest = compute_digest
|
|
274
370
|
cached = read_cache
|
|
275
|
-
|
|
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")
|
|
276
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
|
|
277
383
|
@failed_test_files = cached["failed_test_files"] || []
|
|
278
384
|
@failed_clean_tests = []
|
|
279
385
|
@loaded_dependencies = cached["dependencies"] || {}
|
|
@@ -297,12 +403,15 @@ module Mutineer
|
|
|
297
403
|
def run_phase_a
|
|
298
404
|
@phase_a_ran = true
|
|
299
405
|
@map = {}
|
|
406
|
+
@load_lines = Set.new
|
|
407
|
+
@load_methods = Set.new
|
|
408
|
+
@timings = {}
|
|
300
409
|
@failed_test_files = []
|
|
301
410
|
@failed_clean_tests = []
|
|
302
411
|
@loaded_dependencies = {}
|
|
303
412
|
|
|
304
413
|
@test_paths.each do |test_path|
|
|
305
|
-
payload = capture(test_path)
|
|
414
|
+
payload = timed(test_path) { capture(test_path) }
|
|
306
415
|
next unless payload
|
|
307
416
|
|
|
308
417
|
accept_capture_payload(test_path, payload)
|
|
@@ -317,51 +426,83 @@ module Mutineer
|
|
|
317
426
|
def run_phase_a_via_fork(after_fork:)
|
|
318
427
|
@phase_a_ran = true
|
|
319
428
|
@map = {}
|
|
429
|
+
@timings = {}
|
|
320
430
|
@failed_test_files = []
|
|
321
431
|
@failed_clean_tests = []
|
|
322
432
|
@loaded_dependencies = {}
|
|
323
433
|
abs_sources = abs_source_paths
|
|
324
434
|
|
|
325
435
|
@test_paths.each do |test_path|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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")
|
|
336
472
|
end
|
|
337
473
|
end
|
|
338
474
|
|
|
339
475
|
# Fork the booted parent, run one test under the inherited Coverage, and
|
|
340
|
-
# 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
|
|
341
478
|
# fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
|
|
342
479
|
def fork_capture(abs_test, abs_sources, after_fork)
|
|
480
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
|
|
343
481
|
rd, wr = IO.pipe
|
|
344
482
|
# Marshal output is binary: an un-binmoded pipe can raise
|
|
345
483
|
# Encoding::UndefinedConversionError on write, which the child's rescue then
|
|
346
484
|
# swallows, losing the real error and yielding a bare "no result".
|
|
347
485
|
rd.binmode
|
|
348
486
|
wr.binmode
|
|
487
|
+
parent = Process.pid
|
|
349
488
|
pid = fork do
|
|
350
489
|
rd.close
|
|
490
|
+
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
491
|
+
OrphanGuard.start(parent)
|
|
351
492
|
payload =
|
|
352
493
|
begin
|
|
353
494
|
ChildStdout.silence
|
|
354
495
|
# Fork-safety hook: the in-process path reconnects AR; the daemon
|
|
355
|
-
# routes to its worker DB. Nil
|
|
356
|
-
# 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.
|
|
357
498
|
after_fork&.call
|
|
358
499
|
Coverage.result(clear: true, stop: false) # discard pre-test delta
|
|
359
500
|
passed = TestRunners.for(@framework).run([abs_test]).zero?
|
|
360
|
-
#
|
|
501
|
+
# {file => {lines: [...], methods: {...}}}; reduce to the counts
|
|
361
502
|
# array record() expects, keeping only our source files.
|
|
362
503
|
coverage = Coverage.result(stop: false)
|
|
363
504
|
.select { |f, _| abs_sources.include?(f) }
|
|
364
|
-
.transform_values { |v| v.is_a?(Hash) ? v
|
|
505
|
+
.transform_values { |v| v.is_a?(Hash) ? lines_with_called_defs(v) : v }
|
|
365
506
|
{ "passed" => passed, "coverage" => coverage,
|
|
366
507
|
"loaded_files" => capture_loaded_files }
|
|
367
508
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
@@ -379,18 +520,22 @@ module Mutineer
|
|
|
379
520
|
end
|
|
380
521
|
end
|
|
381
522
|
wr.close
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
+
|
|
385
528
|
# An empty pipe means the child died before writing (e.g. a hard crash,
|
|
386
529
|
# OOM, or a signal from the test's own subprocess handling). Report HOW it
|
|
387
530
|
# died (exit status / signal) as a diagnostic string so --verbose has
|
|
388
531
|
# something actionable instead of a silent "no result".
|
|
389
|
-
return "child wrote no result (#{describe_status(status)})" if
|
|
532
|
+
return "child wrote no result (#{describe_status(status)})" if payload.nil?
|
|
390
533
|
|
|
391
|
-
|
|
534
|
+
payload
|
|
392
535
|
rescue StandardError => e
|
|
393
536
|
"parent could not read capture result: #{e.class}: #{e.message}"
|
|
537
|
+
ensure
|
|
538
|
+
[rd, wr].compact.each { |io| io.close unless io.closed? }
|
|
394
539
|
end
|
|
395
540
|
|
|
396
541
|
# Human description of a child Process::Status for capture diagnostics.
|
|
@@ -462,6 +607,7 @@ module Mutineer
|
|
|
462
607
|
end
|
|
463
608
|
|
|
464
609
|
record_loaded(payload["loaded_files"])
|
|
610
|
+
record_load(payload["load_coverage"])
|
|
465
611
|
|
|
466
612
|
unless payload["passed"]
|
|
467
613
|
@failed_clean_tests << relativize(test_path)
|
|
@@ -507,16 +653,9 @@ module Mutineer
|
|
|
507
653
|
pending.each do |rel|
|
|
508
654
|
test_path = @test_paths.find { |t| relativize(t) == rel } || rel
|
|
509
655
|
if @boot_path
|
|
510
|
-
|
|
511
|
-
case payload
|
|
512
|
-
when Hash then accept_capture_payload(test_path, payload)
|
|
513
|
-
when String
|
|
514
|
-
fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
|
|
515
|
-
"fork capture produced no result (re-run with --verbose for the error)")
|
|
516
|
-
else fail_test(test_path, "fork capture produced no result")
|
|
517
|
-
end
|
|
656
|
+
accept_fork_payload(test_path, timed(test_path) { fork_capture(absolute(test_path), abs_sources, after_fork) })
|
|
518
657
|
else
|
|
519
|
-
payload = capture(test_path)
|
|
658
|
+
payload = timed(test_path) { capture(test_path) }
|
|
520
659
|
accept_capture_payload(test_path, payload) if payload
|
|
521
660
|
end
|
|
522
661
|
end
|
|
@@ -556,8 +695,8 @@ module Mutineer
|
|
|
556
695
|
# or the result. With `result: true`, the child writes its result as one
|
|
557
696
|
# line to fd {RESULT_FD}, a pipe that only the script uses. The child's
|
|
558
697
|
# stderr is the parent's stderr, so warnings from the script reach the
|
|
559
|
-
# user. A wall clock of `@capture_timeout` bounds the whole call
|
|
560
|
-
# 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.
|
|
561
700
|
#
|
|
562
701
|
# The parent reads one line, not until EOF: a process that a test leaves
|
|
563
702
|
# running can inherit fd {RESULT_FD} (a `fork` without `exec` keeps it
|
|
@@ -574,27 +713,72 @@ module Mutineer
|
|
|
574
713
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
|
|
575
714
|
script_rd, script_wr = IO.pipe
|
|
576
715
|
result_rd, result_wr = IO.pipe if result
|
|
577
|
-
options = { in: script_rd, out: File::NULL }
|
|
716
|
+
options = { in: script_rd, out: File::NULL, pgroup: true }
|
|
578
717
|
options[RESULT_FD] = result_wr if result
|
|
579
718
|
pid = Process.spawn(RbConfig.ruby, "-", **options)
|
|
580
|
-
waiter = Process.detach(pid)
|
|
581
719
|
script_rd.close
|
|
582
720
|
result_wr&.close
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
588
|
-
waiter.join
|
|
589
|
-
reader&.kill
|
|
590
|
-
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.
|
|
591
725
|
end
|
|
592
|
-
|
|
726
|
+
script_wr.close
|
|
727
|
+
status, out = await_child(pid, deadline) { read_result_line(result_rd) if result }
|
|
728
|
+
[status, out.to_s]
|
|
593
729
|
ensure
|
|
594
|
-
reader&.kill
|
|
595
730
|
[script_rd, script_wr, result_rd, result_wr].compact.each { |io| io.close unless io.closed? }
|
|
596
731
|
end
|
|
597
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
|
+
|
|
598
782
|
# Seconds left before `deadline`, never negative.
|
|
599
783
|
#
|
|
600
784
|
# @api private
|
|
@@ -623,12 +807,15 @@ module Mutineer
|
|
|
623
807
|
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
624
808
|
# @return [Boolean]
|
|
625
809
|
def fork_clean_pass?(abs_tests, after_fork)
|
|
810
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
|
|
626
811
|
rd, wr = IO.pipe
|
|
627
812
|
rd.binmode
|
|
628
813
|
wr.binmode
|
|
814
|
+
parent = Process.pid
|
|
629
815
|
pid = fork do
|
|
630
816
|
rd.close
|
|
631
817
|
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
818
|
+
OrphanGuard.start(parent)
|
|
632
819
|
begin
|
|
633
820
|
ChildStdout.silence
|
|
634
821
|
after_fork&.call
|
|
@@ -642,34 +829,32 @@ module Mutineer
|
|
|
642
829
|
end
|
|
643
830
|
end
|
|
644
831
|
wr.close
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
kill_fork_clean(pid)
|
|
648
|
-
rd.close
|
|
649
|
-
return false
|
|
650
|
-
end
|
|
651
|
-
data = rd.read
|
|
652
|
-
rd.close
|
|
653
|
-
Process.waitpid2(pid)
|
|
654
|
-
return false if data.empty?
|
|
655
|
-
|
|
656
|
-
Marshal.load(data)
|
|
832
|
+
_, passed = await_child(pid, deadline) { read_marshal(rd) }
|
|
833
|
+
passed == true
|
|
657
834
|
rescue StandardError
|
|
658
835
|
false
|
|
836
|
+
ensure
|
|
837
|
+
[rd, wr].compact.each { |io| io.close unless io.closed? }
|
|
659
838
|
end
|
|
660
839
|
|
|
661
|
-
# 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.
|
|
662
843
|
#
|
|
663
844
|
# @api private
|
|
664
|
-
# @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`.
|
|
665
847
|
# @return [void]
|
|
666
|
-
def
|
|
848
|
+
def kill_group(pid, waiter)
|
|
667
849
|
begin
|
|
668
850
|
Process.kill(:KILL, -pid)
|
|
669
851
|
rescue Errno::ESRCH, Errno::EPERM
|
|
670
|
-
|
|
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
|
|
671
856
|
end
|
|
672
|
-
|
|
857
|
+
waiter.join
|
|
673
858
|
end
|
|
674
859
|
|
|
675
860
|
# Builds a pass/fail-only subprocess script (no coverage instrumentation).
|
|
@@ -681,8 +866,9 @@ module Mutineer
|
|
|
681
866
|
@framework == "rspec" ? rspec_clean_check_script(test_paths) : minitest_clean_check_script(test_paths)
|
|
682
867
|
end
|
|
683
868
|
|
|
684
|
-
# Minitest clean-suite check. Preloads configured sources
|
|
685
|
-
# 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.
|
|
686
872
|
#
|
|
687
873
|
# @api private
|
|
688
874
|
# @param test_paths [Array<String>] test file paths.
|
|
@@ -697,7 +883,7 @@ module Mutineer
|
|
|
697
883
|
Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
|
|
698
884
|
Minitest.extensions << "mutineer_report"
|
|
699
885
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
700
|
-
#{
|
|
886
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
701
887
|
#{loads}
|
|
702
888
|
_passed = Minitest.run([])
|
|
703
889
|
$stderr.write(_report.string) unless _passed
|
|
@@ -705,7 +891,8 @@ module Mutineer
|
|
|
705
891
|
RUBY
|
|
706
892
|
end
|
|
707
893
|
|
|
708
|
-
# RSpec clean-suite check. Preloads configured sources
|
|
894
|
+
# RSpec clean-suite check. Preloads configured sources and `--require`
|
|
895
|
+
# files like capture.
|
|
709
896
|
#
|
|
710
897
|
# @api private
|
|
711
898
|
# @param test_paths [Array<String>] spec file paths.
|
|
@@ -721,7 +908,7 @@ module Mutineer
|
|
|
721
908
|
end
|
|
722
909
|
RSpec::Core::Runner.disable_autorun!
|
|
723
910
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
724
|
-
#{
|
|
911
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
725
912
|
_sink = StringIO.new
|
|
726
913
|
status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
|
|
727
914
|
$stderr.write(_sink.string) unless status.zero?
|
|
@@ -754,13 +941,16 @@ module Mutineer
|
|
|
754
941
|
_report = StringIO.new
|
|
755
942
|
Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
|
|
756
943
|
Minitest.extensions << "mutineer_report"
|
|
757
|
-
Coverage.start(lines: true)
|
|
944
|
+
Coverage.start(lines: true, methods: true)
|
|
758
945
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
759
|
-
#{
|
|
946
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
947
|
+
_load = #{load_coverage_expression}
|
|
760
948
|
load #{absolute(test_path).inspect}
|
|
761
949
|
_passed = Minitest.run([])
|
|
762
950
|
$stderr.write(_report.string) unless _passed
|
|
763
|
-
|
|
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,
|
|
764
954
|
"loaded_files" => #{loaded_files_expression})
|
|
765
955
|
_result.close
|
|
766
956
|
RUBY
|
|
@@ -785,13 +975,16 @@ module Mutineer
|
|
|
785
975
|
exit 3
|
|
786
976
|
end
|
|
787
977
|
RSpec::Core::Runner.disable_autorun!
|
|
788
|
-
Coverage.start(lines: true)
|
|
978
|
+
Coverage.start(lines: true, methods: true)
|
|
789
979
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
790
|
-
#{
|
|
980
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
981
|
+
_load = #{load_coverage_expression}
|
|
791
982
|
_sink = StringIO.new
|
|
792
983
|
_status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
|
|
793
984
|
$stderr.write(_sink.string) unless _status.zero?
|
|
794
|
-
|
|
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,
|
|
795
988
|
"loaded_files" => #{loaded_files_expression})
|
|
796
989
|
_result.close
|
|
797
990
|
RUBY
|
|
@@ -802,16 +995,95 @@ module Mutineer
|
|
|
802
995
|
# path outside the project (stdlib/gem files).
|
|
803
996
|
def record(coverage, test_path)
|
|
804
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)
|
|
805
1079
|
coverage.each do |abs_file, data|
|
|
806
1080
|
rel = relativize(abs_file)
|
|
807
1081
|
next if rel.start_with?("/") # outside project_root: not our source
|
|
808
1082
|
|
|
809
|
-
counts = data.is_a?(
|
|
810
|
-
counts.
|
|
811
|
-
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
|
|
812
1085
|
|
|
813
|
-
|
|
814
|
-
end
|
|
1086
|
+
counts.each_with_index { |count, idx| yield "#{rel}:#{idx + 1}" if count&.positive? }
|
|
815
1087
|
end
|
|
816
1088
|
end
|
|
817
1089
|
|
|
@@ -914,7 +1186,9 @@ module Mutineer
|
|
|
914
1186
|
d = Digest::SHA256.new
|
|
915
1187
|
digest_group(d, "source", @source_paths)
|
|
916
1188
|
digest_group(d, "test", @test_paths)
|
|
917
|
-
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
|
|
918
1192
|
ownership_paths.each do |rel|
|
|
919
1193
|
d.update("owner\0")
|
|
920
1194
|
d.update(rel)
|
|
@@ -961,10 +1235,13 @@ module Mutineer
|
|
|
961
1235
|
paths.uniq.sort
|
|
962
1236
|
end
|
|
963
1237
|
|
|
964
|
-
# boot_path
|
|
965
|
-
# resolve
|
|
966
|
-
|
|
967
|
-
|
|
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"
|
|
968
1245
|
end
|
|
969
1246
|
|
|
970
1247
|
# Groups a digest with its role and paths.
|
|
@@ -1026,7 +1303,9 @@ module Mutineer
|
|
|
1026
1303
|
|
|
1027
1304
|
FileUtils.mkdir_p(@cache_dir)
|
|
1028
1305
|
data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
|
|
1029
|
-
"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 }
|
|
1030
1309
|
tmp = "#{cache_path}.tmp"
|
|
1031
1310
|
File.write(tmp, JSON.generate(data))
|
|
1032
1311
|
File.rename(tmp, cache_path) # atomic swap
|
|
@@ -1046,6 +1325,13 @@ module Mutineer
|
|
|
1046
1325
|
# @return [Array<String>] absolute source paths.
|
|
1047
1326
|
def abs_source_paths = @source_paths.map { |p| absolute(p) }
|
|
1048
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
|
+
|
|
1049
1335
|
# Returns absolute load paths.
|
|
1050
1336
|
#
|
|
1051
1337
|
# @return [Array<String>] absolute load paths.
|