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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "stringio"
4
+ require_relative "../kill_channel"
4
5
 
5
6
  module Mutineer
6
7
  # Raised when the target project asks for a framework whose gem isn't present.
@@ -18,12 +19,26 @@ module Mutineer
18
19
  # @param spec_files [String, Array<String>] one file or many files.
19
20
  # @param stop_at_first_failure [Boolean] when true, the run ends at the
20
21
  # first failing example (RSpec `--fail-fast`).
22
+ # @param record_to [IO, nil] a `--matrix` run: send each example's outcome
23
+ # to this channel (see {KillFormatter}) and run every example.
21
24
  # @return [Integer] 0 on success, 1 on failure.
22
- def self.run(spec_files, stop_at_first_failure: false)
25
+ # @raise [ArgumentError] when both options are given: a matrix run never stops.
26
+ def self.run(spec_files, stop_at_first_failure: false, record_to: nil)
27
+ if stop_at_first_failure && record_to
28
+ raise ArgumentError, "stop_at_first_failure and record_to cannot be combined: a matrix run runs every example"
29
+ end
30
+
23
31
  require_rspec!
24
32
 
25
33
  ::RSpec::Core::Runner.disable_autorun!
26
- ::RSpec.reset
34
+ # Not RSpec.reset: it also drops the settings that gems added when Bundler
35
+ # required them, such as rspec-retry's verbose_retry.
36
+ ::RSpec.clear_examples
37
+ # Added before the run reads its options, which leaves the reporter
38
+ # unbuilt, so the run's output stream still applies. With a formatter
39
+ # present, RSpec adds no default one; its output went to the sink anyway.
40
+ KillFormatter.last = nil
41
+ ::RSpec.configuration.add_formatter(KillFormatter.registered, record_to) if record_to
27
42
 
28
43
  # The sink takes RSpec's own formatter output. Spec output is not
29
44
  # silenced here: the fork boundary that calls this method has already
@@ -31,11 +46,236 @@ module Mutineer
31
46
  sink = StringIO.new
32
47
  args = ["--no-color"]
33
48
  args << "--fail-fast" if stop_at_first_failure
34
- status = ::RSpec::Core::Runner.run([*args, *Array(spec_files)], sink, sink)
49
+ status = with_fail_fast_off(record_to) do
50
+ ::RSpec::Core::Runner.run([*args, *Array(spec_files)], sink, sink)
51
+ end
52
+ # Reached only when the suite returned: an example that exits the
53
+ # process skips it, and the parent then keeps the row incomplete. A
54
+ # return alone proves nothing: fail-fast turned on mid-run stops RSpec
55
+ # early, so `end` needs a reported outcome for every planned example.
56
+ KillChannel.write_end(record_to) if record_to && KillFormatter.last&.saw_every_example?
35
57
 
36
58
  status.zero? ? 0 : 1
37
59
  end
38
60
 
