mutation_tester 1.5.1 → 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e32ba3afbad23fb7f2d04c12f197c8e7d7af50d0eb537266f76e3bd957165110
4
- data.tar.gz: b27a1b44965b42cb4498daaba8d221dcf820b71fd3e61f3b94c47a71c7cad41d
3
+ metadata.gz: b33e53b5e67f12380151690883608e8bc76ab3915d4771baa6d5293e5cedd94c
4
+ data.tar.gz: 24fda20aa66c67f0a828c44fd3cd064a586c8ea40f6c9fe2f8263518ef6318be
5
5
  SHA512:
6
- metadata.gz: 4a05a309477ff66005be2371bedd4b5ce4b3622a064f2013b3b62336dbe5d43af7c0e27526eee2ab186c07d9faef5fc8e3a568fe1bdda5e428c3142af19b0165
7
- data.tar.gz: 3fd04d05930cf47e0a4fb15680531586dfd9183ecd77ec3040aae3e5397ca1cd0cef2ac68ea087acf87c0acdd43dd8f3a18a40a46f05d8c5fac9a545511988f0
6
+ metadata.gz: 76f242526719139f3e78ba2a32e145671db00486f7a017d75011330c6c390c8b12e91ff142e779270d2f05711da36970747831cf1af9aadd06735f604010cfa8
7
+ data.tar.gz: 58d9d64c6344dfcee881455109b9638e9e380233a7e3a4be826879e14f8b4ca21de201f79978b9eb07fbcd0376a30b9e9b71cc440bb1c033fc92804062c6770a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.0] - 2026-09-19
4
+
5
+ - Added an opt-in kill matrix (`--kill-matrix`, `config.kill_matrix = true`) that records which tests kill each mutant, so the JSON report can be used to find redundant tests and not only missing ones. In this mode every mutant runs the full test file (no stop at the first failing test and no RSpec test selection), for RSpec and Minitest on all three runners, serial and parallel. The JSON report gains three additive fields, so `schema_version` stays at 1: `kill_matrix: true`, `tests[]` (every test of the baseline run with `id`, `name`, `line` and `status`) and `mutations[].killed_by` (the sorted ids of every test that failed under the mutant). An empty `killed_by` on a `killed` or `timeout` mutant means the killers are unknown (every timeout, and a mutated file that no longer loads), so a test must never be called redundant because of such a mutant. `--kill-matrix` together with `--fail-fast` is a usage error (exit code `2`), and a baseline that records no test aborts the run instead of reporting an empty matrix. The README has a new "Finding redundant tests" section with `jq` recipes, and `examples/github_actions/redundant_tests.yml` ships a scheduled audit workflow.
6
+ - The live progress line now shows the elapsed time, an estimate of the remaining time and running tallies of survived and timed out mutants (plus errored ones once any occur): `69.2% | 848/1226 processed | elapsed 37m 2s, remaining ~16m 32s | 170 survived, 0 timed out`. The estimate is extrapolated from real throughput measured from the first completed mutant, so worker boot and the shadow copy do not distort it and it holds for parallel runs; it is withheld until the sample is stable. On a terminal the line is fitted to the terminal width so it never wraps. The `| type | line N` suffix is gone (it was always empty in parallel runs) and the counter label is shortened to `processed`.
7
+ - Behavior change for Ruby API users: the progress callback of `MutationRunner#run` now fires for every completed mutant in parallel mode (it used to fire for every second one) and receives the result hash as a third argument. A plain two-argument block keeps working; a two-argument lambda or `method(:name)` passed with `&` now raises `ArgumentError` and needs a third parameter. `ProgressDisplay#update` now takes `(index, result = nil)` and `ProgressDisplay#current_mutation` is removed.
8
+ - Fixed a false 100% score on Minitest projects without a Gemfile whose test file pins `gem 'minitest', '~> 5.0'` while a newer Minitest is installed. The Minitest preloads and the fork worker loaded Minitest before the test file ran, which activated the newer version and made the pin raise `Gem::LoadError`: the baseline failed on the default runner, and under `--runner spawn` every mutant ended in that load error and counted as killed, so a test that asserts nothing scored 100%. Nothing loads Minitest before the test file does anymore.
9
+ - Fixed `format_duration` returning `1m 60s` for 119.6 seconds (it now rounds before splitting into units), and fixed the spinner thread staying alive after Ctrl-C, which printed the interruption message onto a half-drawn progress line.
10
+
3
11
  ## [1.5.1] - 2026-08-13
