mutineer 1.4.0 → 1.5.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +250 -0
  3. data/README.md +198 -27
  4. data/lib/mutineer/changed_lines.rb +24 -1
  5. data/lib/mutineer/cli.rb +112 -16
  6. data/lib/mutineer/config.rb +64 -13
  7. data/lib/mutineer/coverage_map.rb +161 -14
  8. data/lib/mutineer/daemon_backend.rb +3 -0
  9. data/lib/mutineer/daemon_client.rb +170 -13
  10. data/lib/mutineer/daemon_server.rb +2 -1
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/isolation.rb +91 -6
  13. data/lib/mutineer/kill_channel.rb +284 -0
  14. data/lib/mutineer/kill_matrix.rb +88 -0
  15. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  16. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  17. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  18. data/lib/mutineer/minitest_integration.rb +27 -10
  19. data/lib/mutineer/mutant_id.rb +5 -4
  20. data/lib/mutineer/mutators/array_literal.rb +0 -7
  21. data/lib/mutineer/mutators/base.rb +63 -4
  22. data/lib/mutineer/mutators/chain_link.rb +0 -7
  23. data/lib/mutineer/mutators/collection_method.rb +1 -1
  24. data/lib/mutineer/mutators/condition_forcing.rb +0 -7
  25. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  26. data/lib/mutineer/mutators/operator_assignment.rb +0 -7
  27. data/lib/mutineer/mutators/return_nil.rb +5 -11
  28. data/lib/mutineer/mutators/string_literal.rb +1 -1
  29. data/lib/mutineer/pairing.rb +222 -15
  30. data/lib/mutineer/parser.rb +12 -3
  31. data/lib/mutineer/project.rb +7 -0
  32. data/lib/mutineer/rails_worker_db.rb +1 -1
  33. data/lib/mutineer/reporter.rb +288 -52
  34. data/lib/mutineer/result.rb +37 -1
  35. data/lib/mutineer/runner.rb +133 -16
  36. data/lib/mutineer/statement_lines.rb +143 -0
  37. data/lib/mutineer/test_runners/minitest.rb +4 -2
  38. data/lib/mutineer/test_runners/rspec.rb +243 -3
  39. data/lib/mutineer/test_runners.rb +5 -3
  40. data/lib/mutineer/version.rb +1 -1
  41. data/lib/mutineer/worker_pool.rb +4 -2
  42. data/lib/mutineer.rb +1 -0
  43. metadata +8 -3
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mutineer
6
+ # The line protocol a `--matrix` mutant child uses to tell its parent how its
7
+ # tests went. One JSON array per line:
8
+ #
9
+ # ["start"] the recorder is armed; once, first
10
+ # ["pass", file, name, id] a test passed against the mutant
11
+ # ["kill", file, name, id] a test failed or errored: it killed it
12
+ # ["parallel"] the run reached its parallel tests; once
13
+ # ["skip"] what runs next, a run without --matrix
14
+ # skips (see below)
15
+ # ["unskip"] the matching end of a ["skip"]
16
+ # ["cleanup"] every test ran; suite hooks follow; once
17
+ # ["lost"] a test the recorder could not describe
18
+ # ["end"] the suite returned with every test seen;
19
+ # once, last
20
+ #
21
+ # `id` tells two tests apart when `[file, name]` does not, and it is what
22
+ # identifies the test across mutants (an RSpec example id such as
23
+ # `./spec/calc_spec.rb[1:2]`; the name can change with the mutant). For
24
+ # Minitest it equals the name. A test line is written when the test is
25
+ # recorded, so a child killed at the timeout has already sent every kill it
26
+ # saw. A skipped test sends nothing.
27
+ #
28
+ # Minitest runs its serial test classes first and its `parallelize_me!`
29
+ # classes after them, and the stop at the first failure of a run without
30
+ # `--matrix` skips everything after a serial kill. `parallel` marks where the
31
+ # parallel tests begin, so the parent can tell a kill that the stop would have
32
+ # followed from one it could not. `cleanup` marks where the tests end and the
33
+ # suite's own hooks begin (RSpec `after(:suite)`). It decides no verdict
34
+ # (the `skip` regions do); it only marks a later test line as out of order.
35
+ #
36
+ # After the first serial kill, a run without `--matrix` skips every later
37
+ # test and every later test class or example group, but it still runs the
38
+ # rest of the code around the failing test: the remainder of its Minitest
39
+ # class `run` wrapper, the `after(:all)` hooks of its RSpec groups, and the
40
+ # suite hooks. The recorder writes `skip` as it enters something that run
41
+ # would skip and `unskip` as that returns, so a child that exits, crashes or
42
+ # hangs inside a skipped region is one the plain run would have stopped
43
+ # before (see {Isolation.finish}). The pairs nest.
44
+ #
45
+ # A row is complete only with `start` and `end`, in order, and no lost line: a
46
+ # test that exits the process, a crash, an interrupted run or a recorder that
47
+ # never armed leaves one out. A stream out of order (a duplicate marker, a
48
+ # test line before `start` or after `end`) is invalid, and the parent trusts
49
+ # nothing from it.
50
+ #
51
+ # Writing never raises: a recorder that broke the test run would change the
52
+ # verdict, and the exit status decides the verdict. A lost line leaves the row
53
+ # incomplete. After one write to a channel fails, nothing more is written to
54
+ # it, so a later `end` can never make a row with a missing line look complete.
55
+ #
56
+ # Stdlib-only, so the app-side daemon can load the files that require it.
57
+ module KillChannel
58
+ # Event for a test that passed against the mutant.
59
+ PASS = "pass"
60
+
61
+ # Event for a test that failed or errored against the mutant: it killed it.
62
+ KILL = "kill"
63
+
64
+ # Event that opens a recorded run.
65
+ START = "start"
66
+
67
+ # Event that marks the first parallel test of a run.
68
+ PARALLEL = "parallel"
69
+
70
+ # Event that marks the end of the tests and the start of the suite's cleanup.
71
+ CLEANUP = "cleanup"
72
+
73
+ # Event that opens a region a run without `--matrix` would skip.
74
+ SKIP = "skip"
75
+
76
+ # Event that closes the latest open {SKIP} region.
77
+ UNSKIP = "unskip"
78
+
79
+ # Event for a test the recorder could not describe.
80
+ LOST = "lost"
81
+
82
+ # Event that closes a run whose suite returned with every test seen.
83
+ FINISH = "end"
84
+
85
+ # Serializes writes from tests that record on several threads.
86
+ LOCK = Mutex.new
87
+
88
+ # What one child sent. `killed` and `ran` are sorted, unique
89
+ # `[file, name, id]` tests (`ran` includes the killers); `lost` counts lines
90
+ # that could not be read, including a partial last line. `parallel` and
91
+ # `cleanup` say the marker arrived, and `serial_kill` that a test killed
92
+ # before the `parallel` marker. `skipping` counts the {SKIP} regions still
93
+ # open when the stream ended. `invalid` is set by any line out of order.
94
+ Report = Struct.new(:killed, :ran, :started, :finished, :parallel, :cleanup, :serial_kill, :skipping, :invalid,
95
+ :lost, keyword_init: true)
96
+
97
+ # Writes one test line.
98
+ #
99
+ # @param io [IO] the write end of the channel.
100
+ # @param event [String] {PASS} or {KILL}.
101
+ # @param file [String] the file that defines the test.
102
+ # @param name [String] the test's display name, e.g. `CalculatorTest#test_add`.
103
+ # @param id [String] what tells the test apart from others with its file and
104
+ # name; defaults to the name.
105
+ # @return [void]
106
+ def self.write(io, event, file, name, id = name)
107
+ emit(io, [event, utf8(file), utf8(name), utf8(id)])
108
+ end
109
+
110
+ # Writes the `start` line.
111
+ #
112
+ # @param io [IO] the write end of the channel.
113
+ # @return [void]
114
+ def self.write_start(io)
115
+ emit(io, [START])
116
+ end
117
+
118
+ # Writes the `parallel` line.
119
+ #
120
+ # @param io [IO] the write end of the channel.
121
+ # @return [void]
122
+ def self.write_parallel(io)
123
+ emit(io, [PARALLEL])
124
+ end
125
+
126
+ # Writes the `skip` line.
127
+ #
128
+ # @param io [IO] the write end of the channel.
129
+ # @return [void]
130
+ def self.write_skip(io)
131
+ emit(io, [SKIP])
132
+ end
133
+
134
+ # Writes the `unskip` line.
135
+ #
136
+ # @param io [IO] the write end of the channel.
137
+ # @return [void]
138
+ def self.write_unskip(io)
139
+ emit(io, [UNSKIP])
140
+ end
141
+
142
+ # Writes the `cleanup` line.
143
+ #
144
+ # @param io [IO] the write end of the channel.
145
+ # @return [void]
146
+ def self.write_cleanup(io)
147
+ emit(io, [CLEANUP])
148
+ end
149
+
150
+ # Writes the `lost` line, for a test the recorder could not describe.
151
+ #
152
+ # @param io [IO] the write end of the channel.
153
+ # @return [void]
154
+ def self.write_lost(io)
155
+ emit(io, [LOST])
156
+ end
157
+
158
+ # Writes the `end` line.
159
+ #
160
+ # @param io [IO] the write end of the channel.
161
+ # @return [void]
162
+ def self.write_end(io)
163
+ emit(io, [FINISH])
164
+ end
165
+
166
+ # Reads every complete line of `buffer`.
167
+ #
168
+ # @param buffer [String] bytes read from the channel.
169
+ # @return [Report]
170
+ def self.parse(buffer)
171
+ report = Report.new(killed: [], ran: [], started: false, finished: false, parallel: false, cleanup: false,
172
+ serial_kill: false, skipping: 0, invalid: false, lost: 0)
173
+ buffer.dup.force_encoding(Encoding::UTF_8).each_line do |line|
174
+ fields = line.end_with?("\n") ? parse_line(line) : nil
175
+ fields ? apply(report, fields) : report.lost += 1
176
+ end
177
+ report.killed = report.killed.uniq.sort
178
+ report.ran = report.ran.uniq.sort
179
+ report
180
+ end
181
+
182
+ # Adds one parsed line to `report`. A line out of order marks the report
183
+ # invalid; the line still counts, since what it names happened.
184
+ #
185
+ # @api private
186
+ # @param report [Report] the report being built.
187
+ # @param fields [Array<String>] a valid line's fields.
188
+ # @return [void]
189
+ def self.apply(report, fields)
190
+ case fields[0]
191
+ when START
192
+ report.invalid = true if report.started || report.ran.any? || report.parallel || report.cleanup || report.finished
193
+ report.started = true
194
+ when PARALLEL
195
+ report.invalid = true unless open?(report) && !report.parallel && !report.cleanup
196
+ report.parallel = true
197
+ when SKIP
198
+ report.invalid = true unless open?(report) && !report.cleanup
199
+ report.skipping += 1
200
+ when UNSKIP
201
+ report.invalid = true unless open?(report) && !report.cleanup && report.skipping.positive?
202
+ report.skipping -= 1 if report.skipping.positive?
203
+ when CLEANUP
204
+ report.invalid = true unless open?(report) && !report.cleanup
205
+ report.cleanup = true
206
+ when LOST then report.lost += 1
207
+ when FINISH
208
+ report.invalid = true unless open?(report) && !report.finished
209
+ report.finished = true
210
+ else
211
+ report.invalid = true unless open?(report) && !report.cleanup
212
+ test = fields[1, 3]
213
+ report.ran << test
214
+ return unless fields[0] == KILL
215
+
216
+ report.killed << test
217
+ report.serial_kill = true unless report.parallel
218
+ end
219
+ end
220
+
221
+ # True between `start` and `end`.
222
+ #
223
+ # @api private
224
+ # @param report [Report] the report being built.
225
+ # @return [Boolean]
226
+ def self.open?(report)
227
+ report.started && !report.finished
228
+ end
229
+
230
+ # Parses one line into its fields, or nil when it is not a valid line.
231
+ #
232
+ # @api private
233
+ # @param line [String] one newline-terminated line.
234
+ # @return [Array<String>, nil]
235
+ def self.parse_line(line)
236
+ fields = JSON.parse(line)
237
+ return unless fields.is_a?(Array) && fields.all?(String)
238
+
239
+ valid =
240
+ case fields[0]
241
+ when PASS, KILL then fields.size == 4
242
+ when START, PARALLEL, SKIP, UNSKIP, CLEANUP, LOST, FINISH then fields.size == 1
243
+ end
244
+ fields if valid
245
+ rescue JSON::ParserError
246
+ nil
247
+ end
248
+
249
+ # Writes one line. Never raises. After a write to `io` fails, writes
250
+ # nothing more to it.
251
+ #
252
+ # @api private
253
+ # @param io [IO] the write end of the channel.
254
+ # @param fields [Array<String>] the line's fields.
255
+ # @return [void]
256
+ def self.emit(io, fields)
257
+ line = "#{JSON.generate(fields)}\n"
258
+ LOCK.synchronize do
259
+ next if @broken.equal?(io)
260
+
261
+ begin
262
+ io.write(line)
263
+ rescue StandardError
264
+ @broken = io
265
+ end
266
+ end
267
+ nil
268
+ rescue StandardError
269
+ nil
270
+ end
271
+
272
+ # A UTF-8 copy of `value`, with invalid bytes replaced, so JSON can encode it.
273
+ #
274
+ # @api private
275
+ # @param value [Object] a file path or test name.
276
+ # @return [String]
277
+ def self.utf8(value)
278
+ str = value.to_s
279
+ str = str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless str.encoding == Encoding::UTF_8
280
+ str.scrub
281
+ end
282
+ private_class_method :apply, :open?, :parse_line, :emit, :utf8
283
+ end
284
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Mutineer
6
+ # Which tests kill which mutants, read from the {Kills} rows of a `--matrix`
7
+ # run. Pure: it reads results and runs nothing, and it never touches the score.
8
+ #
9
+ # A test is a `[file, name, id]` triple (see {Kills}); file and id identify it,
10
+ # and every row names a test the same way (see {Runner.share_tests}). The tests it knows are
11
+ # the ones that ran against at least one mutant of this run, so every answer
12
+ # is relative to the run's mutants: a test of code outside the run's sources
13
+ # kills nothing here.
14
+ #
15
+ # - A blind test ran in at least one complete row and killed no mutant. A
16
+ # test seen only in incomplete rows is never called blind.
17
+ # - A redundant test killed at least one mutant, and each mutant it killed has
18
+ # another killer. Each test is judged alone: two redundant tests can be the
19
+ # only killers of one mutant, so delete them one at a time. Finding the
20
+ # smallest set of tests that keeps every kill is a set-cover problem, out of
21
+ # scope here.
22
+ #
23
+ # Kills in incomplete rows count, since they did happen. An incomplete row can
24
+ # miss kills, so when {#complete?} is false a blind test may have killed one
25
+ # of those mutants.
26
+ class KillMatrix
27
+ # The results that carry a {Kills} row, in run order.
28
+ #
29
+ # @return [Array<Mutineer::Result>]
30
+ attr_reader :rows
31
+
32
+ # @param results [Array<Mutineer::Result>] every result of the run.
33
+ def initialize(results)
34
+ @rows = results.select(&:kills)
35
+ @kill_counts = Hash.new(0)
36
+ @rows.each { |r| r.kills.killed_by.each { |test| @kill_counts[test] += 1 } }
37
+ end
38
+
39
+ # Every test that ran against at least one mutant, sorted by file, name and id.
40
+ #
41
+ # @return [Array<Array(String, String, String)>]
42
+ def tests
43
+ @tests ||= @rows.flat_map { |r| r.kills.ran + r.kills.killed_by }.uniq.sort
44
+ end
45
+
46
+ # True when every row is complete.
47
+ #
48
+ # @return [Boolean]
49
+ def complete?
50
+ @rows.all? { |r| r.kills.complete }
51
+ end
52
+
53
+ # The rows that are not complete.
54
+ #
55
+ # @return [Array<Mutineer::Result>]
56
+ def incomplete_rows
57
+ @rows.reject { |r| r.kills.complete }
58
+ end
59
+
60
+ # How many mutants `test` killed.
61
+ #
62
+ # @param test [Array(String, String, String)] a `[file, name, id]` test.
63
+ # @return [Integer]
64
+ def kill_count(test)
65
+ @kill_counts[test]
66
+ end
67
+
68
+ # Tests that ran in a complete row and killed no mutant.
69
+ #
70
+ # @return [Array<Array(String, String, String)>] sorted.
71
+ def blind
72
+ @blind ||= begin
73
+ seen = @rows.select { |r| r.kills.complete }.flat_map { |r| r.kills.ran }.to_set
74
+ tests.select { |test| kill_count(test).zero? && seen.include?(test) }
75
+ end
76
+ end
77
+
78
+ # Tests that killed a mutant, where every mutant they killed has another killer.
79
+ #
80
+ # @return [Array<Array(String, String, String)>] sorted.
81
+ def redundant
82
+ @redundant ||= begin
83
+ sole = @rows.filter_map { |r| r.kills.killed_by.first if r.kills.killed_by.size == 1 }.to_set
84
+ tests.select { |test| kill_count(test).positive? && !sole.include?(test) }
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "outer_reporter"
4
+ require_relative "../kill_channel"
5
+
6
+ module Mutineer
7
+ class MinitestIntegration
8
+ # Sends the outcome of each test to the parent of a `--matrix` mutant run,
9
+ # through {KillChannel}. It never stops the run: every covering test runs,
10
+ # so the parent learns exactly which tests kill the mutant. Child-process
11
+ # only, like MinitestIntegration.
12
+ #
13
+ # Only the outer reporter counts (see {OuterReporter}): a test can build and
14
+ # record on its own reporter, and those results are not the run's.
15
+ module KillRecorder
16
+ extend OuterReporter::Client
17
+
18
+ # Serializes the counting and the writing of results, which Minitest
19
+ # records from several threads in a parallel class.
20
+ LOCK = Mutex.new
21
+
22
+ # Prepended on each loaded test class: one test's run. Serial and parallel
23
+ # runs both call it, in Minitest 5 and 6.
24
+ module MarkSkippedTest
25
+ # Runs the test inside a `skip` region once a serial test has killed.
26
+ #
27
+ # @return [Minitest::Result]
28
+ def run
29
+ KillRecorder.skipping { super }
30
+ end
31
+ end
32
+
33
+ # Prepended on each loaded test class's singleton under Minitest 6: one
34
+ # class's run and each test's class-level run, user wrappers included.
35
+ # A run without `--matrix` skips both after a stop (see
36
+ # StopAtFirstFailure::SkipAfterStop6).
37
+ module MarkSkippedClass6
38
+ # Runs the class inside a `skip` region once a serial test has killed.
39
+ #
40
+ # @param reporter [Minitest::CompositeReporter]
41
+ # @param options [Hash]
42
+ # @return [Object]
43
+ def run_suite(reporter, options = {})
44
+ KillRecorder.class_starting(self, reporter)
45
+ KillRecorder.skipping { super }
46
+ end
47
+
48
+ # Runs one test's class-level step inside a `skip` region once a
49
+ # serial test has killed.
50
+ #
51
+ # @param klass [Class]
52
+ # @param method_name [String]
53
+ # @param reporter [Minitest::CompositeReporter]
54
+ # @return [Object]
55
+ def run(klass, method_name, reporter)
56
+ KillRecorder.skipping { super }
57
+ end
58
+ end
59
+
60
+ # The Minitest 5 form of {MarkSkippedClass6}.
61
+ module MarkSkippedClass5
62
+ # Runs the class inside a `skip` region once a serial test has killed.
63
+ #
64
+ # @param reporter [Minitest::CompositeReporter]
65
+ # @param options [Hash]
66
+ # @return [Object]
67
+ def run(reporter, options = {})
68
+ KillRecorder.class_starting(self, reporter)
69
+ KillRecorder.skipping { super }
70
+ end
71
+
72
+ # Runs one test's class-level step inside a `skip` region once a
73
+ # serial test has killed.
74
+ #
75
+ # @param klass [Class]
76
+ # @param method_name [String]
77
+ # @param reporter [Minitest::CompositeReporter]
78
+ # @return [Object]
79
+ def run_one_method(klass, method_name, reporter)
80
+ KillRecorder.skipping { super }
81
+ end
82
+ end
83
+
84
+ # Prepended on `Minitest::CompositeReporter`.
85
+ module Record
86
+ # Records the result, then sends it to the channel.
87
+ #
88
+ # @param result [Minitest::Result] the result of one test.
89
+ # @return [void]
90
+ def record(result)
91
+ super
92
+ KillRecorder.record(self, result)
93
+ end
94
+ end
95
+
96
+ class << self
97
+ # The write end of the channel of the armed run.
98
+ #
99
+ # @return [IO, nil]
100
+ attr_accessor :channel
101
+
102
+ # True once the `parallel` line has gone out.
103
+ #
104
+ # @return [Boolean, nil]
105
+ attr_accessor :parallel_marked
106
+
107
+ # The loaded test classes of the armed run.
108
+ #
109
+ # @return [Array<Class>, nil]
110
+ attr_accessor :runnables
111
+
112
+ # How many results the outer reporter has recorded, skips included.
113
+ #
114
+ # @return [Integer, nil]
115
+ attr_accessor :seen
116
+
117
+ # True once a serial test killed: from then on, a run without `--matrix`
118
+ # skips each later test and class.
119
+ #
120
+ # @return [Boolean, nil]
121
+ attr_accessor :serial_killed
122
+
123
+ # Installs the hooks, arms the recorder for this process, and writes the
124
+ # channel's `start` line. Call it after the test files load, so the
125
+ # recorder knows the test classes. An unknown Minitest shape arms
126
+ # nothing and writes nothing, so the parent sees no `start` and keeps
127
+ # the row incomplete.
128
+ #
129
+ # @param channel [IO] the write end of the channel.
130
+ # @param runnables [Array<Class>] the loaded test classes.
131
+ # @return [Boolean] false when the Minitest shape is unknown.
132
+ def arm!(channel, runnables)
133
+ outer = OuterReporter.hook_for_loaded_minitest
134
+ return false unless outer
135
+
136
+ OuterReporter.prepend_once(::Minitest::CompositeReporter, Record)
137
+ OuterReporter.prepend_once(::Minitest.singleton_class, outer)
138
+ class_hook = outer.equal?(OuterReporter::Hook6) ? MarkSkippedClass6 : MarkSkippedClass5
139
+ runnables.each do |klass|
140
+ OuterReporter.prepend_once(klass, MarkSkippedTest)
141
+ OuterReporter.prepend_once(klass.singleton_class, class_hook)
142
+ end
143
+ OuterReporter.register(self)
144
+ self.armed_pid = Process.pid
145
+ self.armed_reporter = nil
146
+ self.channel = channel
147
+ self.parallel_marked = false
148
+ # A copy: Minitest's list is live, and a class a test defines during
149
+ # the run joins it without running, which would break the count.
150
+ self.runnables = runnables.dup
151
+ self.seen = 0
152
+ self.serial_killed = false
153
+ KillChannel.write_start(channel)
154
+ true
155
+ end
156
+
157
+ # Writes the channel's `end` line once the suite has returned and the
158
+ # outer reporter has seen every test the loaded classes run. A return
159
+ # alone proves nothing: Minitest catches an Interrupt in a test and
160
+ # returns after the tests so far. Does nothing unless the recorder is
161
+ # armed in this process.
162
+ #
163
+ # @return [void]
164
+ def finish!
165
+ return unless channel && armed_here?
166
+
167
+ KillChannel.write_end(channel) if seen == planned_tests
168
+ end
169
+
170
+ # True when the test class runs its tests in parallel threads
171
+ # (`parallelize_me!`). Minitest runs these after every serial class, and
172
+ # the stop at the first failure cannot skip the tests it already queued.
173
+ # Minitest 6 names the order `run_order`; Minitest 5 names it `test_order`.
174
+ #
175
+ # @param klass [Class] a loaded test class.
176
+ # @return [Boolean]
177
+ def parallel?(klass)
178
+ %i[run_order test_order].any? { |order| klass.respond_to?(order) && klass.public_send(order) == :parallel }
179
+ end
180
+
181
+ # Clears the armed state.
182
+ #
183
+ # @return [void]
184
+ def disarm!
185
+ self.armed_pid = nil
186
+ self.armed_reporter = nil
187
+ self.channel = nil
188
+ self.runnables = nil
189
+ self.serial_killed = false
190
+ end
191
+
192
+ # Writes the `parallel` line when the first parallel class starts on the
193
+ # outer reporter. The class object is known here, so an anonymous class
194
+ # (which Minitest records with no name) counts too. Minitest runs every
195
+ # serial class first, so a kill before this line came from a serial
196
+ # test. A test that runs a class on its own reporter is not the run's
197
+ # parallel phase, so it marks nothing.
198
+ #
199
+ # @param klass [Class] the test class starting its run.
200
+ # @param reporter [Minitest::CompositeReporter] the reporter it runs on.
201
+ # @return [void]
202
+ def class_starting(klass, reporter)
203
+ return unless channel && outer_reporter?(reporter) && parallel?(klass)
204
+
205
+ LOCK.synchronize { mark_parallel }
206
+ end
207
+
208
+ # Runs the block between `skip` and `unskip` when a serial test has
209
+ # killed in this armed process, else just runs it. No `ensure`: a block
210
+ # that never returns (an exit, a crash) must leave its region open, so
211
+ # the parent knows the child ended where a run without `--matrix`
212
+ # would not have been.
213
+ #
214
+ # @yieldreturn [Object]
215
+ # @return [Object] the block's value.
216
+ def skipping
217
+ open = channel && serial_killed && armed_here?
218
+ KillChannel.write_skip(channel) if open
219
+ value = yield
220
+ KillChannel.write_unskip(channel) if open
221
+ value
222
+ end
223
+
224
+ # Sends one result recorded on `reporter`: a pass, or a kill for a
225
+ # failure or an error. Skips send no test line, though they count as
226
+ # seen, and results on other reporters count for nothing. The
227
+ # `parallel` line goes out when the first parallel class starts (see
228
+ # {.class_starting}), so a kill that precedes it came from a serial test.
229
+ #
230
+ # @param reporter [Minitest::CompositeReporter] the recording reporter.
231
+ # @param result [Minitest::Result] the result of one test.
232
+ # @return [void]
233
+ def record(reporter, result)
234
+ return unless channel && outer_reporter?(reporter)
235
+
236
+ LOCK.synchronize do
237
+ self.seen += 1
238
+ send_result(result)
239
+ end
240
+ end
241
+
242
+ private
243
+
244
+ # Writes one result's line. A recorder that raised would change the
245
+ # verdict, so a result it cannot describe (a Minitest without
246
+ # `source_location` or `klass` on its results) is a `lost` line, which
247
+ # keeps the row incomplete, as on the RSpec side.
248
+ #
249
+ # @param result [Minitest::Result] the result of one test.
250
+ # @return [void]
251
+ def send_result(result)
252
+ return if result.skipped?
253
+
254
+ event = result.passed? ? KillChannel::PASS : KillChannel::KILL
255
+ self.serial_killed = true if event == KillChannel::KILL && !parallel_marked
256
+ file, line = Array(result.source_location)
257
+ KillChannel.write(channel, event, file, *name_and_id(result, line))
258
+ rescue StandardError
259
+ KillChannel.write_lost(channel)
260
+ end
261
+
262
+ # The test's name and id. `Class#method` is unique within a run, so the
263
+ # name is also the id. An anonymous class has no name (Minitest records
264
+ # nil), so its tests would all merge into `#method`; their id adds the
265
+ # line that defines the method, which no mutant changes. The test's file
266
+ # is already part of its identity, so the id holds no path.
267
+ #
268
+ # @param result [Minitest::Result] the result of one test.
269
+ # @param line [Integer, nil] the line that defines the test method.
270
+ # @return [Array(String, String)] name and id.
271
+ def name_and_id(result, line)
272
+ return ["#{result.klass}##{result.name}"] * 2 unless result.klass.to_s.empty?
273
+
274
+ name = "(anonymous)##{result.name}"
275
+ [name, "#{name}@#{line}"]
276
+ end
277
+
278
+ # Writes the `parallel` line, once.
279
+ #
280
+ # @return [void]
281
+ def mark_parallel
282
+ return if parallel_marked
283
+
284
+ self.parallel_marked = true
285
+ KillChannel.write_parallel(channel)
286
+ end
287
+
288
+ # How many tests the loaded classes ran, or nil when it cannot be told.
289
+ # Asked after the run, because `runnable_methods` shuffles with the
290
+ # seed that `Minitest.run` sets.
291
+ #
292
+ # @return [Integer, nil]
293
+ def planned_tests
294
+ runnables.sum { |klass| klass.runnable_methods.size }
295
+ rescue StandardError
296
+ nil
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end