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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +250 -0
- data/README.md +198 -27
- data/lib/mutineer/changed_lines.rb +24 -1
- data/lib/mutineer/cli.rb +112 -16
- data/lib/mutineer/config.rb +64 -13
- data/lib/mutineer/coverage_map.rb +161 -14
- data/lib/mutineer/daemon_backend.rb +3 -0
- data/lib/mutineer/daemon_client.rb +170 -13
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/isolation.rb +91 -6
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
- data/lib/mutineer/minitest_integration.rb +27 -10
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +0 -7
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +0 -7
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +7 -0
- data/lib/mutineer/rails_worker_db.rb +1 -1
- data/lib/mutineer/reporter.rb +288 -52
- data/lib/mutineer/result.rb +37 -1
- data/lib/mutineer/runner.rb +133 -16
- data/lib/mutineer/statement_lines.rb +143 -0
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +243 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +1 -0
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
12
|
-
#
|
|
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
|
#
|
data/lib/mutineer/version.rb
CHANGED
data/lib/mutineer/worker_pool.rb
CHANGED
|
@@ -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
|
+
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.
|
|
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
|