rspecq-instructure 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +106 -0
- data/LICENSE +20 -0
- data/README.md +268 -0
- data/Rakefile +10 -0
- data/bin/rspecq +49 -0
- data/lib/rspecq/configuration.rb +103 -0
- data/lib/rspecq/formatters/README.md +4 -0
- data/lib/rspecq/formatters/example_count_recorder.rb +15 -0
- data/lib/rspecq/formatters/failure_recorder.rb +62 -0
- data/lib/rspecq/formatters/job_timing_recorder.rb +23 -0
- data/lib/rspecq/formatters/junit_formatter.rb +53 -0
- data/lib/rspecq/formatters/worker_heartbeat_recorder.rb +16 -0
- data/lib/rspecq/parser.rb +252 -0
- data/lib/rspecq/queue.rb +634 -0
- data/lib/rspecq/reporter.rb +195 -0
- data/lib/rspecq/version.rb +3 -0
- data/lib/rspecq/worker.rb +418 -0
- data/lib/rspecq.rb +15 -0
- metadata +206 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require "rspec_junit_formatter"
|
|
2
|
+
|
|
3
|
+
module RSpecQ
|
|
4
|
+
module Formatters
|
|
5
|
+
# Junit output formatter that handles outputting of requeued examples,
|
|
6
|
+
# parallel gem, and multiple suites per rspecq run.
|
|
7
|
+
class JUnitFormatter < RSpecJUnitFormatter
|
|
8
|
+
def initialize(queue, job, max_requeues, job_index, path)
|
|
9
|
+
@queue = queue
|
|
10
|
+
@job = job
|
|
11
|
+
@max_requeues = max_requeues
|
|
12
|
+
@requeued_passed_examples = []
|
|
13
|
+
@requeued_failed_examples = []
|
|
14
|
+
path = path.gsub(/{{TEST_ENV_NUMBER}}/, ENV["TEST_ENV_NUMBER"].to_s)
|
|
15
|
+
path = path.gsub(/{{JOB_INDEX}}/, job_index.to_s)
|
|
16
|
+
RSpec::Support::DirectoryMaker.mkdir_p(File.dirname(path))
|
|
17
|
+
output_file = File.new(path, "w")
|
|
18
|
+
super(output_file)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def example_passed(notification)
|
|
22
|
+
# if it is a requeued run, store the notification
|
|
23
|
+
if !ENV["ERROR_CONTEXT_BASE_PATH"].nil?
|
|
24
|
+
@requeued_passed_examples << notification.example
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def example_failed(notification)
|
|
29
|
+
# if it is a requeued run, store the notification
|
|
30
|
+
if !ENV["ERROR_CONTEXT_BASE_PATH"].nil?
|
|
31
|
+
@requeued_failed_examples << notification.example
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def example_count
|
|
38
|
+
@summary_notification.example_count - (@requeued_passed_examples.size + @requeued_failed_examples.size)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def failure_count
|
|
42
|
+
@summary_notification.failure_count - @requeued_failed_examples.size
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def examples
|
|
46
|
+
ignore_examples = @requeued_failed_examples.union(@requeued_passed_examples)
|
|
47
|
+
@examples_notification.notifications.reject do |example_notification|
|
|
48
|
+
ignore_examples.map(&:id).include?(example_notification.example.id)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module RSpecQ
|
|
2
|
+
module Formatters
|
|
3
|
+
# Updates the respective heartbeat key of the worker after each example.
|
|
4
|
+
#
|
|
5
|
+
# Refer to the documentation of the worker_liveness_sec configuration option for more info.
|
|
6
|
+
class WorkerHeartbeatRecorder
|
|
7
|
+
def initialize(worker)
|
|
8
|
+
@worker = worker
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def example_finished(*)
|
|
12
|
+
@worker.update_heartbeat
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module RSpecQ
|
|
4
|
+
class Parser
|
|
5
|
+
DEFAULT_REDIS_HOST = "127.0.0.1".freeze
|
|
6
|
+
DEFAULT_REPORT_TIMEOUT = 3600 # 1 hour
|
|
7
|
+
DEFAULT_MAX_REQUEUES = 3
|
|
8
|
+
DEFAULT_QUEUE_WAIT_TIMEOUT = 30
|
|
9
|
+
DEFAULT_FAIL_FAST = 0
|
|
10
|
+
DEFAULT_WORKER_LIVENESS_SEC = 60
|
|
11
|
+
DEFAULT_REDIS_CONNECT_TIMEOUT = 1.0
|
|
12
|
+
DEFAULT_REDIS_READ_TIMEOUT = 5.0
|
|
13
|
+
DEFAULT_REDIS_WRITE_TIMEOUT = 5.0
|
|
14
|
+
# Comma-separated backoff (seconds) before each reconnect attempt. An empty
|
|
15
|
+
# value disables reconnects. See Configuration for how this is parsed.
|
|
16
|
+
DEFAULT_REDIS_RECONNECT_ATTEMPTS = "0.05,0.1,0.25,0.5,1.0".freeze
|
|
17
|
+
|
|
18
|
+
def self.parse!(args)
|
|
19
|
+
new(args).parse!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :args, :opts
|
|
23
|
+
|
|
24
|
+
def initialize(args)
|
|
25
|
+
@args = args
|
|
26
|
+
@opts = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# This method mutates `args` in order to allow both rspecq and
|
|
30
|
+
# rspec options to be passed to rspecq. ["--build", "foo",
|
|
31
|
+
# "--", "--pattern", "bar"] will set `build: "foo"` for rspecq
|
|
32
|
+
# options and leave ["--pattern", "bar"] to be passed to rspec
|
|
33
|
+
def parse!
|
|
34
|
+
parse_args!
|
|
35
|
+
parse_env
|
|
36
|
+
|
|
37
|
+
# rubocop:disable Style/RaiseArgs
|
|
38
|
+
raise OptionParser::MissingArgument.new(:build) if opts[:build].nil?
|
|
39
|
+
raise OptionParser::MissingArgument.new(:worker) if !opts[:report] && opts[:worker].nil?
|
|
40
|
+
# rubocop:enable Style/RaiseArgs
|
|
41
|
+
|
|
42
|
+
opts
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def parse_args!
|
|
48
|
+
OptionParser.new do |o|
|
|
49
|
+
name = File.basename($PROGRAM_NAME)
|
|
50
|
+
|
|
51
|
+
o.banner = <<~BANNER
|
|
52
|
+
NAME:
|
|
53
|
+
#{name} - Optimally distribute and run RSpec suites among parallel workers
|
|
54
|
+
|
|
55
|
+
USAGE:
|
|
56
|
+
#{name} [<options>] [spec files or directories]
|
|
57
|
+
BANNER
|
|
58
|
+
|
|
59
|
+
o.separator ""
|
|
60
|
+
o.separator "OPTIONS:"
|
|
61
|
+
|
|
62
|
+
o.on("-b", "--build ID", "A unique identifier for the build. Should be " \
|
|
63
|
+
"common among workers participating in the same build.") do |v|
|
|
64
|
+
opts[:build] = v
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
o.on("-w", "--worker ID", "An identifier for the worker. Workers " \
|
|
68
|
+
"participating in the same build should have distinct IDs.") do |v|
|
|
69
|
+
opts[:worker] = v
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
o.on("--seed SEED", "The RSpec seed. Passing the seed can be helpful in " \
|
|
73
|
+
"many ways i.e reproduction and testing.") do |v|
|
|
74
|
+
opts[:seed] = v
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
o.on("-r", "--redis HOST", "Redis host to connect to " \
|
|
78
|
+
"(default: #{DEFAULT_REDIS_HOST}).") do |v|
|
|
79
|
+
puts "--redis is deprecated. Use --redis-host or --redis-url instead"
|
|
80
|
+
opts[:redis_host] = v
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
o.on("--redis-host HOST", "Redis host to connect to " \
|
|
84
|
+
"(default: #{DEFAULT_REDIS_HOST}).") do |v|
|
|
85
|
+
opts[:redis_host] = v
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
o.on("--redis-url URL", "The URL of the Redis host to connect to " \
|
|
89
|
+
"(e.g.: redis://127.0.0.1:6379/0).") do |v|
|
|
90
|
+
opts[:redis_url] = v
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
o.on("--redis-connect-timeout N", Float, "Seconds to wait when establishing a Redis " \
|
|
94
|
+
"connection (default: #{DEFAULT_REDIS_CONNECT_TIMEOUT}).") do |v|
|
|
95
|
+
opts[:redis_connect_timeout] = v
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
o.on("--redis-read-timeout N", Float, "Seconds to wait for a Redis read " \
|
|
99
|
+
"(default: #{DEFAULT_REDIS_READ_TIMEOUT}).") do |v|
|
|
100
|
+
opts[:redis_read_timeout] = v
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
o.on("--redis-write-timeout N", Float, "Seconds to wait for a Redis write " \
|
|
104
|
+
"(default: #{DEFAULT_REDIS_WRITE_TIMEOUT}).") do |v|
|
|
105
|
+
opts[:redis_write_timeout] = v
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
o.on("--redis-reconnect-attempts LIST", "Comma-separated backoff (seconds) " \
|
|
109
|
+
"before each Redis reconnect attempt, e.g. " \
|
|
110
|
+
"\"#{DEFAULT_REDIS_RECONNECT_ATTEMPTS}\". " \
|
|
111
|
+
"Empty disables reconnects.") do |v|
|
|
112
|
+
opts[:redis_reconnect_attempts] = v
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
o.on("--update-timings", "Update the global job timings key with the " \
|
|
116
|
+
"timings of this build. Note: This key is used as the basis for job " \
|
|
117
|
+
"scheduling.") do |v|
|
|
118
|
+
opts[:timings] = v
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
o.on("--timings-key KEY", "Promote timings to KEY instead of the default " \
|
|
122
|
+
"global timings key (requires --update-timings).") do |v|
|
|
123
|
+
opts[:timings_key] = v
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
o.on("--file-split-threshold N", Integer, "Split spec files slower than N " \
|
|
127
|
+
"seconds and schedule them as individual examples.") do |v|
|
|
128
|
+
opts[:file_split_threshold] = v
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
o.on("--chunk-target-duration N", Integer, "Target duration in seconds for " \
|
|
132
|
+
"time-balanced example chunks when splitting slow files " \
|
|
133
|
+
"(default: 30). Groups examples into chunks of approximately " \
|
|
134
|
+
"this duration to reduce Kernel.load overhead.") do |v|
|
|
135
|
+
opts[:chunk_target_duration] = v
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
o.on("--report", "Enable reporter mode: do not pull tests off the queue; " \
|
|
139
|
+
"instead print build progress and exit when it's " \
|
|
140
|
+
"finished.\n#{o.summary_indent * 9} " \
|
|
141
|
+
"Exits with a non-zero status code if there were any " \
|
|
142
|
+
"failures.") do |v|
|
|
143
|
+
opts[:report] = v
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
o.on("--report-timeout N", Integer, "Fail if build is not finished after " \
|
|
147
|
+
"N seconds. Only applicable if --report is enabled " \
|
|
148
|
+
"(default: #{DEFAULT_REPORT_TIMEOUT}).") do |v|
|
|
149
|
+
opts[:report_timeout] = v
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
o.on("--max-requeues N", Integer, "Retry failed examples up to N times " \
|
|
153
|
+
"before considering them legit failures " \
|
|
154
|
+
"(default: #{DEFAULT_MAX_REQUEUES}).") do |v|
|
|
155
|
+
opts[:max_requeues] = v
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
o.on("--queue-wait-timeout N", Integer, "Time to wait for a queue to be " \
|
|
159
|
+
"ready before considering it failed " \
|
|
160
|
+
"(default: #{DEFAULT_QUEUE_WAIT_TIMEOUT}).") do |v|
|
|
161
|
+
opts[:queue_wait_timeout] = v
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
o.on("--fail-fast N", Integer, "Abort build with a non-zero status code " \
|
|
165
|
+
"after N failed examples.") do |v|
|
|
166
|
+
opts[:fail_fast] = v
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
o.on("--reproduction", "Enable reproduction mode: run rspec on the given files " \
|
|
170
|
+
"and examples in the exact order they are given. Incompatible with " \
|
|
171
|
+
"--timings.") do |v|
|
|
172
|
+
opts[:reproduction] = v
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
o.on("--tag TAG", "Run examples with the specified tag, or exclude examples " \
|
|
176
|
+
"by prefixing the tag with ~ (e.g. ~slow). Repeatable. " \
|
|
177
|
+
"TAG is passed through to rspec.") do |tag|
|
|
178
|
+
(opts[:tags] ||= []) << tag
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
o.on("--junit-output filepath", String, "Output junit formatted xml " \
|
|
182
|
+
"for CI suites to the defined file path. Substitution parameters " \
|
|
183
|
+
"{{TEST_ENV_NUMBER}} - parallel gem proc number. " \
|
|
184
|
+
"{{JOB_INDEX}} - increments with each suite that is run.") do |v|
|
|
185
|
+
opts[:junit_output] = v
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
o.on("--include-pattern PATTERN", "Run tests matching the regex") do |v|
|
|
189
|
+
opts[:include_pattern] = /#{v}/
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
o.on("--exclude-pattern PATTERN", "Regex of tests to exclude from run.") do |v|
|
|
193
|
+
opts[:exclude_pattern] = /#{v}/
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
o.on("--worker-liveness-sec N", Integer, "Seconds before a worker is considered dead " \
|
|
197
|
+
"(default: #{DEFAULT_WORKER_LIVENESS_SEC})") do |v|
|
|
198
|
+
opts[:worker_liveness_sec] = v
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
o.on_tail("-h", "--help", "Show this message.") do
|
|
202
|
+
puts o
|
|
203
|
+
exit
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
o.on_tail("-v", "--version", "Print the version and exit.") do
|
|
207
|
+
puts "#{name} #{RSpecQ::VERSION}"
|
|
208
|
+
exit
|
|
209
|
+
end
|
|
210
|
+
end.parse!(args)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def parse_env
|
|
214
|
+
opts[:build] ||= ENV.fetch("RSPECQ_BUILD", nil)
|
|
215
|
+
opts[:worker] ||= ENV.fetch("RSPECQ_WORKER", nil)
|
|
216
|
+
opts[:seed] ||= ENV.fetch("RSPECQ_SEED", nil)
|
|
217
|
+
opts[:redis_host] ||= ENV["RSPECQ_REDIS"] || DEFAULT_REDIS_HOST
|
|
218
|
+
opts[:timings] = opts.fetch(:timings, env_set?("RSPECQ_UPDATE_TIMINGS"))
|
|
219
|
+
opts[:timings_key] ||= ENV.fetch("RSPECQ_TIMINGS_KEY", nil)
|
|
220
|
+
opts[:file_split_threshold] ||= Integer(ENV["RSPECQ_FILE_SPLIT_THRESHOLD"] || 9_999_999)
|
|
221
|
+
opts[:report] = opts.fetch(:report, env_set?("RSPECQ_REPORT"))
|
|
222
|
+
opts[:report_timeout] ||= Integer(ENV["RSPECQ_REPORT_TIMEOUT"] || DEFAULT_REPORT_TIMEOUT)
|
|
223
|
+
opts[:max_requeues] ||= Integer(ENV["RSPECQ_MAX_REQUEUES"] || DEFAULT_MAX_REQUEUES)
|
|
224
|
+
opts[:queue_wait_timeout] ||= Integer(ENV["RSPECQ_QUEUE_WAIT_TIMEOUT"] || DEFAULT_QUEUE_WAIT_TIMEOUT)
|
|
225
|
+
opts[:redis_url] ||= ENV.fetch("RSPECQ_REDIS_URL", nil)
|
|
226
|
+
opts[:redis_connect_timeout] ||= env_float("RSPECQ_REDIS_CONNECT_TIMEOUT", DEFAULT_REDIS_CONNECT_TIMEOUT)
|
|
227
|
+
opts[:redis_read_timeout] ||= env_float("RSPECQ_REDIS_READ_TIMEOUT", DEFAULT_REDIS_READ_TIMEOUT)
|
|
228
|
+
opts[:redis_write_timeout] ||= env_float("RSPECQ_REDIS_WRITE_TIMEOUT", DEFAULT_REDIS_WRITE_TIMEOUT)
|
|
229
|
+
opts[:redis_reconnect_attempts] ||= ENV["RSPECQ_REDIS_RECONNECT_ATTEMPTS"] || DEFAULT_REDIS_RECONNECT_ATTEMPTS
|
|
230
|
+
opts[:fail_fast] ||= Integer(ENV["RSPECQ_FAIL_FAST"] || DEFAULT_FAIL_FAST)
|
|
231
|
+
opts[:reproduction] ||= env_set?("RSPECQ_REPRODUCTION")
|
|
232
|
+
opts[:tags] ||= []
|
|
233
|
+
opts[:junit_output] ||= ENV.fetch("RSPECQ_JUNIT_OUTPUT", nil)
|
|
234
|
+
opts[:include_pattern] ||= ENV.fetch("INCLUDE_PATTERN", nil)
|
|
235
|
+
opts[:exclude_pattern] ||= ENV.fetch("EXCLUDE_PATTERN", nil)
|
|
236
|
+
opts[:worker_liveness_sec] ||= Integer(ENV["RSPECQ_WORKER_LIVENESS_SEC"] || DEFAULT_WORKER_LIVENESS_SEC)
|
|
237
|
+
opts[:chunk_target_duration] ||= Integer(ENV["RSPECQ_CHUNK_TARGET_DURATION"] || 30)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def env_set?(var)
|
|
241
|
+
["1", "true"].include?(ENV.fetch(var, nil))
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# A Float from `var`, treating a blank value (unset or empty/whitespace) as
|
|
245
|
+
# absent and falling back to `default`. Jenkins string parameters commonly
|
|
246
|
+
# default to "", which would otherwise crash Float("").
|
|
247
|
+
def env_float(var, default)
|
|
248
|
+
value = ENV.fetch(var, nil)
|
|
249
|
+
value.nil? || value.strip.empty? ? default : Float(value)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|