61
+ # Runs the block with fail-fast forced off when `record_to` is set: a
62
+ # matrix run must run every example. RSpec reads options from .rspec, then
63
+ # the command line, then `SPEC_OPTS`, and the last one wins, so the option
64
+ # goes at the end of `SPEC_OPTS` (restored afterwards). A forced option
65
+ # also beats `config.fail_fast = true` in a spec helper.
66
+ #
67
+ # @api private
68
+ # @param record_to [IO, nil] the channel of a matrix run, or nil.
69
+ # @yieldreturn [Integer] RSpec's exit status.
70
+ # @return [Integer] the block's value.
71
+ def self.with_fail_fast_off(record_to)
72
+ return yield unless record_to
73
+
74
+ saved = ENV.fetch("SPEC_OPTS", nil)
75
+ ENV["SPEC_OPTS"] = [saved, "--no-fail-fast"].compact.join(" ")
76
+ begin
77
+ yield
78
+ ensure
79
+ ENV["SPEC_OPTS"] = saved
80
+ end
81
+ end
82
+
83
+ # Sends each example's outcome to the channel of a `--matrix` run. A
84
+ # pending or skipped example sends nothing. A test is its spec file, its
85
+ # full description (the name reports show) and its example id, which
86
+ # tells apart examples that share a description and stays the same when a
87
+ # mutant changes a generated description. Example ids need RSpec 3.3 or
88
+ # later; before that no id tells apart examples on one line, so an older
89
+ # RSpec sends no `start` and every row stays incomplete.
90
+ #
91
+ # It writes only from the process that created it, so an example that runs
92
+ # an RSpec suite in a forked process cannot add lines. It writes `start`
93
+ # when the run begins with fail-fast off; a run that could stop early sends
94
+ # no `start`, and its rows stay incomplete. It also registers an
95
+ # `after(:suite)` hook that writes `cleanup` ahead of the suite's own hooks.
96
+ #
97
+ # After the first failure, a run without `--matrix` (`--fail-fast`) skips
98
+ # each later example and each example group that starts later, hooks
99
+ # included, but it still runs the `after(:all)` hooks of the groups it is
100
+ # in. So once an example has failed, the formatter writes `skip` when a
101
+ # later example or group starts and `unskip` when it finishes (see
102
+ # {KillChannel}).
103
+ class KillFormatter
104
+ class << self
105
+ # The formatter of the latest run in this process.
106
+ #
107
+ # @return [KillFormatter, nil]
108
+ attr_accessor :last
109
+ end
110
+
111
+ # Registers the formatter with RSpec for the notifications it handles.
112
+ # Called at run time, because rspec-core is not loaded with Mutineer.
113
+ #
114
+ # @return [Class] this class.
115
+ def self.registered
116
+ ::RSpec::Core::Formatters.register(self, :start, :example_started, :example_passed, :example_failed,
117
+ :example_pending, :example_group_started, :example_group_finished)
118
+ self
119
+ end
120
+
121
+ # @param channel [IO] the write end of the channel.
122
+ def initialize(channel)
123
+ @channel = channel
124
+ @pid = Process.pid
125
+ @failed = false
126
+ @example_skipping = false
127
+ @groups_skipping = []
128
+ @planned = nil
129
+ @reported = 0
130
+ self.class.last = self
131
+ end
132
+
133
+ # True when RSpec reported an outcome for every example it planned to run.
134
+ #
135
+ # @return [Boolean]
136
+ def saw_every_example?
137
+ !@planned.nil? && @reported == @planned
138
+ end
139
+
140
+ # Sends `start`, unless fail-fast is on or RSpec has no example ids, and
141
+ # registers the hook that sends `cleanup`. RSpec runs `after(:suite)`
142
+ # hooks last-defined first, and this runs once the spec files have
143
+ # loaded, so the hook goes before every hook the suite defines.
144
+ #
145
+ # @param notification [RSpec::Core::Notifications::StartNotification]
146
+ # @return [void]
147
+ def start(notification)
148
+ return unless owner?
149
+ return if ::RSpec.configuration.fail_fast
150
+ return unless ::RSpec::Core::Example.method_defined?(:id)
151
+
152
+ @planned = notification.count
153
+ KillChannel.write_start(@channel)
154
+ formatter = self
155
+ ::RSpec.configuration.after(:suite) { formatter.cleanup_begins }
156
+ end
157
+
158
+ # Sends `cleanup`: every example has run, and the suite's own hooks follow.
159
+ #
160
+ # @return [void]
161
+ def cleanup_begins
162
+ KillChannel.write_cleanup(@channel) if owner?
163
+ end
164
+
165
+ # Opens a `skip` region for an example that starts after a failure.
166
+ #
167
+ # @param _notification [RSpec::Core::Notifications::ExampleNotification]
168
+ # @return [void]
169
+ def example_started(_notification)
170
+ return unless owner? && @failed
171
+
172
+ @example_skipping = true
173
+ KillChannel.write_skip(@channel)
174
+ end
175
+
176
+ # Sends a pass.
177
+ #
178
+ # @param notification [RSpec::Core::Notifications::ExampleNotification]
179
+ # @return [void]
180
+ def example_passed(notification)
181
+ send_event(KillChannel::PASS, notification.example)
182
+ example_finished
183
+ end
184
+
185
+ # Sends a kill.
186
+ #
187
+ # @param notification [RSpec::Core::Notifications::FailedExampleNotification]
188
+ # @return [void]
189
+ def example_failed(notification)
190
+ send_event(KillChannel::KILL, notification.example)
191
+ example_finished
192
+ @failed = true
193
+ end
194
+
195
+ # A pending or skipped example sends no test line, but it finishes.
196
+ #
197
+ # @param _notification [RSpec::Core::Notifications::ExampleNotification]
198
+ # @return [void]
199
+ def example_pending(_notification)
200
+ example_finished
201
+ end
202
+
203
+ # Opens a `skip` region for a group that starts after a failure.
204
+ #
205
+ # @param _notification [RSpec::Core::Notifications::GroupNotification]
206
+ # @return [void]
207
+ def example_group_started(_notification)
208
+ return unless owner?
209
+
210
+ # The exception in flight now: RSpec can start and finish a group
211
+ # inside its own `rescue` (a failed `before(:all)` with nested
212
+ # groups), where `$!` is already set.
213
+ @groups_skipping.push([@failed, $!])
214
+ KillChannel.write_skip(@channel) if @failed
215
+ end
216
+
217
+ # Closes the group's `skip` region, if it opened one. RSpec sends this
218
+ # after the group's `after(:all)` hooks, from an `ensure`, so it also
219
+ # comes while an exit or a crash unwinds the group. Then `$!` holds an
220
+ # exception that was not in flight when the group started, and the
221
+ # region stays open: the run ended inside it.
222
+ #
223
+ # @param _notification [RSpec::Core::Notifications::GroupNotification]
224
+ # @return [void]
225
+ def example_group_finished(_notification)
226
+ return unless owner?
227
+
228
+ opened, in_flight = @groups_skipping.pop
229
+ KillChannel.write_unskip(@channel) if opened && $!.equal?(in_flight)
230
+ end
231
+
232
+ private
233
+
234
+ # True in the process that created the formatter.
235
+ #
236
+ # @return [Boolean]
237
+ def owner?
238
+ Process.pid == @pid
239
+ end
240
+
241
+ # Counts an example's outcome and closes its `skip` region, if any.
242
+ #
243
+ # @return [void]
244
+ def example_finished
245
+ return unless owner?
246
+
247
+ @reported += 1
248
+ return unless @example_skipping
249
+
250
+ @example_skipping = false
251
+ KillChannel.write_unskip(@channel)
252
+ end
253
+
254
+ # Writes one test line for `example`.
255
+ #
256
+ # @param event [String] {KillChannel::PASS} or {KillChannel::KILL}.
257
+ # @param example [RSpec::Core::Example] the example.
258
+ # @return [void]
259
+ def send_event(event, example)
260
+ return unless owner?
261
+
262
+ file = example.metadata[:absolute_file_path] || File.expand_path(example.file_path)
263
+ KillChannel.write(@channel, event, file, example.full_description, identify(example))
264
+ rescue StandardError
265
+ # A recorder that raised would change the verdict; the lost line keeps the row incomplete.
266
+ KillChannel.write_lost(@channel)
267
+ end
268
+
269
+ # What tells `example` apart from the others, and stays the same across
270
+ # mutants: its id (RSpec 3.3 and later; see {#start}).
271
+ #
272
+ # @param example [RSpec::Core::Example] the example.
273
+ # @return [String]
274
+ def identify(example)
275
+ example.id
276
+ end
277
+ end
278
+
39
279
  # Requires rspec-core from the project under test.
