mutation_tester 1.3.0 → 1.4.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 +13 -0
- data/Gemfile.lock +1 -1
- data/docs/execution-runners.md +48 -14
- data/lib/mutation_tester/batch_runner.rb +1 -1
- data/lib/mutation_tester/core.rb +22 -8
- data/lib/mutation_tester/fork_runner/worker.rb +70 -9
- data/lib/mutation_tester/fork_runner.rb +30 -16
- data/lib/mutation_tester/minitest_fail_fast.rb +42 -0
- data/lib/mutation_tester/mutation_runner.rb +20 -9
- data/lib/mutation_tester/reporters/console_reporter.rb +45 -0
- data/lib/mutation_tester/test_command.rb +30 -6
- data/lib/mutation_tester/version.rb +1 -1
- data/readme.md +54 -15
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8391c9817c1f430ec2231982495e11ff1f6866c3ce11b8e67d9ab421894c44ab
|
|
4
|
+
data.tar.gz: c82152c72bf0c0faaa22807ade06134fc814dd0faddde7f3849100b29b2f54fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d663fe8bc725c6b99e50d0752c3c8452397794672ac2a79a64e1cc6da70b1076abca47bf5143050cc3afd405320ff584d17c587cca8fff47bed27a3f184ed543
|
|
7
|
+
data.tar.gz: f33e5cbcdf08e032015b358e2b027b7846af163d2d5bab5954abf13f6d06d48e3a584677ace35e97f9aee67e48faa96a5c5ffcc2704b9416984e4605bcecdff1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.1] - 2026-08-03
|
|
4
|
+
|
|
5
|
+
- Fixed parallel runs reporting every mutant as survived (`Killed: 0`, score `0.0%`) on projects whose test file reaches its source through `$LOAD_PATH` (a Rails app run with `-Itest` in `RUBYOPT`, for example). A preloaded worker is started once in the real project root, so Ruby absolutizes every `-I`/`RUBYLIB` entry against that directory; the forked child then changed into the shadow workspace but kept resolving `require "test_helper"` back to the original tree, which loaded the unmutated source. Each job now rewrites the `$LOAD_PATH` entries of the mirrored project root into the workspace, so a preloaded worker resolves project code exactly like the `spawn` runner that starts inside the workspace. Only Minitest suites hit this in practice: RSpec re-adds `lib` and its default path at run time, after the child has changed directory. Serial runs and the `spawn` runner were never affected, which is why the same file scored 96.55% serially and 0.0% with `-p 8`.
|
|
6
|
+
- The shadow-workspace sanity check now also proves that the workspace copy of the source is the code the tests execute: after the unmutated source passes there, the check replaces that copy with a `raise` and requires the run to fail. When it still passes, the run aborts as an infrastructure failure (exit code `3`) naming the likely causes, instead of reporting a complete-looking 0.0% score in which every mutant falsely survived.
|
|
7
|
+
- The console summary now warns when a file with at least five scored mutants killed none of them, pointing at the runner rather than at test quality and suggesting a `--runner spawn` comparison.
|
|
8
|
+
|
|
9
|
+
## [1.4.0] - 2026-08-01
|
|
10
|
+
|
|
11
|
+
- Minitest suites now use the same execution runners as RSpec instead of being pinned to `spawn`. The fork worker preloads `minitest` (disabling the `minitest/autorun` at-exit hook and driving `Minitest.run` itself, so the file still runs exactly once per mutant) and the in-memory runner preloads the test file once and re-evaluates each mutant in a fresh fork, so a Minitest project no longer pays a full interpreter, Bundler and framework boot per mutant. Preloaded workers are now keyed by framework, so a mixed-framework `--glob` run never hands a Minitest file to an RSpec-preloaded worker.
|
|
12
|
+
- Every mutant run now stops at its first failing test: RSpec runs get `--fail-fast` and Minitest runs get a preloaded reporter that aborts on the first non-passing result, on all three runners. This cannot change a verdict (a run that stops early has already failed, which is what makes a mutant killed), and only mutant runs opt in: the baseline run and the shadow sanity check still run the whole file. It removes the pathology where a mutant that breaks something every test touches (a class body that no longer loads, a constant every test reads) re-raised the same error once per test, crossed the calibrated deadline, and was reported as a `timeout` instead of a `killed` - a failure mode that got worse as tests were added to the file. Measured on a 15-mutant fixture with a 0.6 s boot and 12 tests: 22.2 s -> 6.5 s by default and 22.7 s -> 13.2 s with `--runner spawn`, with an unchanged score.
|
|
13
|
+
- The console summary now names the deadline that timed-out mutants were measured against and where it came from (`deadline: 6.50s (5x baseline 1.30s)`, or `(explicitly configured)`), so a genuine hang and a deadline calibrated from a slow test file are no longer indistinguishable.
|
|
14
|
+
- `--fail-fast` now stops a batch at the first file with a surviving mutant even when that file's own run completed. `Core#stopped_on_survivor?` reports the fail-fast stop, while `Core#interrupted?` keeps its narrower meaning (mutants were left unprocessed) for the reports and the interruption banner.
|
|
15
|
+
|
|
3
16
|
## [1.3.0] - 2026-07-15
|
|
4
17
|
|
|
5
18
|
- Calibrated the per-mutant timeout against the measured baseline run: unless `config.timeout` is set explicitly, each mutant now gets `max(5s, timeout_factor * baseline duration)` (factor configurable via `config.timeout_factor` / `--timeout-factor N`, default 5) instead of a fixed 30 s, so a loaded machine no longer inflates the mutation score by killing healthy-but-slow runs as timeouts. An explicit `config.timeout` (including `nil` for no deadline) keeps today's fixed-budget behavior and disables calibration. Added the opt-in `config.timeout_policy = :separate` / `--timeout-policy separate`, which scores `killed / (killed + survived)` with timeouts excluded from the score and reported only as their own category; the default `:killed` policy and its output are unchanged.
|
data/Gemfile.lock
CHANGED
data/docs/execution-runners.md
CHANGED
|
@@ -10,14 +10,17 @@ force a runner.
|
|
|
10
10
|
Every mutant is executed by one of three runners:
|
|
11
11
|
|
|
12
12
|
- **fork**: a helper process preloads the environment
|
|
13
|
-
once (RubyGems, Bundler and `rspec-core
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
once (RubyGems, Bundler and the test framework: `rspec-core` for RSpec,
|
|
14
|
+
`minitest` for Minitest, without loading the mutated file or the tests), and
|
|
15
|
+
each mutant runs in a fresh fork of that process. The fork loads the test file
|
|
16
|
+
only after the mutated source has been written, so every mutant is visible and
|
|
17
|
+
no state leaks between mutants. For Minitest the worker disables the
|
|
18
|
+
`minitest/autorun` at-exit hook and drives `Minitest.run` itself, so the file
|
|
19
|
+
runs exactly once per mutant. This removes most of
|
|
17
20
|
the fixed per-mutant boot cost, which matters on large suites and in CI.
|
|
18
|
-
- **spawn**: each mutant starts a full new process (`bundle exec rspec ...`
|
|
19
|
-
Slower per mutant, but works everywhere.
|
|
20
|
-
- **in_memory** (default where supported): the helper process additionally preloads the
|
|
21
|
+
- **spawn**: each mutant starts a full new process (`bundle exec rspec ...` or
|
|
22
|
+
`bundle exec ruby test_file.rb`). Slower per mutant, but works everywhere.
|
|
23
|
+
- **in_memory** (default where supported): the helper process additionally preloads the test
|
|
21
24
|
file and, through it, the original source, once per run. Each mutant then
|
|
22
25
|
runs in a fresh fork that re-evaluates the mutated source in memory
|
|
23
26
|
(redefining the loaded methods and class constants, with the
|
|
@@ -34,15 +37,16 @@ Selection is automatic (`auto`): the fastest safe path is tried first and every
|
|
|
34
37
|
step down to a slower one prints a single stderr warning with its reason, so a
|
|
35
38
|
fallback is never silent. The order is `in_memory` (RSpec with `Process.fork`
|
|
36
39
|
available and a passing unmutated-source probe), then `fork`, then `spawn`.
|
|
40
|
+
Both RSpec and Minitest suites use the same three runners.
|
|
37
41
|
All runners produce identical scores and per-mutant statuses, and all enforce
|
|
38
42
|
the same hard per-mutant timeout (monotonic deadline plus a process-group
|
|
39
43
|
kill).
|
|
40
44
|
|
|
41
45
|
| Mode | Picked by `auto` when | Falls back to |
|
|
42
46
|
|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
|
|
43
|
-
| `in_memory` | the
|
|
44
|
-
| `fork` |
|
|
45
|
-
| `spawn` | the
|
|
47
|
+
| `in_memory` | the platform has `Process.fork`, the file has no load-time `defined?` guard, and re-applying the unmutated source in a probe child passes the suite | `fork`/`spawn` (whole run) with a stderr warning naming the reason; a single worker dying mid-run falls back only for its share of mutants; a mutant that raises while being applied falls back alone |
|
|
48
|
+
| `fork` | `Process.fork` is available, but in-memory is unavailable (each reason is printed) | `spawn`, with a stderr warning, when the helper process fails to preload the environment |
|
|
49
|
+
| `spawn` | the platform has no `Process.fork` | nothing; it works everywhere |
|
|
46
50
|
|
|
47
51
|
Forcing a mode with `--runner fork|spawn|in_memory` skips the auto attempts and
|
|
48
52
|
uses that mode directly (`in_memory` keeps its own documented safety fallbacks;
|
|
@@ -77,14 +81,44 @@ MutationTester.configure do |config|
|
|
|
77
81
|
end
|
|
78
82
|
```
|
|
79
83
|
|
|
84
|
+
### Stopping a mutant at its first failing test
|
|
85
|
+
|
|
86
|
+
Every mutant run stops as soon as one test fails, on all three runners:
|
|
87
|
+
|
|
88
|
+
- RSpec mutant runs are given `--fail-fast` (as a CLI argument on `spawn`, in the
|
|
89
|
+
runner arguments on `fork`, and in the preloaded configuration on `in_memory`).
|
|
90
|
+
- Minitest mutant runs load `lib/mutation_tester/minitest_fail_fast.rb`, which
|
|
91
|
+
registers a Minitest plugin whose reporter raises `Interrupt` on the first
|
|
92
|
+
non-passing result. On `spawn` the file is preloaded with `ruby -r`, on the
|
|
93
|
+
preloaded runners the worker enables the same reporter per job.
|
|
94
|
+
|
|
95
|
+
This cannot change a verdict. A run that stops early has already recorded a
|
|
96
|
+
failure, which is exactly what makes a mutant killed, and a run without a failure
|
|
97
|
+
is untouched and executes every test. Only the mutant runs opt in: the baseline
|
|
98
|
+
run and the shadow-workspace sanity check are expected to pass and always run the
|
|
99
|
+
whole file, so a failing baseline still reports every failure it finds.
|
|
100
|
+
|
|
101
|
+
The pathological case it removes is a mutant that breaks something every test
|
|
102
|
+
touches (a class body that no longer loads, a constant every test reads). Such a
|
|
103
|
+
mutant used to re-raise the same error once per test, which on a large test file
|
|
104
|
+
can cross the per-mutant deadline and be reported as a `timeout` instead of a
|
|
105
|
+
`killed`, and which gets worse as tests are added to the file.
|
|
106
|
+
|
|
80
107
|
### Limitations of the fork runner
|
|
81
108
|
|
|
82
|
-
- Minitest suites always use `spawn` (fork support for Minitest is a separate
|
|
83
|
-
decision after RSpec experience is collected).
|
|
84
109
|
- Platforms without `Process.fork` (for example Windows or JRuby) always use
|
|
85
110
|
`spawn`, even when `--runner fork` is requested.
|
|
86
111
|
- If the helper process fails to preload the environment, the run warns once
|
|
87
112
|
and falls back to `spawn`.
|
|
113
|
+
- The helper process is started once in the real project root, so Ruby has
|
|
114
|
+
already absolutized every `-I` / `RUBYLIB` entry against that directory before
|
|
115
|
+
any mutant runs. Changing directory into a shadow workspace cannot undo that,
|
|
116
|
+
so each job additionally rewrites the `$LOAD_PATH` entries that point into the
|
|
117
|
+
mirrored project root so they point into the workspace. Without it a Minitest
|
|
118
|
+
file reaching its source through `require "test_helper"` would load the
|
|
119
|
+
original, unmutated tree and every mutant would falsely survive. RSpec re-adds
|
|
120
|
+
`lib` and its default path at run time, after the child has changed directory,
|
|
121
|
+
so it resolves the workspace copy either way.
|
|
88
122
|
|
|
89
123
|
### Limitations of the in-memory runner
|
|
90
124
|
|
|
@@ -92,8 +126,8 @@ The in-memory runner never fails silently: each case below falls back to
|
|
|
92
126
|
file-based execution with a warning, and a mutant is marked `error` only when
|
|
93
127
|
no fallback is possible.
|
|
94
128
|
|
|
95
|
-
-
|
|
96
|
-
|
|
129
|
+
- The file must be classic loadable code (classes/modules) that survives being
|
|
130
|
+
evaluated a second time.
|
|
97
131
|
- With `-p N` (N > 1) the run stays fully in memory: the environment, the
|
|
98
132
|
original source and the specs are preloaded once, the preloaded process is
|
|
99
133
|
forked into N pooled clones, and every parallel worker applies each mutant
|
data/lib/mutation_tester/core.rb
CHANGED
|
@@ -44,10 +44,12 @@ module MutationTester
|
|
|
44
44
|
ForkRunner.shutdown_all
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def stopped_on_survivor?
|
|
48
|
+
@config.fail_fast && @results.any? { |result| result[:status] == :survived }
|
|
49
|
+
end
|
|
50
|
+
|
|
47
51
|
def interrupted?
|
|
48
|
-
@
|
|
49
|
-
@results.size < @mutations.size &&
|
|
50
|
-
@results.any? { |result| result[:status] == :survived }
|
|
52
|
+
stopped_on_survivor? && @results.size < @mutations.size
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
def mutation_score
|
|
@@ -130,13 +132,25 @@ module MutationTester
|
|
|
130
132
|
return true if mutation_runner.in_memory_first?
|
|
131
133
|
|
|
132
134
|
puts Rainbow("\n🩺 Verifying the shadow workspace with the unmutated source...").yellow
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
case mutation_runner.shadow_workspace_check
|
|
136
|
+
when :ok
|
|
137
|
+
puts Rainbow('✓ Shadow workspace verified: the unmutated source passes and the workspace copy is what the tests execute').green
|
|
138
|
+
true
|
|
139
|
+
when :canary
|
|
140
|
+
report_unreachable_workspace_source
|
|
141
|
+
else
|
|
142
|
+
puts Rainbow('❌ The unmutated source fails inside the shadow workspace; the shadow environment is unreliable.').red
|
|
143
|
+
puts Rainbow(' Every mutant would falsely die there, so the run is aborted instead of reporting a misleading score.').red
|
|
144
|
+
false
|
|
136
145
|
end
|
|
146
|
+
end
|
|
137
147
|
|
|
138
|
-
|
|
139
|
-
puts Rainbow('
|
|
148
|
+
def report_unreachable_workspace_source
|
|
149
|
+
puts Rainbow('❌ The tests still pass with the workspace copy of the source replaced by a raise.').red
|
|
150
|
+
puts Rainbow(' The mutated file is therefore not the code the tests execute, so every mutant would falsely').red
|
|
151
|
+
puts Rainbow(' survive; the run is aborted instead of reporting a misleading 0.0% score.').red
|
|
152
|
+
puts Rainbow(' Usual causes: the tests resolve this source outside the workspace (an absolute entry in').red
|
|
153
|
+
puts Rainbow(' $LOAD_PATH, a symlinked .rb file, a preloaded copy of the class), or they never load it at all.').red
|
|
140
154
|
false
|
|
141
155
|
end
|
|
142
156
|
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
-
require 'rspec/core'
|
|
5
4
|
require_relative '../in_memory_loader'
|
|
6
5
|
|
|
7
6
|
control = $stdout.dup
|
|
8
7
|
control.sync = true
|
|
9
8
|
STDOUT.reopen(File::NULL)
|
|
10
9
|
|
|
10
|
+
framework = ARGV.shift == 'minitest' ? :minitest : :rspec
|
|
11
|
+
|
|
12
|
+
if framework == :minitest
|
|
13
|
+
require 'minitest'
|
|
14
|
+
require_relative '../minitest_fail_fast'
|
|
15
|
+
Minitest.class_variable_set(:@@installed_at_exit, true)
|
|
16
|
+
else
|
|
17
|
+
require 'rspec/core'
|
|
18
|
+
end
|
|
19
|
+
|
|
11
20
|
kill_group = lambda do |pid|
|
|
12
21
|
begin
|
|
13
22
|
Process.kill('KILL', -pid)
|
|
@@ -17,6 +26,26 @@ end
|
|
|
17
26
|
|
|
18
27
|
monotonic = lambda { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
19
28
|
|
|
29
|
+
resolved_path = lambda do |path|
|
|
30
|
+
File.realpath(path)
|
|
31
|
+
rescue SystemCallError
|
|
32
|
+
path
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
mirror_load_path = lambda do |from, to|
|
|
36
|
+
next if from.nil? || to.nil? || from == to
|
|
37
|
+
|
|
38
|
+
roots = [from, resolved_path.call(from)].uniq.map { |root| root.chomp('/') }
|
|
39
|
+
$LOAD_PATH.map! do |entry|
|
|
40
|
+
path = File.expand_path(entry.to_s, from)
|
|
41
|
+
root = roots.find { |candidate| path == candidate || path.start_with?("#{candidate}/") }
|
|
42
|
+
next entry unless root
|
|
43
|
+
|
|
44
|
+
suffix = path.delete_prefix(root).delete_prefix('/')
|
|
45
|
+
suffix.empty? ? to : File.join(to, suffix)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
20
49
|
current_child = nil
|
|
21
50
|
preloaded = nil
|
|
22
51
|
|
|
@@ -41,6 +70,7 @@ supervise_child = lambda do |job, out, child_body|
|
|
|
41
70
|
rescue Errno::EACCES, Errno::EPERM
|
|
42
71
|
end
|
|
43
72
|
Dir.chdir(job['chdir']) if job['chdir']
|
|
73
|
+
mirror_load_path.call(job['mirror_of'], job['chdir'])
|
|
44
74
|
sink = File.open(job['log'] || File::NULL, 'w')
|
|
45
75
|
sink.sync = true
|
|
46
76
|
STDOUT.reopen(sink)
|
|
@@ -91,10 +121,25 @@ supervise_child = lambda do |job, out, child_body|
|
|
|
91
121
|
[timed_out, payload, reaped]
|
|
92
122
|
end
|
|
93
123
|
|
|
124
|
+
load_test_file = lambda do |path|
|
|
125
|
+
$PROGRAM_NAME = path
|
|
126
|
+
load(path)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
run_test_file = lambda do |job|
|
|
130
|
+
if framework == :minitest
|
|
131
|
+
MutationTester::MinitestFailFast.enabled = job['stop_on_first_failure'] ? true : false
|
|
132
|
+
load_test_file.call(job['spec'])
|
|
133
|
+
Minitest.run(Array(job['args'])) ? 0 : 1
|
|
134
|
+
else
|
|
135
|
+
args = [job['spec'], *Array(job['args'])]
|
|
136
|
+
args << '--fail-fast' if job['stop_on_first_failure']
|
|
137
|
+
RSpec::Core::Runner.run(args, STDERR, STDOUT).to_i
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
94
141
|
run_job = lambda do |job, out|
|
|
95
|
-
timed_out, payload, reaped = supervise_child.call(job, out, lambda
|
|
96
|
-
RSpec::Core::Runner.run([job['spec'], *Array(job['args'])], STDERR, STDOUT).to_i
|
|
97
|
-
end)
|
|
142
|
+
timed_out, payload, reaped = supervise_child.call(job, out, lambda { run_test_file.call(job) })
|
|
98
143
|
|
|
99
144
|
status =
|
|
100
145
|
if timed_out
|
|
@@ -111,16 +156,32 @@ end
|
|
|
111
156
|
preload_specs = lambda do |request, out|
|
|
112
157
|
begin
|
|
113
158
|
Dir.chdir(request['chdir']) if request['chdir']
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
159
|
+
if framework == :minitest
|
|
160
|
+
MutationTester::MinitestFailFast.enabled = request['stop_on_first_failure'] ? true : false
|
|
161
|
+
load_test_file.call(request['spec'])
|
|
162
|
+
preloaded = true
|
|
163
|
+
else
|
|
164
|
+
sink = File.open(File::NULL, 'w')
|
|
165
|
+
options = [request['spec']]
|
|
166
|
+
options << '--fail-fast' if request['stop_on_first_failure']
|
|
167
|
+
runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(options))
|
|
168
|
+
runner.setup(sink, sink)
|
|
169
|
+
preloaded = runner
|
|
170
|
+
end
|
|
118
171
|
out.puts(JSON.generate('event' => 'preloaded', 'status' => 'ok'))
|
|
119
172
|
rescue ScriptError, StandardError => e
|
|
120
173
|
out.puts(JSON.generate('event' => 'preloaded', 'status' => 'error', 'message' => "#{e.class}: #{e.message}"))
|
|
121
174
|
end
|
|
122
175
|
end
|
|
123
176
|
|
|
177
|
+
run_preloaded_suite = lambda do
|
|
178
|
+
if framework == :minitest
|
|
179
|
+
Minitest.run([]) ? 0 : 1
|
|
180
|
+
else
|
|
181
|
+
preloaded.run_specs(RSpec.world.ordered_example_groups).to_i
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
124
185
|
run_in_memory_job = lambda do |job, out|
|
|
125
186
|
request = job['in_memory']
|
|
126
187
|
|
|
@@ -132,7 +193,7 @@ run_in_memory_job = lambda do |job, out|
|
|
|
132
193
|
timed_out, payload, reaped = supervise_child.call(job, out, lambda do
|
|
133
194
|
begin
|
|
134
195
|
MutationTester::InMemoryLoader.apply(request['source'], request['path'])
|
|
135
|
-
JSON.generate('code' =>
|
|
196
|
+
JSON.generate('code' => run_preloaded_suite.call)
|
|
136
197
|
rescue ScriptError, StandardError => e
|
|
137
198
|
JSON.generate('error' => "#{e.class}: #{e.message}")
|
|
138
199
|
end
|
|
@@ -21,22 +21,22 @@ module MutationTester
|
|
|
21
21
|
Process.respond_to?(:fork)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def acquire(use_bundle_exec:)
|
|
24
|
+
def acquire(use_bundle_exec:, framework: :rspec)
|
|
25
25
|
return nil unless available?
|
|
26
26
|
|
|
27
|
-
key = [Process.pid, use_bundle_exec]
|
|
27
|
+
key = [Process.pid, use_bundle_exec, framework]
|
|
28
28
|
return registry[key] if registry.key?(key)
|
|
29
29
|
|
|
30
|
-
registry[key] = checkout_pooled(use_bundle_exec) || boot(use_bundle_exec)
|
|
30
|
+
registry[key] = checkout_pooled(pool_key(use_bundle_exec, framework)) || boot(use_bundle_exec, framework)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def prepare_pool(count, use_bundle_exec:, env_for: nil)
|
|
33
|
+
def prepare_pool(count, use_bundle_exec:, framework: :rspec, env_for: nil)
|
|
34
34
|
return unless available?
|
|
35
35
|
|
|
36
|
-
primary = acquire(use_bundle_exec: use_bundle_exec)
|
|
36
|
+
primary = acquire(use_bundle_exec: use_bundle_exec, framework: framework)
|
|
37
37
|
return unless primary
|
|
38
38
|
|
|
39
|
-
refill_pool(use_bundle_exec, count, primary, env_for: env_for)
|
|
39
|
+
refill_pool(pool_key(use_bundle_exec, framework), count, primary, env_for: env_for)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def prepare_in_memory_pool(count, primary)
|
|
@@ -100,11 +100,15 @@ module MutationTester
|
|
|
100
100
|
|
|
101
101
|
private
|
|
102
102
|
|
|
103
|
-
def
|
|
103
|
+
def pool_key(use_bundle_exec, framework)
|
|
104
|
+
[use_bundle_exec, framework]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def checkout_pooled(key)
|
|
104
108
|
number = parallel_worker_number
|
|
105
109
|
return nil unless number
|
|
106
110
|
|
|
107
|
-
entry = pool[
|
|
111
|
+
entry = pool[key]
|
|
108
112
|
entry && entry[:runners][number]
|
|
109
113
|
end
|
|
110
114
|
|
|
@@ -123,8 +127,8 @@ module MutationTester
|
|
|
123
127
|
entry[:runners]
|
|
124
128
|
end
|
|
125
129
|
|
|
126
|
-
def boot(use_bundle_exec)
|
|
127
|
-
runner = new(use_bundle_exec: use_bundle_exec)
|
|
130
|
+
def boot(use_bundle_exec, framework)
|
|
131
|
+
runner = new(use_bundle_exec: use_bundle_exec, framework: framework)
|
|
128
132
|
return runner if runner.ready?
|
|
129
133
|
|
|
130
134
|
runner.shutdown
|
|
@@ -133,8 +137,8 @@ module MutationTester
|
|
|
133
137
|
end
|
|
134
138
|
end
|
|
135
139
|
|
|
136
|
-
def initialize(use_bundle_exec:)
|
|
137
|
-
argv = ['ruby', WORKER_PATH]
|
|
140
|
+
def initialize(use_bundle_exec:, framework: :rspec)
|
|
141
|
+
argv = ['ruby', WORKER_PATH, framework.to_s]
|
|
138
142
|
argv = ['bundle', 'exec', *argv] if use_bundle_exec
|
|
139
143
|
|
|
140
144
|
job_reader, job_writer = IO.pipe
|
|
@@ -149,9 +153,18 @@ module MutationTester
|
|
|
149
153
|
@ready
|
|
150
154
|
end
|
|
151
155
|
|
|
152
|
-
def execute(spec_file, timeout: nil, chdir: nil, capture: false, args: []
|
|
156
|
+
def execute(spec_file, timeout: nil, chdir: nil, capture: false, args: [], stop_on_first_failure: false,
|
|
157
|
+
mirror_of: nil)
|
|
153
158
|
log = capture ? Tempfile.new(['mutation_tester_fork', '.log']) : nil
|
|
154
|
-
job = {
|
|
159
|
+
job = {
|
|
160
|
+
spec: spec_file,
|
|
161
|
+
timeout: timeout,
|
|
162
|
+
chdir: chdir,
|
|
163
|
+
log: log&.path,
|
|
164
|
+
args: args,
|
|
165
|
+
stop_on_first_failure: stop_on_first_failure,
|
|
166
|
+
mirror_of: mirror_of
|
|
167
|
+
}
|
|
155
168
|
@job_writer.puts(JSON.generate(job))
|
|
156
169
|
status = await_result(timeout)['status']
|
|
157
170
|
result = TestCommand::Result.new(status == 'pass', status == 'timeout')
|
|
@@ -164,8 +177,9 @@ module MutationTester
|
|
|
164
177
|
log&.unlink
|
|
165
178
|
end
|
|
166
179
|
|
|
167
|
-
def preload(spec_file, chdir: nil)
|
|
168
|
-
|
|
180
|
+
def preload(spec_file, chdir: nil, stop_on_first_failure: false)
|
|
181
|
+
request = { spec: spec_file, chdir: chdir, stop_on_first_failure: stop_on_first_failure }
|
|
182
|
+
@job_writer.puts(JSON.generate(preload: request))
|
|
169
183
|
event = read_event(monotonic_time + BOOT_TIMEOUT)
|
|
170
184
|
return [true, nil] if event.is_a?(Hash) && event['event'] == 'preloaded' && event['status'] == 'ok'
|
|
171
185
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutationTester
|
|
4
|
+
module MinitestFailFast
|
|
5
|
+
PLUGIN_NAME = :mutation_tester_fail_fast
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
attr_accessor :enabled
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
self.enabled = true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
require 'minitest'
|
|
17
|
+
rescue LoadError
|
|
18
|
+
Kernel.warn '[MutationTester] minitest is not loadable here; mutant runs will execute every test instead of stopping at the first failure.'
|
|
19
|
+
else
|
|
20
|
+
module MutationTester
|
|
21
|
+
module MinitestFailFast
|
|
22
|
+
class Reporter < Minitest::AbstractReporter
|
|
23
|
+
def record(result)
|
|
24
|
+
return unless MinitestFailFast.enabled
|
|
25
|
+
return if result.passed? || result.skipped?
|
|
26
|
+
|
|
27
|
+
raise Interrupt
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
module Minitest
|
|
34
|
+
def self.plugin_mutation_tester_fail_fast_init(_options)
|
|
35
|
+
reporter << MutationTester::MinitestFailFast::Reporter.new
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless Minitest.extensions.include?(MutationTester::MinitestFailFast::PLUGIN_NAME)
|
|
40
|
+
Minitest.register_plugin(MutationTester::MinitestFailFast::PLUGIN_NAME)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -4,6 +4,7 @@ require 'securerandom'
|
|
|
4
4
|
|
|
5
5
|
module MutationTester
|
|
6
6
|
class MutationRunner
|
|
7
|
+
CANARY_SOURCE = "raise 'mutation_tester canary: the workspace copy of this source was not executed'\n".freeze
|
|
7
8
|
PARALLEL_INTERRUPT_LINE = "Parallel execution interrupted, exiting ...\n"
|
|
8
9
|
|
|
9
10
|
class ParallelInterruptFilter
|
|
@@ -216,7 +217,7 @@ module MutationTester
|
|
|
216
217
|
File.write(shadow_source, mutation[:code])
|
|
217
218
|
|
|
218
219
|
outcome, phase = run_two_phase(mutation) do |example_filters|
|
|
219
|
-
run_specs_in_shadow(shadow_spec, shadow_root, example_filters: example_filters)
|
|
220
|
+
run_specs_in_shadow(shadow_spec, shadow_root, project_root, example_filters: example_filters)
|
|
220
221
|
end
|
|
221
222
|
apply_outcome(result, outcome, phase)
|
|
222
223
|
ensure
|
|
@@ -240,6 +241,10 @@ module MutationTester
|
|
|
240
241
|
end
|
|
241
242
|
|
|
242
243
|
def shadow_baseline_passes?
|
|
244
|
+
shadow_workspace_check == :ok
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def shadow_workspace_check
|
|
243
248
|
project_root = find_project_root
|
|
244
249
|
|
|
245
250
|
Dir.mktmpdir do |temp_dir|
|
|
@@ -254,12 +259,16 @@ module MutationTester
|
|
|
254
259
|
|
|
255
260
|
File.unlink(shadow_source)
|
|
256
261
|
File.write(shadow_source, @original_content)
|
|
262
|
+
return :baseline unless run_specs_in_shadow(shadow_spec, shadow_root, project_root).passed?
|
|
257
263
|
|
|
258
|
-
|
|
264
|
+
File.write(shadow_source, CANARY_SOURCE)
|
|
265
|
+
return :canary if run_specs_in_shadow(shadow_spec, shadow_root, project_root).passed?
|
|
266
|
+
|
|
267
|
+
:ok
|
|
259
268
|
end
|
|
260
269
|
rescue => e
|
|
261
270
|
warn("[MutationTester] Shadow sanity check could not prepare the shadow workspace: #{e.message}")
|
|
262
|
-
|
|
271
|
+
:baseline
|
|
263
272
|
end
|
|
264
273
|
|
|
265
274
|
def shadow_copy_project(source, dest)
|
|
@@ -284,8 +293,9 @@ module MutationTester
|
|
|
284
293
|
end
|
|
285
294
|
end
|
|
286
295
|
|
|
287
|
-
def run_specs_in_shadow(spec_file, working_dir, example_filters: [])
|
|
288
|
-
test_command(spec_file, example_filters: example_filters)
|
|
296
|
+
def run_specs_in_shadow(spec_file, working_dir, project_root, example_filters: [])
|
|
297
|
+
test_command(spec_file, example_filters: example_filters)
|
|
298
|
+
.run(timeout: @config.effective_timeout, chdir: working_dir, mirror_of: project_root)
|
|
289
299
|
end
|
|
290
300
|
|
|
291
301
|
def discoverable_project_root
|
|
@@ -390,19 +400,18 @@ module MutationTester
|
|
|
390
400
|
end
|
|
391
401
|
|
|
392
402
|
def prepare_in_memory_execution
|
|
393
|
-
return 'the in-memory runner supports RSpec suites only' unless detect_test_framework(@spec_file) == :rspec
|
|
394
403
|
return 'Process.fork is not supported on this platform' unless ForkRunner.available?
|
|
395
404
|
if InMemoryLoader.load_time_defined_guard?(@original_content)
|
|
396
405
|
return 'the source file uses defined? at load time, so redefinition would silently skip the guarded code'
|
|
397
406
|
end
|
|
398
407
|
|
|
399
|
-
runner = ForkRunner.new(use_bundle_exec: @use_bundle_exec)
|
|
408
|
+
runner = ForkRunner.new(use_bundle_exec: @use_bundle_exec, framework: detect_test_framework(@spec_file))
|
|
400
409
|
unless runner.ready?
|
|
401
410
|
runner.shutdown
|
|
402
411
|
return 'the in-memory worker failed to preload the environment'
|
|
403
412
|
end
|
|
404
413
|
|
|
405
|
-
preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd)
|
|
414
|
+
preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd, stop_on_first_failure: true)
|
|
406
415
|
unless preloaded
|
|
407
416
|
runner.shutdown
|
|
408
417
|
return "the spec file could not be preloaded (#{message})"
|
|
@@ -521,6 +530,7 @@ module MutationTester
|
|
|
521
530
|
ForkRunner.prepare_pool(
|
|
522
531
|
[@config.parallel_processes, total].min,
|
|
523
532
|
use_bundle_exec: @use_bundle_exec,
|
|
533
|
+
framework: detect_test_framework(@spec_file),
|
|
524
534
|
env_for: worker_env_for
|
|
525
535
|
)
|
|
526
536
|
end
|
|
@@ -637,7 +647,8 @@ module MutationTester
|
|
|
637
647
|
use_bundle_exec: @use_bundle_exec,
|
|
638
648
|
runner: @config.runner,
|
|
639
649
|
example_filters: example_filters,
|
|
640
|
-
worker_env_var: @config.worker_env_var
|
|
650
|
+
worker_env_var: @config.worker_env_var,
|
|
651
|
+
stop_on_first_failure: true
|
|
641
652
|
)
|
|
642
653
|
end
|
|
643
654
|
end
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
module MutationTester
|
|
2
2
|
module Reporters
|
|
3
3
|
class ConsoleReporter < BaseReporter
|
|
4
|
+
NOTHING_KILLED_MIN_MUTANTS = 5
|
|
5
|
+
|
|
4
6
|
def generate
|
|
5
7
|
puts "\n" + Rainbow('=' * 80).bright
|
|
6
8
|
puts Rainbow('🧬 MUTATION TESTING REPORT').bright.cyan
|
|
7
9
|
puts Rainbow('=' * 80).bright
|
|
8
10
|
|
|
9
11
|
print_summary
|
|
12
|
+
print_nothing_killed_warning
|
|
10
13
|
print_survived_mutations if survived_count > 0
|
|
11
14
|
|
|
12
15
|
puts "\n" + Rainbow('=' * 80).bright
|
|
@@ -20,6 +23,7 @@ module MutationTester
|
|
|
20
23
|
puts " #{Rainbow("Killed: " + killed_count.to_s).green} ✅"
|
|
21
24
|
puts " #{Rainbow("Survived: " + survived_count.to_s).red} ❌"
|
|
22
25
|
puts " #{Rainbow("Timeout: " + timeout_count.to_s).yellow} ⏱️"
|
|
26
|
+
print_timeout_deadline
|
|
23
27
|
puts " #{Rainbow("Stillborn: " + stillborn_count.to_s).yellow} 🧬"
|
|
24
28
|
puts " #{Rainbow("Errors: " + error_count.to_s).yellow} 💥"
|
|
25
29
|
print_excluded_summary
|
|
@@ -29,6 +33,47 @@ module MutationTester
|
|
|
29
33
|
puts "\n " + progress_bar
|
|
30
34
|
end
|
|
31
35
|
|
|
36
|
+
def print_nothing_killed_warning
|
|
37
|
+
return unless nothing_killed?
|
|
38
|
+
|
|
39
|
+
puts "\n#{Rainbow("⚠️ Not one of the #{scored_mutant_count} scored mutants was killed.").yellow}"
|
|
40
|
+
puts Rainbow(' A whole file that kills nothing is more often a runner problem (the mutated code never').yellow
|
|
41
|
+
puts Rainbow(' reached the tests) than a test-quality gap. Re-run with --runner spawn and compare before').yellow
|
|
42
|
+
puts Rainbow(' acting on this score.').yellow
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def nothing_killed?
|
|
46
|
+
killed_count.zero? && timeout_count.zero? && scored_mutant_count >= NOTHING_KILLED_MIN_MUTANTS
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def scored_mutant_count
|
|
50
|
+
killed_count + timeout_count + survived_count
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def print_timeout_deadline
|
|
54
|
+
return unless timeout_count.positive?
|
|
55
|
+
|
|
56
|
+
puts " #{Rainbow("deadline: " + timeout_deadline_description).yellow}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def timeout_deadline_description
|
|
60
|
+
deadline = @config.effective_timeout
|
|
61
|
+
return 'none (no deadline configured)' if deadline.nil?
|
|
62
|
+
|
|
63
|
+
"#{format_seconds(deadline)} #{deadline_origin}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def deadline_origin
|
|
67
|
+
baseline = @config.baseline_duration
|
|
68
|
+
return '(explicitly configured)' if @config.timeout_explicitly_set? || baseline.nil?
|
|
69
|
+
|
|
70
|
+
"(#{format('%g', @config.timeout_factor)}x baseline #{format_seconds(baseline)})"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def format_seconds(value)
|
|
74
|
+
"#{format('%.2f', value)}s"
|
|
75
|
+
end
|
|
76
|
+
|
|
32
77
|
def print_excluded_summary
|
|
33
78
|
count = excluded_line_count
|
|
34
79
|
return unless count.positive?
|
|
@@ -8,20 +8,23 @@ module MutationTester
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
POLL_INTERVAL = 0.05
|
|
11
|
+
MINITEST_FAIL_FAST_PATH = File.expand_path('minitest_fail_fast.rb', __dir__).freeze
|
|
11
12
|
|
|
12
13
|
attr_reader :spec_file, :framework, :use_bundle_exec, :example_filters
|
|
13
14
|
|
|
14
|
-
def initialize(spec_file, use_bundle_exec:, framework: nil, runner: :spawn, example_filters: [], worker_env_var: nil
|
|
15
|
+
def initialize(spec_file, use_bundle_exec:, framework: nil, runner: :spawn, example_filters: [], worker_env_var: nil,
|
|
16
|
+
stop_on_first_failure: false)
|
|
15
17
|
@spec_file = spec_file
|
|
16
18
|
@framework = framework || self.class.detect_framework(spec_file)
|
|
17
19
|
@use_bundle_exec = use_bundle_exec
|
|
18
20
|
@runner = runner
|
|
19
21
|
@example_filters = @framework == :rspec ? Array(example_filters) : []
|
|
20
22
|
@worker_env_var = worker_env_var
|
|
23
|
+
@stop_on_first_failure = stop_on_first_failure
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def argv
|
|
24
|
-
parts = [runner, spec_file, *filter_args]
|
|
27
|
+
parts = [runner, *interpreter_args, spec_file, *filter_args, *fail_fast_args]
|
|
25
28
|
@use_bundle_exec ? ['bundle', 'exec', *parts] : parts
|
|
26
29
|
end
|
|
27
30
|
|
|
@@ -37,10 +40,20 @@ module MutationTester
|
|
|
37
40
|
cmd
|
|
38
41
|
end
|
|
39
42
|
|
|
40
|
-
def run(timeout: nil, chdir: nil, capture: false)
|
|
43
|
+
def run(timeout: nil, chdir: nil, capture: false, mirror_of: nil)
|
|
41
44
|
if fork_execution?
|
|
42
|
-
fork_runner = ForkRunner.acquire(use_bundle_exec: @use_bundle_exec)
|
|
43
|
-
|
|
45
|
+
fork_runner = ForkRunner.acquire(use_bundle_exec: @use_bundle_exec, framework: @framework)
|
|
46
|
+
if fork_runner
|
|
47
|
+
return fork_runner.execute(
|
|
48
|
+
spec_file,
|
|
49
|
+
timeout: timeout,
|
|
50
|
+
chdir: chdir || Dir.pwd,
|
|
51
|
+
capture: capture,
|
|
52
|
+
args: filter_args,
|
|
53
|
+
stop_on_first_failure: @stop_on_first_failure,
|
|
54
|
+
mirror_of: mirror_of
|
|
55
|
+
)
|
|
56
|
+
end
|
|
44
57
|
end
|
|
45
58
|
|
|
46
59
|
return run_captured(timeout: timeout, chdir: chdir) if capture
|
|
@@ -53,7 +66,6 @@ module MutationTester
|
|
|
53
66
|
end
|
|
54
67
|
|
|
55
68
|
def fork_execution?
|
|
56
|
-
return false if @framework == :minitest
|
|
57
69
|
return false if @runner == :spawn
|
|
58
70
|
|
|
59
71
|
ForkRunner.available?
|
|
@@ -100,6 +112,18 @@ module MutationTester
|
|
|
100
112
|
example_filters.flat_map { |filter| ['-e', filter] }
|
|
101
113
|
end
|
|
102
114
|
|
|
115
|
+
def interpreter_args
|
|
116
|
+
return [] unless @stop_on_first_failure && @framework == :minitest
|
|
117
|
+
|
|
118
|
+
['-r', MINITEST_FAIL_FAST_PATH]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def fail_fast_args
|
|
122
|
+
return [] unless @stop_on_first_failure && @framework == :rspec
|
|
123
|
+
|
|
124
|
+
['--fail-fast']
|
|
125
|
+
end
|
|
126
|
+
|
|
103
127
|
def run_captured(timeout:, chdir:)
|
|
104
128
|
log = Tempfile.new(['mutation_tester_baseline', '.log'])
|
|
105
129
|
spawn_options = { pgroup: true, %i[out err] => log.path }
|
data/readme.md
CHANGED
|
@@ -55,7 +55,7 @@ MutationTester keeps its requirements low so it drops into a wide range of proje
|
|
|
55
55
|
|-------------------------|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
56
56
|
| Ruby | `>= 3.0` | Floor is Ruby 3.0. CI runs the suite on 3.0, 3.1, 3.2 and 3.3. The gem is developed on Ruby 4.0.2, and 4.x is supported. Ruby 4.x has no prebuilt binary on the GitHub-hosted runners yet, so it is verified on the development host rather than in the CI matrix. |
|
|
57
57
|
| RSpec (your project) | `3.x` | The gem shells out to your project's own `rspec`, so any RSpec 3.x works. Both ends of the range are exercised by a real mutation run: the lowest 3.0.x line in the CI framework matrix, and 3.13.x in the example jobs. |
|
|
58
|
-
| Minitest (your project) | `5.x` and `6.x` | The gem
|
|
58
|
+
| Minitest (your project) | `5.x` and `6.x` | The gem runs your project's own test file (`ruby test_file.rb`, or the same file inside a preloaded fork worker), so both the 5.x and 6.x lines work. Both are exercised by a real mutation run: 5.x in the example jobs, and 6.x in the CI framework matrix. |
|
|
59
59
|
|
|
60
60
|
Notes:
|
|
61
61
|
|
|
@@ -200,7 +200,7 @@ mutation_test [OPTIONS] --glob 'lib/**/*.rb'
|
|
|
200
200
|
| Flag | Description |
|
|
201
201
|
|---|---|
|
|
202
202
|
| `-p, --parallel N` | Run with N parallel processes (default: auto, derived from the CPU core count with a cap of 8; `-p 1` forces serial execution). |
|
|
203
|
-
| `--runner MODE` | Mutant execution runner: `auto` (default) tries `in_memory` first (
|
|
203
|
+
| `--runner MODE` | Mutant execution runner: `auto` (default) tries `in_memory` first (`Process.fork` available and a passing unmutated-source probe), then falls back to `fork`, then `spawn`, announcing every step down on stderr with its reason; `fork` (preloaded environment, on platforms with `Process.fork`), `spawn` (one full process per mutant) and `in_memory` (mutations applied in child-process memory, zero file writes per mutant) force the specific mode. See [Execution runners](#execution-runners-fork-spawn-in-memory). |
|
|
204
204
|
| `--staged` | Mutation-test the files staged in git (`git diff --cached --name-only`; files staged as deleted are ignored), mapping each to its spec like a positional `FILE` list. Cannot be combined with positional arguments or `--glob`. See [File lists and --staged](#file-lists-and---staged-test-what-you-changed). |
|
|
205
205
|
| `--glob PATTERN` | Batch mode: mutation-test every source file matching `PATTERN`, mapping each to its spec by convention (see [Batch mode](#batch-mode-run-many-files-in-one-command)). |
|
|
206
206
|
| `--spec-glob TEMPLATE` | Spec-mapping template with a `{name}` placeholder (default: `spec/{name}_spec.rb`). Requires a positional `FILE` list, `--staged`, or `--glob`. |
|
|
@@ -232,7 +232,9 @@ bundle exec mutation_test --reporters json,html --output-dir build/mutation \
|
|
|
232
232
|
file, unknown reporter, source with a syntax error).
|
|
233
233
|
- `2` - a usage error (conflicting flags; see the batch sections below).
|
|
234
234
|
- `3` - the run aborted or degraded before reaching a verdict: the shadow workspace
|
|
235
|
-
was unreliable
|
|
235
|
+
was unreliable (the unmutated source failed there, or the workspace copy of the
|
|
236
|
+
source turned out not to be the code the tests execute), or every mutant ended as
|
|
237
|
+
`error`/`stillborn` so nothing was scored.
|
|
236
238
|
This signals an infrastructure or runner problem, not a test-quality gap, so CI
|
|
237
239
|
hooks can distinguish it from a genuine threshold failure.
|
|
238
240
|
- `130` - interrupted with Ctrl+C.
|
|
@@ -523,7 +525,16 @@ invalid value (less than 1, or non-numeric) falls back to 1 with a warning on st
|
|
|
523
525
|
In parallel mode each mutant runs in an isolated shadow workspace. Every `.rb`
|
|
524
526
|
file is a physical copy (non-Ruby files stay symlinks for speed), so mutations
|
|
525
527
|
apply correctly even when a spec loads the source indirectly (e.g. via
|
|
526
|
-
`spec_helper`)
|
|
528
|
+
`spec_helper`), and `$LOAD_PATH` entries pointing into the project resolve
|
|
529
|
+
inside the workspace, so a test file that reaches its source through
|
|
530
|
+
`require "test_helper"` gets the mutated copy too. The parallel mutation score
|
|
531
|
+
therefore matches serial.
|
|
532
|
+
|
|
533
|
+
Before the first mutant, the run proves this in the workspace itself: the
|
|
534
|
+
unmutated source must pass there, and the same suite must fail once that copy of
|
|
535
|
+
the source is replaced by a `raise`. A run whose tests pass even then is aborted
|
|
536
|
+
as an infrastructure failure (exit code `3`) rather than reported as a 0.0%
|
|
537
|
+
score, because the mutated file is demonstrably not the code being executed.
|
|
527
538
|
|
|
528
539
|
### When to use serial vs parallel execution
|
|
529
540
|
|
|
@@ -597,18 +608,18 @@ the fastest safe one, announcing every fallback on stderr:
|
|
|
597
608
|
|
|
598
609
|
- **in_memory** (default where supported): re-evaluates the mutated source in the
|
|
599
610
|
memory of a fresh fork of a preloaded process, with zero file writes per mutant
|
|
600
|
-
and no shadow workspaces. RSpec
|
|
601
|
-
effect at class-load time (constants consumed by macros, `validates`/`has_many`/
|
|
611
|
+
and no shadow workspaces. RSpec and Minitest; the fastest path. Mutations that only
|
|
612
|
+
take effect at class-load time (constants consumed by macros, `validates`/`has_many`/
|
|
602
613
|
`before_save`/`scope`/`attribute`, anything inside an `included do` block) cannot
|
|
603
614
|
be observed by re-evaluating source in a preloaded process, so those mutants are
|
|
604
615
|
routed automatically to the file-based path and the rest still run in memory (see
|
|
605
616
|
below); the combined score matches a full `fork` run.
|
|
606
|
-
- **fork**: preloads the environment once (RubyGems, Bundler,
|
|
607
|
-
forks a fresh child per mutant. RSpec on platforms with
|
|
608
|
-
most of the fixed per-mutant boot cost.
|
|
609
|
-
- **spawn**: starts one full process per mutant (`bundle exec rspec ...`
|
|
610
|
-
per mutant, but works everywhere
|
|
611
|
-
platforms without `Process.fork`).
|
|
617
|
+
- **fork**: preloads the environment once (RubyGems, Bundler, the test framework)
|
|
618
|
+
and forks a fresh child per mutant. RSpec and Minitest on platforms with
|
|
619
|
+
`Process.fork`; removes most of the fixed per-mutant boot cost.
|
|
620
|
+
- **spawn**: starts one full process per mutant (`bundle exec rspec ...` or
|
|
621
|
+
`bundle exec ruby test_file.rb`). Slower per mutant, but works everywhere
|
|
622
|
+
(the only runner on platforms without `Process.fork`).
|
|
612
623
|
|
|
613
624
|
`auto` tries `in_memory`, then `fork`, then `spawn`; every step down prints one
|
|
614
625
|
stderr warning with its reason, so a fallback is never silent. All runners
|
|
@@ -628,9 +639,9 @@ do not need to pick `--runner fork` for correctness on load-time code.
|
|
|
628
639
|
|
|
629
640
|
| Mode | Picked by `auto` when | Falls back to |
|
|
630
641
|
|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
|
|
631
|
-
| `in_memory` | the
|
|
632
|
-
| `fork` |
|
|
633
|
-
| `spawn` | the
|
|
642
|
+
| `in_memory` | the platform has `Process.fork`, the file has no load-time `defined?` guard, and re-applying the unmutated source in a probe child passes the suite | `fork`/`spawn` (whole run) with a stderr warning naming the reason; a single worker dying mid-run falls back only for its share of mutants; a mutant that raises while being applied falls back alone |
|
|
643
|
+
| `fork` | `Process.fork` is available, but in-memory is unavailable (each reason is printed) | `spawn`, with a stderr warning, when the helper process fails to preload the environment |
|
|
644
|
+
| `spawn` | the platform has no `Process.fork` | nothing; it works everywhere |
|
|
634
645
|
|
|
635
646
|
Force a specific runner (skipping the auto attempts) with the `--runner
|
|
636
647
|
fork|spawn|in_memory` flag, the `MUTATION_TESTER_RUNNER` environment variable, or
|
|
@@ -648,6 +659,22 @@ fork and in-memory limitation lists (frozen classes, load-time `defined?` guards
|
|
|
648
659
|
worker-death fallback, `require_relative` idempotency), see
|
|
649
660
|
[docs/execution-runners.md](docs/execution-runners.md#execution-runners-fork-spawn-in-memory).
|
|
650
661
|
|
|
662
|
+
### Stopping a mutant at its first failing test
|
|
663
|
+
|
|
664
|
+
A mutant only needs one failing test to be killed, so every mutant run stops at
|
|
665
|
+
its first failure: RSpec mutant runs get `--fail-fast`, and Minitest mutant runs
|
|
666
|
+
get a preloaded reporter that aborts the run the same way (both on the file-based
|
|
667
|
+
runners and inside the preloaded fork worker). This never changes a verdict, only
|
|
668
|
+
the work done to reach it: a run that stops early had already failed, and a run
|
|
669
|
+
with no failure is unaffected and still executes every test.
|
|
670
|
+
|
|
671
|
+
It matters most for a mutant that breaks something every test touches (a broken
|
|
672
|
+
class body, a constant every example reads). Such a mutant used to pay the full
|
|
673
|
+
test file once per mutant, which on a large test file can exceed the per-mutant
|
|
674
|
+
deadline and turn a decided kill into a reported timeout. Adding tests to the file
|
|
675
|
+
then made the score worse. The baseline run and the shadow sanity check are
|
|
676
|
+
unaffected: they are expected to pass, and a passing run runs every test.
|
|
677
|
+
|
|
651
678
|
### Test selection (fast kill with full-file confirmation)
|
|
652
679
|
|
|
653
680
|
For RSpec suites on the file-based runners, MutationTester runs each mutant in
|
|
@@ -741,6 +768,18 @@ few lines of surrounding context:
|
|
|
741
768
|
💡 Suggestion: Add tests to verify behavior for each of the 2 variants above
|
|
742
769
|
```
|
|
743
770
|
|
|
771
|
+
When at least one mutant timed out, the summary also names the deadline those
|
|
772
|
+
mutants were measured against and where it came from, so a genuine hang and a
|
|
773
|
+
deadline calibrated from a slow test file are distinguishable at a glance:
|
|
774
|
+
|
|
775
|
+
```
|
|
776
|
+
Timeout: 3 ⏱️
|
|
777
|
+
deadline: 6.50s (5x baseline 1.30s)
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
With an explicit `config.timeout` / `--timeout` the same line reads
|
|
781
|
+
`deadline: 30.00s (explicitly configured)`.
|
|
782
|
+
|
|
744
783
|
### HTML report
|
|
745
784
|
|
|
746
785
|
Beautiful interactive HTML report with:
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mutation_tester
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kamil Dzierbicki
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: parallel
|
|
@@ -169,6 +169,7 @@ files:
|
|
|
169
169
|
- lib/mutation_tester/fork_runner/worker.rb
|
|
170
170
|
- lib/mutation_tester/framework_detector.rb
|
|
171
171
|
- lib/mutation_tester/in_memory_loader.rb
|
|
172
|
+
- lib/mutation_tester/minitest_fail_fast.rb
|
|
172
173
|
- lib/mutation_tester/mutation_runner.rb
|
|
173
174
|
- lib/mutation_tester/mutator.rb
|
|
174
175
|
- lib/mutation_tester/progress_display.rb
|