mutineer 0.11.4 → 1.0.1
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 +91 -0
- data/README.md +20 -4
- data/lib/mutineer/baseline.rb +48 -10
- data/lib/mutineer/changed_lines.rb +11 -3
- data/lib/mutineer/cli.rb +18 -3
- data/lib/mutineer/config.rb +5 -0
- data/lib/mutineer/coverage_map.rb +404 -30
- data/lib/mutineer/daemon_backend.rb +18 -2
- data/lib/mutineer/daemon_client.rb +7 -4
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/file_swap.rb +136 -40
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/reporter.rb +22 -6
- data/lib/mutineer/runner.rb +59 -32
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +7 -1
- data/lib/mutineer.rb +1 -0
- metadata +3 -1
|
@@ -22,7 +22,7 @@ module Mutineer
|
|
|
22
22
|
# Seconds per coverage subprocess before the parent kills it.
|
|
23
23
|
DEFAULT_CAPTURE_TIMEOUT = 120
|
|
24
24
|
|
|
25
|
-
attr_reader :project_root, :failed_test_files, :phase_a_ran, :map
|
|
25
|
+
attr_reader :project_root, :failed_test_files, :failed_clean_tests, :phase_a_ran, :map
|
|
26
26
|
|
|
27
27
|
# Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
|
|
28
28
|
# map app-side and ships `map` + `failed_test_files` over IPC; the tool
|
|
@@ -33,11 +33,13 @@ module Mutineer
|
|
|
33
33
|
# @param map [Hash] the "file:line" => [test_files] map.
|
|
34
34
|
# @param failed_test_files [Array<String>] test files whose capture failed.
|
|
35
35
|
# @param project_root [String] project root (for path relativization).
|
|
36
|
+
# @param failed_clean_tests [Array<String>] test files whose unmutated run failed.
|
|
36
37
|
# @return [Mutineer::CoverageMap] a query-only map.
|
|
37
|
-
def self.from_data(map:, failed_test_files:, project_root:)
|
|
38
|
+
def self.from_data(map:, failed_test_files:, project_root:, failed_clean_tests: [])
|
|
38
39
|
instance = allocate
|
|
39
40
|
instance.instance_variable_set(:@map, map || {})
|
|
40
41
|
instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
|
|
42
|
+
instance.instance_variable_set(:@failed_clean_tests, failed_clean_tests || [])
|
|
41
43
|
instance.instance_variable_set(:@project_root, project_root)
|
|
42
44
|
instance
|
|
43
45
|
end
|
|
@@ -57,6 +59,8 @@ module Mutineer
|
|
|
57
59
|
@verbose = verbose
|
|
58
60
|
@map = {}
|
|
59
61
|
@failed_test_files = []
|
|
62
|
+
@failed_clean_tests = []
|
|
63
|
+
@loaded_dependencies = {}
|
|
60
64
|
@phase_a_ran = false
|
|
61
65
|
end
|
|
62
66
|
|
|
@@ -75,7 +79,7 @@ module Mutineer
|
|
|
75
79
|
# never collides with a standalone one).
|
|
76
80
|
def build_via_fork(after_fork: nil)
|
|
77
81
|
warn_external_sources
|
|
78
|
-
cached_or { run_phase_a_via_fork(after_fork: after_fork) }
|
|
82
|
+
cached_or(after_fork: after_fork) { run_phase_a_via_fork(after_fork: after_fork) }
|
|
79
83
|
end
|
|
80
84
|
|
|
81
85
|
# Lookup: the test files that cover `file:line`, or [] when none do.
|
|
@@ -141,18 +145,30 @@ module Mutineer
|
|
|
141
145
|
end
|
|
142
146
|
|
|
143
147
|
# Shared cache dance for both build paths: hit the digest-keyed cache, else
|
|
144
|
-
# yield to populate @map and persist it.
|
|
145
|
-
|
|
148
|
+
# yield to populate @map and persist it. A digest match is not proof that
|
|
149
|
+
# today's unmutated suite still passes — re-check on a cache hit.
|
|
150
|
+
#
|
|
151
|
+
# @param after_fork [Proc, nil] boot-mode fork hook forwarded to a clean re-check.
|
|
152
|
+
# @yield when the cache is missing or stale.
|
|
153
|
+
# @return [Mutineer::CoverageMap] self.
|
|
154
|
+
def cached_or(after_fork: nil)
|
|
146
155
|
@digest = compute_digest
|
|
147
156
|
cached = read_cache
|
|
148
|
-
if cached && cached["digest"] == @digest
|
|
157
|
+
if cached && cached["digest"] == @digest && dependencies_match?(cached)
|
|
149
158
|
@map = cached["map"] || {}
|
|
150
159
|
@failed_test_files = cached["failed_test_files"] || []
|
|
160
|
+
@failed_clean_tests = []
|
|
161
|
+
@loaded_dependencies = cached["dependencies"] || {}
|
|
162
|
+
retry_failed_captures(after_fork)
|
|
151
163
|
warn_incomplete unless @failed_test_files.empty?
|
|
164
|
+
verify_cached_clean(after_fork: after_fork)
|
|
165
|
+
verify_combined_clean(after_fork: after_fork)
|
|
166
|
+
save
|
|
152
167
|
return self
|
|
153
168
|
end
|
|
154
169
|
|
|
155
170
|
yield
|
|
171
|
+
verify_combined_clean(after_fork: after_fork)
|
|
156
172
|
save
|
|
157
173
|
self
|
|
158
174
|
end
|
|
@@ -164,12 +180,14 @@ module Mutineer
|
|
|
164
180
|
@phase_a_ran = true
|
|
165
181
|
@map = {}
|
|
166
182
|
@failed_test_files = []
|
|
183
|
+
@failed_clean_tests = []
|
|
184
|
+
@loaded_dependencies = {}
|
|
167
185
|
|
|
168
186
|
@test_paths.each do |test_path|
|
|
169
|
-
|
|
170
|
-
next unless
|
|
187
|
+
payload = capture(test_path)
|
|
188
|
+
next unless payload
|
|
171
189
|
|
|
172
|
-
|
|
190
|
+
accept_capture_payload(test_path, payload)
|
|
173
191
|
end
|
|
174
192
|
end
|
|
175
193
|
|
|
@@ -182,16 +200,18 @@ module Mutineer
|
|
|
182
200
|
@phase_a_ran = true
|
|
183
201
|
@map = {}
|
|
184
202
|
@failed_test_files = []
|
|
203
|
+
@failed_clean_tests = []
|
|
204
|
+
@loaded_dependencies = {}
|
|
185
205
|
abs_sources = abs_source_paths
|
|
186
206
|
|
|
187
207
|
@test_paths.each do |test_path|
|
|
188
|
-
# Tri-state payload: Hash =
|
|
189
|
-
# child, nil = pipe gone / empty. The String diagnostic is what
|
|
190
|
-
# an :uncapturable status.
|
|
191
|
-
case (
|
|
192
|
-
when Hash
|
|
208
|
+
# Tri-state payload: Hash = capture result, String = error diagnostic from
|
|
209
|
+
# the child, nil = pipe gone / empty. The String diagnostic is what
|
|
210
|
+
# becomes an :uncapturable status.
|
|
211
|
+
case (payload = fork_capture(absolute(test_path), abs_sources, after_fork))
|
|
212
|
+
when Hash then accept_capture_payload(test_path, payload)
|
|
193
213
|
when String
|
|
194
|
-
fail_test(test_path, @verbose ? "fork capture failed: #{
|
|
214
|
+
fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
|
|
195
215
|
"fork capture produced no result (re-run with --verbose for the error)")
|
|
196
216
|
else fail_test(test_path, "fork capture produced no result")
|
|
197
217
|
end
|
|
@@ -217,12 +237,14 @@ module Mutineer
|
|
|
217
237
|
# file needs neither Runner (Prism) nor Rails.
|
|
218
238
|
after_fork&.call
|
|
219
239
|
Coverage.result(clear: true, stop: false) # discard pre-test delta
|
|
220
|
-
TestRunners.for(@framework).run([abs_test])
|
|
240
|
+
passed = TestRunners.for(@framework).run([abs_test]).zero?
|
|
221
241
|
# lines:true yields {file => {lines: [...]}}; reduce to the counts
|
|
222
242
|
# array record() expects, keeping only our source files.
|
|
223
|
-
Coverage.result(stop: false)
|
|
224
|
-
|
|
225
|
-
|
|
243
|
+
coverage = Coverage.result(stop: false)
|
|
244
|
+
.select { |f, _| abs_sources.include?(f) }
|
|
245
|
+
.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
|
|
246
|
+
{ "passed" => passed, "coverage" => coverage,
|
|
247
|
+
"loaded_files" => capture_loaded_files }
|
|
226
248
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
227
249
|
# Stringify (an arbitrary Exception may not marshal); the parent
|
|
228
250
|
# surfaces this under --verbose. A String marshals safely over the pipe.
|
|
@@ -268,8 +290,8 @@ module Mutineer
|
|
|
268
290
|
|
|
269
291
|
# Spawns a fresh `ruby` reading an inline script from stdin. A fork would
|
|
270
292
|
# miss already-loaded app lines, so Coverage must start in a clean process
|
|
271
|
-
# before any source is loaded. Returns the
|
|
272
|
-
# nil when the subprocess failed (logged + skipped).
|
|
293
|
+
# before any source is loaded. Returns the wrapped capture payload
|
|
294
|
+
# (`passed` + `coverage`), or nil when the subprocess failed (logged + skipped).
|
|
273
295
|
def capture(test_path)
|
|
274
296
|
out = +""
|
|
275
297
|
status = nil
|
|
@@ -289,7 +311,10 @@ module Mutineer
|
|
|
289
311
|
end
|
|
290
312
|
return fail_test(test_path, "subprocess exited #{status.exitstatus}") unless status.success?
|
|
291
313
|
|
|
292
|
-
JSON.parse(out)
|
|
314
|
+
parsed = JSON.parse(out)
|
|
315
|
+
return fail_test(test_path, "invalid coverage output: missing pass/coverage payload") unless wrapped_capture?(parsed)
|
|
316
|
+
|
|
317
|
+
parsed
|
|
293
318
|
rescue JSON::ParserError => e
|
|
294
319
|
fail_test(test_path, "invalid coverage output: #{e.message}")
|
|
295
320
|
end
|
|
@@ -307,6 +332,241 @@ module Mutineer
|
|
|
307
332
|
nil
|
|
308
333
|
end
|
|
309
334
|
|
|
335
|
+
# True when `payload` is the wrapped capture JSON/Marshal contract
|
|
336
|
+
# (`passed` + `coverage`), not a raw Coverage.result hash.
|
|
337
|
+
#
|
|
338
|
+
# @api private
|
|
339
|
+
# @param payload [Object] parsed subprocess output or forked Marshal value.
|
|
340
|
+
# @return [Boolean]
|
|
341
|
+
def wrapped_capture?(payload)
|
|
342
|
+
payload.is_a?(Hash) && payload.key?("passed") && payload.key?("coverage")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Records a wrapped capture: assertion failures go to {#failed_clean_tests};
|
|
346
|
+
# successful coverage is inverted into the map. Capture crashes stay in
|
|
347
|
+
# {#failed_test_files} via {#fail_test}.
|
|
348
|
+
#
|
|
349
|
+
# @api private
|
|
350
|
+
# @param test_path [String] test file path.
|
|
351
|
+
# @param payload [Hash] wrapped capture with string keys.
|
|
352
|
+
# @return [void]
|
|
353
|
+
def accept_capture_payload(test_path, payload)
|
|
354
|
+
unless wrapped_capture?(payload)
|
|
355
|
+
fail_test(test_path, "invalid coverage output: missing pass/coverage payload")
|
|
356
|
+
return
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
record_loaded(payload["loaded_files"])
|
|
360
|
+
|
|
361
|
+
unless payload["passed"]
|
|
362
|
+
@failed_clean_tests << relativize(test_path)
|
|
363
|
+
return
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
coverage = payload["coverage"]
|
|
367
|
+
record(coverage, test_path) if coverage.is_a?(Hash)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Re-runs each successfully captured test on a cache hit. Digest equality
|
|
371
|
+
# cannot prove the current unmutated suite still passes.
|
|
372
|
+
#
|
|
373
|
+
# @api private
|
|
374
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
375
|
+
# @return [void]
|
|
376
|
+
def verify_cached_clean(after_fork: nil)
|
|
377
|
+
@test_paths.each do |test_path|
|
|
378
|
+
rel = relativize(test_path)
|
|
379
|
+
next if @failed_test_files.include?(rel)
|
|
380
|
+
|
|
381
|
+
ok = if @boot_path
|
|
382
|
+
fork_clean_pass?([absolute(test_path)], after_fork)
|
|
383
|
+
else
|
|
384
|
+
subprocess_clean_pass?([test_path])
|
|
385
|
+
end
|
|
386
|
+
@failed_clean_tests << rel unless ok
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Re-runs tests whose previous capture crashed. A fixed helper is invisible
|
|
391
|
+
# to the source/test digest when that capture never recorded `loaded_files`.
|
|
392
|
+
#
|
|
393
|
+
# @api private
|
|
394
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
395
|
+
# @return [void]
|
|
396
|
+
def retry_failed_captures(after_fork)
|
|
397
|
+
pending = @failed_test_files.dup
|
|
398
|
+
return if pending.empty?
|
|
399
|
+
|
|
400
|
+
@failed_test_files = []
|
|
401
|
+
abs_sources = abs_source_paths
|
|
402
|
+
pending.each do |rel|
|
|
403
|
+
test_path = @test_paths.find { |t| relativize(t) == rel } || rel
|
|
404
|
+
if @boot_path
|
|
405
|
+
payload = fork_capture(absolute(test_path), abs_sources, after_fork)
|
|
406
|
+
case payload
|
|
407
|
+
when Hash then accept_capture_payload(test_path, payload)
|
|
408
|
+
when String
|
|
409
|
+
fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
|
|
410
|
+
"fork capture produced no result (re-run with --verbose for the error)")
|
|
411
|
+
else fail_test(test_path, "fork capture produced no result")
|
|
412
|
+
end
|
|
413
|
+
else
|
|
414
|
+
payload = capture(test_path)
|
|
415
|
+
accept_capture_payload(test_path, payload) if payload
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Runs every successfully captured test together. Per-file capture can miss a
|
|
421
|
+
# failure that only appears when covering files share one process.
|
|
422
|
+
#
|
|
423
|
+
# @api private
|
|
424
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
425
|
+
# @return [void]
|
|
426
|
+
def verify_combined_clean(after_fork: nil)
|
|
427
|
+
runnable = @test_paths.reject { |t| @failed_test_files.include?(relativize(t)) }
|
|
428
|
+
return if runnable.size < 2
|
|
429
|
+
return unless @failed_clean_tests.empty?
|
|
430
|
+
|
|
431
|
+
ok = if @boot_path
|
|
432
|
+
fork_clean_pass?(runnable.map { |t| absolute(t) }, after_fork)
|
|
433
|
+
else
|
|
434
|
+
subprocess_clean_pass?(runnable)
|
|
435
|
+
end
|
|
436
|
+
@failed_clean_tests << "combined suite" unless ok
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Runs test files in a fresh interpreter and returns whether they passed.
|
|
440
|
+
#
|
|
441
|
+
# @api private
|
|
442
|
+
# @param test_paths [Array<String>] test file paths.
|
|
443
|
+
# @return [Boolean]
|
|
444
|
+
def subprocess_clean_pass?(test_paths)
|
|
445
|
+
status = nil
|
|
446
|
+
Open3.popen2(RbConfig.ruby, "-") do |stdin, stdout, wait_thr|
|
|
447
|
+
stdin.write(clean_check_script(test_paths))
|
|
448
|
+
stdin.close
|
|
449
|
+
reader = Thread.new { stdout.read }
|
|
450
|
+
unless wait_thr.join(@capture_timeout)
|
|
451
|
+
Process.kill(:KILL, wait_thr.pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
452
|
+
reader.kill
|
|
453
|
+
return false
|
|
454
|
+
end
|
|
455
|
+
reader.join
|
|
456
|
+
status = wait_thr.value
|
|
457
|
+
end
|
|
458
|
+
status&.success?
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Runs test files in a fork of the booted parent and returns whether they passed.
|
|
462
|
+
# Bounded by `@capture_timeout` so a hung child cannot block the CLI.
|
|
463
|
+
#
|
|
464
|
+
# @api private
|
|
465
|
+
# @param abs_tests [Array<String>] absolute test file paths.
|
|
466
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
467
|
+
# @return [Boolean]
|
|
468
|
+
def fork_clean_pass?(abs_tests, after_fork)
|
|
469
|
+
rd, wr = IO.pipe
|
|
470
|
+
rd.binmode
|
|
471
|
+
wr.binmode
|
|
472
|
+
pid = fork do
|
|
473
|
+
rd.close
|
|
474
|
+
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
475
|
+
begin
|
|
476
|
+
after_fork&.call
|
|
477
|
+
Coverage.result(clear: true, stop: false) if Coverage.running?
|
|
478
|
+
wr.write(Marshal.dump(TestRunners.for(@framework).run(abs_tests).zero?))
|
|
479
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
480
|
+
wr.write(Marshal.dump(false))
|
|
481
|
+
ensure
|
|
482
|
+
wr.close
|
|
483
|
+
exit!(0)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
wr.close
|
|
487
|
+
readable, = IO.select([rd], nil, nil, @capture_timeout)
|
|
488
|
+
unless readable
|
|
489
|
+
kill_fork_clean(pid)
|
|
490
|
+
rd.close
|
|
491
|
+
return false
|
|
492
|
+
end
|
|
493
|
+
data = rd.read
|
|
494
|
+
rd.close
|
|
495
|
+
Process.waitpid2(pid)
|
|
496
|
+
return false if data.empty?
|
|
497
|
+
|
|
498
|
+
Marshal.load(data)
|
|
499
|
+
rescue StandardError
|
|
500
|
+
false
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# SIGKILLs a hung clean-check child (and its group) then reaps it.
|
|
504
|
+
#
|
|
505
|
+
# @api private
|
|
506
|
+
# @param pid [Integer] child pid.
|
|
507
|
+
# @return [void]
|
|
508
|
+
def kill_fork_clean(pid)
|
|
509
|
+
begin
|
|
510
|
+
Process.kill(:KILL, -pid)
|
|
511
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
512
|
+
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
513
|
+
end
|
|
514
|
+
Process.waitpid2(pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Builds a pass/fail-only subprocess script (no coverage instrumentation).
|
|
518
|
+
#
|
|
519
|
+
# @api private
|
|
520
|
+
# @param test_paths [Array<String>] test file paths.
|
|
521
|
+
# @return [String] Ruby script text.
|
|
522
|
+
def clean_check_script(test_paths)
|
|
523
|
+
@framework == "rspec" ? rspec_clean_check_script(test_paths) : minitest_clean_check_script(test_paths)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Minitest clean-suite check. Preloads configured sources like capture and
|
|
527
|
+
# standalone {Runner.execute}, so tests that rely on that preload stay green.
|
|
528
|
+
#
|
|
529
|
+
# @api private
|
|
530
|
+
# @param test_paths [Array<String>] test file paths.
|
|
531
|
+
# @return [String] Ruby script text.
|
|
532
|
+
def minitest_clean_check_script(test_paths)
|
|
533
|
+
loads = Array(test_paths).map { |t| "load #{absolute(t).inspect}" }.join("\n")
|
|
534
|
+
<<~RUBY
|
|
535
|
+
require "minitest"
|
|
536
|
+
require "stringio"
|
|
537
|
+
def Minitest.autorun; end
|
|
538
|
+
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
539
|
+
#{abs_source_paths.inspect}.each { |f| load f }
|
|
540
|
+
#{loads}
|
|
541
|
+
$stdout = StringIO.new
|
|
542
|
+
exit(Minitest.run([]) ? 0 : 1)
|
|
543
|
+
RUBY
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# RSpec clean-suite check. Preloads configured sources like capture.
|
|
547
|
+
#
|
|
548
|
+
# @api private
|
|
549
|
+
# @param test_paths [Array<String>] spec file paths.
|
|
550
|
+
# @return [String] Ruby script text.
|
|
551
|
+
def rspec_clean_check_script(test_paths)
|
|
552
|
+
specs = Array(test_paths).map { |t| absolute(t).inspect }.join(", ")
|
|
553
|
+
<<~RUBY
|
|
554
|
+
require "stringio"
|
|
555
|
+
begin
|
|
556
|
+
require "rspec/core"
|
|
557
|
+
rescue LoadError
|
|
558
|
+
exit 3
|
|
559
|
+
end
|
|
560
|
+
RSpec::Core::Runner.disable_autorun!
|
|
561
|
+
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
562
|
+
#{abs_source_paths.inspect}.each { |f| load f }
|
|
563
|
+
_sink = StringIO.new
|
|
564
|
+
$stdout = _sink
|
|
565
|
+
status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
|
|
566
|
+
exit(status.zero? ? 0 : 1)
|
|
567
|
+
RUBY
|
|
568
|
+
end
|
|
569
|
+
|
|
310
570
|
# Builds the framework-specific subprocess script.
|
|
311
571
|
#
|
|
312
572
|
# @api private
|
|
@@ -334,9 +594,10 @@ module Mutineer
|
|
|
334
594
|
load #{absolute(test_path).inspect}
|
|
335
595
|
_orig = $stdout
|
|
336
596
|
$stdout = StringIO.new
|
|
337
|
-
Minitest.run([])
|
|
597
|
+
_passed = Minitest.run([])
|
|
338
598
|
$stdout = _orig
|
|
339
|
-
puts Coverage.result
|
|
599
|
+
puts JSON.generate("passed" => _passed == true, "coverage" => Coverage.result,
|
|
600
|
+
"loaded_files" => #{loaded_files_expression})
|
|
340
601
|
RUBY
|
|
341
602
|
end
|
|
342
603
|
|
|
@@ -363,9 +624,10 @@ module Mutineer
|
|
|
363
624
|
_orig = $stdout
|
|
364
625
|
_sink = StringIO.new
|
|
365
626
|
$stdout = _sink
|
|
366
|
-
RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
|
|
627
|
+
_status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
|
|
367
628
|
$stdout = _orig
|
|
368
|
-
puts Coverage.result
|
|
629
|
+
puts JSON.generate("passed" => _status.zero?, "coverage" => Coverage.result,
|
|
630
|
+
"loaded_files" => #{loaded_files_expression})
|
|
369
631
|
RUBY
|
|
370
632
|
end
|
|
371
633
|
|
|
@@ -387,6 +649,101 @@ module Mutineer
|
|
|
387
649
|
end
|
|
388
650
|
end
|
|
389
651
|
|
|
652
|
+
# Ruby source of the child-side `$LOADED_FEATURES` filter (project `.rb` files).
|
|
653
|
+
#
|
|
654
|
+
# @api private
|
|
655
|
+
# @return [String] expression to embed in a capture subprocess script.
|
|
656
|
+
def loaded_files_expression
|
|
657
|
+
root = project_root_real
|
|
658
|
+
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
659
|
+
"begin; _root = #{prefix.inspect}; $LOADED_FEATURES.filter_map { |f| next unless f.end_with?(\".rb\"); abs = (File.realpath(f) rescue next); abs if abs.start_with?(_root) }; rescue StandardError; []; end"
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Canonical project root for loaded-feature matching (`/var` vs `/private/var`).
|
|
663
|
+
#
|
|
664
|
+
# @api private
|
|
665
|
+
# @return [String] realpath of the project root when it exists.
|
|
666
|
+
def project_root_real
|
|
667
|
+
File.realpath(File.expand_path(@project_root))
|
|
668
|
+
rescue Errno::ENOENT
|
|
669
|
+
File.expand_path(@project_root)
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# Project-local `.rb` files loaded in this process at capture time.
|
|
673
|
+
#
|
|
674
|
+
# @api private
|
|
675
|
+
# @return [Array<String>] absolute realpaths.
|
|
676
|
+
def capture_loaded_files
|
|
677
|
+
prefix = project_root_real
|
|
678
|
+
prefix = "#{prefix}/" unless prefix.end_with?("/")
|
|
679
|
+
$LOADED_FEATURES.filter_map do |f|
|
|
680
|
+
next unless f.end_with?(".rb")
|
|
681
|
+
|
|
682
|
+
abs = File.realpath(f)
|
|
683
|
+
abs if abs.start_with?(prefix)
|
|
684
|
+
rescue Errno::ENOENT
|
|
685
|
+
nil
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Fingerprints project-local support files from a capture payload.
|
|
690
|
+
#
|
|
691
|
+
# @api private
|
|
692
|
+
# @param paths [Array, nil] absolute loaded-file paths.
|
|
693
|
+
# @return [void]
|
|
694
|
+
def record_loaded(paths)
|
|
695
|
+
Array(paths).each do |raw|
|
|
696
|
+
next unless raw.is_a?(String) && File.file?(raw)
|
|
697
|
+
|
|
698
|
+
abs = File.realpath(raw)
|
|
699
|
+
rel = loaded_relative(abs)
|
|
700
|
+
next unless rel
|
|
701
|
+
next unless rel.end_with?(".rb")
|
|
702
|
+
next if rel.start_with?("vendor/bundle/") || rel.start_with?("node_modules/")
|
|
703
|
+
|
|
704
|
+
@loaded_dependencies[rel] = file_fingerprint(abs)
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Path of `abs` relative to the real project root, or nil when outside it.
|
|
709
|
+
#
|
|
710
|
+
# @api private
|
|
711
|
+
# @param abs [String] absolute realpath.
|
|
712
|
+
# @return [String, nil]
|
|
713
|
+
def loaded_relative(abs)
|
|
714
|
+
root = project_root_real
|
|
715
|
+
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
716
|
+
return unless abs.start_with?(prefix)
|
|
717
|
+
|
|
718
|
+
abs.delete_prefix(prefix)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Byte fingerprint of a file for cache dependency checks.
|
|
722
|
+
#
|
|
723
|
+
# @api private
|
|
724
|
+
# @param abs [String] absolute path.
|
|
725
|
+
# @return [String] hex digest.
|
|
726
|
+
def file_fingerprint(abs)
|
|
727
|
+
content = File.binread(abs)
|
|
728
|
+
Digest::SHA256.hexdigest("#{content.bytesize}\0#{content}")
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# True when the cached map recorded support-file fingerprints and they still
|
|
732
|
+
# match. Missing validity data is a miss (rebuild).
|
|
733
|
+
#
|
|
734
|
+
# @api private
|
|
735
|
+
# @param cached [Hash] parsed coverage.json.
|
|
736
|
+
# @return [Boolean]
|
|
737
|
+
def dependencies_match?(cached)
|
|
738
|
+
deps = cached["dependencies"]
|
|
739
|
+
return false unless deps.is_a?(Hash)
|
|
740
|
+
|
|
741
|
+
deps.all? do |rel, fingerprint|
|
|
742
|
+
abs = absolute(rel)
|
|
743
|
+
File.file?(abs) && file_fingerprint(abs) == fingerprint
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
390
747
|
# Digest each file's ROLE + relative path + content length + content, plus
|
|
391
748
|
# the load_paths. Without role/path/length delimiters the digest collides
|
|
392
749
|
# (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently
|
|
@@ -462,8 +819,11 @@ module Mutineer
|
|
|
462
819
|
# @api private
|
|
463
820
|
# @return [void]
|
|
464
821
|
def save
|
|
822
|
+
return unless @failed_clean_tests.empty?
|
|
823
|
+
|
|
465
824
|
FileUtils.mkdir_p(@cache_dir)
|
|
466
|
-
data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
|
|
825
|
+
data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
|
|
826
|
+
"dependencies" => @loaded_dependencies, "map" => @map }
|
|
467
827
|
tmp = "#{cache_path}.tmp"
|
|
468
828
|
File.write(tmp, JSON.generate(data))
|
|
469
829
|
File.rename(tmp, cache_path) # atomic swap
|
|
@@ -494,9 +854,13 @@ module Mutineer
|
|
|
494
854
|
# @param path [String] path to relativize.
|
|
495
855
|
# @return [String] relative path.
|
|
496
856
|
def relativize(path)
|
|
497
|
-
|
|
857
|
+
abs = path.start_with?("/") ? path : absolute(path)
|
|
858
|
+
abs = realpath_if_exists(abs)
|
|
859
|
+
root = project_root_real
|
|
860
|
+
prefix = root.end_with?("/") ? root : "#{root}/"
|
|
861
|
+
return abs unless abs.start_with?(prefix)
|
|
498
862
|
|
|
499
|
-
|
|
863
|
+
abs.delete_prefix(prefix)
|
|
500
864
|
end
|
|
501
865
|
|
|
502
866
|
# Expands a path relative to the project root.
|
|
@@ -505,7 +869,17 @@ module Mutineer
|
|
|
505
869
|
# @param path [String] path to expand.
|
|
506
870
|
# @return [String] absolute path.
|
|
507
871
|
def absolute(path)
|
|
508
|
-
File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
|
|
872
|
+
raw = File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
|
|
873
|
+
realpath_if_exists(raw)
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# Real path when the file exists, otherwise `path` unchanged.
|
|
877
|
+
#
|
|
878
|
+
# @api private
|
|
879
|
+
# @param path [String] absolute or relative path.
|
|
880
|
+
# @return [String]
|
|
881
|
+
def realpath_if_exists(path)
|
|
882
|
+
File.exist?(path) ? File.realpath(path) : path
|
|
509
883
|
end
|
|
510
884
|
end
|
|
511
885
|
end
|
|
@@ -4,6 +4,7 @@ require_relative "parser"
|
|
|
4
4
|
require_relative "result"
|
|
5
5
|
require_relative "coverage_map"
|
|
6
6
|
require_relative "daemon_client"
|
|
7
|
+
require_relative "progress"
|
|
7
8
|
# No require_relative "runner" on purpose: runner.rb requires this file, and the
|
|
8
9
|
# reverse edge makes Ruby warn "circular require considered harmful" on every -w
|
|
9
10
|
# load. Runner is loaded first on every real path; requiring this file alone leaves
|
|
@@ -104,6 +105,17 @@ module Mutineer
|
|
|
104
105
|
ensure
|
|
105
106
|
client.quit
|
|
106
107
|
end
|
|
108
|
+
# A red unmutated suite must abort, even when the shipped map is empty.
|
|
109
|
+
# Falling back to the full --test set would treat those failures as kills.
|
|
110
|
+
if data.is_a?(Hash) && Array(data["failed_clean_tests"]).any?
|
|
111
|
+
Runner.abort_if_unclean!(CoverageMap.from_data(
|
|
112
|
+
map: data["map"] || {},
|
|
113
|
+
failed_test_files: data["failed_test_files"] || [],
|
|
114
|
+
project_root: config.project_root,
|
|
115
|
+
failed_clean_tests: data["failed_clean_tests"]
|
|
116
|
+
))
|
|
117
|
+
end
|
|
118
|
+
|
|
107
119
|
unless data && !(data["map"] || {}).empty?
|
|
108
120
|
reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
|
|
109
121
|
warn_coverage_fallback(reason)
|
|
@@ -137,10 +149,12 @@ module Mutineer
|
|
|
137
149
|
client = DaemonClient.new(boot: boot_config(config, abs_tests),
|
|
138
150
|
app_root: config.project_root).start
|
|
139
151
|
results = []
|
|
152
|
+
progress = Progress.new(jobs.size)
|
|
140
153
|
begin
|
|
141
154
|
jobs.each_with_index do |job, i|
|
|
142
155
|
r = job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
|
|
143
156
|
results << r
|
|
157
|
+
progress.tick
|
|
144
158
|
break if config.fail_fast && r.survived?
|
|
145
159
|
end
|
|
146
160
|
ensure
|
|
@@ -159,8 +173,9 @@ module Mutineer
|
|
|
159
173
|
# @api private
|
|
160
174
|
# @return [Array<Mutineer::Result>] one result per input job, in input order.
|
|
161
175
|
def self.run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
|
|
162
|
-
results
|
|
163
|
-
|
|
176
|
+
results = Array.new(jobs.size)
|
|
177
|
+
progress = Progress.new(jobs.size)
|
|
178
|
+
queue = Queue.new
|
|
164
179
|
jobs.each_index { |i| queue << i }
|
|
165
180
|
|
|
166
181
|
# Built one at a time so a refused spawn part-way (EMFILE under a high --jobs)
|
|
@@ -189,6 +204,7 @@ module Mutineer
|
|
|
189
204
|
break
|
|
190
205
|
end
|
|
191
206
|
results[i] = job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
|
|
207
|
+
progress.tick
|
|
192
208
|
end
|
|
193
209
|
rescue DaemonBootError
|
|
194
210
|
# The daemon gave up for good. Stop feeding the other workers rather
|
|
@@ -87,10 +87,13 @@ module Mutineer
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
# Ask the daemon to build the coverage map app-side and return it. One-shot
|
|
90
|
-
# control message (no id).
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
90
|
+
# control message (no id). On success, returns
|
|
91
|
+
# `{"map"=>..., "failed_test_files"=>..., "failed_clean_tests"=>...}`.
|
|
92
|
+
# On coverage-build failure, returns
|
|
93
|
+
# `{"map"=>{}, "failed_test_files"=>[], "error"=>...}`.
|
|
94
|
+
# Returns nil if the daemon vanished. The caller then falls back to running
|
|
95
|
+
# the full test set (no narrowing) rather than mis-scoring, except a red
|
|
96
|
+
# unmutated suite which aborts.
|
|
94
97
|
#
|
|
95
98
|
# @return [Hash, nil] the coverage payload, or nil on a dead pipe.
|
|
96
99
|
def coverage
|
|
@@ -150,7 +150,8 @@ module Mutineer
|
|
|
150
150
|
load_paths: Array(@cfg["load_paths"]), project_root: root,
|
|
151
151
|
boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
|
|
152
152
|
).build_via_fork(after_fork: coverage_after_fork)
|
|
153
|
-
{ "map" => cmap.map, "failed_test_files" => cmap.failed_test_files
|
|
153
|
+
{ "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
|
|
154
|
+
"failed_clean_tests" => cmap.failed_clean_tests }
|
|
154
155
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
155
156
|
@errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
|
|
156
157
|
{ "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
|