40
280
  #
41
281
  # @api private
@@ -6,10 +6,12 @@ require_relative "test_runners/rspec"
6
6
  module Mutineer
7
7
  # Picks the test-framework runner.
8
8
  #
9
- # Each runner responds to `.run(files, stop_at_first_failure: false) -> 0/1`
9
+ # Each runner responds to
10
+ # `.run(files, stop_at_first_failure: false, record_to: nil) -> 0/1`
10
11
  # (0 = all passed, 1 = any failure) and is called only inside a forked child.
11
- # Only the mutant path passes `stop_at_first_failure: true`. The fork boundary
12
- # silences stdout (see ChildStdout); the runners do not.
12
+ # Only the mutant path passes `stop_at_first_failure: true`; a `--matrix`
13
+ # mutant run passes `record_to:` instead (a {KillChannel} pipe) and runs every
14
+ # test. The fork boundary silences stdout (see ChildStdout); the runners do not.
13
15
  module TestRunners
14
16
  # Returns the runner module for a framework name.
15
17
  #
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "1.4.0"
5
+ VERSION = "1.5.0"
6
6
  end
@@ -131,13 +131,15 @@ module Mutineer
131
131
  end
132
132
 
133
133
  # A partial/garbage Marshal stream (dead worker) must not crash the pool.
