mutineer 1.6.0 → 1.7.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 +248 -0
- data/README.md +324 -23
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +182 -23
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +175 -12
- data/lib/mutineer/daemon_backend.rb +182 -56
- data/lib/mutineer/daemon_client.rb +30 -0
- data/lib/mutineer/daemon_server.rb +157 -25
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +14 -2
- data/lib/mutineer/job_plan.rb +129 -23
- data/lib/mutineer/migrate.rb +705 -0
- data/lib/mutineer/mutator_registry.rb +6 -2
- data/lib/mutineer/mutators/base.rb +14 -7
- data/lib/mutineer/mutators/call_to_receiver.rb +135 -0
- data/lib/mutineer/mutators/chain_link.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +72 -0
- data/lib/mutineer/project.rb +80 -6
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1408 -53
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +66 -12
- data/lib/mutineer/result.rb +22 -13
- data/lib/mutineer/runner.rb +24 -12
- data/lib/mutineer/statement_lines.rb +15 -8
- data/lib/mutineer/subject.rb +22 -4
- data/lib/mutineer/test_runners/rspec.rb +15 -3
- data/lib/mutineer/test_runners.rb +3 -2
- data/lib/mutineer/triage.rb +111 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +4 -0
- metadata +9 -1
data/lib/mutineer/config.rb
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "did_you_mean"
|
|
3
4
|
require "etc"
|
|
4
5
|
require "yaml"
|
|
5
6
|
|
|
6
7
|
module Mutineer
|
|
8
|
+
# The phrase every 1.x warning uses for behaviour that 2.0 turns into an
|
|
9
|
+
# error, so the 2.0 release can find each of those sites with one search.
|
|
10
|
+
BECOMES_ERROR_IN_2_0 = "this becomes an error in Mutineer 2.0"
|
|
11
|
+
|
|
12
|
+
# A "did you mean" hint for a misspelled name, using the stdlib spell
|
|
13
|
+
# checker that Ruby itself uses for NameError.
|
|
14
|
+
#
|
|
15
|
+
# @param word [String] the name the user wrote.
|
|
16
|
+
# @param candidates [Array<String>] the names that are valid here.
|
|
17
|
+
# @return [String] ` (did you mean "x"?)` for the closest candidate, or "" when none is close.
|
|
18
|
+
def self.did_you_mean(word, candidates)
|
|
19
|
+
match = DidYouMean::SpellChecker.new(dictionary: candidates).correct(word.to_s).first
|
|
20
|
+
match ? " (did you mean #{match.inspect}?)" : ""
|
|
21
|
+
end
|
|
22
|
+
|
|
7
23
|
# Raised by the config layer instead of calling exit/abort. A data class must
|
|
8
24
|
# never kill the host process. The CLI rescues this and maps it to exit 2.
|
|
9
25
|
class ConfigError < StandardError; end
|
|
@@ -36,7 +52,7 @@ module Mutineer
|
|
|
36
52
|
ConfigOption.new(field: :framework, type: :enum, yaml_key: "framework", flag: "--framework",
|
|
37
53
|
values: %w[minitest rspec]),
|
|
38
54
|
ConfigOption.new(field: :verbose, type: :bool, yaml_key: "verbose", flag: "--verbose"),
|
|
39
|
-
ConfigOption.new(field: :ignore, type: :
|
|
55
|
+
ConfigOption.new(field: :ignore, type: :ignore_list, yaml_key: "ignore"),
|
|
40
56
|
ConfigOption.new(field: :baseline, type: :string, yaml_key: "baseline", flag: "--baseline"),
|
|
41
57
|
ConfigOption.new(field: :fail_fast, type: :bool, yaml_key: "fail_fast", flag: "--fail-fast"),
|
|
42
58
|
ConfigOption.new(field: :matrix, type: :bool, yaml_key: "matrix", flag: "--matrix"),
|
|
@@ -46,6 +62,8 @@ module Mutineer
|
|
|
46
62
|
ConfigOption.new(field: :capture_timeout, type: :positive_int, yaml_key: "capture_timeout",
|
|
47
63
|
flag: "--capture-timeout"),
|
|
48
64
|
ConfigOption.new(field: :cache_dir, type: :string, yaml_key: "cache_dir", flag: "--cache-dir"),
|
|
65
|
+
ConfigOption.new(field: :allow_empty, type: :bool, yaml_key: "allow_empty", flag: "--allow-empty"),
|
|
66
|
+
ConfigOption.new(field: :coverage_from, type: :string, flag: "--coverage-from"),
|
|
49
67
|
ConfigOption.new(field: :format, type: :enum, flag: "--format", values: %w[human json html]),
|
|
50
68
|
ConfigOption.new(field: :strategy, type: :enum, flag: "--strategy", values: %w[reload redefine],
|
|
51
69
|
aliases: STRATEGY_ALIASES),
|
|
@@ -74,11 +92,11 @@ module Mutineer
|
|
|
74
92
|
:sources, :tests, :operators, :threshold, :only, :dry_run,
|
|
75
93
|
:cache_dir, :project_root, :load_paths,
|
|
76
94
|
:jobs, :format, :output, :strategy, :require_paths,
|
|
77
|
-
:boot, :rails, :since, :framework, :verbose, :ignore,
|
|
95
|
+
:boot, :rails, :since, :framework, :verbose, :ignore, :ignore_reasons,
|
|
78
96
|
# :daemon is user-facing (--daemon flag + KNOWN_KEYS + boolean coerce).
|
|
79
97
|
# :daemon_timeout stays programmatic (set by tests/Runner; no flag yet).
|
|
80
98
|
:baseline, :baseline_epsilon, :fail_fast, :test_command,
|
|
81
|
-
:daemon, :daemon_timeout, :timeout, :capture_timeout, :matrix,
|
|
99
|
+
:daemon, :daemon_timeout, :timeout, :capture_timeout, :matrix, :allow_empty, :coverage_from,
|
|
82
100
|
keyword_init: true
|
|
83
101
|
) do
|
|
84
102
|
# Config file name.
|
|
@@ -109,10 +127,12 @@ module Mutineer
|
|
|
109
127
|
self.rails = false if rails.nil?
|
|
110
128
|
self.verbose = false if verbose.nil?
|
|
111
129
|
self.ignore ||= []
|
|
130
|
+
self.ignore_reasons ||= {}
|
|
112
131
|
self.baseline_epsilon ||= 0.0
|
|
113
132
|
self.fail_fast = false if fail_fast.nil?
|
|
114
133
|
self.daemon = false if daemon.nil?
|
|
115
134
|
self.matrix = false if matrix.nil?
|
|
135
|
+
self.allow_empty = false if allow_empty.nil?
|
|
116
136
|
end
|
|
117
137
|
|
|
118
138
|
# True when the user wrote `key`, on the command line or in the config
|
|
@@ -180,12 +200,17 @@ module Mutineer
|
|
|
180
200
|
raw.each do |key, value|
|
|
181
201
|
ks = key.to_s
|
|
182
202
|
unless KNOWN_KEYS.include?(ks)
|
|
183
|
-
warn "mutineer: unknown config key #{ks.inspect} in #{name} " \
|
|
184
|
-
"(known: #{KNOWN_KEYS.join(', ')})
|
|
203
|
+
warn "mutineer: unknown config key #{ks.inspect} in #{name}#{Mutineer.did_you_mean(ks, KNOWN_KEYS)}; " \
|
|
204
|
+
"ignored, and #{BECOMES_ERROR_IN_2_0} (known: #{KNOWN_KEYS.join(', ')})"
|
|
185
205
|
next
|
|
186
206
|
end
|
|
187
207
|
field = field_for(ks)
|
|
188
208
|
parsed = parse(field, value, file: name, defer_operators: defer_operators)
|
|
209
|
+
if field == :ignore
|
|
210
|
+
out[:ignore] = parsed.fetch(:ids)
|
|
211
|
+
out[:ignore_reasons] = parsed.fetch(:reasons)
|
|
212
|
+
next
|
|
213
|
+
end
|
|
189
214
|
if field == :operators
|
|
190
215
|
parsed = filter_operators(parsed, name)
|
|
191
216
|
if parsed.empty? && !defer_operators
|
|
@@ -220,7 +245,9 @@ module Mutineer
|
|
|
220
245
|
config.strategy = "redefine"
|
|
221
246
|
end
|
|
222
247
|
unless config.daemon
|
|
223
|
-
|
|
248
|
+
# A typed --no-daemon already chose the serial run; only an explicit
|
|
249
|
+
# --jobs N still needs telling that --daemon is the way to get N.
|
|
250
|
+
if config.jobs.to_i > 1 && (!config.explicit?(:daemon) || config.explicit?(:jobs))
|
|
224
251
|
warn "[mutineer] --rails without --daemon runs serially (shared test DB); " \
|
|
225
252
|
"forcing --jobs 1. Use --daemon for safe --jobs N."
|
|
226
253
|
end
|
|
@@ -235,6 +262,24 @@ module Mutineer
|
|
|
235
262
|
config
|
|
236
263
|
end
|
|
237
264
|
|
|
265
|
+
# Mutineer 2.0 makes the daemon the default under --rails for Minitest, so a
|
|
266
|
+
# run that would change says so now. The CLI calls this after test pairing,
|
|
267
|
+
# which can still switch the framework to rspec. The runs that 2.0 keeps on this path
|
|
268
|
+
# stay quiet: RSpec, --test-command, --matrix and --fail-fast cannot use the
|
|
269
|
+
# daemon, a --dry-run runs no tests, and a written `daemon` value (true or
|
|
270
|
+
# false) is the user's choice.
|
|
271
|
+
#
|
|
272
|
+
# @api private
|
|
273
|
+
# @param config [Mutineer::Config] the resolved config.
|
|
274
|
+
# @return [void]
|
|
275
|
+
def self.warn_rails_default_change(config)
|
|
276
|
+
return unless config.rails && !config.explicit?(:daemon) && config.framework == "minitest"
|
|
277
|
+
return if config.test_command || config.matrix || config.fail_fast || config.dry_run
|
|
278
|
+
|
|
279
|
+
warn "[mutineer] in Mutineer 2.0, --rails uses --daemon by default: parallel workers (unless " \
|
|
280
|
+
"--jobs 1) and the reload strategy. Pass --no-daemon (or daemon: false) to keep this run as it is."
|
|
281
|
+
end
|
|
282
|
+
|
|
238
283
|
# Pick rspec when a MAJORITY of the given test files end with _spec.rb;
|
|
239
284
|
# otherwise minitest. Empty/ambiguous -> minitest (the safe default).
|
|
240
285
|
#
|
|
@@ -299,6 +344,8 @@ module Mutineer
|
|
|
299
344
|
|
|
300
345
|
prefix = file ? "#{file}: " : ""
|
|
301
346
|
raise ConfigError, "#{prefix}unknown #{field} #{value.to_s.inspect}. Expected: #{opt.values.join(', ')}"
|
|
347
|
+
when :ignore_list
|
|
348
|
+
parse_ignore_list(value, file)
|
|
302
349
|
when :string_list
|
|
303
350
|
items = Array(value).map(&:to_s)
|
|
304
351
|
# Only `operators` treats [] as "run these" rather than "use the
|
|
@@ -319,6 +366,7 @@ module Mutineer
|
|
|
319
366
|
# A blank cache directory (an unset CI variable) would put coverage.json
|
|
320
367
|
# in the project root, so it is an error, not the default.
|
|
321
368
|
raise ConfigError, "#{origin} must be a directory, not blank #{got}" if field == :cache_dir && value.to_s.strip.empty?
|
|
369
|
+
raise ConfigError, "#{origin} must be a file, not blank #{got}" if field == :coverage_from && value.to_s.strip.empty?
|
|
322
370
|
|
|
323
371
|
value.to_s
|
|
324
372
|
when :since
|
|
@@ -350,6 +398,98 @@ module Mutineer
|
|
|
350
398
|
f if f&.finite?
|
|
351
399
|
end
|
|
352
400
|
|
|
401
|
+
# Reads an `ignore:` value into ids plus a reason for each mapping.
|
|
402
|
+
# A bare string or integer is an id with no reason. YAML reads an unquoted
|
|
403
|
+
# all-digit id as an integer, and the id text is those decimal digits.
|
|
404
|
+
# A mapping may hold `id` and `reason` only. A mapping with no id, or with
|
|
405
|
+
# any other key, is dropped and warned: 2.0 will reject it. Callers that
|
|
406
|
+
# match ids keep the id list.
|
|
407
|
+
#
|
|
408
|
+
# @api private
|
|
409
|
+
# @param value [Object] the raw YAML value.
|
|
410
|
+
# @param file [String, nil] config file name.
|
|
411
|
+
# @return [Hash] `:ids` is an array of id strings. `:reasons` maps an id to its text.
|
|
412
|
+
def self.parse_ignore_list(value, file)
|
|
413
|
+
label = file || CONFIG_FILE
|
|
414
|
+
case value
|
|
415
|
+
when nil then return { ids: [], reasons: {} }
|
|
416
|
+
when String, Integer then return { ids: [ignore_id_text(value)], reasons: {} }
|
|
417
|
+
when Array then items = value
|
|
418
|
+
when Hash then items = [value]
|
|
419
|
+
else
|
|
420
|
+
warn "mutineer: ignore in #{label} must be a list; ignored, and #{BECOMES_ERROR_IN_2_0}"
|
|
421
|
+
return { ids: [], reasons: {} }
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
ids = []
|
|
425
|
+
reasons = {}
|
|
426
|
+
items.each_with_index do |item, index|
|
|
427
|
+
number = index + 1
|
|
428
|
+
id = ignore_id_text(item)
|
|
429
|
+
if id
|
|
430
|
+
ids << id unless id.empty? || ids.include?(id)
|
|
431
|
+
elsif item.is_a?(Hash)
|
|
432
|
+
mapped, reason = ignore_mapping(item, number, label)
|
|
433
|
+
next if mapped.nil?
|
|
434
|
+
|
|
435
|
+
ids << mapped unless ids.include?(mapped)
|
|
436
|
+
reasons[mapped] = reason if reason
|
|
437
|
+
else
|
|
438
|
+
warn "mutineer: ignore entry #{number} in #{label} is not an id or a mapping; " \
|
|
439
|
+
"ignored, and #{BECOMES_ERROR_IN_2_0}"
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
{ ids: ids, reasons: reasons }
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Text of one ignore id. An integer is the decimal digits YAML kept.
|
|
446
|
+
# A leading zero is already gone by then, so quote an id that needs one.
|
|
447
|
+
# Any other type is not an id.
|
|
448
|
+
#
|
|
449
|
+
# @api private
|
|
450
|
+
# @param value [Object] a list item, a scalar ignore value, or a mapping id.
|
|
451
|
+
# @return [String, nil] the id text, or nil when the value cannot be an id.
|
|
452
|
+
def self.ignore_id_text(value)
|
|
453
|
+
case value
|
|
454
|
+
when String then value
|
|
455
|
+
when Integer then value.to_s
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# One `ignore:` mapping. Returns the id and reason, or nil when the entry
|
|
460
|
+
# is not usable.
|
|
461
|
+
#
|
|
462
|
+
# @api private
|
|
463
|
+
# @param item [Hash] the mapping.
|
|
464
|
+
# @param number [Integer] 1-based index in the list.
|
|
465
|
+
# @param label [String] config file name.
|
|
466
|
+
# @return [Array(String, String), Array(nil, nil)]
|
|
467
|
+
def self.ignore_mapping(item, number, label)
|
|
468
|
+
unknown = item.keys.map(&:to_s) - %w[id reason]
|
|
469
|
+
unless unknown.empty?
|
|
470
|
+
names = unknown.map(&:inspect).join(", ")
|
|
471
|
+
warn "mutineer: ignore entry #{number} in #{label} has unknown key #{names}; " \
|
|
472
|
+
"ignored, and #{BECOMES_ERROR_IN_2_0}"
|
|
473
|
+
return [nil, nil]
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
id = ignore_id_text(item["id"] || item[:id])
|
|
477
|
+
unless id.is_a?(String) && !id.strip.empty?
|
|
478
|
+
warn "mutineer: ignore entry #{number} in #{label} has no id; ignored, and #{BECOMES_ERROR_IN_2_0}"
|
|
479
|
+
return [nil, nil]
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
reason = item.key?("reason") ? item["reason"] : item[:reason]
|
|
483
|
+
if !reason.nil? && !reason.is_a?(String)
|
|
484
|
+
warn "mutineer: ignore entry #{number} in #{label} reason must be text; " \
|
|
485
|
+
"ignored, and #{BECOMES_ERROR_IN_2_0}"
|
|
486
|
+
return [nil, nil]
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
text = reason.to_s.strip
|
|
490
|
+
[id, text.empty? ? nil : text]
|
|
491
|
+
end
|
|
492
|
+
|
|
353
493
|
# Drop (with a warning) operator names the registry does not know.
|
|
354
494
|
# Referenced lazily so config.rb carries no load-order dependency on the
|
|
355
495
|
# registry; by the time a config is parsed at runtime, it is loaded.
|
|
@@ -363,8 +503,8 @@ module Mutineer
|
|
|
363
503
|
names.select do |n|
|
|
364
504
|
next true if known.include?(n)
|
|
365
505
|
|
|
366
|
-
warn "mutineer: unknown operator #{n.inspect} in #{file_name} " \
|
|
367
|
-
"(known: #{known.join(', ')})
|
|
506
|
+
warn "mutineer: unknown operator #{n.inspect} in #{file_name}#{Mutineer.did_you_mean(n, known)}; " \
|
|
507
|
+
"ignored, and #{BECOMES_ERROR_IN_2_0} (known: #{known.join(', ')})"
|
|
368
508
|
false
|
|
369
509
|
end
|
|
370
510
|
end
|
|
@@ -10,10 +10,14 @@ require_relative "minitest_integration"
|
|
|
10
10
|
require_relative "test_runners"
|
|
11
11
|
require_relative "child_stdout"
|
|
12
12
|
require_relative "orphan_guard"
|
|
13
|
+
require_relative "redaction"
|
|
13
14
|
require_relative "project_path"
|
|
14
15
|
require_relative "pairing"
|
|
15
16
|
|
|
16
17
|
module Mutineer
|
|
18
|
+
# A `--coverage-from` capture that cannot be read, or does not fit this run.
|
|
19
|
+
class CoverageFromError < StandardError; end
|
|
20
|
+
|
|
17
21
|
# Maps `(source_file, line) -> [test_files]` so each mutant runs only against
|
|
18
22
|
# the tests that actually exercise its line. Built once, then queried per
|
|
19
23
|
# mutant via #tests_for. Persisted to .mutineer/coverage.json with a
|
|
@@ -90,11 +94,12 @@ module Mutineer
|
|
|
90
94
|
def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
|
|
91
95
|
load_paths: ["lib"], project_root: Dir.pwd,
|
|
92
96
|
capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil,
|
|
93
|
-
framework: "minitest", verbose: false, require_paths: [])
|
|
97
|
+
framework: "minitest", verbose: false, require_paths: [], coverage_from: nil)
|
|
94
98
|
@source_paths = Array(source_paths)
|
|
95
99
|
@require_paths = Array(require_paths)
|
|
96
100
|
@test_paths = Array(test_paths)
|
|
97
101
|
@cache_dir = cache_dir
|
|
102
|
+
@coverage_from = coverage_from
|
|
98
103
|
@load_paths = Array(load_paths)
|
|
99
104
|
@project_root = project_root
|
|
100
105
|
@capture_timeout = capture_timeout
|
|
@@ -367,13 +372,20 @@ module Mutineer
|
|
|
367
372
|
# @return [Mutineer::CoverageMap] self.
|
|
368
373
|
def cached_or(after_fork: nil)
|
|
369
374
|
@digest = compute_digest
|
|
375
|
+
return reuse_capture(after_fork) if @coverage_from
|
|
376
|
+
|
|
370
377
|
cached = read_cache
|
|
371
378
|
# A cache from before load methods (#209) or test timings (#203) were
|
|
372
|
-
# saved rebuilds once.
|
|
373
|
-
#
|
|
374
|
-
#
|
|
379
|
+
# saved rebuilds once. A cache that does not record this process's
|
|
380
|
+
# PARALLEL_WORKERS value rebuilds too. Rails reads that value inside
|
|
381
|
+
# parallelize, so a map from another worker count selects the wrong
|
|
382
|
+
# tests. The daemon writes this same file. Boot mode reads its load
|
|
383
|
+
# lines and methods from the live boot instead (#build_via_fork), but
|
|
384
|
+
# its map from before #209 lacks the `def` lines of the methods each
|
|
385
|
+
# test called.
|
|
375
386
|
if cached && cached["digest"] == @digest && dependencies_match?(cached) &&
|
|
376
|
-
cached.key?("load_methods") && cached.key?("timings")
|
|
387
|
+
cached.key?("load_methods") && cached.key?("timings") &&
|
|
388
|
+
parallel_workers_match?(cached)
|
|
377
389
|
@map = cached["map"] || {}
|
|
378
390
|
@timings = cached["timings"] || {}
|
|
379
391
|
unless @boot_path
|
|
@@ -397,6 +409,137 @@ module Mutineer
|
|
|
397
409
|
self
|
|
398
410
|
end
|
|
399
411
|
|
|
412
|
+
# Takes the map from the `--coverage-from` capture instead of capturing,
|
|
413
|
+
# keeping only this run's sources, then runs the tests clean. A mutant runs
|
|
414
|
+
# only the tests that cover its line, often one file alone, so each test
|
|
415
|
+
# that covers one of this run's sources runs alone, as on a cache hit: a
|
|
416
|
+
# test that fails alone here would otherwise kill every mutant it runs. The
|
|
417
|
+
# other tests never run alone in this run, so only the combined check
|
|
418
|
+
# covers them.
|
|
419
|
+
#
|
|
420
|
+
# @api private
|
|
421
|
+
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
422
|
+
# @return [Mutineer::CoverageMap] self.
|
|
423
|
+
# @raise [Mutineer::CoverageFromError] when the capture does not fit.
|
|
424
|
+
def reuse_capture(after_fork)
|
|
425
|
+
cached = accepted_capture
|
|
426
|
+
sources = source_fingerprints.keys.to_set
|
|
427
|
+
@map = cached["map"].select { |key, _| sources.include?(key_source(key)) }
|
|
428
|
+
@timings = cached["timings"] || {}
|
|
429
|
+
@failed_test_files = []
|
|
430
|
+
@failed_clean_tests = []
|
|
431
|
+
@loaded_dependencies = cached["dependencies"]
|
|
432
|
+
covering = @map.values.flatten.to_set
|
|
433
|
+
verify_cached_clean(after_fork: after_fork,
|
|
434
|
+
test_paths: @test_paths.select { |t| covering.include?(relativize(t)) })
|
|
435
|
+
verify_combined_clean(after_fork: after_fork)
|
|
436
|
+
save
|
|
437
|
+
self
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# The parsed `--coverage-from` capture, once it fits this run.
|
|
441
|
+
#
|
|
442
|
+
# @api private
|
|
443
|
+
# @return [Hash] the parsed coverage.json.
|
|
444
|
+
# @raise [Mutineer::CoverageFromError] when it cannot be read or does not fit.
|
|
445
|
+
def accepted_capture
|
|
446
|
+
if File.expand_path(@coverage_from) == File.expand_path(cache_path)
|
|
447
|
+
raise CoverageFromError, "--coverage-from #{@coverage_from} is this run's own cache; give the run another --cache-dir"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
cached = JSON.parse(File.read(@coverage_from))
|
|
451
|
+
reason = capture_mismatch(cached)
|
|
452
|
+
raise CoverageFromError, "--coverage-from #{@coverage_from} cannot be reused: #{reason}" if reason
|
|
453
|
+
|
|
454
|
+
cached
|
|
455
|
+
rescue SystemCallError, JSON::ParserError => e
|
|
456
|
+
raise CoverageFromError, "--coverage-from #{@coverage_from} cannot be read: #{e.message}"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Why a capture does not fit this run, or nil when it does.
|
|
460
|
+
#
|
|
461
|
+
# @api private
|
|
462
|
+
# @param cached [Object] the parsed coverage.json.
|
|
463
|
+
# @return [String, nil]
|
|
464
|
+
def capture_mismatch(cached)
|
|
465
|
+
return "it is not a coverage cache" unless cached.is_a?(Hash) && cached["map"].is_a?(Hash)
|
|
466
|
+
unless cached["sources"] && cached["owners"] && cached["inputs"]
|
|
467
|
+
return "it has no source fingerprints; capture it again with this version and --rails/--boot"
|
|
468
|
+
end
|
|
469
|
+
return "its entries are not the shape a coverage cache saves; capture it again" unless well_formed_capture?(cached)
|
|
470
|
+
return "its tests, --require files, boot file, load paths or framework differ" unless cached["inputs"] == compute_digest(inputs_only: true)
|
|
471
|
+
|
|
472
|
+
fingerprints = source_fingerprints
|
|
473
|
+
missing = fingerprints.keys - cached["sources"].keys
|
|
474
|
+
return "it was not captured over #{missing.join(', ')}" if missing.any?
|
|
475
|
+
|
|
476
|
+
changed = fingerprints.reject { |rel, fingerprint| cached["sources"][rel] == fingerprint }.keys
|
|
477
|
+
return "#{changed.join(', ')} changed since the capture" if changed.any?
|
|
478
|
+
|
|
479
|
+
owners = ownership_paths - cached["owners"]
|
|
480
|
+
return "#{owners.join(', ')} appeared since the capture and can own a split test" if owners.any?
|
|
481
|
+
|
|
482
|
+
# The capture can list owners of sources outside this run, so compare
|
|
483
|
+
# with the disk, not with this run's owners.
|
|
484
|
+
gone = cached["owners"].reject { |rel| File.file?(absolute(rel)) }
|
|
485
|
+
return "#{gone.join(', ')} disappeared since the capture and could own a split test" if gone.any?
|
|
486
|
+
|
|
487
|
+
return "a file its tests loaded changed since the capture" unless dependencies_match?(cached)
|
|
488
|
+
unless parallel_workers_match?(cached)
|
|
489
|
+
return "it was captured with PARALLEL_WORKERS=#{cached['parallel_workers'].inspect}, " \
|
|
490
|
+
"this run has #{ENV.fetch('PARALLEL_WORKERS', nil).inspect}"
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
failed = Array(cached["failed_test_files"])
|
|
494
|
+
return "capture failed for #{failed.join(', ')}" if failed.any?
|
|
495
|
+
|
|
496
|
+
moved = load_mismatch(cached, fingerprints.keys.to_set)
|
|
497
|
+
return nil if moved.empty?
|
|
498
|
+
|
|
499
|
+
"lines of #{moved.join(', ')} ran at load in only one of the capture and this run: loading the capture's " \
|
|
500
|
+
"other sources ran them. Under --strategy redefine, eager-load in both (config.eager_load = true)"
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# True when every entry the reuse reads has the type {#save} writes, so a
|
|
504
|
+
# hand-edited or foreign file is rejected with a reason, not a backtrace.
|
|
505
|
+
#
|
|
506
|
+
# @api private
|
|
507
|
+
# @param cached [Hash] the parsed coverage.json.
|
|
508
|
+
# @return [Boolean]
|
|
509
|
+
def well_formed_capture?(cached)
|
|
510
|
+
strings = ->(value) { value.is_a?(Array) && value.all?(String) }
|
|
511
|
+
cached["inputs"].is_a?(String) && strings.(cached["owners"]) &&
|
|
512
|
+
cached["sources"].is_a?(Hash) && cached["sources"].all? { |k, v| k.is_a?(String) && v.is_a?(String) } &&
|
|
513
|
+
cached["map"].all? { |k, v| k.is_a?(String) && strings.(v) } &&
|
|
514
|
+
%w[load_lines load_methods].all? { |key| cached[key].nil? || strings.(cached[key]) } &&
|
|
515
|
+
(cached["timings"].nil? || cached["timings"].is_a?(Hash))
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# This run's sources whose load lines or load methods differ in the capture.
|
|
519
|
+
#
|
|
520
|
+
# @api private
|
|
521
|
+
# @param cached [Hash] the parsed coverage.json.
|
|
522
|
+
# @param sources [Set<String>] project-relative source paths.
|
|
523
|
+
# @return [Array<String>] the sources that differ, sorted.
|
|
524
|
+
def load_mismatch(cached, sources)
|
|
525
|
+
owned = ->(keys) { Array(keys).select { |key| sources.include?(key_source(key)) }.to_set }
|
|
526
|
+
moved = (owned.(cached["load_lines"]) ^ owned.(@load_lines)) + (owned.(cached["load_methods"]) ^ owned.(@load_methods))
|
|
527
|
+
moved.map { |key| key_source(key) }.uniq.sort
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Fingerprint of each source, by project-relative path.
|
|
531
|
+
#
|
|
532
|
+
# @return [Hash{String => String}]
|
|
533
|
+
def source_fingerprints
|
|
534
|
+
@source_paths.to_h { |p| [relativize(absolute(p)), file_fingerprint(absolute(p))] }
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# The source path of a "file:line" or "file:line:column" key.
|
|
538
|
+
#
|
|
539
|
+
# @param key [String] a map, load line or load method key.
|
|
540
|
+
# @return [String]
|
|
541
|
+
def key_source(key) = key.sub(/(?::\d+)+\z/, "")
|
|
542
|
+
|
|
400
543
|
# Runs standalone coverage capture.
|
|
401
544
|
#
|
|
402
545
|
# @api private
|
|
@@ -578,7 +721,8 @@ module Mutineer
|
|
|
578
721
|
def fail_test(test_path, reason)
|
|
579
722
|
rel = relativize(test_path)
|
|
580
723
|
@failed_test_files << rel
|
|
581
|
-
|
|
724
|
+
# The reason can quote an app error, and so a credentialed DATABASE_URL.
|
|
725
|
+
warn "[mutineer] coverage skipped for #{rel}: #{Redaction.text(reason)}"
|
|
582
726
|
nil
|
|
583
727
|
end
|
|
584
728
|
|
|
@@ -623,9 +767,10 @@ module Mutineer
|
|
|
623
767
|
#
|
|
624
768
|
# @api private
|
|
625
769
|
# @param after_fork [Proc, nil] boot-mode fork hook.
|
|
770
|
+
# @param test_paths [Array<String>] the tests to run, each alone.
|
|
626
771
|
# @return [void]
|
|
627
|
-
def verify_cached_clean(after_fork: nil)
|
|
628
|
-
|
|
772
|
+
def verify_cached_clean(after_fork: nil, test_paths: @test_paths)
|
|
773
|
+
test_paths.each do |test_path|
|
|
629
774
|
rel = relativize(test_path)
|
|
630
775
|
next if @failed_test_files.include?(rel)
|
|
631
776
|
|
|
@@ -1178,18 +1323,32 @@ module Mutineer
|
|
|
1178
1323
|
end
|
|
1179
1324
|
end
|
|
1180
1325
|
|
|
1326
|
+
# True when `cached` records the same +PARALLEL_WORKERS+ value this process
|
|
1327
|
+
# has now. Rails reads that variable inside +parallelize+ and can run one
|
|
1328
|
+
# test file in several workers. A missing key is a map saved before this
|
|
1329
|
+
# value was stored. In-process runs and the daemon write this same file.
|
|
1330
|
+
#
|
|
1331
|
+
# @api private
|
|
1332
|
+
# @param cached [Hash] parsed coverage.json.
|
|
1333
|
+
# @return [Boolean]
|
|
1334
|
+
def parallel_workers_match?(cached)
|
|
1335
|
+
cached.key?("parallel_workers") && cached["parallel_workers"] == ENV.fetch("PARALLEL_WORKERS", nil)
|
|
1336
|
+
end
|
|
1337
|
+
|
|
1181
1338
|
# Digest each file's ROLE + relative path + content length + content, plus
|
|
1182
1339
|
# the load_paths. Without role/path/length delimiters the digest collides
|
|
1183
1340
|
# (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently
|
|
1184
1341
|
# accepting a stale cached map.
|
|
1185
|
-
|
|
1342
|
+
#
|
|
1343
|
+
# @param inputs_only [Boolean] leave out the sources and their owners.
|
|
1344
|
+
def compute_digest(inputs_only: false)
|
|
1186
1345
|
d = Digest::SHA256.new
|
|
1187
|
-
digest_group(d, "source", @source_paths)
|
|
1346
|
+
digest_group(d, "source", @source_paths) unless inputs_only
|
|
1188
1347
|
digest_group(d, "test", @test_paths)
|
|
1189
1348
|
# One group per file: digest_group sorts, and the load order matters.
|
|
1190
1349
|
@require_paths.each { |p| digest_group(d, "require", [digest_path(p)]) }
|
|
1191
1350
|
digest_group(d, "boot", [digest_path(@boot_path)]) if @boot_path
|
|
1192
|
-
ownership_paths.each do |rel|
|
|
1351
|
+
(inputs_only ? [] : ownership_paths).each do |rel|
|
|
1193
1352
|
d.update("owner\0")
|
|
1194
1353
|
d.update(rel)
|
|
1195
1354
|
d.update("\0")
|
|
@@ -1305,7 +1464,11 @@ module Mutineer
|
|
|
1305
1464
|
data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
|
|
1306
1465
|
"dependencies" => @loaded_dependencies, "map" => @map,
|
|
1307
1466
|
"load_lines" => @load_lines.to_a.sort, "load_methods" => @load_methods.to_a.sort,
|
|
1308
|
-
"timings" => @timings }
|
|
1467
|
+
"timings" => @timings, "parallel_workers" => ENV.fetch("PARALLEL_WORKERS", nil) }
|
|
1468
|
+
if @boot_path
|
|
1469
|
+
data.merge!("inputs" => compute_digest(inputs_only: true), "sources" => source_fingerprints,
|
|
1470
|
+
"owners" => ownership_paths)
|
|
1471
|
+
end
|
|
1309
1472
|
tmp = "#{cache_path}.tmp"
|
|
1310
1473
|
File.write(tmp, JSON.generate(data))
|
|
1311
1474
|
File.rename(tmp, cache_path) # atomic swap
|