rspecq 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -2
- data/README.md +1 -1
- data/Rakefile +1 -1
- data/bin/rspecq +16 -4
- data/lib/rspecq/formatters/failure_recorder.rb +11 -3
- data/lib/rspecq/formatters/job_timing_recorder.rb +3 -0
- data/lib/rspecq/formatters/worker_heartbeat_recorder.rb +0 -1
- data/lib/rspecq/queue.rb +5 -4
- data/lib/rspecq/reporter.rb +8 -7
- data/lib/rspecq/version.rb +1 -1
- data/lib/rspecq/worker.rb +20 -13
- metadata +20 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2abc0b960b2d28528c0d8a4f9fff7dc0708fd582c7377af021b6c01777350124
|
4
|
+
data.tar.gz: 23d432f09a68ace0932d3c82a112fd0e7058357fa4029c94401a75b84240d5b7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9c499c1ded556a66e9547e71ebfc3f4cdefd6ec984269986b7ae2e1078a86e157ed9805d57afd615f7e9044869124b8f2359095c65febf2d39b3065191e450b7
|
7
|
+
data.tar.gz: 050a8b4eb4983234cbd2d42a24590c9d0914833231c16692f273020c297757c8afca453f09f9ff72a6ec10564809c81356f3ab23c9220644fbcba89f7f0a90a3
|
data/CHANGELOG.md
CHANGED
@@ -1,8 +1,16 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## master (unreleased)
|
4
|
+
|
3
5
|
Breaking changes are prefixed with a "[BREAKING]" label.
|
4
6
|
|
5
|
-
##
|
7
|
+
## 0.5.0 (2021-02-05)
|
8
|
+
|
9
|
+
### Added
|
10
|
+
|
11
|
+
- New cli parameter `queue_wait_timeout`.
|
12
|
+
It configured the time a queue can wait to be ready. The env equivalent
|
13
|
+
is `RSPECQ_QUEUE_WAIT_TIMEOUT`. [#51](https://github.com/skroutz/rspecq/pull/51)
|
6
14
|
|
7
15
|
## 0.4.0 (2020-10-07)
|
8
16
|
|
@@ -27,7 +35,7 @@ Breaking changes are prefixed with a "[BREAKING]" label.
|
|
27
35
|
## 0.2.2 (2020-09-10)
|
28
36
|
|
29
37
|
### Fixed
|
30
|
-
- Worker would fail if application code was writing to stderr
|
38
|
+
- Worker would fail if application code was writing to stderr
|
31
39
|
[[#35](https://github.com/skroutz/rspecq/pull/35)]
|
32
40
|
|
33
41
|
## 0.2.1 (2020-09-09)
|
data/README.md
CHANGED
@@ -26,7 +26,6 @@ and [ci-queue](https://github.com/Shopify/ci-queue).
|
|
26
26
|
- Handles intermittent worker failures (e.g. network hiccups, faulty hardware etc.)
|
27
27
|
by detecting non-responsive workers and requeing their jobs. See [*Worker failures*](#worker-failures)
|
28
28
|
- Sentry integration for monitoring build-level events. See [*Sentry integration*](#sentry-integration).
|
29
|
-
- [PLANNED] StatsD integration for various build-level metrics and insights.
|
30
29
|
See [#2](https://github.com/skroutz/rspecq/issues/2).
|
31
30
|
- Automatic termination of builds after a certain amount of failures. See [*Fail-fast*](#fail-fast).
|
32
31
|
|
@@ -74,6 +73,7 @@ OPTIONS:
|
|
74
73
|
Exits with a non-zero status code if there were any failures.
|
75
74
|
--report-timeout N Fail if build is not finished after N seconds. Only applicable if --report is enabled (default: 3600).
|
76
75
|
--max-requeues N Retry failed examples up to N times before considering them legit failures (default: 3).
|
76
|
+
--queue-wait-timeout N Time to wait for a queue to be ready before considering it failed (default: 30).
|
77
77
|
--fail-fast N Abort build with a non-zero status code after N failed examples.
|
78
78
|
-h, --help Show this message.
|
79
79
|
-v, --version Print the version and exit.
|
data/Rakefile
CHANGED
data/bin/rspecq
CHANGED
@@ -2,9 +2,10 @@
|
|
2
2
|
require "optparse"
|
3
3
|
require "rspecq"
|
4
4
|
|
5
|
-
DEFAULT_REDIS_HOST = "127.0.0.1"
|
5
|
+
DEFAULT_REDIS_HOST = "127.0.0.1".freeze
|
6
6
|
DEFAULT_REPORT_TIMEOUT = 3600 # 1 hour
|
7
7
|
DEFAULT_MAX_REQUEUES = 3
|
8
|
+
DEFAULT_QUEUE_WAIT_TIMEOUT = 30
|
8
9
|
DEFAULT_FAIL_FAST = 0
|
9
10
|
|
10
11
|
def env_set?(var)
|
@@ -66,7 +67,7 @@ OptionParser.new do |o|
|
|
66
67
|
|
67
68
|
o.on("--report", "Enable reporter mode: do not pull tests off the queue; " \
|
68
69
|
"instead print build progress and exit when it's " \
|
69
|
-
"finished.\n#{o.summary_indent*9} " \
|
70
|
+
"finished.\n#{o.summary_indent * 9} " \
|
70
71
|
"Exits with a non-zero status code if there were any " \
|
71
72
|
"failures.") do |v|
|
72
73
|
opts[:report] = v
|
@@ -84,8 +85,14 @@ OptionParser.new do |o|
|
|
84
85
|
opts[:max_requeues] = v
|
85
86
|
end
|
86
87
|
|
88
|
+
o.on("--queue-wait-timeout N", Integer, "Time to wait for a queue to be " \
|
89
|
+
"ready before considering it failed " \
|
90
|
+
"(default: #{DEFAULT_QUEUE_WAIT_TIMEOUT}).") do |v|
|
91
|
+
opts[:queue_wait_timeout] = v
|
92
|
+
end
|
93
|
+
|
87
94
|
o.on("--fail-fast N", Integer, "Abort build with a non-zero status code " \
|
88
|
-
"after N failed examples."
|
95
|
+
"after N failed examples.") do |v|
|
89
96
|
opts[:fail_fast] = v
|
90
97
|
end
|
91
98
|
|
@@ -104,15 +111,18 @@ opts[:build] ||= ENV["RSPECQ_BUILD"]
|
|
104
111
|
opts[:worker] ||= ENV["RSPECQ_WORKER"]
|
105
112
|
opts[:redis_host] ||= ENV["RSPECQ_REDIS"] || DEFAULT_REDIS_HOST
|
106
113
|
opts[:timings] ||= env_set?("RSPECQ_UPDATE_TIMINGS")
|
107
|
-
opts[:file_split_threshold] ||= Integer(ENV["RSPECQ_FILE_SPLIT_THRESHOLD"] ||
|
114
|
+
opts[:file_split_threshold] ||= Integer(ENV["RSPECQ_FILE_SPLIT_THRESHOLD"] || 9_999_999)
|
108
115
|
opts[:report] ||= env_set?("RSPECQ_REPORT")
|
109
116
|
opts[:report_timeout] ||= Integer(ENV["RSPECQ_REPORT_TIMEOUT"] || DEFAULT_REPORT_TIMEOUT)
|
110
117
|
opts[:max_requeues] ||= Integer(ENV["RSPECQ_MAX_REQUEUES"] || DEFAULT_MAX_REQUEUES)
|
118
|
+
opts[:queue_wait_timeout] ||= Integer(ENV["RSPECQ_QUEUE_WAIT_TIMEOUT"] || DEFAULT_QUEUE_WAIT_TIMEOUT)
|
111
119
|
opts[:redis_url] ||= ENV["RSPECQ_REDIS_URL"]
|
112
120
|
opts[:fail_fast] ||= Integer(ENV["RSPECQ_FAIL_FAST"] || DEFAULT_FAIL_FAST)
|
113
121
|
|
122
|
+
# rubocop:disable Style/RaiseArgs, Layout/EmptyLineAfterGuardClause
|
114
123
|
raise OptionParser::MissingArgument.new(:build) if opts[:build].nil?
|
115
124
|
raise OptionParser::MissingArgument.new(:worker) if !opts[:report] && opts[:worker].nil?
|
125
|
+
# rubocop:enable Style/RaiseArgs, Layout/EmptyLineAfterGuardClause
|
116
126
|
|
117
127
|
redis_opts = {}
|
118
128
|
|
@@ -127,6 +137,7 @@ if opts[:report]
|
|
127
137
|
build_id: opts[:build],
|
128
138
|
timeout: opts[:report_timeout],
|
129
139
|
redis_opts: redis_opts,
|
140
|
+
queue_wait_timeout: opts[:queue_wait_timeout]
|
130
141
|
)
|
131
142
|
|
132
143
|
reporter.report
|
@@ -141,6 +152,7 @@ else
|
|
141
152
|
worker.populate_timings = opts[:timings]
|
142
153
|
worker.file_split_threshold = opts[:file_split_threshold]
|
143
154
|
worker.max_requeues = opts[:max_requeues]
|
155
|
+
worker.queue_wait_timeout = opts[:queue_wait_timeout]
|
144
156
|
worker.fail_fast = opts[:fail_fast]
|
145
157
|
worker.work
|
146
158
|
end
|
@@ -1,5 +1,10 @@
|
|
1
1
|
module RSpecQ
|
2
2
|
module Formatters
|
3
|
+
# Persists failed examples information (i.e. message and backtrace), so
|
4
|
+
# that they can be reported to the end user by the Reporter.
|
5
|
+
#
|
6
|
+
# Also persists non-example error information (e.g. a syntax error that
|
7
|
+
# in a spec file).
|
3
8
|
class FailureRecorder
|
4
9
|
def initialize(queue, job, max_requeues)
|
5
10
|
@queue = queue
|
@@ -33,16 +38,19 @@ module RSpecQ
|
|
33
38
|
end
|
34
39
|
|
35
40
|
presenter = RSpec::Core::Formatters::ExceptionPresenter.new(
|
36
|
-
example.exception, example
|
41
|
+
example.exception, example
|
42
|
+
)
|
37
43
|
|
38
44
|
msg = presenter.fully_formatted(nil, @colorizer)
|
39
45
|
msg << "\n"
|
40
46
|
msg << @colorizer.wrap(
|
41
47
|
"bin/rspec #{example.location_rerun_argument}",
|
42
|
-
RSpec.configuration.failure_color
|
48
|
+
RSpec.configuration.failure_color
|
49
|
+
)
|
43
50
|
|
44
51
|
msg << @colorizer.wrap(
|
45
|
-
" # #{example.full_description}", RSpec.configuration.detail_color
|
52
|
+
" # #{example.full_description}", RSpec.configuration.detail_color
|
53
|
+
)
|
46
54
|
|
47
55
|
@queue.record_example_failure(notification.example.id, msg)
|
48
56
|
end
|
@@ -1,5 +1,8 @@
|
|
1
1
|
module RSpecQ
|
2
2
|
module Formatters
|
3
|
+
# Persists each job's timing (in seconds). Those timings are used when
|
4
|
+
# determining the ordering in which jobs are scheduled (slower jobs will
|
5
|
+
# be enqueued first).
|
3
6
|
class JobTimingRecorder
|
4
7
|
def initialize(queue, job)
|
5
8
|
@queue = queue
|
data/lib/rspecq/queue.rb
CHANGED
@@ -79,7 +79,7 @@ module RSpecQ
|
|
79
79
|
# NOTE: jobs will be processed from head to tail (lpop)
|
80
80
|
def publish(jobs, fail_fast = 0)
|
81
81
|
@redis.multi do
|
82
|
-
@redis.hset(key_queue_config,
|
82
|
+
@redis.hset(key_queue_config, "fail_fast", fail_fast)
|
83
83
|
@redis.rpush(key_queue_unprocessed, jobs)
|
84
84
|
@redis.set(key_queue_status, STATUS_READY)
|
85
85
|
end.first
|
@@ -131,7 +131,7 @@ module RSpecQ
|
|
131
131
|
@redis.eval(
|
132
132
|
REQUEUE_JOB,
|
133
133
|
keys: [key_queue_unprocessed, key_requeues],
|
134
|
-
argv: [job, max_requeues]
|
134
|
+
argv: [job, max_requeues]
|
135
135
|
)
|
136
136
|
end
|
137
137
|
|
@@ -210,9 +210,10 @@ module RSpecQ
|
|
210
210
|
@redis.get(key_queue_status) == STATUS_READY
|
211
211
|
end
|
212
212
|
|
213
|
-
def wait_until_published(timeout=30)
|
213
|
+
def wait_until_published(timeout = 30)
|
214
214
|
(timeout * 10).times do
|
215
215
|
return if published?
|
216
|
+
|
216
217
|
sleep 0.1
|
217
218
|
end
|
218
219
|
|
@@ -250,7 +251,7 @@ module RSpecQ
|
|
250
251
|
def fail_fast
|
251
252
|
return nil unless published?
|
252
253
|
|
253
|
-
@fail_fast ||= Integer(@redis.hget(key_queue_config,
|
254
|
+
@fail_fast ||= Integer(@redis.hget(key_queue_config, "fail_fast"))
|
254
255
|
end
|
255
256
|
|
256
257
|
# Returns true if the number of failed tests, has surpassed the threshold
|
data/lib/rspecq/reporter.rb
CHANGED
@@ -9,18 +9,19 @@ module RSpecQ
|
|
9
9
|
#
|
10
10
|
# Reporters are readers of the queue.
|
11
11
|
class Reporter
|
12
|
-
def initialize(build_id:, timeout:, redis_opts:)
|
12
|
+
def initialize(build_id:, timeout:, redis_opts:, queue_wait_timeout: 30)
|
13
13
|
@build_id = build_id
|
14
14
|
@timeout = timeout
|
15
15
|
@queue = Queue.new(build_id, "reporter", redis_opts)
|
16
|
+
@queue_wait_timeout = queue_wait_timeout
|
16
17
|
|
17
18
|
# We want feedback to be immediattely printed to CI users, so
|
18
19
|
# we disable buffering.
|
19
|
-
|
20
|
+
$stdout.sync = true
|
20
21
|
end
|
21
22
|
|
22
23
|
def report
|
23
|
-
@queue.wait_until_published
|
24
|
+
@queue.wait_until_published(@queue_wait_timeout)
|
24
25
|
|
25
26
|
finished = false
|
26
27
|
|
@@ -28,7 +29,7 @@ module RSpecQ
|
|
28
29
|
failure_heading_printed = false
|
29
30
|
|
30
31
|
tests_duration = measure_duration do
|
31
|
-
@timeout.times do
|
32
|
+
@timeout.times do
|
32
33
|
@queue.example_failures.each do |job, rspec_output|
|
33
34
|
next if reported_failures[job]
|
34
35
|
|
@@ -124,13 +125,13 @@ module RSpecQ
|
|
124
125
|
return if jobs.empty?
|
125
126
|
|
126
127
|
jobs.each do |job|
|
127
|
-
filename = job.sub(/\[.+\]/,
|
128
|
+
filename = job.sub(/\[.+\]/, "")
|
128
129
|
|
129
130
|
extra = {
|
130
131
|
build: @build_id,
|
131
132
|
build_timeout: @timeout,
|
132
133
|
queue: @queue.inspect,
|
133
|
-
object:
|
134
|
+
object: inspect,
|
134
135
|
pid: Process.pid,
|
135
136
|
job_path: job,
|
136
137
|
build_duration: build_duration
|
@@ -143,7 +144,7 @@ module RSpecQ
|
|
143
144
|
|
144
145
|
Raven.capture_message(
|
145
146
|
"Flaky test in #{filename}",
|
146
|
-
level:
|
147
|
+
level: "warning",
|
147
148
|
extra: extra,
|
148
149
|
tags: tags
|
149
150
|
)
|
data/lib/rspecq/version.rb
CHANGED
data/lib/rspecq/worker.rb
CHANGED
@@ -46,6 +46,11 @@ module RSpecQ
|
|
46
46
|
# Defaults to 0
|
47
47
|
attr_accessor :fail_fast
|
48
48
|
|
49
|
+
# Time to wait for a queue to be published.
|
50
|
+
#
|
51
|
+
# Defaults to 30
|
52
|
+
attr_accessor :queue_wait_timeout
|
53
|
+
|
49
54
|
attr_reader :queue
|
50
55
|
|
51
56
|
def initialize(build_id:, worker_id:, redis_opts:)
|
@@ -55,9 +60,10 @@ module RSpecQ
|
|
55
60
|
@fail_fast = 0
|
56
61
|
@files_or_dirs_to_run = "spec"
|
57
62
|
@populate_timings = false
|
58
|
-
@file_split_threshold =
|
63
|
+
@file_split_threshold = 999_999
|
59
64
|
@heartbeat_updated_at = nil
|
60
65
|
@max_requeues = 3
|
66
|
+
@queue_wait_timeout = 30
|
61
67
|
|
62
68
|
RSpec::Core::Formatters.register(Formatters::JobTimingRecorder, :dump_summary)
|
63
69
|
RSpec::Core::Formatters.register(Formatters::ExampleCountRecorder, :dump_summary)
|
@@ -69,7 +75,7 @@ module RSpecQ
|
|
69
75
|
puts "Working for build #{@build_id} (worker=#{@worker_id})"
|
70
76
|
|
71
77
|
try_publish_queue!(queue)
|
72
|
-
queue.wait_until_published
|
78
|
+
queue.wait_until_published(queue_wait_timeout)
|
73
79
|
|
74
80
|
loop do
|
75
81
|
# we have to bootstrap this so that it can be used in the first call
|
@@ -98,7 +104,7 @@ module RSpecQ
|
|
98
104
|
# reconfigure rspec
|
99
105
|
RSpec.configuration.detail_color = :magenta
|
100
106
|
RSpec.configuration.seed = srand && srand % 0xFFFF
|
101
|
-
RSpec.configuration.backtrace_formatter.filter_gem(
|
107
|
+
RSpec.configuration.backtrace_formatter.filter_gem("rspecq")
|
102
108
|
RSpec.configuration.add_formatter(Formatters::FailureRecorder.new(queue, job, max_requeues))
|
103
109
|
RSpec.configuration.add_formatter(Formatters::ExampleCountRecorder.new(queue))
|
104
110
|
RSpec.configuration.add_formatter(Formatters::WorkerHeartbeatRecorder.new(self))
|
@@ -155,7 +161,7 @@ module RSpecQ
|
|
155
161
|
jobs.concat(files_to_run)
|
156
162
|
end
|
157
163
|
|
158
|
-
default_timing = timings.values[timings.values.size/2]
|
164
|
+
default_timing = timings.values[timings.values.size / 2]
|
159
165
|
|
160
166
|
# assign timings (based on previous runs) to all jobs
|
161
167
|
jobs = jobs.each_with_object({}) do |j, h|
|
@@ -180,7 +186,8 @@ module RSpecQ
|
|
180
186
|
# see https://github.com/rspec/rspec-core/pull/2723
|
181
187
|
if Gem::Version.new(RSpec::Core::Version::STRING) <= Gem::Version.new("3.9.1")
|
182
188
|
RSpec.world.instance_variable_set(
|
183
|
-
:@example_group_counts_by_spec_file, Hash.new(0)
|
189
|
+
:@example_group_counts_by_spec_file, Hash.new(0)
|
190
|
+
)
|
184
191
|
end
|
185
192
|
|
186
193
|
# RSpec.clear_examples does not reset those, which causes issues when
|
@@ -204,17 +211,17 @@ module RSpecQ
|
|
204
211
|
|
205
212
|
if !cmd_result.success?
|
206
213
|
rspec_output = begin
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
214
|
+
JSON.parse(out)
|
215
|
+
rescue JSON::ParserError
|
216
|
+
out
|
217
|
+
end
|
211
218
|
|
212
219
|
log_event(
|
213
220
|
"Failed to split slow files, falling back to regular scheduling.\n #{err}",
|
214
221
|
"error",
|
215
222
|
rspec_stdout: rspec_output,
|
216
223
|
rspec_stderr: err,
|
217
|
-
cmd_result: cmd_result.inspect
|
224
|
+
cmd_result: cmd_result.inspect
|
218
225
|
)
|
219
226
|
|
220
227
|
pp rspec_output
|
@@ -236,7 +243,7 @@ module RSpecQ
|
|
236
243
|
|
237
244
|
# Prints msg to standard output and emits an event to Sentry, if the
|
238
245
|
# SENTRY_DSN environment variable is set.
|
239
|
-
def log_event(msg, level, additional={})
|
246
|
+
def log_event(msg, level, additional = {})
|
240
247
|
puts msg
|
241
248
|
|
242
249
|
Raven.capture_message(msg, level: level, extra: {
|
@@ -247,8 +254,8 @@ module RSpecQ
|
|
247
254
|
populate_timings: populate_timings,
|
248
255
|
file_split_threshold: file_split_threshold,
|
249
256
|
heartbeat_updated_at: @heartbeat_updated_at,
|
250
|
-
object:
|
251
|
-
pid: Process.pid
|
257
|
+
object: inspect,
|
258
|
+
pid: Process.pid
|
252
259
|
}.merge(additional))
|
253
260
|
end
|
254
261
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rspecq
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Agis Anastasopoulos
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-02-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rspec-core
|
@@ -53,7 +53,7 @@ dependencies:
|
|
53
53
|
- !ruby/object:Gem::Version
|
54
54
|
version: '0'
|
55
55
|
- !ruby/object:Gem::Dependency
|
56
|
-
name:
|
56
|
+
name: minitest
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
58
58
|
requirements:
|
59
59
|
- - ">="
|
@@ -81,7 +81,7 @@ dependencies:
|
|
81
81
|
- !ruby/object:Gem::Version
|
82
82
|
version: '0'
|
83
83
|
- !ruby/object:Gem::Dependency
|
84
|
-
name:
|
84
|
+
name: rake
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
86
86
|
requirements:
|
87
87
|
- - ">="
|
@@ -108,6 +108,20 @@ dependencies:
|
|
108
108
|
- - ">="
|
109
109
|
- !ruby/object:Gem::Version
|
110
110
|
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rubocop
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: 0.93.0
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: 0.93.0
|
111
125
|
description:
|
112
126
|
email: agis.anast@gmail.com
|
113
127
|
executables:
|
@@ -149,7 +163,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
149
163
|
- !ruby/object:Gem::Version
|
150
164
|
version: '0'
|
151
165
|
requirements: []
|
152
|
-
|
166
|
+
rubyforge_project:
|
167
|
+
rubygems_version: 2.7.6.2
|
153
168
|
signing_key:
|
154
169
|
specification_version: 4
|
155
170
|
summary: Optimally distribute and run RSpec suites among parallel workers; for faster
|