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,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require "shellwords"
5
+
6
+ module RSpec
7
+ module Hopper
8
+ class Worker
9
+ # Loads the RSpec suite once per process, rejects unsupported options,
10
+ # discovers file units, computes the fingerprint and adopts the build seed.
11
+ # This is the only place that mutates RSpec.configuration.
12
+ class Suite
13
+ RUNNER_OPTIONS = {
14
+ "InitializeProject" => "--init", "PrintVersion" => "--version", "PrintHelp" => "--help",
15
+ "Bisect" => "--bisect", "DRbWithFallback" => "--drb"
16
+ }.freeze
17
+
18
+ # Captures `message` notifications while spec files load; that is how
19
+ # `Reporter#notify_non_example_exception` surfaces load errors.
20
+ class LoadErrorCapture
21
+ attr_reader :messages
22
+
23
+ def initialize
24
+ @messages = []
25
+ @active = false
26
+ end
27
+
28
+ def activate = @active = true
29
+ def deactivate = @active = false
30
+
31
+ def message(notification)
32
+ @messages << notification.message.to_s if @active
33
+ end
34
+ end
35
+
36
+ attr_reader :rspec_args, :config, :options, :runner, :units, :file_counts, :total_examples, :example_ids,
37
+ :load_errors, :fingerprint, :file_args
38
+
39
+ # @return [Suite] loaded and ready; raises UnsupportedOption or BootError
40
+ def self.load(rspec_args, config:, out: $stdout, err: $stderr)
41
+ new(rspec_args, config: config, out: out, err: err).load
42
+ end
43
+
44
+ def initialize(rspec_args, config:, out: $stdout, err: $stderr)
45
+ @rspec_args = Array(rspec_args).map(&:to_s)
46
+ @config = config
47
+ @out = out
48
+ @err = err
49
+ @load_errors = []
50
+ @groups_by_file = {}
51
+ end
52
+
53
+ def configuration = RSpec.configuration
54
+ def world = RSpec.world
55
+
56
+ def load
57
+ reject_init_option!
58
+ @options = RSpec::Core::ConfigurationOptions.new(rspec_args)
59
+ reject_runner_option!
60
+ @runner = Runner.new(options, configuration, world)
61
+ runner.configure(@err, @out)
62
+ reject_failed_configuration!
63
+ reject_unsupported_configuration!
64
+ normalize_file_args!
65
+ load_spec_files
66
+ world.announce_filters
67
+ discover_units
68
+ @fingerprint = Fingerprint.compute(configuration: configuration, options: options, example_ids: example_ids,
69
+ file_args: file_args, revision: config.revision)
70
+ self
71
+ rescue InfrastructureError
72
+ raise
73
+ rescue StandardError, ScriptError => e
74
+ raise BootError, "#{e.class}: #{e.message}", e.backtrace
75
+ end
76
+
77
+ def unit_ids = units.map(&:id)
78
+
79
+ # Top-level groups whose file is the unit, in configured order.
80
+ def groups_for(unit_id) = @groups_by_file.fetch(unit_id, [])
81
+
82
+ # Selected examples of the unit, including nested groups.
83
+ def examples_for(unit_id) = groups_for(unit_id).flat_map(&:descendant_filtered_examples)
84
+
85
+ # Sets the build seed without changing the global ordering strategy:
86
+ # `Configuration#seed=` switches an unforced global ordering to random,
87
+ # which would silently reorder a suite that runs in defined order.
88
+ def adopt_seed(seed)
89
+ return if seed.nil?
90
+
91
+ registry = configuration.ordering_registry
92
+ strategy = registry.fetch(:global)
93
+ configuration.seed = seed
94
+ registry.register(:global, strategy) unless registry.fetch(:global).equal?(strategy)
95
+ configuration.seed
96
+ end
97
+ alias seed= adopt_seed
98
+
99
+ # Applies the `--format`/`--out` pairs of `args` to the already
100
+ # configured RSpec, for a suite loaded once in a shared-boot parent
101
+ # whose children each need their own formatter output. Other arguments
102
+ # are ignored: the suite already applied them. Pairing follows RSpec's
103
+ # parser: `--out` attaches to the preceding `--format`, or to the
104
+ # default progress formatter when there is none.
105
+ def apply_formatter_args(args)
106
+ _remaining, pairs = CLI::FormatterArgs.split(Array(args).map(&:to_s))
107
+ entries = CLI::FormatterArgs.entries(pairs).map { |formatter, out| out ? [formatter, out] : [formatter] }
108
+ entries.each { |entry| configuration.add_formatter(*entry) }
109
+ entries
110
+ end
111
+
112
+ def to_manifest(revision: config.revision)
113
+ Manifest.new(
114
+ total_examples: total_examples, file_counts: file_counts, file_args: file_args,
115
+ fingerprint: fingerprint.value, fingerprint_digests: fingerprint.digests,
116
+ seed: configuration.seed, revision: revision, load_errors: load_errors
117
+ )
118
+ end
119
+
120
+ private
121
+
122
+ def reject_init_option!
123
+ env_args = ENV["SPEC_OPTS"] ? Shellwords.split(ENV["SPEC_OPTS"]) : []
124
+ return unless (rspec_args + env_args).include?("--init")
125
+
126
+ raise UnsupportedOption, unsupported("--init")
127
+ end
128
+
129
+ def reject_runner_option!
130
+ runner = options.options[:runner]
131
+ return if runner.nil?
132
+
133
+ name = RUNNER_OPTIONS.fetch(runner.class.name.to_s.split("::").last, runner.class.name)
134
+ raise UnsupportedOption, unsupported(name)
135
+ end
136
+
137
+ # A `--require` that fails is reported by RSpec while the options are
138
+ # applied, before spec files load; every worker shares the arguments,
139
+ # so it is a boot failure rather than a spec-file load error.
140
+ def reject_failed_configuration!
141
+ return unless world.wants_to_quit || world.rspec_is_quitting
142
+
143
+ raise BootError, "RSpec reported an error while applying its options (for example a --require that " \
144
+ "could not be loaded); see the RSpec output"
145
+ end
146
+
147
+ def reject_unsupported_configuration!
148
+ raise UnsupportedOption, unsupported("--bisect") if options.options[:bisect]
149
+ raise UnsupportedOption, unsupported("--drb") if options.options[:drb]
150
+ raise UnsupportedOption, unsupported("--only-failures / --next-failure") if configuration.only_failures?
151
+ raise UnsupportedOption, unsupported("--fail-fast") if configuration.fail_fast
152
+ raise UnsupportedOption, unsupported("--dry-run") if configuration.dry_run?
153
+ end
154
+
155
+ def unsupported(name)
156
+ "RSpec option #{name} is not supported by rspec-hopper: every selected unit must reach a final state"
157
+ end
158
+
159
+ # RSpec only falls back to `default_path` when `$0` is `rspec`; the
160
+ # worker applies the same default so `rspec-hopper work` with no file
161
+ # arguments runs the spec directory like `rspec` would.
162
+ def normalize_file_args!
163
+ files = Array(options.options[:files_or_directories_to_run]).map(&:to_s)
164
+ files = [configuration.default_path.to_s] if files.empty? && configuration.default_path
165
+ configuration.files_or_directories_to_run = files
166
+ @file_args = files.freeze
167
+ end
168
+
169
+ def load_spec_files
170
+ capture = LoadErrorCapture.new
171
+ configuration.reporter.register_listener(capture, :message)
172
+ capture.activate
173
+ begin
174
+ configuration.load_spec_files unless world.wants_to_quit
175
+ rescue SystemExit => e
176
+ capture.messages << "#{e.class} raised while loading spec files" if capture.messages.empty?
177
+ ensure
178
+ capture.deactivate
179
+ end
180
+ return unless world.wants_to_quit || world.rspec_is_quitting
181
+
182
+ @load_errors = capture.messages.dup.freeze
183
+ @load_errors = ["RSpec reported a failure while loading spec files"] if @load_errors.empty?
184
+ end
185
+
186
+ def discover_units
187
+ world.ordered_example_groups.each do |group|
188
+ next if group.descendant_filtered_examples.empty?
189
+
190
+ (@groups_by_file[group.metadata[:file_path]] ||= []) << group
191
+ end
192
+ @file_counts = @groups_by_file.transform_values do |groups|
193
+ groups.sum do |g|
194
+ g.descendant_filtered_examples.size
195
+ end
196
+ end
197
+ .freeze
198
+ @units = @file_counts.keys.map { |path| Unit.file(path) }.freeze
199
+ @total_examples = @file_counts.values.sum
200
+ @example_ids = @groups_by_file.values.flatten.flat_map(&:descendant_filtered_examples).map(&:id).sort.freeze
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "worker/runner"
5
+
6
+ module RSpec
7
+ module Hopper
8
+ # The RSpec adapter: loads the suite, joins or initializes the build, pulls
9
+ # units, runs each through `ExampleGroup.run`, decides requeue from the
10
+ # execution results and forwards final attempts to the formatters.
11
+ class Worker
12
+ POLL_INTERVAL = 0.5
13
+
14
+ Outcome = Data.define(:duration_ms, :escaped, :stale)
15
+
16
+ # Internal: unwinds to `run` with an exit code after the error was recorded and printed.
17
+ class Abort < StandardError
18
+ attr_reader :code
19
+
20
+ def initialize(code)
21
+ @code = code
22
+ super("worker exiting with #{code}")
23
+ end
24
+ end
25
+
26
+ # Matches redis-rb connection failures without requiring the redis gem.
27
+ REDIS_ERROR = Module.new do
28
+ def self.===(error)
29
+ error.class.ancestors.any? { |a| a.name == "Redis::BaseConnectionError" }
30
+ end
31
+ end
32
+
33
+ attr_reader :config, :suite, :queue
34
+
35
+ # @param queue_factory [#call] returns the Queue; called after the suite boots
36
+ # @param suite [Suite, nil] a pre-loaded suite (shared boot mode); the
37
+ # `--format`/`--out` pairs in `config.rspec_args` are applied to it
38
+ # @param clock [#call] monotonic seconds
39
+ # @param sleeper [#call] sleeps for the given seconds
40
+ # @param aborter [#call] receives an exit code; defaults to `exit!`
41
+ # @param ppid [#call] the parent pid, checked between units when supervised
42
+ def initialize(config:, queue_factory:, suite: nil, out: $stdout, err: $stderr, clock: nil, sleeper: nil,
43
+ aborter: nil, ppid: nil)
44
+ @config = config
45
+ @queue_factory = queue_factory
46
+ @suite = suite
47
+ @out = out
48
+ @err = err
49
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
50
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
51
+ @aborter = aborter
52
+ @ppid = ppid || -> { Process.ppid }
53
+ @parent_pid = @ppid.call
54
+ end
55
+
56
+ def worker_id = config.worker_id
57
+ def build_id = config.build_id
58
+
59
+ # @return [Integer] exit code: 0 on build completion, 2 on infrastructure failure
60
+ def run
61
+ phase(:boot) do
62
+ if @suite
63
+ @suite.apply_formatter_args(config.rspec_args)
64
+ else
65
+ @suite = Suite.load(config.rspec_args, config: config, out: @out, err: @err)
66
+ end
67
+ end
68
+ phase(:redis) { @queue = @queue_factory.call }
69
+ manifest = phase(:init) { join_build }
70
+ phase(:init) do
71
+ check_fingerprint(manifest)
72
+ suite.adopt_seed(manifest.seed)
73
+ end
74
+ suite.runner.run_specs(manifest.total_examples) { |reporter| work_loop(reporter) }
75
+ ExitCode::OK
76
+ rescue Abort => e
77
+ e.code
78
+ end
79
+
80
+ private
81
+
82
+ # --- initialization ---------------------------------------------------
83
+
84
+ def join_build
85
+ deadline = @clock.call + config.init_timeout
86
+ loop do
87
+ status = queue.status
88
+ return joined if status.ready?
89
+ raise InitFailed.new("build #{build_id} failed to initialize", load_errors: recorded_load_errors(status)) if
90
+ status.init_failed?
91
+ raise PreviouslyInitialized, previously_initialized_message if status.tombstone && !status.present?
92
+
93
+ token = queue.acquire_leader(worker_id)
94
+ if token
95
+ manifest = publish(token)
96
+ return manifest if manifest
97
+ else
98
+ raise BuildNeverInitialized, "build never initialized (waited #{config.init_timeout}s)" if
99
+ @clock.call >= deadline
100
+
101
+ @sleeper.call(POLL_INTERVAL)
102
+ end
103
+ end
104
+ end
105
+
106
+ def joined
107
+ manifest = queue.manifest
108
+ raise BuildStateMissing, "build #{build_id} state is missing" if manifest.nil?
109
+
110
+ say "joined build #{build_id} (#{manifest.total_units} units, #{manifest.total_examples} examples)"
111
+ manifest
112
+ end
113
+
114
+ def publish(token)
115
+ manifest = suite.to_manifest
116
+ if suite.load_errors.any?
117
+ queue.fail_initialization(token: token, manifest: manifest)
118
+ raise InitFailed.new("build #{build_id} failed to initialize", load_errors: suite.load_errors)
119
+ end
120
+ queue.initialize_build(token: token, manifest: manifest, unit_ids: suite.unit_ids)
121
+ say "initialized build #{build_id} (#{manifest.total_units} units, #{manifest.total_examples} examples)"
122
+ queue.manifest
123
+ rescue LeaseLost, AlreadyInitialized
124
+ nil
125
+ end
126
+
127
+ def recorded_load_errors(status)
128
+ JSON.parse(status.meta&.fetch("load_errors", nil) || "[]")
129
+ rescue JSON::ParserError
130
+ []
131
+ end
132
+
133
+ def previously_initialized_message
134
+ "build #{build_id} was previously initialized; its state is gone. Choose a new build id."
135
+ end
136
+
137
+ def check_fingerprint(manifest)
138
+ return if manifest.fingerprint.nil? || manifest.fingerprint == suite.fingerprint.value
139
+
140
+ raise FingerprintMismatch,
141
+ Fingerprint::Mismatch.explain(suite.fingerprint, manifest.fingerprint, manifest.fingerprint_digests)
142
+ end
143
+
144
+ # --- the loop -----------------------------------------------------------
145
+
146
+ def work_loop(reporter)
147
+ loop do
148
+ phase(:execution) do
149
+ raise InfrastructureError, "RSpec wants to quit (a before(:suite) hook failed?)" if world.wants_to_quit
150
+ end
151
+ reservation = phase(:reserve) { queue.reclaim_lost(worker_id) || queue.reserve(worker_id) }
152
+ if reservation
153
+ execute(reservation, reporter)
154
+ else
155
+ phase(:reserve) { queue.touch_liveness(worker_id) }
156
+ end
157
+ break if phase(:reserve) { queue.complete? }
158
+
159
+ check_parent!
160
+ end
161
+ end
162
+
163
+ def check_parent!
164
+ return unless config.supervised
165
+ return if @ppid.call == @parent_pid
166
+
167
+ alert "parent gone; exiting"
168
+ raise Abort, ExitCode::INFRASTRUCTURE
169
+ end
170
+
171
+ def execute(reservation, reporter)
172
+ unit_id = reservation.unit_id
173
+ groups = suite.groups_for(unit_id)
174
+ examples = suite.examples_for(unit_id)
175
+ buffer = BufferingReporter.new
176
+ outcome = phase(:execution, unit_id: unit_id) do
177
+ raise InfrastructureError, "unit #{unit_id} is not part of this worker's suite" if groups.empty?
178
+
179
+ run_groups(groups, buffer, reservation)
180
+ end
181
+ decision = RequeuePolicy.decide(examples, escaped: outcome.escaped)
182
+ phase(:execution, unit_id: unit_id) do
183
+ if decision.unexecuted.any? && outcome.escaped.nil?
184
+ raise InfrastructureError, "#{decision.unexecuted.size} example(s) of #{unit_id} did not run"
185
+ end
186
+
187
+ settle(reservation, decision, buffer, outcome.duration_ms, reporter)
188
+ end
189
+ return unless outcome.escaped
190
+
191
+ alert "#{outcome.escaped.class} escaped an example in #{unit_id}; the unit was finalized as failed"
192
+ end
193
+
194
+ def run_groups(groups, buffer, reservation)
195
+ ExampleReset.reset(groups)
196
+ heartbeat = Heartbeat.new(queue: queue, reservation: reservation, config: config, err: @err, clock: @clock,
197
+ aborter: @aborter, worker_id: worker_id)
198
+ started = @clock.call
199
+ escaped = nil
200
+ heartbeat.start
201
+ begin
202
+ groups.each { |group| group.run(buffer) }
203
+ rescue *RequeuePolicy::NON_REQUEUEABLE => e
204
+ escaped = e
205
+ ensure
206
+ heartbeat.stop
207
+ end
208
+ Outcome.new(duration_ms: ((@clock.call - started) * 1000).round, escaped: escaped, stale: heartbeat.stale?)
209
+ end
210
+
211
+ def settle(reservation, decision, buffer, duration_ms, reporter)
212
+ unit_id = reservation.unit_id
213
+ operation = decision.requeue_candidate? ? :requeue : :finalize
214
+ begin
215
+ return if requeued?(reservation, decision, duration_ms)
216
+ rescue StaleReservation
217
+ buffer.discard
218
+ queue.record_stale_rejected(reservation, operation: operation)
219
+ say "reservation for #{unit_id} is stale; result discarded"
220
+ return
221
+ end
222
+ phase(:formatter, unit_id: unit_id) { buffer.replay(reporter) }
223
+ end
224
+
225
+ # Finalizes or requeues; true when the unit was requeued (buffer dropped).
226
+ def requeued?(reservation, decision, duration_ms)
227
+ unit_id = reservation.unit_id
228
+ if decision.passed?
229
+ queue.finalize(reservation, outcome: :passed, duration_ms: duration_ms)
230
+ elsif decision.final_failure?
231
+ queue.finalize(reservation, outcome: :failed, duration_ms: duration_ms, reason: "test_failure",
232
+ errors: decision.errors)
233
+ else
234
+ result = queue.requeue(reservation, duration_ms: duration_ms, failure_summary: decision.summary,
235
+ errors: decision.errors)
236
+ if result.requeued?
237
+ say "Retrying #{unit_id} (retry #{result.retry_index} of #{config.max_requeues}; " \
238
+ "next attempt #{result.retry_index + 1}): #{decision.summary}"
239
+ return true
240
+ end
241
+ end
242
+ false
243
+ end
244
+
245
+ # --- error handling -----------------------------------------------------
246
+
247
+ # Runs a block for a phase; any failure is recorded, printed and turned
248
+ # into an Abort with the infrastructure exit code.
249
+ def phase(name, unit_id: nil)
250
+ yield
251
+ rescue Abort, StaleReservation, *RequeuePolicy::NON_REQUEUEABLE
252
+ raise
253
+ rescue BuildStateMissing => e
254
+ fail_with(e, phase: "redis", unit_id: unit_id, message: "build state for #{build_id} is gone: #{e.message}")
255
+ rescue CorruptBuild => e
256
+ fail_with(e, phase: "redis", unit_id: unit_id, message: "build #{build_id} is corrupt: #{e.message}")
257
+ rescue REDIS_ERROR => e
258
+ error = RedisUnreachable.new("Redis unreachable: #{e.class}: #{e.message}")
259
+ error.set_backtrace(e.backtrace)
260
+ fail_with(error, phase: "redis", unit_id: unit_id, record: false)
261
+ rescue InitFailed => e
262
+ fail_with(e, phase: name.to_s, unit_id: unit_id, message: init_failed_message(e))
263
+ rescue InfrastructureError => e
264
+ fail_with(e, phase: name.to_s, unit_id: unit_id)
265
+ rescue StandardError => e
266
+ fail_with(e, phase: name.to_s, unit_id: unit_id,
267
+ message: "#{e.class}: #{e.message}\n #{Array(e.backtrace).first(5).join("\n ")}")
268
+ end
269
+
270
+ def fail_with(error, phase:, unit_id: nil, message: error.message, record: true)
271
+ alert message
272
+ record_worker_error(error, phase: phase, unit_id: unit_id) if record
273
+ raise Abort, ExitCode::INFRASTRUCTURE
274
+ end
275
+
276
+ def record_worker_error(error, phase:, unit_id:)
277
+ return if queue.nil?
278
+
279
+ queue.record_worker_error(worker_id: worker_id, phase: phase, error: error, unit_id: unit_id)
280
+ rescue StandardError => e
281
+ alert "could not record worker error: #{e.class}: #{e.message}"
282
+ end
283
+
284
+ def init_failed_message(error)
285
+ lines = ["#{error.message}; spec files could not be loaded:"]
286
+ lines.concat(error.load_errors.map(&:to_s))
287
+ lines.join("\n")
288
+ end
289
+
290
+ # --- output -------------------------------------------------------------
291
+
292
+ def world = RSpec.world
293
+
294
+ def say(message) = @out.puts("[hopper #{worker_id}] #{message}")
295
+
296
+ def alert(message) = @err.puts("[hopper #{worker_id}] #{message}")
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "hopper/version"
6
+ require_relative "hopper/errors"
7
+ require_relative "hopper/config"
8
+ require_relative "hopper/unit"
9
+ require_relative "hopper/reservation"
10
+ require_relative "hopper/keys"
11
+ require_relative "hopper/manifest"
12
+ require_relative "hopper/attempt_log"
13
+ require_relative "hopper/queue"
14
+ require_relative "hopper/queue/redis_streams"
15
+ require_relative "hopper/fingerprint"
16
+ require_relative "hopper/example_reset"
17
+ require_relative "hopper/worker/buffering_reporter"
18
+ require_relative "hopper/worker/heartbeat"
19
+ require_relative "hopper/worker/requeue_policy"
20
+ require_relative "hopper/worker/runner"
21
+ require_relative "hopper/worker/suite"
22
+ require_relative "hopper/worker"
23
+ require_relative "hopper/report"
24
+ require_relative "hopper/ci_env"
25
+ require_relative "hopper/supervisor"
26
+ require_relative "hopper/cli/report"
27
+ require_relative "hopper/cli/formatter_args"
28
+ require_relative "hopper/cli/work"
29
+ require_relative "hopper/cli"
30
+
31
+ module RSpec
32
+ # Distributes an RSpec suite across CI workers through Redis Streams.
33
+ module Hopper
34
+ @before_fork_hooks = []
35
+ @after_fork_hooks = []
36
+
37
+ class << self
38
+ # Registers a block to run in the parent, once, before any child is forked
39
+ # in `--boot shared` mode. Close connections opened during boot here.
40
+ def before_fork(&block)
41
+ @before_fork_hooks << block
42
+ block
43
+ end
44
+
45
+ # Registers a block to run in every child after fork in `--boot shared`
46
+ # mode. The block receives the child's TEST_ENV_NUMBER value ("" or "2".."N").
47
+ def after_fork(&block)
48
+ @after_fork_hooks << block
49
+ block
50
+ end
51
+
52
+ # @api private
53
+ attr_reader :before_fork_hooks
54
+
55
+ # @api private
56
+ attr_reader :after_fork_hooks
57
+
58
+ # @api private
59
+ def reset_hooks!
60
+ @before_fork_hooks = []
61
+ @after_fork_hooks = []
62
+ end
63
+ end
64
+ end
65
+ end