mutation_tester 1.2.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 +12 -0
- data/Gemfile.lock +1 -1
- data/docs/execution-runners.md +39 -14
- data/docs/json-schema.md +4 -4
- data/exe/mutation_test +12 -1
- data/lib/mutation_tester/batch_runner.rb +15 -3
- data/lib/mutation_tester/configuration.rb +50 -3
- data/lib/mutation_tester/core.rb +37 -5
- 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 +9 -8
- data/lib/mutation_tester/reporters/base_reporter.rb +9 -5
- data/lib/mutation_tester/reporters/batch_json_reporter.rb +1 -1
- 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/lib/tasks/mutation_tester.rake +1 -1
- data/readme.md +80 -16
- 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,17 @@
|
|
|
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
|
+
|
|
10
|
+
## [1.3.0] - 2026-07-15
|
|
11
|
+
|
|
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.
|
|
13
|
+
- Runs that finish without a single scored mutant (every mutant errored or was stillborn) are now reported as an infrastructure/runner failure with the error/stillborn counts instead of a misleading "score 0.0% is below threshold" verdict. `Core` exposes `infrastructure_failure?` to distinguish such degraded runs (including the shadow-workspace abort) from a genuine threshold failure, and `mutation_test` maps them to the new exit code `3` in single-file mode (`0` pass, `1` threshold failure, `2` usage error, `130` interrupt). The batch summary names degraded files instead of blaming the threshold.
|
|
14
|
+
|
|
3
15
|
## [1.2.0] - 2026-07-14
|
|
4
16
|
|
|
5
17
|
- Fixed the in-memory runner reporting false survivors for mutations that only take effect at class-load time (constants consumed by macros, `validates`/`has_many`/`before_save`/`scope`/`attribute`, and anything inside an `included do` block). The mutator now classifies each mutation by AST context, and the in-memory run routes load-time mutations to the file-based path while keeping method-body mutations in memory, so the default (`auto`) score matches a full `fork` run on Rails concerns and models. Measured on a real Rails concern the default score went from a misleading 1.63% to the correct 35.77%.
|
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/docs/json-schema.md
CHANGED
|
@@ -52,13 +52,13 @@ history of changes.
|
|
|
52
52
|
| `metadata.source_file` | string | Absolute path of the mutated source file. |
|
|
53
53
|
| `metadata.spec_file` | string | Absolute path of the test file that was run. |
|
|
54
54
|
| `summary.total` | integer | Total number of mutations produced (all statuses). |
|
|
55
|
-
| `summary.killed` | integer | Effective kills (a `timeout` counts as a kill). Kept for backward compatibility.
|
|
55
|
+
| `summary.killed` | integer | Effective kills (a `timeout` counts as a kill; under the opt-in `timeout_policy: :separate` only real kills are counted). Kept for backward compatibility. |
|
|
56
56
|
| `summary.survived` | integer | Number of surviving mutants. |
|
|
57
|
-
| `summary.mutation_score` | number | Percentage `(killed + timeout) / (killed + timeout + survived) * 100`, rounded to 2 decimals. `stillborn` and `error` are excluded from the denominator.
|
|
57
|
+
| `summary.mutation_score` | number | Percentage `(killed + timeout) / (killed + timeout + survived) * 100`, rounded to 2 decimals; under the opt-in `timeout_policy: :separate` it is `killed / (killed + survived) * 100` with timeouts excluded. `stillborn` and `error` are excluded from the denominator. |
|
|
58
58
|
| `summary.quality_rating` | string | Human label derived from the score (Excellent/Good/Fair/Poor/Critical). |
|
|
59
59
|
| `summary.categories.killed` | integer | Mutants whose covering tests failed. |
|
|
60
60
|
| `summary.categories.survived` | integer | Mutants whose covering tests still passed (a test gap candidate). |
|
|
61
|
-
| `summary.categories.timeout` | integer | Mutants that exceeded the per-mutant deadline (counted as kills in the score).
|
|
61
|
+
| `summary.categories.timeout` | integer | Mutants that exceeded the per-mutant deadline (counted as kills in the score by default; excluded from the score under `timeout_policy: :separate`). |
|
|
62
62
|
| `summary.categories.stillborn` | integer | Mutants whose code no longer parses. Never run; excluded from the score. |
|
|
63
63
|
| `summary.categories.error` | integer | Mutants that hit a runner-side error. Excluded from the score. |
|
|
64
64
|
| `mutations[].id` | integer | Stable id of the mutation within this run. |
|
|
@@ -144,7 +144,7 @@ the exit code keeps its meaning (`0` passed, `1` failed).
|
|
|
144
144
|
| `summary.files` | integer | Files that entered the run: processed plus skipped. Files a `--since` filter left out as unchanged are not counted; they are reported on stderr. |
|
|
145
145
|
| `summary.processed` | integer | Files that were actually mutation-tested. |
|
|
146
146
|
| `summary.skipped` | array | One entry per skipped file: `file` (the path as given) and `reason`, one of `file not found`, `not a Ruby source file`, `a test file, not a mutable source`, `no matching spec file`. |
|
|
147
|
-
| `summary.score` | number | Aggregate mutation score over every mutant of every processed file, with the per-file formula: `(killed + timeout) / (killed + timeout + survived) * 100
|
|
147
|
+
| `summary.score` | number | Aggregate mutation score over every mutant of every processed file, with the per-file formula: `(killed + timeout) / (killed + timeout + survived) * 100` (or `killed / (killed + survived) * 100` under `timeout_policy: :separate`), rounded to 2 decimals; `stillborn` and `error` are excluded from the denominator. |
|
|
148
148
|
| `summary.passed` | boolean | `true` exactly when the process exits `0`: the run matched/processed files and every processed file met the threshold. |
|
|
149
149
|
| `summary.interrupted` | boolean | `true` when `--fail-fast` stopped the batch at a surviving mutant, so remaining files were not run and the envelope is partial. |
|
|
150
150
|
| `survivors[].file` | string | Absolute path of the mutated source file, the same convention as `files[].metadata.source_file`, so a survivor joins its full per-file report by an exact string match on this value. |
|
data/exe/mutation_test
CHANGED
|
@@ -68,6 +68,14 @@ OptionParser.new do |opts|
|
|
|
68
68
|
options[:fail_fast] = true
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
opts.on('--timeout-factor N', Float, "Per-mutant timeout budget as N times the measured baseline test run, never below #{MutationTester::Configuration::CALIBRATED_TIMEOUT_FLOOR}s (default: #{MutationTester::Configuration::DEFAULT_TIMEOUT_FACTOR}; ignored when config.timeout is set explicitly, which keeps a fixed budget)") do |n|
|
|
72
|
+
options[:timeout_factor] = n
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
opts.on('--timeout-policy MODE', 'Scoring policy for timed-out mutants: killed (default) counts a timeout as a kill; separate keeps timeouts out of the score entirely (killed / (killed + survived)) and reports them only as their own category') do |mode|
|
|
76
|
+
options[:timeout_policy] = mode
|
|
77
|
+
end
|
|
78
|
+
|
|
71
79
|
opts.on('--strict-equality', 'Enable strict equality probes (== to eql? and equal?); default off, expect noise on code that does not distinguish numeric types or object identity') do
|
|
72
80
|
options[:strict_equality] = true
|
|
73
81
|
end
|
|
@@ -151,6 +159,8 @@ apply_configuration = lambda do
|
|
|
151
159
|
config.reporters = selected_reporters if selected_reporters
|
|
152
160
|
config.output_dir = options[:output_dir] if options[:output_dir]
|
|
153
161
|
config.test_selection = false if options[:no_test_selection]
|
|
162
|
+
config.timeout_factor = options[:timeout_factor] if options[:timeout_factor]
|
|
163
|
+
config.timeout_policy = options[:timeout_policy] if options[:timeout_policy]
|
|
154
164
|
config.worker_env_var = options[:worker_env] if options.key?(:worker_env)
|
|
155
165
|
config.fail_fast = options.fetch(:fail_fast, false)
|
|
156
166
|
config.mutation_types[:strict_equality] = true if options[:strict_equality]
|
|
@@ -342,4 +352,5 @@ else
|
|
|
342
352
|
success = guard_interrupt.call { core.run }
|
|
343
353
|
end
|
|
344
354
|
|
|
345
|
-
exit
|
|
355
|
+
exit 0 if success
|
|
356
|
+
exit(core.infrastructure_failure? ? 3 : 1)
|
|
@@ -20,10 +20,14 @@ module MutationTester
|
|
|
20
20
|
|
|
21
21
|
SKIP_ORDER = %i[missing not_ruby test_file no_spec].freeze
|
|
22
22
|
|
|
23
|
-
ProcessedEntry = Struct.new(:source_file, :spec_file, :score, :passed, :output_dir, :results, :interrupted, keyword_init: true) do
|
|
23
|
+
ProcessedEntry = Struct.new(:source_file, :spec_file, :score, :passed, :output_dir, :results, :interrupted, :degraded, keyword_init: true) do
|
|
24
24
|
def passed?
|
|
25
25
|
passed
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
def degraded?
|
|
29
|
+
!!degraded
|
|
30
|
+
end
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
SkippedEntry = Struct.new(:source_file, :expected_spec, :reason, keyword_init: true)
|
|
@@ -192,9 +196,10 @@ module MutationTester
|
|
|
192
196
|
passed: passed,
|
|
193
197
|
output_dir: file_config.output_dir,
|
|
194
198
|
results: core.results,
|
|
195
|
-
interrupted: core.interrupted
|
|
199
|
+
interrupted: core.interrupted?,
|
|
200
|
+
degraded: core.infrastructure_failure?
|
|
196
201
|
)
|
|
197
|
-
[entry, core.
|
|
202
|
+
[entry, core.stopped_on_survivor?]
|
|
198
203
|
end
|
|
199
204
|
|
|
200
205
|
def spec_path_for(source_file)
|
|
@@ -247,12 +252,19 @@ module MutationTester
|
|
|
247
252
|
end
|
|
248
253
|
|
|
249
254
|
puts Rainbow('=' * 80).bright
|
|
255
|
+
failed = result.processed.reject(&:passed?)
|
|
256
|
+
degraded = failed.select(&:degraded?)
|
|
250
257
|
if @files && result.processed.empty?
|
|
251
258
|
puts Rainbow('❌ No files were mutation-tested: every listed file was skipped').red
|
|
252
259
|
elsif result.success?
|
|
253
260
|
puts Rainbow('✓ All processed files met the mutation score threshold').green
|
|
261
|
+
elsif degraded.size == failed.size
|
|
262
|
+
puts Rainbow('❌ The failing files produced no scored mutants; this indicates an infrastructure or runner problem, not a test-quality gap').red
|
|
254
263
|
else
|
|
255
264
|
puts Rainbow('❌ One or more files did not meet the mutation score threshold').red
|
|
265
|
+
unless degraded.empty?
|
|
266
|
+
puts Rainbow("⚠️ #{degraded.size} of the failing files produced no scored mutants (infrastructure or runner problem); see the per-file output above").yellow
|
|
267
|
+
end
|
|
256
268
|
end
|
|
257
269
|
|
|
258
270
|
print_survivors(result.survivors)
|
|
@@ -3,7 +3,11 @@ require 'etc'
|
|
|
3
3
|
module MutationTester
|
|
4
4
|
class Configuration
|
|
5
5
|
RUNNER_MODES = %i[auto fork spawn in_memory].freeze
|
|
6
|
+
TIMEOUT_POLICIES = %i[killed separate].freeze
|
|
6
7
|
AUTO_PARALLEL_CAP = 8
|
|
8
|
+
DEFAULT_TIMEOUT = 30
|
|
9
|
+
DEFAULT_TIMEOUT_FACTOR = 5
|
|
10
|
+
CALIBRATED_TIMEOUT_FLOOR = 5
|
|
7
11
|
|
|
8
12
|
def self.auto_parallel_processes
|
|
9
13
|
[[Etc.nprocessors, AUTO_PARALLEL_CAP].min, 1].max
|
|
@@ -14,9 +18,9 @@ module MutationTester
|
|
|
14
18
|
number <= 0 ? '' : (number + 1).to_s
|
|
15
19
|
end
|
|
16
20
|
|
|
17
|
-
attr_reader :parallel_processes, :runner, :worker_env_var
|
|
21
|
+
attr_reader :parallel_processes, :runner, :worker_env_var, :timeout, :timeout_factor, :timeout_policy
|
|
18
22
|
|
|
19
|
-
attr_accessor :
|
|
23
|
+
attr_accessor :baseline_duration,
|
|
20
24
|
:baseline_timeout,
|
|
21
25
|
:mutation_types,
|
|
22
26
|
:reporters,
|
|
@@ -33,7 +37,9 @@ module MutationTester
|
|
|
33
37
|
self.parallel_processes = ENV['MUTATION_TESTER_PARALLEL_PROCESSES'] || self.class.auto_parallel_processes
|
|
34
38
|
self.runner = ENV['MUTATION_TESTER_RUNNER'] || :auto
|
|
35
39
|
self.worker_env_var = ENV['MUTATION_TESTER_WORKER_ENV']
|
|
36
|
-
@timeout =
|
|
40
|
+
@timeout = DEFAULT_TIMEOUT
|
|
41
|
+
@timeout_factor = DEFAULT_TIMEOUT_FACTOR
|
|
42
|
+
@timeout_policy = :killed
|
|
37
43
|
@baseline_timeout = 300
|
|
38
44
|
@mutation_types = {
|
|
39
45
|
arithmetic: true,
|
|
@@ -73,6 +79,39 @@ module MutationTester
|
|
|
73
79
|
@reporters = source.reporters.dup
|
|
74
80
|
end
|
|
75
81
|
|
|
82
|
+
def timeout=(value)
|
|
83
|
+
@timeout_explicit = true
|
|
84
|
+
@timeout = value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def timeout_explicitly_set?
|
|
88
|
+
!!@timeout_explicit
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def timeout_factor=(value)
|
|
92
|
+
factor = coerce_numeric(value)
|
|
93
|
+
unless factor.positive?
|
|
94
|
+
warn "[MutationTester] timeout_factor must be a number greater than 0; got #{value.inspect}, falling back to #{DEFAULT_TIMEOUT_FACTOR}."
|
|
95
|
+
factor = DEFAULT_TIMEOUT_FACTOR
|
|
96
|
+
end
|
|
97
|
+
@timeout_factor = factor
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def timeout_policy=(value)
|
|
101
|
+
policy = value.to_s.strip.downcase.to_sym
|
|
102
|
+
unless TIMEOUT_POLICIES.include?(policy)
|
|
103
|
+
warn "[MutationTester] timeout_policy must be one of #{TIMEOUT_POLICIES.join(", ")}; got #{value.inspect}, falling back to killed."
|
|
104
|
+
policy = :killed
|
|
105
|
+
end
|
|
106
|
+
@timeout_policy = policy
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def effective_timeout
|
|
110
|
+
return @timeout if timeout_explicitly_set? || @baseline_duration.nil?
|
|
111
|
+
|
|
112
|
+
[CALIBRATED_TIMEOUT_FLOOR, @timeout_factor * @baseline_duration].max
|
|
113
|
+
end
|
|
114
|
+
|
|
76
115
|
def parallel_processes=(value)
|
|
77
116
|
count = value.to_i
|
|
78
117
|
if count < 1
|
|
@@ -101,5 +140,13 @@ module MutationTester
|
|
|
101
140
|
|
|
102
141
|
{ @worker_env_var => self.class.worker_env_value(index) }
|
|
103
142
|
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def coerce_numeric(value)
|
|
147
|
+
Float(value)
|
|
148
|
+
rescue ArgumentError, TypeError
|
|
149
|
+
0
|
|
150
|
+
end
|
|
104
151
|
end
|
|
105
152
|
end
|
data/lib/mutation_tester/core.rb
CHANGED
|
@@ -11,6 +11,7 @@ module MutationTester
|
|
|
11
11
|
@mutations = []
|
|
12
12
|
@results = []
|
|
13
13
|
@parse_failed = false
|
|
14
|
+
@shadow_aborted = false
|
|
14
15
|
MutationRunner.recover_in_place_backup(@source_file)
|
|
15
16
|
@original_content = File.read(@source_file)
|
|
16
17
|
end
|
|
@@ -27,11 +28,15 @@ module MutationTester
|
|
|
27
28
|
return true
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
unless shadow_environment_reliable?
|
|
32
|
+
@shadow_aborted = true
|
|
33
|
+
return false
|
|
34
|
+
end
|
|
31
35
|
|
|
32
36
|
run_mutations
|
|
33
37
|
generate_reports
|
|
34
38
|
return report_interruption if interrupted?
|
|
39
|
+
return report_infrastructure_failure if infrastructure_failure?
|
|
35
40
|
|
|
36
41
|
check_threshold
|
|
37
42
|
ensure
|
|
@@ -39,14 +44,20 @@ module MutationTester
|
|
|
39
44
|
ForkRunner.shutdown_all
|
|
40
45
|
end
|
|
41
46
|
|
|
47
|
+
def stopped_on_survivor?
|
|
48
|
+
@config.fail_fast && @results.any? { |result| result[:status] == :survived }
|
|
49
|
+
end
|
|
50
|
+
|
|
42
51
|
def interrupted?
|
|
43
|
-
@
|
|
44
|
-
@results.size < @mutations.size &&
|
|
45
|
-
@results.any? { |result| result[:status] == :survived }
|
|
52
|
+
stopped_on_survivor? && @results.size < @mutations.size
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
def mutation_score
|
|
49
|
-
Reporters::BaseReporter.score(@results)
|
|
56
|
+
Reporters::BaseReporter.score(@results, policy: @config.timeout_policy)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def infrastructure_failure?
|
|
60
|
+
@shadow_aborted || (!@results.empty? && scored_count.zero?)
|
|
50
61
|
end
|
|
51
62
|
|
|
52
63
|
def threshold_met?
|
|
@@ -69,7 +80,9 @@ module MutationTester
|
|
|
69
80
|
def run_original_tests
|
|
70
81
|
puts Rainbow("\n🧪 Running original tests...").yellow
|
|
71
82
|
command = test_command
|
|
83
|
+
baseline_started = monotonic_time
|
|
72
84
|
result = command.run(timeout: @config.baseline_timeout, capture: true)
|
|
85
|
+
baseline_elapsed = monotonic_time - baseline_started
|
|
73
86
|
|
|
74
87
|
if result.timed_out?
|
|
75
88
|
puts Rainbow("❌ Original tests exceeded the baseline deadline of #{@config.baseline_timeout}s and were terminated.").red
|
|
@@ -85,9 +98,14 @@ module MutationTester
|
|
|
85
98
|
return false
|
|
86
99
|
end
|
|
87
100
|
puts Rainbow('✓ Original tests passed').green
|
|
101
|
+
@config.baseline_duration = baseline_elapsed
|
|
88
102
|
true
|
|
89
103
|
end
|
|
90
104
|
|
|
105
|
+
def monotonic_time
|
|
106
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
107
|
+
end
|
|
108
|
+
|
|
91
109
|
def replay_baseline_output(output)
|
|
92
110
|
return if output.nil? || output.strip.empty?
|
|
93
111
|
|
|
@@ -178,6 +196,20 @@ module MutationTester
|
|
|
178
196
|
false
|
|
179
197
|
end
|
|
180
198
|
|
|
199
|
+
def scored_count
|
|
200
|
+
@results.count { |r| %i[killed timeout survived].include?(Reporters::BaseReporter.status_for(r)) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def status_count(status)
|
|
204
|
+
@results.count { |r| Reporters::BaseReporter.status_for(r) == status }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def report_infrastructure_failure
|
|
208
|
+
puts Rainbow("\n❌ Run failed: none of the #{@results.size} mutants could be scored (#{status_count(:error)} error, #{status_count(:stillborn)} stillborn).").red
|
|
209
|
+
puts Rainbow(' This indicates an infrastructure or runner problem (test environment, workspace, or test command), not a test-quality gap.').red
|
|
210
|
+
false
|
|
211
|
+
end
|
|
212
|
+
|
|
181
213
|
def check_threshold
|
|
182
214
|
score = mutation_score
|
|
183
215
|
|
|
@@ -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
|
|
@@ -285,7 +285,7 @@ module MutationTester
|
|
|
285
285
|
end
|
|
286
286
|
|
|
287
287
|
def run_specs_in_shadow(spec_file, working_dir, example_filters: [])
|
|
288
|
-
test_command(spec_file, example_filters: example_filters).run(timeout: @config.
|
|
288
|
+
test_command(spec_file, example_filters: example_filters).run(timeout: @config.effective_timeout, chdir: working_dir)
|
|
289
289
|
end
|
|
290
290
|
|
|
291
291
|
def discoverable_project_root
|
|
@@ -325,7 +325,7 @@ module MutationTester
|
|
|
325
325
|
end
|
|
326
326
|
|
|
327
327
|
def run_specs_in_place(spec_file, example_filters: [])
|
|
328
|
-
test_command(spec_file, example_filters: example_filters).run(timeout: @config.
|
|
328
|
+
test_command(spec_file, example_filters: example_filters).run(timeout: @config.effective_timeout)
|
|
329
329
|
end
|
|
330
330
|
|
|
331
331
|
def run_mutation_in_memory(mutation, result, project_root = nil)
|
|
@@ -335,7 +335,7 @@ module MutationTester
|
|
|
335
335
|
outcome = runner.execute_in_memory(
|
|
336
336
|
source: mutation[:code],
|
|
337
337
|
path: @source_file,
|
|
338
|
-
timeout: @config.
|
|
338
|
+
timeout: @config.effective_timeout,
|
|
339
339
|
chdir: Dir.pwd
|
|
340
340
|
)
|
|
341
341
|
|
|
@@ -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})"
|
|
@@ -419,7 +418,7 @@ module MutationTester
|
|
|
419
418
|
outcome = runner.execute_in_memory(
|
|
420
419
|
source: @original_content,
|
|
421
420
|
path: @source_file,
|
|
422
|
-
timeout: @config.
|
|
421
|
+
timeout: @config.effective_timeout,
|
|
423
422
|
chdir: Dir.pwd
|
|
424
423
|
)
|
|
425
424
|
return nil if outcome.status == 'pass'
|
|
@@ -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
|
|
@@ -3,12 +3,14 @@ module MutationTester
|
|
|
3
3
|
class BaseReporter
|
|
4
4
|
attr_reader :results, :source_file, :spec_file, :config
|
|
5
5
|
|
|
6
|
-
def self.score(results)
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
def self.score(results, policy: :killed)
|
|
7
|
+
killing_statuses = policy == :separate ? %i[killed] : %i[killed timeout]
|
|
8
|
+
scoring_statuses = killing_statuses + %i[survived]
|
|
9
|
+
kills = results.count { |r| killing_statuses.include?(status_for(r)) }
|
|
10
|
+
scored = results.count { |r| scoring_statuses.include?(status_for(r)) }
|
|
9
11
|
return 0.0 if scored.zero?
|
|
10
12
|
|
|
11
|
-
(
|
|
13
|
+
(kills.to_f / scored * 100).round(2)
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def self.status_for(result)
|
|
@@ -62,6 +64,8 @@ module MutationTester
|
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
def effective_killed_count
|
|
67
|
+
return killed_count if @config.timeout_policy == :separate
|
|
68
|
+
|
|
65
69
|
killed_count + timeout_count
|
|
66
70
|
end
|
|
67
71
|
|
|
@@ -70,7 +74,7 @@ module MutationTester
|
|
|
70
74
|
end
|
|
71
75
|
|
|
72
76
|
def mutation_score
|
|
73
|
-
self.class.score(@results)
|
|
77
|
+
self.class.score(@results, policy: @config.timeout_policy)
|
|
74
78
|
end
|
|
75
79
|
|
|
76
80
|
def quality_rating
|
|
@@ -53,7 +53,7 @@ module MutationTester
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def aggregate_score
|
|
56
|
-
BaseReporter.score(@result.processed.flat_map { |entry| entry.results || [] })
|
|
56
|
+
BaseReporter.score(@result.processed.flat_map { |entry| entry.results || [] }, policy: @config.timeout_policy)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def file_reports
|
|
@@ -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 }
|
|
@@ -45,7 +45,7 @@ namespace :mutation do
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
unless all_passed
|
|
48
|
-
puts Rainbow("\n❌ One or more files
|
|
48
|
+
puts Rainbow("\n❌ One or more files failed mutation testing (see the per-file output above)").red
|
|
49
49
|
abort
|
|
50
50
|
end
|
|
51
51
|
end
|
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,12 +200,14 @@ 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`. |
|
|
207
207
|
| `--since REV` | Incremental batch mode: mutate only the files matched by `--glob` that changed since git revision `REV` (new files count as changed). Requires `--glob`. See [Incremental mode](#incremental-mode-mutate-only-what-changed). |
|
|
208
208
|
| `--fail-fast` | Stop the run at the first surviving mutant and finish with a failing status. Works in single-file mode and with `--glob`. |
|
|
209
|
+
| `--timeout-factor N` | Per-mutant timeout budget as `N` times the measured baseline test run, never below 5 s (default: 5, must be > 0). Ignored when `config.timeout` is set explicitly, which keeps a fixed budget. See [Configuration](#configuration). |
|
|
210
|
+
| `--timeout-policy MODE` | Scoring policy for timed-out mutants: `killed` (default) counts a timeout as a kill; `separate` keeps timeouts out of the score entirely (`killed / (killed + survived)`) and reports them only as their own category in the console, JSON and HTML reports. |
|
|
209
211
|
| `--worker-env NAME` | Set environment variable `NAME` to a distinct per-worker value before each parallel worker boots (`parallel_tests` `TEST_ENV_NUMBER` convention: worker 0 -> `""`, worker N -> `N+1`), so a `parallel_tests`-style `database.yml` selects a per-worker database. You provision the databases (e.g. `rake parallel:prepare`). Not supported by the `in_memory` runner (it falls back to `fork`). See [Making parallelism work with Rails](#making-parallelism-work-with-rails). |
|
|
210
212
|
| `--strict-equality` | Enable the opt-in strict-equality probes (`==` → `eql?` and `==` → `equal?`). Default off; expect noise on code that does not distinguish numeric types or object identity. See [Strict Equality Mutations](docs/mutation-types.md#strict-equality-mutations-opt-in). |
|
|
211
213
|
| `-h, --help` | Show help message. |
|
|
@@ -223,6 +225,22 @@ bundle exec mutation_test --reporters json,html --output-dir build/mutation \
|
|
|
223
225
|
app/models/user.rb spec/models/user_spec.rb
|
|
224
226
|
```
|
|
225
227
|
|
|
228
|
+
### Exit codes (single-file mode)
|
|
229
|
+
|
|
230
|
+
- `0` - the run passed (score met the threshold, or `fail_on_threshold` is disabled).
|
|
231
|
+
- `1` - the mutation score is below the threshold, or the input is unusable (missing
|
|
232
|
+
file, unknown reporter, source with a syntax error).
|
|
233
|
+
- `2` - a usage error (conflicting flags; see the batch sections below).
|
|
234
|
+
- `3` - the run aborted or degraded before reaching a verdict: the shadow workspace
|
|
235
|
+
was unreliable, or every mutant ended as `error`/`stillborn` so nothing was scored.
|
|
236
|
+
This signals an infrastructure or runner problem, not a test-quality gap, so CI
|
|
237
|
+
hooks can distinguish it from a genuine threshold failure.
|
|
238
|
+
- `130` - interrupted with Ctrl+C.
|
|
239
|
+
|
|
240
|
+
Batch modes (`FILE...` lists, `--staged`, `--glob`) keep the exit codes documented in
|
|
241
|
+
their sections below (`0`/`1`/`2`); a degraded file is named explicitly in the batch
|
|
242
|
+
summary instead of being blamed on the threshold.
|
|
243
|
+
|
|
226
244
|
### Running with rake
|
|
227
245
|
|
|
228
246
|
You can also run mutation tests through rake tasks.
|
|
@@ -403,8 +421,24 @@ MutationTester.configure do |config|
|
|
|
403
421
|
# config.parallel_processes = 1
|
|
404
422
|
config.parallel_processes = 4
|
|
405
423
|
|
|
406
|
-
#
|
|
407
|
-
|
|
424
|
+
# Per-mutant timeout (seconds). Calibrated automatically by default:
|
|
425
|
+
# max(5, timeout_factor * measured baseline duration), so a loaded machine
|
|
426
|
+
# cannot inflate the score by turning healthy-but-slow runs into timeout
|
|
427
|
+
# kills; on code paths without a measured baseline the fixed default of 30
|
|
428
|
+
# applies. Setting config.timeout explicitly (a number, or nil for no
|
|
429
|
+
# deadline at all) disables calibration and keeps that fixed budget:
|
|
430
|
+
# config.timeout = 30
|
|
431
|
+
|
|
432
|
+
# Multiplier for the baseline-calibrated per-mutant timeout (> 0; an
|
|
433
|
+
# invalid value falls back to the default with a warning):
|
|
434
|
+
config.timeout_factor = 5
|
|
435
|
+
|
|
436
|
+
# Scoring policy for timed-out mutants. :killed (default) counts a timeout
|
|
437
|
+
# as a kill: (killed + timeout) / (killed + timeout + survived). :separate
|
|
438
|
+
# keeps timeouts out of the score entirely, killed / (killed + survived),
|
|
439
|
+
# and reports them only as their own category, so timeouts under load can
|
|
440
|
+
# never raise the score:
|
|
441
|
+
config.timeout_policy = :killed
|
|
408
442
|
|
|
409
443
|
# Mutant execution runner: :auto (default), :fork, :spawn or :in_memory.
|
|
410
444
|
# :auto picks the fastest safe path and announces any fallback on stderr;
|
|
@@ -563,18 +597,18 @@ the fastest safe one, announcing every fallback on stderr:
|
|
|
563
597
|
|
|
564
598
|
- **in_memory** (default where supported): re-evaluates the mutated source in the
|
|
565
599
|
memory of a fresh fork of a preloaded process, with zero file writes per mutant
|
|
566
|
-
and no shadow workspaces. RSpec
|
|
567
|
-
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`/
|
|
568
602
|
`before_save`/`scope`/`attribute`, anything inside an `included do` block) cannot
|
|
569
603
|
be observed by re-evaluating source in a preloaded process, so those mutants are
|
|
570
604
|
routed automatically to the file-based path and the rest still run in memory (see
|
|
571
605
|
below); the combined score matches a full `fork` run.
|
|
572
|
-
- **fork**: preloads the environment once (RubyGems, Bundler,
|
|
573
|
-
forks a fresh child per mutant. RSpec on platforms with
|
|
574
|
-
most of the fixed per-mutant boot cost.
|
|
575
|
-
- **spawn**: starts one full process per mutant (`bundle exec rspec ...`
|
|
576
|
-
per mutant, but works everywhere
|
|
577
|
-
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`).
|
|
578
612
|
|
|
579
613
|
`auto` tries `in_memory`, then `fork`, then `spawn`; every step down prints one
|
|
580
614
|
stderr warning with its reason, so a fallback is never silent. All runners
|
|
@@ -594,9 +628,9 @@ do not need to pick `--runner fork` for correctness on load-time code.
|
|
|
594
628
|
|
|
595
629
|
| Mode | Picked by `auto` when | Falls back to |
|
|
596
630
|
|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
|
|
597
|
-
| `in_memory` | the
|
|
598
|
-
| `fork` |
|
|
599
|
-
| `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 |
|
|
600
634
|
|
|
601
635
|
Force a specific runner (skipping the auto attempts) with the `--runner
|
|
602
636
|
fork|spawn|in_memory` flag, the `MUTATION_TESTER_RUNNER` environment variable, or
|
|
@@ -614,6 +648,22 @@ fork and in-memory limitation lists (frozen classes, load-time `defined?` guards
|
|
|
614
648
|
worker-death fallback, `require_relative` idempotency), see
|
|
615
649
|
[docs/execution-runners.md](docs/execution-runners.md#execution-runners-fork-spawn-in-memory).
|
|
616
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
|
+
|
|
617
667
|
### Test selection (fast kill with full-file confirmation)
|
|
618
668
|
|
|
619
669
|
For RSpec suites on the file-based runners, MutationTester runs each mutant in
|
|
@@ -707,6 +757,18 @@ few lines of surrounding context:
|
|
|
707
757
|
💡 Suggestion: Add tests to verify behavior for each of the 2 variants above
|
|
708
758
|
```
|
|
709
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
|
+
|
|
710
772
|
### HTML report
|
|
711
773
|
|
|
712
774
|
Beautiful interactive HTML report with:
|
|
@@ -725,7 +787,9 @@ to get a single, clean JSON document on **stdout** and nothing else: the banner,
|
|
|
725
787
|
progress spinner, colours and the "report saved" notice all go to **stderr**, so
|
|
726
788
|
the stream is safe to pipe straight into `jq` or a parser. The process still
|
|
727
789
|
exits `0` when the mutation score meets the configured threshold and `1` when it
|
|
728
|
-
does not
|
|
790
|
+
does not (a degraded single-file run exits `3`, see
|
|
791
|
+
[Exit codes](#exit-codes-single-file-mode)), so the exit code remains a
|
|
792
|
+
pass/fail signal.
|
|
729
793
|
|
|
730
794
|
```bash
|
|
731
795
|
bundle exec mutation_test --json examples/calculator.rb examples/calculator_spec.rb | jq .
|
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
|