mutation_tester 1.3.0 → 1.4.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 +7 -0
- data/Gemfile.lock +1 -1
- data/docs/execution-runners.md +39 -14
- data/lib/mutation_tester/batch_runner.rb +1 -1
- data/lib/mutation_tester/core.rb +5 -3
- data/lib/mutation_tester/fork_runner/worker.rb +49 -9
- data/lib/mutation_tester/fork_runner.rb +28 -16
- data/lib/mutation_tester/minitest_fail_fast.rb +42 -0
- data/lib/mutation_tester/mutation_runner.rb +5 -4
- data/lib/mutation_tester/reporters/console_reporter.rb +25 -0
- data/lib/mutation_tester/test_command.rb +28 -5
- data/lib/mutation_tester/version.rb +1 -1
- data/readme.md +41 -13
- 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: be945d45b6c4219df627808a62336abe51ce94b088d79db47e25c794cdf227bd
|
|
4
|
+
data.tar.gz: 80aaefd644319415bfb25cebab5eb28077f3b99c763d234d20cf6885f3c001d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ad47074139c21bb7cd6db12eb53d2dd8c350b85c24c43f2ce44ba25590da1b3a4aa3f2e9f5ea2394abbdb8779e50cb9b3bb94418a94b03a1148ef2bd186b1f0
|
|
7
|
+
data.tar.gz: 58078c8af01ec3155be934d6917a3b66b0779f70bc20b04a23d05f82c06e9198000156fc13a7f7eece21cff11582d6b142a5ffb4aaa11d3191c15ebe4c3d9fbb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.0] - 2026-08-01
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- 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.
|
|
7
|
+
- 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.
|
|
8
|
+
- `--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.
|
|
9
|
+
|
|
3
10
|
## [1.3.0] - 2026-07-15
|
|
4
11
|
|
|
5
12
|
- 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,10 +81,31 @@ 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
|
|
@@ -92,8 +117,8 @@ The in-memory runner never fails silently: each case below falls back to
|
|
|
92
117
|
file-based execution with a warning, and a mutant is marked `error` only when
|
|
93
118
|
no fallback is possible.
|
|
94
119
|
|
|
95
|
-
-
|
|
96
|
-
|
|
120
|
+
- The file must be classic loadable code (classes/modules) that survives being
|
|
121
|
+
evaluated a second time.
|
|
97
122
|
- With `-p N` (N > 1) the run stays fully in memory: the environment, the
|
|
98
123
|
original source and the specs are preloaded once, the preloaded process is
|
|
99
124
|
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
|
|
@@ -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)
|
|
@@ -91,10 +100,25 @@ supervise_child = lambda do |job, out, child_body|
|
|
|
91
100
|
[timed_out, payload, reaped]
|
|
92
101
|
end
|
|
93
102
|
|
|
103
|
+
load_test_file = lambda do |path|
|
|
104
|
+
$PROGRAM_NAME = path
|
|
105
|
+
load(path)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
run_test_file = lambda do |job|
|
|
109
|
+
if framework == :minitest
|
|
110
|
+
MutationTester::MinitestFailFast.enabled = job['stop_on_first_failure'] ? true : false
|
|
111
|
+
load_test_file.call(job['spec'])
|
|
112
|
+
Minitest.run(Array(job['args'])) ? 0 : 1
|
|
113
|
+
else
|
|
114
|
+
args = [job['spec'], *Array(job['args'])]
|
|
115
|
+
args << '--fail-fast' if job['stop_on_first_failure']
|
|
116
|
+
RSpec::Core::Runner.run(args, STDERR, STDOUT).to_i
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
94
120
|
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)
|
|
121
|
+
timed_out, payload, reaped = supervise_child.call(job, out, lambda { run_test_file.call(job) })
|
|
98
122
|
|
|
99
123
|
status =
|
|
100
124
|
if timed_out
|
|
@@ -111,16 +135,32 @@ end
|
|
|
111
135
|
preload_specs = lambda do |request, out|
|
|
112
136
|
begin
|
|
113
137
|
Dir.chdir(request['chdir']) if request['chdir']
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
138
|
+
if framework == :minitest
|
|
139
|
+
MutationTester::MinitestFailFast.enabled = request['stop_on_first_failure'] ? true : false
|
|
140
|
+
load_test_file.call(request['spec'])
|
|
141
|
+
preloaded = true
|
|
142
|
+
else
|
|
143
|
+
sink = File.open(File::NULL, 'w')
|
|
144
|
+
options = [request['spec']]
|
|
145
|
+
options << '--fail-fast' if request['stop_on_first_failure']
|
|
146
|
+
runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(options))
|
|
147
|
+
runner.setup(sink, sink)
|
|
148
|
+
preloaded = runner
|
|
149
|
+
end
|
|
118
150
|
out.puts(JSON.generate('event' => 'preloaded', 'status' => 'ok'))
|
|
119
151
|
rescue ScriptError, StandardError => e
|
|
120
152
|
out.puts(JSON.generate('event' => 'preloaded', 'status' => 'error', 'message' => "#{e.class}: #{e.message}"))
|
|
121
153
|
end
|
|
122
154
|
end
|
|
123
155
|
|
|
156
|
+
run_preloaded_suite = lambda do
|
|
157
|
+
if framework == :minitest
|
|
158
|
+
Minitest.run([]) ? 0 : 1
|
|
159
|
+
else
|
|
160
|
+
preloaded.run_specs(RSpec.world.ordered_example_groups).to_i
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
124
164
|
run_in_memory_job = lambda do |job, out|
|
|
125
165
|
request = job['in_memory']
|
|
126
166
|
|
|
@@ -132,7 +172,7 @@ run_in_memory_job = lambda do |job, out|
|
|
|
132
172
|
timed_out, payload, reaped = supervise_child.call(job, out, lambda do
|
|
133
173
|
begin
|
|
134
174
|
MutationTester::InMemoryLoader.apply(request['source'], request['path'])
|
|
135
|
-
JSON.generate('code' =>
|
|
175
|
+
JSON.generate('code' => run_preloaded_suite.call)
|
|
136
176
|
rescue ScriptError, StandardError => e
|
|
137
177
|
JSON.generate('error' => "#{e.class}: #{e.message}")
|
|
138
178
|
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,16 @@ 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)
|
|
153
157
|
log = capture ? Tempfile.new(['mutation_tester_fork', '.log']) : nil
|
|
154
|
-
job = {
|
|
158
|
+
job = {
|
|
159
|
+
spec: spec_file,
|
|
160
|
+
timeout: timeout,
|
|
161
|
+
chdir: chdir,
|
|
162
|
+
log: log&.path,
|
|
163
|
+
args: args,
|
|
164
|
+
stop_on_first_failure: stop_on_first_failure
|
|
165
|
+
}
|
|
155
166
|
@job_writer.puts(JSON.generate(job))
|
|
156
167
|
status = await_result(timeout)['status']
|
|
157
168
|
result = TestCommand::Result.new(status == 'pass', status == 'timeout')
|
|
@@ -164,8 +175,9 @@ module MutationTester
|
|
|
164
175
|
log&.unlink
|
|
165
176
|
end
|
|
166
177
|
|
|
167
|
-
def preload(spec_file, chdir: nil)
|
|
168
|
-
|
|
178
|
+
def preload(spec_file, chdir: nil, stop_on_first_failure: false)
|
|
179
|
+
request = { spec: spec_file, chdir: chdir, stop_on_first_failure: stop_on_first_failure }
|
|
180
|
+
@job_writer.puts(JSON.generate(preload: request))
|
|
169
181
|
event = read_event(monotonic_time + BOOT_TIMEOUT)
|
|
170
182
|
return [true, nil] if event.is_a?(Hash) && event['event'] == 'preloaded' && event['status'] == 'ok'
|
|
171
183
|
|
|
@@ -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
|
|
@@ -390,19 +390,18 @@ module MutationTester
|
|
|
390
390
|
end
|
|
391
391
|
|
|
392
392
|
def prepare_in_memory_execution
|
|
393
|
-
return 'the in-memory runner supports RSpec suites only' unless detect_test_framework(@spec_file) == :rspec
|
|
394
393
|
return 'Process.fork is not supported on this platform' unless ForkRunner.available?
|
|
395
394
|
if InMemoryLoader.load_time_defined_guard?(@original_content)
|
|
396
395
|
return 'the source file uses defined? at load time, so redefinition would silently skip the guarded code'
|
|
397
396
|
end
|
|
398
397
|
|
|
399
|
-
runner = ForkRunner.new(use_bundle_exec: @use_bundle_exec)
|
|
398
|
+
runner = ForkRunner.new(use_bundle_exec: @use_bundle_exec, framework: detect_test_framework(@spec_file))
|
|
400
399
|
unless runner.ready?
|
|
401
400
|
runner.shutdown
|
|
402
401
|
return 'the in-memory worker failed to preload the environment'
|
|
403
402
|
end
|
|
404
403
|
|
|
405
|
-
preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd)
|
|
404
|
+
preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd, stop_on_first_failure: true)
|
|
406
405
|
unless preloaded
|
|
407
406
|
runner.shutdown
|
|
408
407
|
return "the spec file could not be preloaded (#{message})"
|
|
@@ -521,6 +520,7 @@ module MutationTester
|
|
|
521
520
|
ForkRunner.prepare_pool(
|
|
522
521
|
[@config.parallel_processes, total].min,
|
|
523
522
|
use_bundle_exec: @use_bundle_exec,
|
|
523
|
+
framework: detect_test_framework(@spec_file),
|
|
524
524
|
env_for: worker_env_for
|
|
525
525
|
)
|
|
526
526
|
end
|
|
@@ -637,7 +637,8 @@ module MutationTester
|
|
|
637
637
|
use_bundle_exec: @use_bundle_exec,
|
|
638
638
|
runner: @config.runner,
|
|
639
639
|
example_filters: example_filters,
|
|
640
|
-
worker_env_var: @config.worker_env_var
|
|
640
|
+
worker_env_var: @config.worker_env_var,
|
|
641
|
+
stop_on_first_failure: true
|
|
641
642
|
)
|
|
642
643
|
end
|
|
643
644
|
end
|
|
@@ -20,6 +20,7 @@ module MutationTester
|
|
|
20
20
|
puts " #{Rainbow("Killed: " + killed_count.to_s).green} ✅"
|
|
21
21
|
puts " #{Rainbow("Survived: " + survived_count.to_s).red} ❌"
|
|
22
22
|
puts " #{Rainbow("Timeout: " + timeout_count.to_s).yellow} ⏱️"
|
|
23
|
+
print_timeout_deadline
|
|
23
24
|
puts " #{Rainbow("Stillborn: " + stillborn_count.to_s).yellow} 🧬"
|
|
24
25
|
puts " #{Rainbow("Errors: " + error_count.to_s).yellow} 💥"
|
|
25
26
|
print_excluded_summary
|
|
@@ -29,6 +30,30 @@ module MutationTester
|
|
|
29
30
|
puts "\n " + progress_bar
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
def print_timeout_deadline
|
|
34
|
+
return unless timeout_count.positive?
|
|
35
|
+
|
|
36
|
+
puts " #{Rainbow("deadline: " + timeout_deadline_description).yellow}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def timeout_deadline_description
|
|
40
|
+
deadline = @config.effective_timeout
|
|
41
|
+
return 'none (no deadline configured)' if deadline.nil?
|
|
42
|
+
|
|
43
|
+
"#{format_seconds(deadline)} #{deadline_origin}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def deadline_origin
|
|
47
|
+
baseline = @config.baseline_duration
|
|
48
|
+
return '(explicitly configured)' if @config.timeout_explicitly_set? || baseline.nil?
|
|
49
|
+
|
|
50
|
+
"(#{format('%g', @config.timeout_factor)}x baseline #{format_seconds(baseline)})"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def format_seconds(value)
|
|
54
|
+
"#{format('%.2f', value)}s"
|
|
55
|
+
end
|
|
56
|
+
|
|
32
57
|
def print_excluded_summary
|
|
33
58
|
count = excluded_line_count
|
|
34
59
|
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
|
|
|
@@ -39,8 +42,17 @@ module MutationTester
|
|
|
39
42
|
|
|
40
43
|
def run(timeout: nil, chdir: nil, capture: false)
|
|
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
|
+
)
|
|
55
|
+
end
|
|
44
56
|
end
|
|
45
57
|
|
|
46
58
|
return run_captured(timeout: timeout, chdir: chdir) if capture
|
|
@@ -53,7 +65,6 @@ module MutationTester
|
|
|
53
65
|
end
|
|
54
66
|
|
|
55
67
|
def fork_execution?
|
|
56
|
-
return false if @framework == :minitest
|
|
57
68
|
return false if @runner == :spawn
|
|
58
69
|
|
|
59
70
|
ForkRunner.available?
|
|
@@ -100,6 +111,18 @@ module MutationTester
|
|
|
100
111
|
example_filters.flat_map { |filter| ['-e', filter] }
|
|
101
112
|
end
|
|
102
113
|
|
|
114
|
+
def interpreter_args
|
|
115
|
+
return [] unless @stop_on_first_failure && @framework == :minitest
|
|
116
|
+
|
|
117
|
+
['-r', MINITEST_FAIL_FAST_PATH]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fail_fast_args
|
|
121
|
+
return [] unless @stop_on_first_failure && @framework == :rspec
|
|
122
|
+
|
|
123
|
+
['--fail-fast']
|
|
124
|
+
end
|
|
125
|
+
|
|
103
126
|
def run_captured(timeout:, chdir:)
|
|
104
127
|
log = Tempfile.new(['mutation_tester_baseline', '.log'])
|
|
105
128
|
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`. |
|
|
@@ -597,18 +597,18 @@ the fastest safe one, announcing every fallback on stderr:
|
|
|
597
597
|
|
|
598
598
|
- **in_memory** (default where supported): re-evaluates the mutated source in the
|
|
599
599
|
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`/
|
|
600
|
+
and no shadow workspaces. RSpec and Minitest; the fastest path. Mutations that only
|
|
601
|
+
take effect at class-load time (constants consumed by macros, `validates`/`has_many`/
|
|
602
602
|
`before_save`/`scope`/`attribute`, anything inside an `included do` block) cannot
|
|
603
603
|
be observed by re-evaluating source in a preloaded process, so those mutants are
|
|
604
604
|
routed automatically to the file-based path and the rest still run in memory (see
|
|
605
605
|
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`).
|
|
606
|
+
- **fork**: preloads the environment once (RubyGems, Bundler, the test framework)
|
|
607
|
+
and forks a fresh child per mutant. RSpec and Minitest on platforms with
|
|
608
|
+
`Process.fork`; removes most of the fixed per-mutant boot cost.
|
|
609
|
+
- **spawn**: starts one full process per mutant (`bundle exec rspec ...` or
|
|
610
|
+
`bundle exec ruby test_file.rb`). Slower per mutant, but works everywhere
|
|
611
|
+
(the only runner on platforms without `Process.fork`).
|
|
612
612
|
|
|
613
613
|
`auto` tries `in_memory`, then `fork`, then `spawn`; every step down prints one
|
|
614
614
|
stderr warning with its reason, so a fallback is never silent. All runners
|
|
@@ -628,9 +628,9 @@ do not need to pick `--runner fork` for correctness on load-time code.
|
|
|
628
628
|
|
|
629
629
|
| Mode | Picked by `auto` when | Falls back to |
|
|
630
630
|
|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
|
|
631
|
-
| `in_memory` | the
|
|
632
|
-
| `fork` |
|
|
633
|
-
| `spawn` | the
|
|
631
|
+
| `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 |
|
|
632
|
+
| `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 |
|
|
633
|
+
| `spawn` | the platform has no `Process.fork` | nothing; it works everywhere |
|
|
634
634
|
|
|
635
635
|
Force a specific runner (skipping the auto attempts) with the `--runner
|
|
636
636
|
fork|spawn|in_memory` flag, the `MUTATION_TESTER_RUNNER` environment variable, or
|
|
@@ -648,6 +648,22 @@ fork and in-memory limitation lists (frozen classes, load-time `defined?` guards
|
|
|
648
648
|
worker-death fallback, `require_relative` idempotency), see
|
|
649
649
|
[docs/execution-runners.md](docs/execution-runners.md#execution-runners-fork-spawn-in-memory).
|
|
650
650
|
|
|
651
|
+
### Stopping a mutant at its first failing test
|
|
652
|
+
|
|
653
|
+
A mutant only needs one failing test to be killed, so every mutant run stops at
|
|
654
|
+
its first failure: RSpec mutant runs get `--fail-fast`, and Minitest mutant runs
|
|
655
|
+
get a preloaded reporter that aborts the run the same way (both on the file-based
|
|
656
|
+
runners and inside the preloaded fork worker). This never changes a verdict, only
|
|
657
|
+
the work done to reach it: a run that stops early had already failed, and a run
|
|
658
|
+
with no failure is unaffected and still executes every test.
|
|
659
|
+
|
|
660
|
+
It matters most for a mutant that breaks something every test touches (a broken
|
|
661
|
+
class body, a constant every example reads). Such a mutant used to pay the full
|
|
662
|
+
test file once per mutant, which on a large test file can exceed the per-mutant
|
|
663
|
+
deadline and turn a decided kill into a reported timeout. Adding tests to the file
|
|
664
|
+
then made the score worse. The baseline run and the shadow sanity check are
|
|
665
|
+
unaffected: they are expected to pass, and a passing run runs every test.
|
|
666
|
+
|
|
651
667
|
### Test selection (fast kill with full-file confirmation)
|
|
652
668
|
|
|
653
669
|
For RSpec suites on the file-based runners, MutationTester runs each mutant in
|
|
@@ -741,6 +757,18 @@ few lines of surrounding context:
|
|
|
741
757
|
💡 Suggestion: Add tests to verify behavior for each of the 2 variants above
|
|
742
758
|
```
|
|
743
759
|
|
|
760
|
+
When at least one mutant timed out, the summary also names the deadline those
|
|
761
|
+
mutants were measured against and where it came from, so a genuine hang and a
|
|
762
|
+
deadline calibrated from a slow test file are distinguishable at a glance:
|
|
763
|
+
|
|
764
|
+
```
|
|
765
|
+
Timeout: 3 ⏱️
|
|
766
|
+
deadline: 6.50s (5x baseline 1.30s)
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
With an explicit `config.timeout` / `--timeout` the same line reads
|
|
770
|
+
`deadline: 30.00s (explicitly configured)`.
|
|
771
|
+
|
|
744
772
|
### HTML report
|
|
745
773
|
|
|
746
774
|
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.0
|
|
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-01 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
|