rspec-hopper 0.1.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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +537 -0
  5. data/Rakefile +10 -0
  6. data/docs/DESIGN.md +386 -0
  7. data/exe/rspec-hopper +6 -0
  8. data/lib/rspec/hopper/attempt_log.rb +133 -0
  9. data/lib/rspec/hopper/ci_env.rb +87 -0
  10. data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
  11. data/lib/rspec/hopper/cli/report.rb +117 -0
  12. data/lib/rspec/hopper/cli/work/parser.rb +166 -0
  13. data/lib/rspec/hopper/cli/work.rb +59 -0
  14. data/lib/rspec/hopper/cli.rb +61 -0
  15. data/lib/rspec/hopper/config.rb +48 -0
  16. data/lib/rspec/hopper/errors.rb +88 -0
  17. data/lib/rspec/hopper/example_reset.rb +41 -0
  18. data/lib/rspec/hopper/fingerprint.rb +185 -0
  19. data/lib/rspec/hopper/keys.rb +38 -0
  20. data/lib/rspec/hopper/manifest.rb +113 -0
  21. data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
  22. data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
  23. data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
  24. data/lib/rspec/hopper/queue.rb +24 -0
  25. data/lib/rspec/hopper/report.rb +286 -0
  26. data/lib/rspec/hopper/reservation.rb +18 -0
  27. data/lib/rspec/hopper/supervisor.rb +196 -0
  28. data/lib/rspec/hopper/unit.rb +15 -0
  29. data/lib/rspec/hopper/version.rb +7 -0
  30. data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
  31. data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
  32. data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
  33. data/lib/rspec/hopper/worker/runner.rb +28 -0
  34. data/lib/rspec/hopper/worker/suite.rb +205 -0
  35. data/lib/rspec/hopper/worker.rb +299 -0
  36. data/lib/rspec/hopper.rb +65 -0
  37. metadata +137 -0
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Hopper
5
+ # The `--processes N` parent. Forks N children (each a standalone worker
6
+ # with worker id `<worker>.<n>` and its own TEST_ENV_NUMBER), forwards INT
7
+ # and TERM to them, waits for all of them, and exits per the precedence
8
+ # rules: 2 if any child failed on infrastructure, else 4 if any child
9
+ # aborted a hung unit, else 0 -- or, with --report-on-exit, the report's
10
+ # exit code unless a child exited 2.
11
+ class Supervisor
12
+ FORWARDED_SIGNALS = %w[INT TERM].freeze
13
+
14
+ # @param worker_class [#new] `Worker` by default; must accept
15
+ # `config:, queue_factory:, suite:, out:, err:` and respond to `#run`.
16
+ # @param suite_loader [#call] `(rspec_args, config:, out:, err:) -> suite`
17
+ # used once in the parent for `--boot shared`.
18
+ # @param report_runner [#call] `(args, out:, err:) -> Integer`
19
+ # @param queue_factory_for [#call] `(config) -> lambda` building a queue.
20
+ # @param fork [#call] `Process.fork` (with block) replacement for tests.
21
+ def initialize(config:, out: $stdout, err: $stderr, worker_class: nil, suite_loader: nil,
22
+ report_runner: nil, queue_factory_for: nil, fork: Process.method(:fork),
23
+ fork_available: Process.respond_to?(:fork), env: ENV)
24
+ @config = config
25
+ @out = out
26
+ @err = err
27
+ @worker_class = worker_class
28
+ @suite_loader = suite_loader
29
+ @report_runner = report_runner
30
+ @queue_factory_for = queue_factory_for
31
+ @fork = fork
32
+ @fork_available = fork_available
33
+ @env = env
34
+ @children = {} # pid => n
35
+ @statuses = {} # n => Process::Status
36
+ end
37
+
38
+ # @return [Integer] exit code
39
+ def run
40
+ return run_inline if @config.processes == 1
41
+ unless @fork_available
42
+ raise ForkUnavailable, "--processes #{@config.processes} needs fork, which this platform lacks"
43
+ end
44
+
45
+ remaining, formatter_pairs = CLI::FormatterArgs.split(@config.rspec_args)
46
+ announce_formatter_output(formatter_pairs)
47
+ suite = @config.boot == :shared ? load_shared_suite(remaining) : nil
48
+ with_signal_forwarding do
49
+ 1.upto(@config.processes) { |number| start_child(number, remaining, formatter_pairs, suite) }
50
+ wait_for_children
51
+ end
52
+ exit_code
53
+ rescue InfrastructureError => e
54
+ log(e.message)
55
+ e.exit_code
56
+ end
57
+
58
+ # Children write their formatter output to files, so nothing here prints
59
+ # an RSpec summary per process; the verdict comes from `report`.
60
+ def announce_formatter_output(formatter_pairs)
61
+ return unless CLI::FormatterArgs.defaults_needed?(formatter_pairs)
62
+
63
+ log("formatter output: #{CLI::FormatterArgs::DEFAULT_DIR}/ " \
64
+ "(the build verdict comes from `rspec-hopper report`)")
65
+ end
66
+
67
+ # The value of TEST_ENV_NUMBER for child `number` (1-based): "" for the
68
+ # first, then "2".."N", matching parallel_tests.
69
+ def self.env_number(number) = number == 1 ? "" : number.to_s
70
+
71
+ def child_worker_id(number) = "#{@config.worker_id}.#{number}"
72
+
73
+ # The config child `number` runs with; public so specs can assert on it.
74
+ def child_config(number, remaining, formatter_pairs)
75
+ env_number = self.class.env_number(number)
76
+ worker_id = child_worker_id(number)
77
+ child_args = CLI::FormatterArgs.for_child(formatter_pairs, env_number, label: worker_id)
78
+ @config.with(
79
+ worker_id: worker_id, supervised: true, processes: 1,
80
+ rspec_args: (child_args + remaining).freeze
81
+ )
82
+ end
83
+
84
+ private
85
+
86
+ def run_inline
87
+ worker_class.new(config: @config, queue_factory: queue_factory_for.call(@config), out: @out, err: @err).run
88
+ end
89
+
90
+ def load_shared_suite(remaining)
91
+ @env.delete("TEST_ENV_NUMBER")
92
+ suite = suite_loader.call(remaining, config: @config, out: @out, err: @err)
93
+ unless RSpec::Hopper.after_fork_hooks.any?
94
+ raise SharedBootWithoutHook,
95
+ "--boot shared needs at least one RSpec::Hopper.after_fork hook: the suite was booted once " \
96
+ "with TEST_ENV_NUMBER unset, so each child must re-derive every value computed from it " \
97
+ "(database name, Redis db, ports, paths). Register a hook or use --boot per-process."
98
+ end
99
+ RSpec::Hopper.before_fork_hooks.each(&:call)
100
+ suite
101
+ end
102
+
103
+ def start_child(number, remaining, formatter_pairs, suite)
104
+ config = child_config(number, remaining, formatter_pairs)
105
+ env_number = self.class.env_number(number)
106
+ pid = @fork.call { run_child(config, env_number, suite) }
107
+ @children[pid] = number
108
+ log("started #{config.worker_id} (pid #{pid}, TEST_ENV_NUMBER=#{env_number.inspect})")
109
+ end
110
+
111
+ def run_child(config, env_number, suite)
112
+ # SYSTEM_DEFAULT, not DEFAULT: Ruby's own default handling for INT and
113
+ # TERM raises Interrupt/SignalException, which prints a stack trace per
114
+ # child before re-signalling. A forwarded signal would therefore bury
115
+ # the run in N backtraces, and an Interrupt raised inside a unit would
116
+ # be caught as a non-requeueable failure rather than stopping the
117
+ # child. The OS default ends the child silently with the same wait
118
+ # status, which is what the parent reports.
119
+ FORWARDED_SIGNALS.each { |sig| trap(sig, "SYSTEM_DEFAULT") }
120
+ @env["TEST_ENV_NUMBER"] = env_number
121
+ RSpec::Hopper.after_fork_hooks.each { |hook| hook.call(env_number) } if @config.boot == :shared
122
+ kwargs = { config: config, queue_factory: queue_factory_for.call(config), out: @out, err: @err }
123
+ kwargs[:suite] = suite unless suite.nil?
124
+ # `exit`, not `exit!`: RSpec formatters may rely on at_exit handlers. An
125
+ # exception escaping the worker ends the child with status 1, which the
126
+ # parent treats as an infrastructure failure.
127
+ exit(worker_class.new(**kwargs).run)
128
+ end
129
+
130
+ def wait_for_children
131
+ @children.each do |pid, number|
132
+ _, status = Process.wait2(pid)
133
+ @statuses[number] = status
134
+ log("#{child_worker_id(number)} (pid #{pid}) #{describe(status)}")
135
+ rescue Errno::ECHILD
136
+ @statuses[number] = nil
137
+ log("#{child_worker_id(number)} (pid #{pid}) was already reaped; treating as failed")
138
+ end
139
+ end
140
+
141
+ def with_signal_forwarding
142
+ previous = FORWARDED_SIGNALS.to_h { |sig| [sig, trap(sig) { forward(sig) }] }
143
+ yield
144
+ ensure
145
+ previous&.each { |sig, handler| trap(sig, handler || "DEFAULT") }
146
+ end
147
+
148
+ def forward(sig)
149
+ @children.each_key do |pid|
150
+ next if @statuses.key?(@children[pid])
151
+
152
+ Process.kill(sig, pid)
153
+ rescue Errno::ESRCH
154
+ nil
155
+ end
156
+ end
157
+
158
+ def exit_code
159
+ codes = @statuses.values.map { |status| status&.exitstatus }
160
+ return ExitCode::INFRASTRUCTURE if codes.any? { |c| ![ExitCode::OK, ExitCode::ABORTED].include?(c) }
161
+
162
+ aborted = codes.include?(ExitCode::ABORTED)
163
+ return aborted ? ExitCode::ABORTED : ExitCode::OK unless @config.report_on_exit
164
+
165
+ log("a child aborted a hung unit (exit 4); the verdict comes from report") if aborted
166
+ report_runner.call(@config.report_args, out: @out, err: @err)
167
+ end
168
+
169
+ def describe(status)
170
+ return "exited #{status.exitstatus}" if status.exited?
171
+ return "killed by SIG#{Signal.signame(status.termsig)}" if status.signaled?
172
+
173
+ "stopped with #{status.inspect}"
174
+ end
175
+
176
+ def log(message)
177
+ @err.puts "[hopper #{@config.worker_id}] #{message}"
178
+ @err.flush if @err.respond_to?(:flush)
179
+ end
180
+
181
+ def worker_class = @worker_class || Worker
182
+
183
+ def suite_loader
184
+ @suite_loader || ->(args, config:, out:, err:) { Worker::Suite.load(args, config: config, out: out, err: err) }
185
+ end
186
+
187
+ def report_runner
188
+ @report_runner || ->(args, out:, err:) { CLI::Report.run(args, out: out, err: err) }
189
+ end
190
+
191
+ def queue_factory_for
192
+ @queue_factory_for || ->(config) { CLI::Work.queue_factory(config) }
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Hopper
5
+ UNIT_TYPES = %w[file].freeze
6
+
7
+ # A schedulable unit of work. Phase 1 units are spec files; the `type`
8
+ # field exists so example-level units can be added without a schema change.
9
+ Unit = Data.define(:id, :type) do
10
+ def self.file(path) = new(id: path, type: "file")
11
+
12
+ def to_h = { "id" => id, "type" => type }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Hopper
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Hopper
5
+ class Worker
6
+ # Records every notification RSpec sends while a unit's example groups
7
+ # run, so that the worker can replay them to the real reporter exactly
8
+ # once for a final attempt or discard them for a requeued or stale one.
9
+ # Suite lifecycle notifications (`start`, `finish`, `close`, `report`) are
10
+ # deliberately not defined: they belong to the outer runner.
11
+ class BufferingReporter
12
+ BUFFERED = %i[
13
+ example_group_started example_group_finished
14
+ example_started example_finished example_passed example_failed example_pending
15
+ message publish notify_non_example_exception deprecation
16
+ ].freeze
17
+
18
+ attr_reader :events
19
+
20
+ def initialize
21
+ @events = []
22
+ end
23
+
24
+ BUFFERED.each do |name|
25
+ define_method(name) do |*args, **kwargs|
26
+ @events << [name, args, kwargs]
27
+ nil
28
+ end
29
+ end
30
+
31
+ # RSpec consults this after every failure; the worker never fails fast.
32
+ def fail_fast_limit_met? = false
33
+
34
+ def size = @events.size
35
+ def empty? = @events.empty?
36
+
37
+ # Sends every recorded notification to `real`, in original order.
38
+ def replay(real)
39
+ @events.each { |name, args, kwargs| real.public_send(name, *args, **kwargs) }
40
+ discard
41
+ self
42
+ end
43
+
44
+ def discard
45
+ @events = []
46
+ self
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Hopper
5
+ class Worker
6
+ # Keeps a reservation alive while its unit executes. Runs in a thread
7
+ # that renews the reservation every `config.heartbeat_interval` seconds.
8
+ # After `max_unit_duration` it records `abandoned` once and stops
9
+ # renewing; after a further `timeout` it aborts the whole process, since
10
+ # the hung test thread cannot be interrupted safely.
11
+ class Heartbeat
12
+ # How long to wait before retrying a renewal that failed on a Redis
13
+ # connection error, capped by the normal interval.
14
+ RETRY_INTERVAL = 1.0
15
+
16
+ attr_reader :reservation, :abandoned_at, :renewals, :renewal_failures
17
+
18
+ # @param clock [#call] monotonic seconds
19
+ # @param sleeper [#call, nil] waits for the given seconds; nil uses an
20
+ # interruptible wait so `stop` returns promptly
21
+ # @param aborter [#call] receives the exit code; defaults to `exit!`
22
+ def initialize(queue:, reservation:, config:, err: $stderr, clock: nil, sleeper: nil, aborter: nil,
23
+ worker_id: config.worker_id)
24
+ @queue = queue
25
+ @reservation = reservation
26
+ @config = config
27
+ @err = err
28
+ @worker_id = worker_id
29
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
30
+ @sleeper = sleeper
31
+ @aborter = aborter || ->(code) { Kernel.exit!(code) }
32
+ @signal = Thread::Queue.new
33
+ @stopped = false
34
+ @stale = false
35
+ @abandoned_at = nil
36
+ @renewals = 0
37
+ @renewal_failures = 0
38
+ end
39
+
40
+ def interval = @config.heartbeat_interval
41
+ def stale? = @stale
42
+ def abandoned? = !@abandoned_at.nil?
43
+ def running? = !@thread.nil? && @thread.alive?
44
+
45
+ # Marks the unit as started at `now` without spawning the thread, so
46
+ # the loop can be driven through `tick`.
47
+ def prime(now = @clock.call)
48
+ @started_at = @last_renewal = now
49
+ self
50
+ end
51
+
52
+ def start
53
+ prime
54
+ @thread = Thread.new { run_loop }
55
+ @thread.name = "hopper-heartbeat" if @thread.respond_to?(:name=)
56
+ # Giving up on renewals re-raises through `join` in `stop`, where the
57
+ # worker turns it into one message and exit 2. Ruby's own report would
58
+ # only add a bare backtrace ahead of it.
59
+ @thread.report_on_exception = false
60
+ self
61
+ end
62
+
63
+ def stop
64
+ @stopped = true
65
+ @signal << :stop
66
+ @thread&.join
67
+ self
68
+ end
69
+
70
+ # One step of the loop, at time `now`. Public so it can be driven
71
+ # without a thread. Returns what happened.
72
+ def tick(now)
73
+ return :stopped if @stopped
74
+
75
+ elapsed = now - @started_at
76
+ if elapsed >= abort_after
77
+ abort!
78
+ :aborted
79
+ elsif elapsed >= @config.max_unit_duration
80
+ abandon!(elapsed)
81
+ elsif now - @last_renewal >= interval
82
+ renew!(now)
83
+ else
84
+ :idle
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def abort_after = @config.max_unit_duration + @config.timeout
91
+
92
+ def run_loop
93
+ until @stopped
94
+ result = tick(@clock.call)
95
+ break if %i[aborted stale].include?(result)
96
+
97
+ wait(result == :retrying ? [RETRY_INTERVAL, interval].min : interval)
98
+ end
99
+ end
100
+
101
+ def wait(seconds)
102
+ if @sleeper
103
+ @sleeper.call(seconds)
104
+ else
105
+ @signal.pop(timeout: seconds)
106
+ end
107
+ end
108
+
109
+ def renew!(now)
110
+ @queue.heartbeat(@reservation)
111
+ @last_renewal = now
112
+ @renewals += 1
113
+ recovered if @renewal_failures.positive?
114
+ :renewed
115
+ rescue StaleReservation
116
+ @stale = true
117
+ :stale
118
+ rescue REDIS_ERROR => e
119
+ failed_renewal(e, now)
120
+ end
121
+
122
+ # A connection error is not yet a lost unit: the reservation stays this
123
+ # worker's until `timeout` passes without a renewal. Retry until then
124
+ # rather than killing a worker that is running tests fine, and give up
125
+ # once the entry is reclaimable, when carrying on would only let a
126
+ # sibling run the unit while this process still owns its output.
127
+ def failed_renewal(error, now)
128
+ @renewal_failures += 1
129
+ raise error if now - @last_renewal >= @config.timeout
130
+
131
+ if @renewal_failures == 1
132
+ warn_heartbeat "heartbeat failed (#{error.class}: #{error.message}); " \
133
+ "retrying for up to #{@config.timeout}s"
134
+ end
135
+ :retrying
136
+ end
137
+
138
+ def recovered
139
+ warn_heartbeat "heartbeat recovered after #{@renewal_failures} failed " \
140
+ "#{@renewal_failures == 1 ? "attempt" : "attempts"}"
141
+ @renewal_failures = 0
142
+ end
143
+
144
+ def warn_heartbeat(message)
145
+ @err.puts "[hopper #{@worker_id}] #{@reservation.unit_id}: #{message}"
146
+ @err.flush if @err.respond_to?(:flush)
147
+ end
148
+
149
+ # The `abandoned` event is a warning, not a terminal state, so a Redis
150
+ # error while recording it retries on the next tick and never ends the
151
+ # worker: the abort at `max_unit_duration + timeout` still fires.
152
+ def abandon!(elapsed)
153
+ return :abandoned if @abandoned_at
154
+
155
+ @queue.record_abandoned(@reservation, elapsed_ms: (elapsed * 1000).round)
156
+ @abandoned_at = elapsed
157
+ :abandoned
158
+ rescue StaleReservation
159
+ @stale = true
160
+ :stale
161
+ rescue REDIS_ERROR
162
+ :retrying
163
+ end
164
+
165
+ def abort!
166
+ @err.puts "[hopper #{@worker_id}] Aborting worker: #{@reservation.unit_id} exceeded " \
167
+ "#{format_seconds(@config.max_unit_duration)}s"
168
+ @err.flush if @err.respond_to?(:flush)
169
+ @aborter.call(ExitCode::ABORTED)
170
+ end
171
+
172
+ def format_seconds(value)
173
+ value == value.to_i ? value.to_i : value
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Hopper
5
+ class Worker
6
+ # Decides, from the execution results of a unit's selected examples,
7
+ # whether a failed attempt may be requeued. An attempt is a requeue
8
+ # candidate only if every failure in it is requeueable.
9
+ module RequeuePolicy
10
+ # These never justify a retry; one anywhere in the unit makes the
11
+ # attempt final. They also escape `Example#run` rather than being
12
+ # recorded on the example, so the worker passes them in as `escaped`.
13
+ NON_REQUEUEABLE = [SystemExit, Interrupt, SignalException, NoMemoryError].freeze
14
+
15
+ Decision = Data.define(:status, :failed_examples, :unexecuted, :escaped) do
16
+ def passed? = status == :passed
17
+ def requeue_candidate? = status == :requeue_candidate
18
+ def final_failure? = status == :final_failure
19
+
20
+ def failure_count = failed_examples.size + (escaped ? 1 : 0)
21
+
22
+ def summary = RequeuePolicy.failure_summary(failure_count)
23
+
24
+ # Size-capped error payload for the attempt log.
25
+ def errors
26
+ entries = failed_examples.flat_map do |example|
27
+ RequeuePolicy.exceptions_of(example).map do |exception|
28
+ ErrorPayload.from_exception(exception, example_id: example.id, description: example.full_description)
29
+ end
30
+ end
31
+ entries << escaped_payload if escaped
32
+ ErrorPayload.cap(entries)
33
+ end
34
+
35
+ private
36
+
37
+ def escaped_payload
38
+ example = unexecuted.find { |ex| ex.execution_result.started_at }
39
+ ErrorPayload.from_exception(escaped, example_id: example&.id, description: example&.full_description)
40
+ end
41
+ end
42
+
43
+ module_function
44
+
45
+ # @param examples [Array<RSpec::Core::Example>] the unit's selected examples
46
+ # @param escaped [Exception, nil] an exception that escaped `ExampleGroup.run`
47
+ def decide(examples, escaped: nil)
48
+ failed = examples.select { |ex| ex.execution_result.status == :failed }
49
+ unexecuted = examples.select { |ex| ex.execution_result.status.nil? }
50
+ Decision.new(status: status_for(failed, escaped), failed_examples: failed, unexecuted: unexecuted,
51
+ escaped: escaped)
52
+ end
53
+
54
+ def status_for(failed, escaped)
55
+ return :final_failure if escaped
56
+ return :passed if failed.empty?
57
+ return :requeue_candidate if failed.all? { |ex| exceptions_of(ex).all? { |e| requeueable?(e) } }
58
+
59
+ :final_failure
60
+ end
61
+
62
+ def requeueable?(exception)
63
+ NON_REQUEUEABLE.none? { |klass| exception.is_a?(klass) }
64
+ end
65
+
66
+ # Every exception behind a failed example, with aggregate errors
67
+ # (`MultipleExceptionError` and friends) flattened.
68
+ def exceptions_of(example)
69
+ flatten(example.execution_result.exception || example.exception)
70
+ end
71
+
72
+ def flatten(exception)
73
+ return [] if exception.nil?
74
+ return [exception] unless exception.respond_to?(:all_exceptions)
75
+
76
+ nested = exception.all_exceptions.flat_map { |e| flatten(e) }
77
+ nested.empty? ? [exception] : nested
78
+ end
79
+
80
+ def failure_summary(count)
81
+ "#{count} failure#{"s" unless count == 1}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+
5
+ module RSpec
6
+ module Hopper
7
+ class Worker
8
+ # The RSpec runner the worker drives. `configure` is inherited unchanged;
9
+ # `run_specs` is replaced so that the reporter lifecycle (`start`,
10
+ # `finish`, `close`) and the per-process suite hooks fire exactly once
11
+ # around the hopper loop instead of around `ordered_example_groups`.
12
+ #
13
+ # Runner#setup is not used: its `ensure world.announce_filters` calls
14
+ # `reporter.abort_with` (an `exit!`) for `--only-failures` before the
15
+ # worker could reject the option, and load-error capture has to be
16
+ # scoped to `load_spec_files` alone. Suite performs those steps itself.
17
+ class Runner < RSpec::Core::Runner
18
+ # @param expected_example_count [Integer] the manifest's total examples
19
+ # @yield [RSpec::Core::Reporter] the real reporter, inside suite hooks
20
+ def run_specs(expected_example_count)
21
+ @configuration.reporter.report(expected_example_count) do |reporter|
22
+ @configuration.with_suite_hooks { yield reporter }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end