4
12
 
5
13
  - Fixed load-time mutants losing per-worker database isolation in a parallel in-memory run with `--worker-env` and `--after-fork`. Mutants on load-time code (constants, class macros, `included do`) are decided file-based on purpose, but that file-based run acquires a fork-runner worker, and a worker booted inside a `Parallel` worker process inherited the parent process's value of the worker-env variable instead of the per-worker assignment: with 8 workers, every load-time mutant's test run hit the parent's database concurrently, races on shared rows failed tests unrelated to the mutant, and those failures were scored as kills. The same file could report 75% and 100% on consecutive runs with no error and no timeout in the output, while the spawn path and the pooled fork runner (which already carried the per-worker value) stayed stable. The fork execution path now passes the same per-worker env override the spawn path always used, and the worker applies it inside the forked test child before the spec loads, so every execution path resolves the same per-worker database. Reported in the field with exactly this signature: only class-macro mutants unstable, serial and fork-runner scores identical and stable.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mutation_tester (1.5.1)
4
+ mutation_tester (1.6.0)
5
5
  parallel (~> 1.20)
6
6
  parser (~> 3.3)
7
7
  rainbow (~> 3.0)
data/docs/ci.md CHANGED
@@ -23,6 +23,9 @@ It also carries commented variants for parallel execution
23
23
  (`MUTATION_TESTER_PARALLEL_PROCESSES`), for testing several file pairs, and for
24
24
  an incremental pull-request gate (`--since`/`--fail-fast`).
25
25
 
