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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1194 -0
  3. data/LICENSE +21 -0
  4. data/README.md +659 -0
  5. data/bin/mutineer +6 -0
  6. data/lib/mutineer/baseline.rb +185 -0
  7. data/lib/mutineer/changed_lines.rb +136 -0
  8. data/lib/mutineer/child_stdout.rb +35 -0
  9. data/lib/mutineer/cli.rb +681 -0
  10. data/lib/mutineer/config.rb +372 -0
  11. data/lib/mutineer/coverage_map.rb +1366 -0
  12. data/lib/mutineer/daemon_backend.rb +351 -0
  13. data/lib/mutineer/daemon_client.rb +407 -0
  14. data/lib/mutineer/daemon_server.rb +351 -0
  15. data/lib/mutineer/external_backend.rb +288 -0
  16. data/lib/mutineer/file_swap.rb +194 -0
  17. data/lib/mutineer/isolation.rb +321 -0
  18. data/lib/mutineer/job_plan.rb +352 -0
  19. data/lib/mutineer/kill_channel.rb +284 -0
  20. data/lib/mutineer/kill_matrix.rb +88 -0
  21. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  22. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  23. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
  24. data/lib/mutineer/minitest_integration.rb +137 -0
  25. data/lib/mutineer/mongoid_failures.rb +82 -0
  26. data/lib/mutineer/mongoid_worker_db.rb +97 -0
  27. data/lib/mutineer/mutant_id.rb +126 -0
  28. data/lib/mutineer/mutation.rb +27 -0
  29. data/lib/mutineer/mutator_registry.rb +109 -0
  30. data/lib/mutineer/mutators/arithmetic.rb +35 -0
  31. data/lib/mutineer/mutators/array_literal.rb +44 -0
  32. data/lib/mutineer/mutators/base.rb +105 -0
  33. data/lib/mutineer/mutators/boolean_connector.rb +52 -0
  34. data/lib/mutineer/mutators/boolean_literal.rb +56 -0
  35. data/lib/mutineer/mutators/chain_link.rb +132 -0
  36. data/lib/mutineer/mutators/collection_method.rb +44 -0
  37. data/lib/mutineer/mutators/comparison.rb +35 -0
  38. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  39. data/lib/mutineer/mutators/condition_negation.rb +49 -0
  40. data/lib/mutineer/mutators/literal_mutation.rb +51 -0
  41. data/lib/mutineer/mutators/negation_removal.rb +44 -0
  42. data/lib/mutineer/mutators/operand_removal.rb +58 -0
  43. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  44. data/lib/mutineer/mutators/range_literal.rb +54 -0
  45. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  46. data/lib/mutineer/mutators/return_nil.rb +72 -0
  47. data/lib/mutineer/mutators/safe_navigation.rb +76 -0
  48. data/lib/mutineer/mutators/statement_removal.rb +33 -0
  49. data/lib/mutineer/mutators/string_literal.rb +34 -0
  50. data/lib/mutineer/orphan_guard.rb +28 -0
  51. data/lib/mutineer/pairing.rb +298 -0
  52. data/lib/mutineer/parser.rb +45 -0
  53. data/lib/mutineer/progress.rb +46 -0
  54. data/lib/mutineer/project.rb +506 -0
  55. data/lib/mutineer/project_path.rb +57 -0
  56. data/lib/mutineer/rails_worker_db.rb +212 -0
  57. data/lib/mutineer/reporter.rb +926 -0
  58. data/lib/mutineer/result.rb +240 -0
  59. data/lib/mutineer/runner.rb +418 -0
  60. data/lib/mutineer/statement_lines.rb +183 -0
  61. data/lib/mutineer/subject.rb +38 -0
  62. data/lib/mutineer/test_runners/minitest.rb +22 -0
  63. data/lib/mutineer/test_runners/rspec.rb +330 -0
  64. data/lib/mutineer/test_runners.rb +30 -0
  65. data/lib/mutineer/version.rb +6 -0
  66. data/lib/mutineer/worker_pool.rb +147 -0
  67. data/lib/mutineer.rb +48 -0
  68. 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # Current Mutineer release version.
5
+ VERSION = "2.0.0"
6
+ end