mutineer-mongoid 2.0.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 +7 -0
- data/CHANGELOG.md +1194 -0
- data/LICENSE +21 -0
- data/README.md +659 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/baseline.rb +185 -0
- data/lib/mutineer/changed_lines.rb +136 -0
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/cli.rb +681 -0
- data/lib/mutineer/config.rb +372 -0
- data/lib/mutineer/coverage_map.rb +1366 -0
- data/lib/mutineer/daemon_backend.rb +351 -0
- data/lib/mutineer/daemon_client.rb +407 -0
- data/lib/mutineer/daemon_server.rb +351 -0
- data/lib/mutineer/external_backend.rb +288 -0
- data/lib/mutineer/file_swap.rb +194 -0
- data/lib/mutineer/isolation.rb +321 -0
- data/lib/mutineer/job_plan.rb +352 -0
- 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 +145 -0
- data/lib/mutineer/minitest_integration.rb +137 -0
- data/lib/mutineer/mongoid_failures.rb +82 -0
- data/lib/mutineer/mongoid_worker_db.rb +97 -0
- data/lib/mutineer/mutant_id.rb +126 -0
- data/lib/mutineer/mutation.rb +27 -0
- data/lib/mutineer/mutator_registry.rb +109 -0
- data/lib/mutineer/mutators/arithmetic.rb +35 -0
- data/lib/mutineer/mutators/array_literal.rb +44 -0
- data/lib/mutineer/mutators/base.rb +105 -0
- data/lib/mutineer/mutators/boolean_connector.rb +52 -0
- data/lib/mutineer/mutators/boolean_literal.rb +56 -0
- data/lib/mutineer/mutators/chain_link.rb +132 -0
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/comparison.rb +35 -0
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/condition_negation.rb +49 -0
- data/lib/mutineer/mutators/literal_mutation.rb +51 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +58 -0
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +72 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/mutators/statement_removal.rb +33 -0
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +298 -0
- data/lib/mutineer/parser.rb +45 -0
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/project.rb +506 -0
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/rails_worker_db.rb +212 -0
- data/lib/mutineer/reporter.rb +926 -0
- data/lib/mutineer/result.rb +240 -0
- data/lib/mutineer/runner.rb +418 -0
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +38 -0
- data/lib/mutineer/test_runners/minitest.rb +22 -0
- data/lib/mutineer/test_runners/rspec.rb +330 -0
- data/lib/mutineer/test_runners.rb +30 -0
- data/lib/mutineer/version.rb +6 -0
- data/lib/mutineer/worker_pool.rb +147 -0
- data/lib/mutineer.rb +48 -0
- metadata +143 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# The lines of the statement that holds a position in a method body.
|
|
5
|
+
#
|
|
6
|
+
# Ruby counts one line of a statement only. A position on another line of a
|
|
7
|
+
# multi-line statement has no count of its own, but the tests that ran the
|
|
8
|
+
# statement ran it. Prism marks the statements (`Node#newline?`), so the
|
|
9
|
+
# nearest marked ancestor of the position is the statement. The caller reads
|
|
10
|
+
# the counts on the lines of the statement; a line Ruby does not count has none.
|
|
11
|
+
module StatementLines
|
|
12
|
+
# @param def_node [Prism::DefNode] the method that holds the position.
|
|
13
|
+
# @param source [String] the source text of the file.
|
|
14
|
+
# @param offset [Integer] byte offset of the position.
|
|
15
|
+
# @return [Array<Integer>] the lines of the statement, with the body of each
|
|
16
|
+
# heredoc in it. Empty when no statement in the body holds the position.
|
|
17
|
+
# A first or last line that holds other code is left out, because Ruby
|
|
18
|
+
# can count that line without the statement: the `def` line counts when
|
|
19
|
+
# the file loads, and `x if done; y(` counts when `y` does not run.
|
|
20
|
+
def self.for(def_node, source, offset)
|
|
21
|
+
return [] unless def_node.body
|
|
22
|
+
|
|
23
|
+
path = path_to(def_node.body, offset) || []
|
|
24
|
+
index = path.rindex(&:newline?)
|
|
25
|
+
return [] unless index
|
|
26
|
+
|
|
27
|
+
statement = path[index]
|
|
28
|
+
# Code that runs only sometimes inside the statement: the tests that ran
|
|
29
|
+
# the statement need not have run it.
|
|
30
|
+
return [] if path[index..].each_cons(2).any? { |parent, child| sometimes?(parent, child) }
|
|
31
|
+
|
|
32
|
+
# Ruby counts the line of `x while c` or `x if c` when it checks `c`,
|
|
33
|
+
# also when `x` never runs. The path is the modifier, its body, statement.
|
|
34
|
+
return [] if index >= 2 && modifier?(path[index - 2])
|
|
35
|
+
|
|
36
|
+
location = statement.location
|
|
37
|
+
lines = (location.start_line..last_line(statement)).to_a
|
|
38
|
+
lines.delete(location.start_line) unless before(source, location).strip.empty?
|
|
39
|
+
after = after(source, location).strip
|
|
40
|
+
lines.delete(location.end_line) unless after.empty? || after.start_with?("#")
|
|
41
|
+
lines
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Whether the code at `offset` runs each time the method runs: it is in
|
|
45
|
+
# the first statement of the body, not in a statement nested in it or in
|
|
46
|
+
# a {NESTED} node (a block or loop body), and not in code that runs only
|
|
47
|
+
# sometimes ({sometimes?}, the body of `x if c`). The first statement in
|
|
48
|
+
# parentheses or `begin ... end` runs with them, so it counts
|
|
49
|
+
# ({grouped_first?}). Used for a method whose body shares the `def` line,
|
|
50
|
+
# where only the method's call count tells that the code ran (#209).
|
|
51
|
+
#
|
|
52
|
+
# @param def_node [Prism::DefNode] the method that holds the position.
|
|
53
|
+
# @param offset [Integer] byte offset of the position.
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def self.runs_with_method?(def_node, offset)
|
|
56
|
+
first = def_node.body.is_a?(Prism::StatementsNode) && def_node.body.body.first
|
|
57
|
+
path = first && path_to(first, offset)
|
|
58
|
+
return false unless path
|
|
59
|
+
|
|
60
|
+
return false if path.drop(1).any? { |node| NESTED.any? { |klass| node.is_a?(klass) } }
|
|
61
|
+
return false if path[1]&.newline?
|
|
62
|
+
|
|
63
|
+
path.each_cons(3).none? { |grand, parent, node| node.newline? && !grouped_first?(grand, parent, node) } &&
|
|
64
|
+
path.each_cons(2).none? { |parent, child| sometimes?(parent, child) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Whether `node` is the first statement in parentheses or `begin ... end`,
|
|
68
|
+
# which runs each time they do.
|
|
69
|
+
#
|
|
70
|
+
# @param grand [Prism::Node] the parent of `parent`.
|
|
71
|
+
# @param parent [Prism::Node] the parent of `node`.
|
|
72
|
+
# @param node [Prism::Node]
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def self.grouped_first?(grand, parent, node)
|
|
75
|
+
(grand.is_a?(Prism::ParenthesesNode) || grand.is_a?(Prism::BeginNode)) &&
|
|
76
|
+
parent.is_a?(Prism::StatementsNode) && parent.body.first.equal?(node)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Nodes whose code can run any number of times, or not at all, when the
|
|
80
|
+
# statement that holds them runs.
|
|
81
|
+
NESTED = [Prism::BlockNode, Prism::LambdaNode, Prism::WhileNode, Prism::UntilNode, Prism::ForNode,
|
|
82
|
+
Prism::DefNode].freeze
|
|
83
|
+
|
|
84
|
+
# Nodes whose code runs only when a branch, a match or an exception picks
|
|
85
|
+
# it: a `when` or `in` clause (its condition or pattern), a `rescue` clause
|
|
86
|
+
# (its class list), an `else` branch, parameters (their defaults) and
|
|
87
|
+
# `defined?` (its operand never runs). A statement in their bodies is a statement of its own, found
|
|
88
|
+
# deeper on the path.
|
|
89
|
+
SOMETIMES = [Prism::WhenNode, Prism::InNode, Prism::RescueNode, Prism::ElseNode, Prism::ParametersNode,
|
|
90
|
+
Prism::BlockParametersNode, Prism::DefinedNode].freeze
|
|
91
|
+
|
|
92
|
+
# Whether `child` runs only sometimes when `parent` runs. Besides the
|
|
93
|
+
# {SOMETIMES} nodes: a branch of an `if`/`unless` inside the statement (a
|
|
94
|
+
# ternary, or `if c then a else b end` on the statement's lines), the
|
|
95
|
+
# rescue side of `x rescue y`, the pattern of `v in p` or `v => p`, the value of
|
|
96
|
+
# `a ||= v` or `a &&= v`, the right side of `a || b` or `a && b`, and the
|
|
97
|
+
# arguments and block of `x&.m(...)` and the value of `x&.m += v`, which do
|
|
98
|
+
# not run when `x` is nil.
|
|
99
|
+
#
|
|
100
|
+
# @param parent [Prism::Node]
|
|
101
|
+
# @param child [Prism::Node]
|
|
102
|
+
# @return [Boolean]
|
|
103
|
+
def self.sometimes?(parent, child)
|
|
104
|
+
return true if SOMETIMES.any? { |klass| child.is_a?(klass) }
|
|
105
|
+
return !child.equal?(parent.predicate) if parent.is_a?(Prism::IfNode) || parent.is_a?(Prism::UnlessNode)
|
|
106
|
+
# A pattern stops at its first part that does not match (`v in 1 | 2`).
|
|
107
|
+
if parent.is_a?(Prism::MatchPredicateNode) || parent.is_a?(Prism::MatchRequiredNode)
|
|
108
|
+
return child.equal?(parent.pattern)
|
|
109
|
+
end
|
|
110
|
+
return child.equal?(parent.rescue_expression) if parent.is_a?(Prism::RescueModifierNode)
|
|
111
|
+
return child.equal?(parent.right) if parent.is_a?(Prism::OrNode) || parent.is_a?(Prism::AndNode)
|
|
112
|
+
if parent.is_a?(Prism::CallNode) && parent.safe_navigation?
|
|
113
|
+
return child.equal?(parent.arguments) || child.equal?(parent.block)
|
|
114
|
+
end
|
|
115
|
+
# `a&.b += v` skips `v` when `a` is nil, like `a&.b ||= v`.
|
|
116
|
+
return true if parent.respond_to?(:safe_navigation?) && parent.safe_navigation? &&
|
|
117
|
+
parent.respond_to?(:value) && child.equal?(parent.value)
|
|
118
|
+
|
|
119
|
+
parent.class.name.end_with?("OrWriteNode", "AndWriteNode") && child.equal?(parent.value)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The nodes from `node` down to the deepest one that holds the offset. A
|
|
123
|
+
# heredoc body lies outside the node of its opener, so a child is searched
|
|
124
|
+
# even when its parent does not hold the offset.
|
|
125
|
+
#
|
|
126
|
+
# @param node [Prism::Node]
|
|
127
|
+
# @param offset [Integer] byte offset.
|
|
128
|
+
# @return [Array<Prism::Node>, nil] nil when no node in the subtree holds it.
|
|
129
|
+
def self.path_to(node, offset)
|
|
130
|
+
node.compact_child_nodes.each do |child|
|
|
131
|
+
path = path_to(child, offset)
|
|
132
|
+
return [node, *path] if path
|
|
133
|
+
end
|
|
134
|
+
location = node.location
|
|
135
|
+
[node] if location.start_offset <= offset && offset < location.end_offset
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The last line of `node` and its children. A heredoc body ends below the
|
|
139
|
+
# node of its opener.
|
|
140
|
+
#
|
|
141
|
+
# @param node [Prism::Node]
|
|
142
|
+
# @return [Integer]
|
|
143
|
+
def self.last_line(node)
|
|
144
|
+
[node.location.end_line, *node.compact_child_nodes.map { |child| last_line(child) }].max
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Whether `node` is a modifier whose body comes before its keyword, such
|
|
148
|
+
# as `x while c` or `x if c`. The body of `begin ... end while c` runs once
|
|
149
|
+
# before the check, so it is not.
|
|
150
|
+
#
|
|
151
|
+
# @param node [Prism::Node, nil] the parent of the body that holds the statement.
|
|
152
|
+
# @return [Boolean]
|
|
153
|
+
def self.modifier?(node)
|
|
154
|
+
keyword =
|
|
155
|
+
case node
|
|
156
|
+
when Prism::WhileNode, Prism::UntilNode then node.keyword_loc unless node.begin_modifier?
|
|
157
|
+
when Prism::IfNode then node.if_keyword_loc
|
|
158
|
+
when Prism::UnlessNode then node.keyword_loc
|
|
159
|
+
end
|
|
160
|
+
return false if keyword.nil? || node.statements.nil?
|
|
161
|
+
|
|
162
|
+
node.statements.location.start_offset < keyword.start_offset
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The text before `location` on its first line.
|
|
166
|
+
#
|
|
167
|
+
# @param source [String]
|
|
168
|
+
# @param location [Prism::Location]
|
|
169
|
+
# @return [String]
|
|
170
|
+
def self.before(source, location)
|
|
171
|
+
source.byteslice(location.start_offset - location.start_column, location.start_column)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The text after `location` on its last line.
|
|
175
|
+
#
|
|
176
|
+
# @param source [String]
|
|
177
|
+
# @param location [Prism::Location]
|
|
178
|
+
# @return [String]
|
|
179
|
+
def self.after(source, location)
|
|
180
|
+
source.byteslice(location.end_offset, source.bytesize).each_line.first.to_s
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# One discoverable method and its AST node.
|
|
5
|
+
#
|
|
6
|
+
# Location, namespace context, and the live Prism::DefNode are kept together
|
|
7
|
+
# because mutators walk the def node directly. `namespace` names the owner
|
|
8
|
+
# (`module ::X` inside `Outer` owns into `X`); `lexical` is the class/module
|
|
9
|
+
# chain as written (`["Outer", "::X"]`), which the redefine strategy needs to
|
|
10
|
+
# rebuild the same Module.nesting as a whole-file reload (#145). `block_owner`
|
|
11
|
+
# is the constant a `Data.define`-style block is assigned to, as written;
|
|
12
|
+
# `owner_unknown` is true when the class that block builds cannot be named,
|
|
13
|
+
# so the redefine strategy cannot load a mutant onto it.
|
|
14
|
+
Subject = Struct.new(:file, :namespace, :name, :singleton, :def_node, :lexical, :block_owner, :owner_unknown,
|
|
15
|
+
keyword_init: true) do
|
|
16
|
+
# Returns the fully-qualified subject name.
|
|
17
|
+
#
|
|
18
|
+
# @return [String] namespaced method name like `Billing::Invoice#total`.
|
|
19
|
+
def qualified_name
|
|
20
|
+
namespace.join("::") + (singleton ? "." : "#") + name.to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Class/module chain as written in the source, for textual wrappers. Falls
|
|
24
|
+
# back to `namespace` for subjects built without one.
|
|
25
|
+
#
|
|
26
|
+
# @return [Array<String>] names; a root-anchored element keeps its `::`.
|
|
27
|
+
def lexical_namespace
|
|
28
|
+
lexical || namespace
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the body location for the subject, if any.
|
|
32
|
+
#
|
|
33
|
+
# @return [Prism::Location, nil] body location or nil for empty methods.
|
|
34
|
+
def body_loc
|
|
35
|
+
def_node.body&.location
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../minitest_integration"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module TestRunners
|
|
7
|
+
# Thin wrapper around the shared Minitest integration runner.
|
|
8
|
+
module Minitest
|
|
9
|
+
# Runs the given Minitest files.
|
|
10
|
+
#
|
|
11
|
+
# @param test_files [String, Array<String>] one file or many files.
|
|
12
|
+
# @param stop_at_first_failure [Boolean] when true, the run ends at the
|
|
13
|
+
# first failing test.
|
|
14
|
+
# @param record_to [IO, nil] a `--matrix` run: send each test's outcome to
|
|
15
|
+
# this channel and run every test.
|
|
16
|
+
# @return [Integer] 0 on success, 1 on failure.
|
|
17
|
+
def self.run(test_files, stop_at_first_failure: false, record_to: nil)
|
|
18
|
+
MinitestIntegration.run(test_files, stop_at_first_failure: stop_at_first_failure, record_to: record_to)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../kill_channel"
|
|
5
|
+
|
|
6
|
+
module Mutineer
|
|
7
|
+
# Raised when the target project asks for a framework whose gem isn't present.
|
|
8
|
+
# rspec is NOT a Mutineer dependency — it must come from the project's bundle.
|
|
9
|
+
class FrameworkUnavailable < StandardError; end
|
|
10
|
+
|
|
11
|
+
module TestRunners
|
|
12
|
+
# Child-process-only RSpec runner.
|
|
13
|
+
#
|
|
14
|
+
# Mirrors MinitestIntegration's contract: run the given spec files and
|
|
15
|
+
# return 0 (all passed) or 1 (any failure).
|
|
16
|
+
module RSpec
|
|
17
|
+
# Runs the given RSpec files.
|
|
18
|
+
#
|
|
19
|
+
# @param spec_files [String, Array<String>] one file or many files.
|
|
20
|
+
# @param stop_at_first_failure [Boolean] when true, the run ends at the
|
|
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.
|
|
24
|
+
# @return [Integer] 0 on success, 1 on failure.
|
|
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
|
+
|
|
31
|
+
require_rspec!
|
|
32
|
+
|
|
33
|
+
::RSpec::Core::Runner.disable_autorun!
|
|
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
|
|
42
|
+
|
|
43
|
+
# The sink takes RSpec's own formatter output. Spec output is not
|
|
44
|
+
# silenced here: the fork boundary that calls this method has already
|
|
45
|
+
# pointed stdout at File::NULL (see ChildStdout).
|
|
46
|
+
sink = StringIO.new
|
|
47
|
+
keep_file_order(spec_files) if stop_at_first_failure || record_to
|
|
48
|
+
args = ["--no-color"]
|
|
49
|
+
args << "--fail-fast" if stop_at_first_failure
|
|
50
|
+
status = with_fail_fast_off(record_to) do
|
|
51
|
+
::RSpec::Core::Runner.run([*args, *Array(spec_files)], sink, sink)
|
|
52
|
+
end
|
|
53
|
+
# Reached only when the suite returned: an example that exits the
|
|
54
|
+
# process skips it, and the parent then keeps the row incomplete. A
|
|
55
|
+
# return alone proves nothing: fail-fast turned on mid-run stops RSpec
|
|
56
|
+
# early, so `end` needs a reported outcome for every planned example.
|
|
57
|
+
KillChannel.write_end(record_to) if record_to && KillFormatter.last&.saw_every_example?
|
|
58
|
+
|
|
59
|
+
status.zero? ? 0 : 1
|
|
60
|
+
ensure
|
|
61
|
+
FileOrder.rank = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Runs the top-level example groups in the order of `spec_files` (#203),
|
|
65
|
+
# after RSpec's own ordering, which then decides the order within a file.
|
|
66
|
+
#
|
|
67
|
+
# @api private
|
|
68
|
+
# @param spec_files [String, Array<String>] the files, in run order.
|
|
69
|
+
# @return [void]
|
|
70
|
+
def self.keep_file_order(spec_files)
|
|
71
|
+
FileOrder.rank = Array(spec_files).each_with_index.to_h { |f, i| [File.expand_path(f), i] }
|
|
72
|
+
::RSpec::Core::World.prepend(FileOrder) unless ::RSpec::Core::World <= FileOrder
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Prepended on `RSpec::Core::World`: a stable sort of the ordered
|
|
76
|
+
# top-level groups by their file's position. Does nothing without a rank.
|
|
77
|
+
module FileOrder
|
|
78
|
+
class << self
|
|
79
|
+
# Each spec file's position, keyed by absolute path, or nil.
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash{String => Integer}, nil]
|
|
82
|
+
attr_accessor :rank
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The groups RSpec ordered, sorted by file position.
|
|
86
|
+
#
|
|
87
|
+
# @return [Array<RSpec::Core::ExampleGroup>]
|
|
88
|
+
def ordered_example_groups
|
|
89
|
+
groups = super
|
|
90
|
+
rank = FileOrder.rank
|
|
91
|
+
return groups unless rank
|
|
92
|
+
|
|
93
|
+
groups.sort_by.with_index do |group, i|
|
|
94
|
+
file = group.metadata[:absolute_file_path] || File.expand_path(group.file_path)
|
|
95
|
+
[rank.fetch(file, rank.size), i]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Runs the block with fail-fast forced off when `record_to` is set: a
|
|
101
|
+
# matrix run must run every example. RSpec reads options from .rspec, then
|
|
102
|
+
# the command line, then `SPEC_OPTS`, and the last one wins, so the option
|
|
103
|
+
# goes at the end of `SPEC_OPTS` (restored afterwards). A forced option
|
|
104
|
+
# also beats `config.fail_fast = true` in a spec helper.
|
|
105
|
+
#
|
|
106
|
+
# @api private
|
|
107
|
+
# @param record_to [IO, nil] the channel of a matrix run, or nil.
|
|
108
|
+
# @yieldreturn [Integer] RSpec's exit status.
|
|
109
|
+
# @return [Integer] the block's value.
|
|
110
|
+
def self.with_fail_fast_off(record_to)
|
|
111
|
+
return yield unless record_to
|
|
112
|
+
|
|
113
|
+
saved = ENV.fetch("SPEC_OPTS", nil)
|
|
114
|
+
ENV["SPEC_OPTS"] = [saved, "--no-fail-fast"].compact.join(" ")
|
|
115
|
+
begin
|
|
116
|
+
yield
|
|
117
|
+
ensure
|
|
118
|
+
ENV["SPEC_OPTS"] = saved
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Sends each example's outcome to the channel of a `--matrix` run. A
|
|
123
|
+
# pending or skipped example sends nothing. A test is its spec file, its
|
|
124
|
+
# full description (the name reports show) and its example id, which
|
|
125
|
+
# tells apart examples that share a description and stays the same when a
|
|
126
|
+
# mutant changes a generated description. Example ids need RSpec 3.3 or
|
|
127
|
+
# later; before that no id tells apart examples on one line, so an older
|
|
128
|
+
# RSpec sends no `start` and every row stays incomplete.
|
|
129
|
+
#
|
|
130
|
+
# It writes only from the process that created it, so an example that runs
|
|
131
|
+
# an RSpec suite in a forked process cannot add lines. It writes `start`
|
|
132
|
+
# when the run begins with fail-fast off; a run that could stop early sends
|
|
133
|
+
# no `start`, and its rows stay incomplete. It also registers an
|
|
134
|
+
# `after(:suite)` hook that writes `cleanup` ahead of the suite's own hooks.
|
|
135
|
+
#
|
|
136
|
+
# After the first failure, a run without `--matrix` (`--fail-fast`) skips
|
|
137
|
+
# each later example and each example group that starts later, hooks
|
|
138
|
+
# included, but it still runs the `after(:all)` hooks of the groups it is
|
|
139
|
+
# in. So once an example has failed, the formatter writes `skip` when a
|
|
140
|
+
# later example or group starts and `unskip` when it finishes (see
|
|
141
|
+
# {KillChannel}).
|
|
142
|
+
class KillFormatter
|
|
143
|
+
class << self
|
|
144
|
+
# The formatter of the latest run in this process.
|
|
145
|
+
#
|
|
146
|
+
# @return [KillFormatter, nil]
|
|
147
|
+
attr_accessor :last
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Registers the formatter with RSpec for the notifications it handles.
|
|
151
|
+
# Called at run time, because rspec-core is not loaded with Mutineer.
|
|
152
|
+
#
|
|
153
|
+
# @return [Class] this class.
|
|
154
|
+
def self.registered
|
|
155
|
+
::RSpec::Core::Formatters.register(self, :start, :example_started, :example_passed, :example_failed,
|
|
156
|
+
:example_pending, :example_group_started, :example_group_finished)
|
|
157
|
+
self
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @param channel [IO] the write end of the channel.
|
|
161
|
+
def initialize(channel)
|
|
162
|
+
@channel = channel
|
|
163
|
+
@pid = Process.pid
|
|
164
|
+
@failed = false
|
|
165
|
+
@example_skipping = false
|
|
166
|
+
@groups_skipping = []
|
|
167
|
+
@planned = nil
|
|
168
|
+
@reported = 0
|
|
169
|
+
self.class.last = self
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# True when RSpec reported an outcome for every example it planned to run.
|
|
173
|
+
#
|
|
174
|
+
# @return [Boolean]
|
|
175
|
+
def saw_every_example?
|
|
176
|
+
!@planned.nil? && @reported == @planned
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Sends `start`, unless fail-fast is on or RSpec has no example ids, and
|
|
180
|
+
# registers the hook that sends `cleanup`. RSpec runs `after(:suite)`
|
|
181
|
+
# hooks last-defined first, and this runs once the spec files have
|
|
182
|
+
# loaded, so the hook goes before every hook the suite defines.
|
|
183
|
+
#
|
|
184
|
+
# @param notification [RSpec::Core::Notifications::StartNotification]
|
|
185
|
+
# @return [void]
|
|
186
|
+
def start(notification)
|
|
187
|
+
return unless owner?
|
|
188
|
+
return if ::RSpec.configuration.fail_fast
|
|
189
|
+
return unless ::RSpec::Core::Example.method_defined?(:id)
|
|
190
|
+
|
|
191
|
+
@planned = notification.count
|
|
192
|
+
KillChannel.write_start(@channel)
|
|
193
|
+
formatter = self
|
|
194
|
+
::RSpec.configuration.after(:suite) { formatter.cleanup_begins }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Sends `cleanup`: every example has run, and the suite's own hooks follow.
|
|
198
|
+
#
|
|
199
|
+
# @return [void]
|
|
200
|
+
def cleanup_begins
|
|
201
|
+
KillChannel.write_cleanup(@channel) if owner?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Opens a `skip` region for an example that starts after a failure.
|
|
205
|
+
#
|
|
206
|
+
# @param _notification [RSpec::Core::Notifications::ExampleNotification]
|
|
207
|
+
# @return [void]
|
|
208
|
+
def example_started(_notification)
|
|
209
|
+
return unless owner? && @failed
|
|
210
|
+
|
|
211
|
+
@example_skipping = true
|
|
212
|
+
KillChannel.write_skip(@channel)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Sends a pass.
|
|
216
|
+
#
|
|
217
|
+
# @param notification [RSpec::Core::Notifications::ExampleNotification]
|
|
218
|
+
# @return [void]
|
|
219
|
+
def example_passed(notification)
|
|
220
|
+
send_event(KillChannel::PASS, notification.example)
|
|
221
|
+
example_finished
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Sends a kill.
|
|
225
|
+
#
|
|
226
|
+
# @param notification [RSpec::Core::Notifications::FailedExampleNotification]
|
|
227
|
+
# @return [void]
|
|
228
|
+
def example_failed(notification)
|
|
229
|
+
send_event(KillChannel::KILL, notification.example)
|
|
230
|
+
example_finished
|
|
231
|
+
@failed = true
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# A pending or skipped example sends no test line, but it finishes.
|
|
235
|
+
#
|
|
236
|
+
# @param _notification [RSpec::Core::Notifications::ExampleNotification]
|
|
237
|
+
# @return [void]
|
|
238
|
+
def example_pending(_notification)
|
|
239
|
+
example_finished
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Opens a `skip` region for a group that starts after a failure.
|
|
243
|
+
#
|
|
244
|
+
# @param _notification [RSpec::Core::Notifications::GroupNotification]
|
|
245
|
+
# @return [void]
|
|
246
|
+
def example_group_started(_notification)
|
|
247
|
+
return unless owner?
|
|
248
|
+
|
|
249
|
+
# The exception in flight now: RSpec can start and finish a group
|
|
250
|
+
# inside its own `rescue` (a failed `before(:all)` with nested
|
|
251
|
+
# groups), where `$!` is already set.
|
|
252
|
+
@groups_skipping.push([@failed, $!])
|
|
253
|
+
KillChannel.write_skip(@channel) if @failed
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Closes the group's `skip` region, if it opened one. RSpec sends this
|
|
257
|
+
# after the group's `after(:all)` hooks, from an `ensure`, so it also
|
|
258
|
+
# comes while an exit or a crash unwinds the group. Then `$!` holds an
|
|
259
|
+
# exception that was not in flight when the group started, and the
|
|
260
|
+
# region stays open: the run ended inside it.
|
|
261
|
+
#
|
|
262
|
+
# @param _notification [RSpec::Core::Notifications::GroupNotification]
|
|
263
|
+
# @return [void]
|
|
264
|
+
def example_group_finished(_notification)
|
|
265
|
+
return unless owner?
|
|
266
|
+
|
|
267
|
+
opened, in_flight = @groups_skipping.pop
|
|
268
|
+
KillChannel.write_unskip(@channel) if opened && $!.equal?(in_flight)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private
|
|
272
|
+
|
|
273
|
+
# True in the process that created the formatter.
|
|
274
|
+
#
|
|
275
|
+
# @return [Boolean]
|
|
276
|
+
def owner?
|
|
277
|
+
Process.pid == @pid
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Counts an example's outcome and closes its `skip` region, if any.
|
|
281
|
+
#
|
|
282
|
+
# @return [void]
|
|
283
|
+
def example_finished
|
|
284
|
+
return unless owner?
|
|
285
|
+
|
|
286
|
+
@reported += 1
|
|
287
|
+
return unless @example_skipping
|
|
288
|
+
|
|
289
|
+
@example_skipping = false
|
|
290
|
+
KillChannel.write_unskip(@channel)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Writes one test line for `example`.
|
|
294
|
+
#
|
|
295
|
+
# @param event [String] {KillChannel::PASS} or {KillChannel::KILL}.
|
|
296
|
+
# @param example [RSpec::Core::Example] the example.
|
|
297
|
+
# @return [void]
|
|
298
|
+
def send_event(event, example)
|
|
299
|
+
return unless owner?
|
|
300
|
+
|
|
301
|
+
file = example.metadata[:absolute_file_path] || File.expand_path(example.file_path)
|
|
302
|
+
KillChannel.write(@channel, event, file, example.full_description, identify(example))
|
|
303
|
+
rescue StandardError
|
|
304
|
+
# A recorder that raised would change the verdict; the lost line keeps the row incomplete.
|
|
305
|
+
KillChannel.write_lost(@channel)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# What tells `example` apart from the others, and stays the same across
|
|
309
|
+
# mutants: its id (RSpec 3.3 and later; see {#start}).
|
|
310
|
+
#
|
|
311
|
+
# @param example [RSpec::Core::Example] the example.
|
|
312
|
+
# @return [String]
|
|
313
|
+
def identify(example)
|
|
314
|
+
example.id
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Requires rspec-core from the project under test.
|
|
319
|
+
#
|
|
320
|
+
# @api private
|
|
321
|
+
def self.require_rspec!
|
|
322
|
+
require "rspec/core"
|
|
323
|
+
rescue LoadError
|
|
324
|
+
raise Mutineer::FrameworkUnavailable,
|
|
325
|
+
"framework 'rspec' requested but rspec is not available; " \
|
|
326
|
+
"add rspec to the project under test (its bundle), then retry"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_runners/minitest"
|
|
4
|
+
require_relative "test_runners/rspec"
|
|
5
|
+
|
|
6
|
+
module Mutineer
|
|
7
|
+
# Picks the test-framework runner.
|
|
8
|
+
#
|
|
9
|
+
# Each runner responds to
|
|
10
|
+
# `.run(files, stop_at_first_failure: false, record_to: nil) -> 0/1`
|
|
11
|
+
# (0 = all passed, 1 = any failure) and is called only inside a forked child.
|
|
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.
|
|
15
|
+
module TestRunners
|
|
16
|
+
# Returns the runner module for a framework name.
|
|
17
|
+
#
|
|
18
|
+
# @param framework [String, nil] framework name or nil.
|
|
19
|
+
# @return [Module] `Mutineer::TestRunners::Minitest` or `::RSpec`.
|
|
20
|
+
# @raise [Mutineer::ConfigError] when the framework is unknown.
|
|
21
|
+
def self.for(framework)
|
|
22
|
+
case framework
|
|
23
|
+
when "rspec" then RSpec
|
|
24
|
+
when "minitest", nil, "" then Minitest
|
|
25
|
+
else
|
|
26
|
+
raise Mutineer::ConfigError, "unknown framework #{framework.inspect} (expected: minitest, rspec)"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|