26
+ Two more templates sit next to it: the [AI mutation gate](#ai-workflow-mutation-gate)
27
+ and the scheduled [redundant test audit](#redundant-test-audit-scheduled-job).
28
+
26
29
  ## GitHub Actions (minimal inline workflow)
27
30
 
28
31
  The same thing, condensed to a copy-pasteable minimal workflow. It matches the
@@ -179,3 +182,31 @@ the template carries a commented step showing where to wire your agent CLI. Use
179
182
  it alongside the plain [5-minute CI template](#mutation-testing-in-ci-in-5-minutes):
180
183
  the AI gate adds the surviving-mutant worklist, the plain template is just the
181
184
  pass/fail gate.
185
+
186
+ ## Redundant test audit (scheduled job)
187
+
188
+ The opt-in kill matrix (`--kill-matrix`) records, for every mutant, the tests
189
+ that kill it, which makes it possible to list tests that add no protection. The
190
+ method, the `jq` recipes and their limits are described under
191
+ [Finding redundant tests](../readme.md#finding-redundant-tests) in the README.
192
+
193
+ The gem ships a ready-to-copy workflow at
194
+ [`examples/github_actions/redundant_tests.yml`](../examples/github_actions/redundant_tests.yml)
195
+ (installed with the gem, so you have it offline). Copy it to
196
+ `.github/workflows/redundant_tests.yml`, add the gem to your bundle, and edit the
197
+ `EDIT:` lines. It:
198
+
199
+ 1. runs on a weekly schedule and on demand (`workflow_dispatch`), not on every
200
+ push: without the stop at the first failing test the run is slower than the
201
+ normal gate,
202
+ 2. runs `mutation_test --kill-matrix --json --glob ...` with `--minimum-score 0`,
203
+ so the mutation score never fails the audit while a broken run (failing
204
+ baseline, no file measured) still does,
205
+ 3. writes two lists to the GitHub job summary (`$GITHUB_STEP_SUMMARY`): tests
206
+ that kill no mutant, and tests whose every kill is shared with another test,
207
+ plus the number of mutants whose killers are unknown,
208
+ 4. uploads `kill_matrix.json` as an artifact.
209
+
210
+ It is an audit, not a gate: the findings never fail the job. Treat the lists as
211
+ candidates to review and remove one test at a time, because two listed tests can
212
+ cover for each other.
@@ -92,12 +92,32 @@ Every mutant run stops as soon as one test fails, on all three runners:
92
92
  non-passing result. On `spawn` the file is preloaded with `ruby -r`, on the
93
93
  preloaded runners the worker enables the same reporter per job.
94
94
 
95
+ Neither preload loads Minitest itself. Each one waits until the test file has
96
+ loaded Minitest and only then registers its plugin, and the fork worker sets its
97
+ autorun guard the same way. The test file therefore still chooses the Minitest
98
+ version: a project without a Gemfile that pins `gem 'minitest', '~> 5.0'` at the
99
+ top of its test file keeps working when a newer Minitest is installed. Loading
100
+ Minitest first would activate the newest installed version, the pin would then
101
+ raise `Gem::LoadError`, and every mutant run would end in that load error and be
102
+ counted as killed.
103
+
95
104
  This cannot change a verdict. A run that stops early has already recorded a
96
105
  failure, which is exactly what makes a mutant killed, and a run without a failure
97
106
  is untouched and executes every test. Only the mutant runs opt in: the baseline
98
107
  run and the shadow-workspace sanity check are expected to pass and always run the
99
108
  whole file, so a failing baseline still reports every failure it finds.
100
109
 
110
+ The opt-in kill matrix (`--kill-matrix`, `config.kill_matrix = true`) turns the
111
+ early stop off for mutant runs too, on all three runners, because it has to
112
+ record every failing test of every mutant. A small recorder is loaded next to the
113
+ test file (`--require` for RSpec and `ruby -r` for Minitest on `spawn`, at worker
114
+ boot on the preloaded runners) and appends the failing test ids to a per-run
115
+ temporary file named through the environment. The ids deliberately do not travel
116
+ through the fork worker's result pipe: the parent reads that pipe only after the
117
+ child has finished, so a list larger than the pipe buffer would block the child
118
+ until the deadline and turn a kill into a timeout. See
119
+ [Finding redundant tests](../readme.md#finding-redundant-tests).
120
+
101
121
  The pathological case it removes is a mutant that breaks something every test
102
122
  touches (a class body that no longer loads, a constant every test reads). Such a
103
123
  mutant used to re-raise the same error once per test, which on a large test file
@@ -200,6 +220,9 @@ runner (the default path) performs no selection at all: every mutant runs the
200
220
  full preloaded example set, which is why its report omits the `Selection:`
201
221
  summary line (see the in-memory limitations above).
202
222
 
223
+ The kill matrix mode (`--kill-matrix`) turns selection off for the run, because a
224
+ subset run would under-report the tests that kill a mutant.
225
+
203
226
  Disable it with the `--no-test-selection` CLI flag or in Ruby:
204
227
 
205
228
  ```ruby
data/docs/json-schema.md CHANGED
@@ -73,6 +73,9 @@ history of changes.
73
73
  | `mutations[].timeout` | boolean | Legacy passthrough flag from the runner (`true` only when the mutant timed out). Kept for backward compatibility; prefer `status`. |
74
74
  | `mutations[].status` | string | One of the taxonomy statuses below. This is the authoritative per-mutant result. |
75
75
  | `mutations[].description` | string | Human-readable description of the mutation (for `error`, the failure message). |
76
+ | `kill_matrix` | boolean | Optional, additive (does not bump `schema_version`). Present and `true` only for a run with the opt-in `--kill-matrix` mode, which guarantees that every `mutations[].killed_by` list is complete. Absent otherwise. See [Kill matrix fields](#kill-matrix-fields-opt-in). |
77
+ | `tests[]` | array | Optional, additive. Present only with `--kill-matrix`: every test of the unmutated baseline run, ordered by line. See [Kill matrix fields](#kill-matrix-fields-opt-in). |
78
+ | `mutations[].killed_by` | array | Optional, additive. Present only with `--kill-matrix`, on every mutant: the sorted `tests[].id` values of every test that failed under this mutant. See [Kill matrix fields](#kill-matrix-fields-opt-in). |
76
79
  | `mutations[].diff` | string | Optional, additive (does not bump `schema_version`). Present only for `survived` and `timeout` mutants: a unified diff of the change with a few lines of surrounding context (`@@` hunk header, lines prefixed with `- `, `+ ` or two spaces). Falls back to a context-free `- `/`+ ` pair when the source file is not readable at report time. |
77
80
 
78
81
  **Status taxonomy** (`mutations[].status`): `killed` (tests caught it), `survived`
@@ -80,6 +83,47 @@ history of changes.
80
83
  (unparseable, excluded from the score), `error` (runner failure, excluded from
81
84
  the score).
82
85
 
86
+ ## Kill matrix fields (opt-in)
87
+
88
+ A normal run stops every mutant at its first failing test, so it cannot say which
89
+ tests kill a mutant and emits none of the fields below. With `--kill-matrix`
90
+ (`config.kill_matrix = true`) every mutant runs the full test file, on every
91
+ runner and for both RSpec and Minitest, and the report gains:
92
+
93
+ ```json
94
+ {
95
+ "schema_version": 1,
96
+ "interrupted": false,
97
+ "kill_matrix": true,
98
+ "tests": [
99
+ { "id": "InvoiceTest#test_rejects_negative_total", "name": "test_rejects_negative_total", "line": 12, "status": "passed" },
100
+ { "id": "InvoiceTest#test_total_sums_lines", "name": "test_total_sums_lines", "line": 20, "status": "passed" }
101
+ ],
102
+ "mutations": [
103
+ {
104
+ "id": 12,
105
+ "status": "killed",
106
+ "line": 41,
107
+ "killed_by": ["InvoiceTest#test_rejects_negative_total", "InvoiceTest#test_total_sums_lines"]
108
+ }
109
+ ]
110
+ }
111
+ ```
112
+
113
+ | Field | Type | Description |
114
+ |-------------------------|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
115
+ | `tests[].id` | string | Stable id of the test within the run. Minitest: `TestClass#test_name`. RSpec: the spec path relative to the project root plus the scoped example id, e.g. `spec/invoice_spec.rb[1:2:1]`, which `rspec` accepts as an argument from the project root. RSpec older than 3.3 has no scoped ids, so there the id is the file and line, e.g. `spec/invoice_spec.rb:12`. |
116
+ | `tests[].name` | string | Human-readable name: the full example description for RSpec, the test method name for Minitest. |
117
+ | `tests[].line` | integer or null | 1-based line where the test is defined in the test file. `null` when the framework does not report it or the example is defined in another file (RSpec shared examples). |
118
+ | `tests[].status` | string | `passed` or `skipped` (RSpec `pending`/`skip`/`xit`, Minitest `skip`) in the baseline run. The baseline must pass, so there is no `failed`. |
119
+ | `mutations[].killed_by` | array of string | Sorted ids of every test that failed under the mutant. Always `[]` for `survived`, `stillborn`, `error` and `timeout`. Also `[]` for a `killed` mutant whose run failed without any test failing (typically the mutated file no longer loads, or an error outside a test). |
120
+
121
+ An empty `killed_by` on a `killed` or `timeout` mutant means the killers are
122
+ **unknown**, not that there are none: a consumer must never call a test redundant
123
+ because of such a mutant. In the multi-file envelope the fields appear inside each
124
+ `files[]` report. The redundancy recipes live under
125
+ [Finding redundant tests](../readme.md#finding-redundant-tests) in the README.
126
+
83
127
  ## Extracting the score and the survived mutants
84
128
 
85
129
  A ready-to-use snippet for an agent or script: read the score and list every
@@ -0,0 +1,77 @@
1
+ # Redundant-test audit for GitHub Actions (mutation_tester gem). Runs
2
+ # mutation_test --kill-matrix, which records for every mutant the tests that
3
+ # kill it, and publishes two lists of candidates to the job summary: tests that
4
+ # kill no mutant, and tests whose every kill is shared with another test. This
5
+ # is an AUDIT, not a gate: it runs on a schedule and never fails the job because
6
+ # of what it finds. Copy to .github/workflows/, edit the "EDIT:" lines. Needs jq
7
+ # (preinstalled on ubuntu runners); see mutation_test.yml for the quality gate.
8
+ #
9
+ # Read the lists as candidates to review. Remove one test at a time and re-run:
10
+ # two tests can cover for each other, so deleting both may let mutants survive.
11
+
12
+ name: Redundant Test Audit
13
+
14
+ on:
15
+ schedule:
16
+ - cron: '0 4 * * 1' # EDIT: weekly, Monday 04:00 UTC
17
+ workflow_dispatch:
18
+
19
+ jobs:
20
+ redundant_tests:
21
+ name: Redundant test audit
22
+ runs-on: ubuntu-latest
23
+ # The kill matrix runs every mutant against the full test file without
24
+ # stopping at the first failure, so it is slower than the normal gate.
25
+ timeout-minutes: 60
26
+ steps:
27
+ - name: Checkout
28
+ uses: actions/checkout@v5
29
+
30
+ - name: Set up Ruby
31
+ uses: ruby/setup-ruby@v1
32
+ with:
33
+ ruby-version: '3.3' # EDIT: match your project's Ruby (floor is 3.0)
34
+ bundler-cache: true
35
+
36
+ - name: Run mutation tests with the kill matrix (JSON report on stdout)
37
+ run: |
38
+ # EDIT: select YOUR sources; add --spec-glob 'test/{name}_test.rb' for Minitest.
39
+ # --minimum-score 0 keeps the score from failing this audit; a broken run
40
+ # (failing baseline, no file measured) still fails the step.
41
+ bundle exec mutation_test --kill-matrix --json --minimum-score 0 \
42
+ --timeout-factor 10 --glob 'lib/**/*.rb' > kill_matrix.json
43
+
44
+ - name: List redundancy candidates in the job summary
45
+ run: |
46
+ kill_nothing=$(jq -r '(.files // [.])[]
47
+ | (.metadata.spec_file | ltrimstr("\($ENV.PWD)/")) as $spec
48
+ | [.mutations[].killed_by[]] as $killers
49
+ | .tests[] | select(.status == "passed" and (.id | IN($killers[]) | not))
50
+ | "- `\(.id)` \(.name) (\($spec))"' kill_matrix.json)
51
+ shared_only=$(jq -r '(.files // [.])[]
52
+ | (.metadata.spec_file | ltrimstr("\($ENV.PWD)/")) as $spec
53
+ | [.mutations[] | select(.status == "killed") | .killed_by] as $kills
54
+ | .tests[] | .id as $id
55
+ | [$kills[] | select(index($id))] as $mine
56
+ | select(($mine | length) > 0 and all($mine[]; length > 1))
57
+ | "- `\(.id)` \(.name) (\($spec))"' kill_matrix.json)
58
+ unknown=$(jq '[(.files // [.])[] | .mutations[]
59
+ | select((.status == "killed" or .status == "timeout") and (.killed_by | length) == 0)]
60
+ | length' kill_matrix.json)
61
+ {
62
+ echo "## Redundant test audit"
63
+ echo "### Tests that kill no mutant"
64
+ echo "${kill_nothing:-None.}"
65
+ echo "### Tests whose every kill is shared with another test"
66
+ echo "${shared_only:-None.}"
67
+ echo
68
+ echo "Mutants with unknown killers (timeouts, load failures): **${unknown}**."
69
+ echo "Remove one test at a time and re-run: two listed tests can cover for each other."
70
+ } >> "$GITHUB_STEP_SUMMARY"
71
+
72
+ - name: Upload the kill matrix report
73
+ if: always()
74
+ uses: actions/upload-artifact@v4
75
+ with:
76
+ name: kill-matrix
77
+ path: kill_matrix.json
data/exe/mutation_test CHANGED
@@ -76,6 +76,10 @@ OptionParser.new do |opts|
76
76
  options[:fail_fast] = true
77
77
  end
78
78
 
79
+ opts.on('--kill-matrix', 'Audit mode: run every mutant against the full test file without stopping at its first failing test and without test selection, and record in the JSON report which tests kill each mutant (mutations[].killed_by) plus the list of tests that ran (tests[]). Slower than a normal run; cannot be combined with --fail-fast') do
80
+ options[:kill_matrix] = true
81
+ end
82
+
79
83
  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|
80
84
  options[:timeout_factor] = n
81
85
  end
@@ -184,6 +188,7 @@ apply_configuration = lambda do
184
188
  config.worker_env_var = options[:worker_env] if options.key?(:worker_env)
185
189
  config.after_fork_file = options[:after_fork] if options.key?(:after_fork)
186
190
  config.fail_fast = options.fetch(:fail_fast, false)
191
+ config.kill_matrix = true if options[:kill_matrix]
187
192
  config.mutation_types[:strict_equality] = true if options[:strict_equality]
188
193
 
189
194
  if json_mode
@@ -208,6 +213,11 @@ run_batch_machine = lambda do |batch, passed_for|
208
213
  exit(passed ? 0 : 1)
209
214
  end
210
215
 
216
+ if options[:kill_matrix] && options[:fail_fast]
217
+ usage_error.call('--kill-matrix cannot be combined with --fail-fast.',
218
+ '--fail-fast stops the run at the first surviving mutant, which would leave the kill matrix incomplete; drop one of the two flags.')
219
+ end
220
+
211
221
  if options[:since] && !glob_mode
212
222
  usage_error.call('--since requires --glob (batch mode).',
213
223
  'Usage: mutation_test --glob "lib/**/*.rb" --since origin/main')
@@ -360,7 +370,7 @@ if json_mode
360
370
  end
361
371
 
362
372
  reporter = MutationTester::Reporters::JsonReporter.new(
363
- core.results, core.source_file, core.spec_file, core.config, interrupted: core.interrupted?
373
+ core.results, core.source_file, core.spec_file, core.config, interrupted: core.interrupted?, tests: core.tests
364
374
  )
365
375
 
366
376
  unless core.results.empty?
@@ -22,7 +22,7 @@ module MutationTester
22
22
 
23
23
  SKIP_ORDER = %i[missing not_ruby test_file no_spec].freeze
24
24
 
25
- ProcessedEntry = Struct.new(:source_file, :spec_file, :score, :passed, :output_dir, :results, :interrupted, :degraded, keyword_init: true) do
25
+ ProcessedEntry = Struct.new(:source_file, :spec_file, :score, :passed, :output_dir, :results, :interrupted, :degraded, :tests, keyword_init: true) do
26
26
  def passed?
27
27
  passed
28
28
  end
@@ -230,7 +230,8 @@ module MutationTester
230
230
  output_dir: file_config.output_dir,
231
231
  results: core.results,
232
232
  interrupted: core.interrupted?,
233
- degraded: core.infrastructure_failure?
233
+ degraded: core.infrastructure_failure?,
234
+ tests: core.tests
234
235
  )
235
236
  [entry, core.stopped_on_survivor?]
236
237
  end
@@ -33,7 +33,8 @@ module MutationTester
33
33
  :show_file_path,
34
34
  :show_progress,
35
35
  :test_selection,
36
- :fail_fast
36
+ :fail_fast,
37
+ :kill_matrix
37
38
 
38
39
  def initialize
39
40
  self.parallel_processes = ENV['MUTATION_TESTER_PARALLEL_PROCESSES'] || self.class.auto_parallel_processes
@@ -66,6 +67,7 @@ module MutationTester
66
67
  @show_progress = true
67
68
  @test_selection = true
68
69
  @fail_fast = false
70
+ @kill_matrix = false
69
71
  end
70
72
 
71
73
  def merge(options)
@@ -2,7 +2,7 @@ require_relative 'progress_display'
2
2
 
3
3
  module MutationTester
4
4
  class Core
5
- attr_reader :source_file, :spec_file, :mutations, :results, :config
5
+ attr_reader :source_file, :spec_file, :mutations, :results, :config, :tests
6
6
 
7
7
  def initialize(source_file, spec_file, config = MutationTester.configuration)
8
8
  @source_file = File.expand_path(source_file)
@@ -10,6 +10,7 @@ module MutationTester
10
10
  @config = config
11
11
  @mutations = []
12
12
  @results = []
13
+ @tests = []
13
14
  @parse_failed = false
14
15
  @shadow_aborted = false
15
16
  MutationRunner.recover_in_place_backup(@source_file)
@@ -18,6 +19,7 @@ module MutationTester
18
19
 
19
20
  def run
20
21
  print_header
22
+ return report_conflicting_modes if @config.kill_matrix && @config.fail_fast
21
23
  return false unless run_original_tests
22
24
 
23
25
  generate_mutations
@@ -98,14 +100,35 @@ module MutationTester
98
100
  return false
99
101
  end
100
102
  puts Rainbow('✓ Original tests passed').green
103
+ @tests = recorded_tests(result.tests)
104
+ return report_unrecorded_baseline if @config.kill_matrix && @tests.empty?
105
+
101
106
  @config.baseline_duration = baseline_elapsed
102
107
  true
103
108
  end
104
109
 
110
+ def report_conflicting_modes
111
+ puts Rainbow('❌ kill_matrix cannot be combined with fail_fast.').red
112
+ puts Rainbow(' fail_fast stops the run at the first surviving mutant, which would leave the kill matrix incomplete.').red
113
+ false
114
+ end
115
+
116
+ def report_unrecorded_baseline
117
+ puts Rainbow('❌ The kill matrix could not record a single test of the passing baseline run.').red
118
+ puts Rainbow(' Without the list of tests it cannot tell which tests kill a mutant, so the run is aborted instead of').red
119
+ puts Rainbow(' reporting an empty matrix. Usual causes: the test file defines no tests, or a plugin or hook replaces').red
120
+ puts Rainbow(' the test framework reporters or ends the process before the results are written.').red
121
+ false
122
+ end
123
+
105
124
  def monotonic_time
106
125
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
107
126
  end
108
127
 
128
+ def recorded_tests(entries)
129
+ Array(entries).uniq { |entry| entry[:id] }.sort_by { |entry| [entry[:line].to_i, entry[:id]] }
130
+ end
131
+
109
132
  def replay_baseline_output(output)
110
133
  return if output.nil? || output.strip.empty?
111
134
 
@@ -161,11 +184,14 @@ module MutationTester
161
184
 
162
185
  progress_display = ProgressDisplay.new(@mutations.size, @config)
163
186
 
164
- @results = mutation_runner.run(@mutations) do |mutation, index|
165
- progress_display.update(mutation, index)
187
+ begin
188
+ @results = mutation_runner.run(@mutations) do |_mutation, index, result|
189
+ progress_display.update(index, result)
190
+ end
191
+ progress_display.finish
192
+ ensure
193
+ progress_display.stop
166
194
  end
167
-
168
- progress_display.finish
169
195
  puts Rainbow("✓ Completed #{@results.size} mutations").green
170
196
  end
171
197
 
@@ -186,11 +212,11 @@ module MutationTester
186
212
  def create_reporter(type)
187
213
  case type
188
214
  when :console
189
- Reporters::ConsoleReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?)
215
+ Reporters::ConsoleReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?, tests: @tests)
190
216
  when :html
191
- Reporters::HtmlReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?)
217
+ Reporters::HtmlReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?, tests: @tests)
192
218
  when :json
193
- Reporters::JsonReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?)
219
+ Reporters::JsonReporter.new(@results, @source_file, @spec_file, @config, interrupted: interrupted?, tests: @tests)
194
220
  end
195
221
  end
196
222
 
@@ -198,7 +224,9 @@ module MutationTester
198
224
  TestCommand.new(
199
225
  @spec_file,
200
226
  use_bundle_exec: TestCommand.use_bundle_exec?(@source_file),
201
- runner: @config.runner
227
+ runner: @config.runner,
228
+ record: @config.kill_matrix ? :all : nil,
229
+ record_root: @config.kill_matrix ? mutation_runner.recording_root : nil
202
230
  )
203
231
  end
204
232
 
@@ -10,11 +10,12 @@ STDOUT.reopen(File::NULL)
10
10
  framework = ARGV.shift == 'minitest' ? :minitest : :rspec
11
11
 
12
12
  if framework == :minitest
13
- require 'minitest'
14
13
  require_relative '../minitest_fail_fast'
15
- Minitest.class_variable_set(:@@installed_at_exit, true)
14
+ require_relative '../test_recorder/minitest_hook'
15
+ MutationTester::MinitestLoadHook.on_load { Minitest.class_variable_set(:@@installed_at_exit, true) }
16
16
  else
17
17
  require 'rspec/core'
18
+ require_relative '../test_recorder/rspec_hook'
18
19
  end
19
20
 
20
21
  kill_group = lambda do |pid|
@@ -190,8 +190,8 @@ module MutationTester
190
190
  [false, 'the fork runner worker terminated unexpectedly']
191
191
  end
192
192
 
193
- def execute_in_memory(source:, path:, timeout: nil, chdir: nil)
194
- job = { in_memory: { source: source, path: path }, timeout: timeout, chdir: chdir }
193
+ def execute_in_memory(source:, path:, timeout: nil, chdir: nil, env: nil)
194
+ job = { in_memory: { source: source, path: path }, timeout: timeout, chdir: chdir, env: env }
195
195
  @job_writer.puts(JSON.generate(job))
196
196
  event = await_result(timeout)
197
197
  InMemoryOutcome.new(event['status'], event['message'])
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'minitest_load_hook'
4
+
3
5
  module MutationTester
4
6
  module MinitestFailFast
5
7
  PLUGIN_NAME = :mutation_tester_fail_fast
@@ -12,11 +14,7 @@ module MutationTester
12
14
  end
13
15
  end
14
16
 
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
17
+ MutationTester::MinitestLoadHook.on_load do
20
18
  module MutationTester
21
19
  module MinitestFailFast
22
20
  class Reporter < Minitest::AbstractReporter
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutationTester
4
+ module MinitestLoadHook
5
+ class << self
6
+ def on_load(&block)
7
+ return block.call if loaded?
8
+
9
+ callbacks << block
10
+ trace.enable unless trace.enabled?
11
+ end
12
+
13
+ private
14
+
15
+ def loaded?
16
+ return false unless defined?(::Minitest::AbstractReporter)
17
+
18
+ ::Minitest.respond_to?(:register_plugin) && ::Minitest.respond_to?(:extensions) && !::Minitest.extensions.nil?
19
+ end
20
+
21
+ def callbacks
22
+ @callbacks ||= []
23
+ end
24
+
25
+ def trace
26
+ @trace ||= TracePoint.new(:end) do |point|
27
+ next unless defined?(::Minitest) && point.self.equal?(::Minitest) && loaded?
28
+
29
+ @trace.disable
30
+ callbacks.shift.call until callbacks.empty?
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end