134
- # Degrade to an error Result.
134
+ # Degrade to an error Result. The payload loads frozen, which also shares
135
+ # equal strings across payloads (the test names of a --matrix run repeat
136
+ # in every row).
135
137
  # @param data [String] marshaled payload.
136
138
  # @return [Mutineer::Result] decoded result or error result.
137
139
  def decode(data)
138
140
  return Result.error("worker produced no result") if data.empty?
139
141
 
140
- Marshal.load(data)
142
+ Marshal.load(data, freeze: true)
141
143
  rescue StandardError => e
142
144
  Result.error("worker result unreadable: #{e.class}: #{e.message}")
143
145
  end
data/lib/mutineer.rb CHANGED
@@ -37,6 +37,7 @@ require_relative "mutineer/mutator_registry"
37
37
  require_relative "mutineer/worker_pool"
38
38
  require_relative "mutineer/progress"
39
39
  require_relative "mutineer/runner"
40
+ require_relative "mutineer/kill_matrix"
40
41
  require_relative "mutineer/reporter"
41
42
  require_relative "mutineer/baseline"
42
43
  require_relative "mutineer/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutineer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -38,8 +38,8 @@ dependencies:
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.9'
40
40
  description: Mutineer mutates your source one change at a time and runs your Minitest
41
- suite against each mutant to find tests that don't actually test anything. Prism-based,
42
- fork-isolated, zero runtime dependencies.
41
+ or RSpec suite against each mutant to find tests that don't actually test anything.
42
+ Prism-based, fork-isolated, zero runtime dependencies.
43
43
  email:
44
44
  - dteren@gmail.com
45
45
  executables:
@@ -64,7 +64,11 @@ files:
64
64
  - lib/mutineer/external_backend.rb
65
65
  - lib/mutineer/file_swap.rb
66
66
  - lib/mutineer/isolation.rb
67
+ - lib/mutineer/kill_channel.rb
68
+ - lib/mutineer/kill_matrix.rb
67
69
  - lib/mutineer/minitest_integration.rb
70
+ - lib/mutineer/minitest_integration/kill_recorder.rb
71
+ - lib/mutineer/minitest_integration/outer_reporter.rb
68
72
  - lib/mutineer/minitest_integration/stop_at_first_failure.rb
69
73
  - lib/mutineer/mutant_id.rb
70
74
  - lib/mutineer/mutation.rb
@@ -98,6 +102,7 @@ files:
98
102
  - lib/mutineer/reporter.rb
99
103
  - lib/mutineer/result.rb
100
104
  - lib/mutineer/runner.rb
105
+ - lib/mutineer/statement_lines.rb
101
106
  - lib/mutineer/subject.rb
102
107
  - lib/mutineer/test_runners.rb
103
108
  - lib/mutineer/test_runners/minitest.rb