specbandit 0.4.0 → 0.7.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/lib/specbandit/cli.rb +11 -0
- data/lib/specbandit/configuration.rb +6 -1
- data/lib/specbandit/version.rb +1 -1
- data/lib/specbandit/worker.rb +228 -21
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e9239010371ceb4ec5c34c959413de19ecb62d31004010c4cccd74a7f558f96
|
|
4
|
+
data.tar.gz: 740235b2c5e7174b0b0b5aba2f0da1632db3b789a9deca1800ee3f61568bf0be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7446e60aee84c46d4ea8e1c5c59307c7bd9593b2ba082b9f30dc4835e531b5343291cd1f42be8015f48c808faf8f65f93cd43610a799133ebffda4723b7e1a92
|
|
7
|
+
data.tar.gz: 6a698f1ccb2e6fd7840171f97ad1ffaad5b2f1e00a66349e6bcc4ea79b34768c821516bd3552ef82188c6d8cb6abc2b1218859c7981850e39ac52950b81444df
|
data/lib/specbandit/cli.rb
CHANGED
|
@@ -111,6 +111,10 @@ module Specbandit
|
|
|
111
111
|
Specbandit.configuration.key_rerun_ttl = v
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
opts.on('--verbose', 'Show per-batch file list and full RSpec output (default: quiet)') do
|
|
115
|
+
Specbandit.configuration.verbose = true
|
|
116
|
+
end
|
|
117
|
+
|
|
114
118
|
opts.on('-h', '--help', 'Show this help') do
|
|
115
119
|
puts opts
|
|
116
120
|
return 0
|
|
@@ -120,6 +124,10 @@ module Specbandit
|
|
|
120
124
|
parser.parse!(argv)
|
|
121
125
|
Specbandit.configuration.validate!
|
|
122
126
|
|
|
127
|
+
# Remaining args after `--` are treated as extra rspec options.
|
|
128
|
+
# This allows: specbandit work --key KEY -- --format json --out results.json
|
|
129
|
+
Specbandit.configuration.rspec_opts = argv if argv.any?
|
|
130
|
+
|
|
123
131
|
worker = Worker.new
|
|
124
132
|
worker.run
|
|
125
133
|
end
|
|
@@ -145,6 +153,8 @@ module Specbandit
|
|
|
145
153
|
--rspec-opts OPTS Extra options forwarded to RSpec
|
|
146
154
|
--key-rerun KEY Per-runner rerun key for re-run support
|
|
147
155
|
--key-rerun-ttl N TTL for rerun key (default: 604800 / 1 week)
|
|
156
|
+
--verbose Show per-batch file list and full RSpec output
|
|
157
|
+
-- OPTS... Pass remaining args as RSpec options (alternative to --rspec-opts)
|
|
148
158
|
|
|
149
159
|
Environment variables:
|
|
150
160
|
SPECBANDIT_KEY Queue key
|
|
@@ -154,6 +164,7 @@ module Specbandit
|
|
|
154
164
|
SPECBANDIT_RSPEC_OPTS RSpec options
|
|
155
165
|
SPECBANDIT_KEY_RERUN Per-runner rerun key
|
|
156
166
|
SPECBANDIT_KEY_RERUN_TTL Rerun key TTL in seconds (default: 604800)
|
|
167
|
+
SPECBANDIT_VERBOSE Enable verbose output (1/true/yes)
|
|
157
168
|
|
|
158
169
|
File input priority for push:
|
|
159
170
|
1. stdin (piped) echo "spec/a_spec.rb" | specbandit push --key KEY
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Specbandit
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :redis_url, :batch_size, :key, :rspec_opts, :key_ttl,
|
|
6
|
-
:key_rerun, :key_rerun_ttl
|
|
6
|
+
:key_rerun, :key_rerun_ttl, :verbose
|
|
7
7
|
|
|
8
8
|
DEFAULT_REDIS_URL = 'redis://localhost:6379'
|
|
9
9
|
DEFAULT_BATCH_SIZE = 5
|
|
@@ -18,6 +18,7 @@ module Specbandit
|
|
|
18
18
|
@key_ttl = Integer(ENV.fetch('SPECBANDIT_KEY_TTL', DEFAULT_KEY_TTL))
|
|
19
19
|
@key_rerun = ENV.fetch('SPECBANDIT_KEY_RERUN', nil)
|
|
20
20
|
@key_rerun_ttl = Integer(ENV.fetch('SPECBANDIT_KEY_RERUN_TTL', DEFAULT_KEY_RERUN_TTL))
|
|
21
|
+
@verbose = env_truthy?('SPECBANDIT_VERBOSE')
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
def validate!
|
|
@@ -34,5 +35,9 @@ module Specbandit
|
|
|
34
35
|
|
|
35
36
|
opts.split
|
|
36
37
|
end
|
|
38
|
+
|
|
39
|
+
def env_truthy?(name)
|
|
40
|
+
%w[1 true yes].include?(ENV.fetch(name, '').downcase)
|
|
41
|
+
end
|
|
37
42
|
end
|
|
38
43
|
end
|
data/lib/specbandit/version.rb
CHANGED
data/lib/specbandit/worker.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'stringio'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tempfile'
|
|
4
6
|
require 'rspec/core'
|
|
5
7
|
|
|
6
8
|
module Specbandit
|
|
7
9
|
class Worker
|
|
8
|
-
attr_reader :queue, :key, :batch_size, :rspec_opts, :key_rerun, :key_rerun_ttl, :output
|
|
10
|
+
attr_reader :queue, :key, :batch_size, :rspec_opts, :key_rerun, :key_rerun_ttl, :output, :verbose
|
|
9
11
|
|
|
10
12
|
def initialize(
|
|
11
13
|
key: Specbandit.configuration.key,
|
|
@@ -13,6 +15,7 @@ module Specbandit
|
|
|
13
15
|
rspec_opts: Specbandit.configuration.rspec_opts,
|
|
14
16
|
key_rerun: Specbandit.configuration.key_rerun,
|
|
15
17
|
key_rerun_ttl: Specbandit.configuration.key_rerun_ttl,
|
|
18
|
+
verbose: Specbandit.configuration.verbose,
|
|
16
19
|
queue: nil,
|
|
17
20
|
output: $stdout
|
|
18
21
|
)
|
|
@@ -21,24 +24,35 @@ module Specbandit
|
|
|
21
24
|
@rspec_opts = Array(rspec_opts)
|
|
22
25
|
@key_rerun = key_rerun
|
|
23
26
|
@key_rerun_ttl = key_rerun_ttl
|
|
27
|
+
@verbose = verbose
|
|
24
28
|
@queue = queue || RedisQueue.new
|
|
25
29
|
@output = output
|
|
30
|
+
@batch_durations = []
|
|
31
|
+
@accumulated_examples = []
|
|
32
|
+
@accumulated_summary = { duration: 0.0, example_count: 0, failure_count: 0, pending_count: 0,
|
|
33
|
+
errors_outside_of_examples_count: 0 }
|
|
26
34
|
end
|
|
27
35
|
|
|
28
36
|
# Main entry point. Detects the operating mode and dispatches accordingly.
|
|
29
37
|
#
|
|
30
38
|
# Returns 0 if all batches passed (or nothing to do), 1 if any batch failed.
|
|
31
39
|
def run
|
|
32
|
-
if key_rerun
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
exit_code = if key_rerun
|
|
41
|
+
rerun_files = queue.read_all(key_rerun)
|
|
42
|
+
if rerun_files.any?
|
|
43
|
+
run_replay(rerun_files)
|
|
44
|
+
else
|
|
45
|
+
run_steal(record: true)
|
|
46
|
+
end
|
|
47
|
+
else
|
|
48
|
+
run_steal(record: false)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
print_summary if @batch_durations.any?
|
|
52
|
+
merge_json_results
|
|
53
|
+
write_github_step_summary if ENV['GITHUB_STEP_SUMMARY']
|
|
54
|
+
|
|
55
|
+
exit_code
|
|
42
56
|
end
|
|
43
57
|
|
|
44
58
|
private
|
|
@@ -56,7 +70,7 @@ module Specbandit
|
|
|
56
70
|
files.each_slice(batch_size) do |batch|
|
|
57
71
|
batch_num += 1
|
|
58
72
|
output.puts "[specbandit] Batch ##{batch_num}: running #{batch.size} files"
|
|
59
|
-
batch.each { |f| output.puts " #{f}" }
|
|
73
|
+
batch.each { |f| output.puts " #{f}" } if verbose
|
|
60
74
|
|
|
61
75
|
exit_code = run_rspec_batch(batch)
|
|
62
76
|
if exit_code != 0
|
|
@@ -95,7 +109,7 @@ module Specbandit
|
|
|
95
109
|
|
|
96
110
|
batch_num += 1
|
|
97
111
|
output.puts "[specbandit] Batch ##{batch_num}: running #{files.size} files"
|
|
98
|
-
files.each { |f| output.puts " #{f}" }
|
|
112
|
+
files.each { |f| output.puts " #{f}" } if verbose
|
|
99
113
|
|
|
100
114
|
exit_code = run_rspec_batch(files)
|
|
101
115
|
if exit_code != 0
|
|
@@ -116,23 +130,216 @@ module Specbandit
|
|
|
116
130
|
end
|
|
117
131
|
|
|
118
132
|
def run_rspec_batch(files)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
#
|
|
122
|
-
|
|
133
|
+
reset_rspec_state
|
|
134
|
+
|
|
135
|
+
# Always write JSON to a tempfile so we can accumulate structured results
|
|
136
|
+
# regardless of whether the user passed --format json --out.
|
|
137
|
+
batch_json = Tempfile.new(['specbandit-batch', '.json'])
|
|
138
|
+
args = files + rspec_opts + ['--format', 'json', '--out', batch_json.path]
|
|
123
139
|
|
|
124
|
-
args = files + rspec_opts
|
|
125
140
|
err = StringIO.new
|
|
126
141
|
out = StringIO.new
|
|
127
142
|
|
|
128
|
-
|
|
143
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
144
|
+
exit_code = RSpec::Core::Runner.run(args, err, out)
|
|
145
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
146
|
+
@batch_durations << duration
|
|
147
|
+
|
|
148
|
+
accumulate_json_results(batch_json.path)
|
|
149
|
+
exit_code
|
|
129
150
|
ensure
|
|
130
151
|
# Print RSpec output through our output stream
|
|
131
152
|
rspec_output = out&.string
|
|
132
|
-
output.print(rspec_output)
|
|
153
|
+
output.print(rspec_output) if verbose && rspec_output && !rspec_output.empty?
|
|
133
154
|
|
|
134
155
|
rspec_err = err&.string
|
|
135
|
-
output.print(rspec_err)
|
|
156
|
+
output.print(rspec_err) if verbose && rspec_err && !rspec_err.empty?
|
|
157
|
+
|
|
158
|
+
batch_json&.close
|
|
159
|
+
batch_json&.unlink
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Reset RSpec state between batches so each batch runs cleanly.
|
|
163
|
+
#
|
|
164
|
+
# RSpec.clear_examples resets example groups, the reporter, filters, and
|
|
165
|
+
# the start-time clock -- but it leaves three critical pieces of state
|
|
166
|
+
# that cause cascading failures when running multiple batches in the
|
|
167
|
+
# same process:
|
|
168
|
+
#
|
|
169
|
+
# 1. output_stream -- After batch #1, Runner#configure sets
|
|
170
|
+
# output_stream to a StringIO. On batch #2+, the guard
|
|
171
|
+
# `if output_stream == $stdout` is permanently false, so the new
|
|
172
|
+
# `out` is never used. All RSpec output silently goes to the stale
|
|
173
|
+
# batch-1 StringIO.
|
|
174
|
+
#
|
|
175
|
+
# 2. wants_to_quit -- If any batch triggers a load error or fail-fast,
|
|
176
|
+
# this flag is set to true. On subsequent batches, Runner#setup
|
|
177
|
+
# returns immediately and Runner#run does exit_early -- specs are
|
|
178
|
+
# never loaded or run.
|
|
179
|
+
#
|
|
180
|
+
# 3. non_example_failure -- Once set, exit_code() unconditionally
|
|
181
|
+
# returns the failure exit code, even if all examples passed.
|
|
182
|
+
#
|
|
183
|
+
def reset_rspec_state
|
|
184
|
+
RSpec.clear_examples
|
|
185
|
+
RSpec.world.wants_to_quit = false
|
|
186
|
+
RSpec.world.non_example_failure = false
|
|
187
|
+
RSpec.configuration.output_stream = $stdout
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# --- Reporting helpers ---
|
|
191
|
+
|
|
192
|
+
# Extract the --out file path from rspec_opts.
|
|
193
|
+
# RSpec accepts: --out FILE or -o FILE
|
|
194
|
+
def json_output_path
|
|
195
|
+
rspec_opts.each_with_index do |opt, i|
|
|
196
|
+
return rspec_opts[i + 1] if ['--out', '-o'].include?(opt) && rspec_opts[i + 1]
|
|
197
|
+
end
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# After each batch, read the JSON output from the temp file and accumulate
|
|
202
|
+
# examples and summary fields.
|
|
203
|
+
def accumulate_json_results(path)
|
|
204
|
+
return unless path && File.exist?(path)
|
|
205
|
+
|
|
206
|
+
begin
|
|
207
|
+
data = JSON.parse(File.read(path))
|
|
208
|
+
rescue JSON::ParserError
|
|
209
|
+
return
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
@accumulated_examples.concat(data.fetch('examples', []))
|
|
213
|
+
|
|
214
|
+
summary = data.fetch('summary', {})
|
|
215
|
+
@accumulated_summary[:duration] += summary.fetch('duration', 0.0)
|
|
216
|
+
@accumulated_summary[:example_count] += summary.fetch('example_count', 0)
|
|
217
|
+
@accumulated_summary[:failure_count] += summary.fetch('failure_count', 0)
|
|
218
|
+
@accumulated_summary[:pending_count] += summary.fetch('pending_count', 0)
|
|
219
|
+
@accumulated_summary[:errors_outside_of_examples_count] += summary.fetch('errors_outside_of_examples_count', 0)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# After all batches, write the merged JSON back to the --out file so
|
|
223
|
+
# CI artifact collection picks up the complete results.
|
|
224
|
+
def merge_json_results
|
|
225
|
+
path = json_output_path
|
|
226
|
+
return unless path && @accumulated_examples.any?
|
|
227
|
+
|
|
228
|
+
merged = {
|
|
229
|
+
'version' => RSpec::Core::Version::STRING,
|
|
230
|
+
'specbandit_version' => Specbandit::VERSION,
|
|
231
|
+
'summary' => {
|
|
232
|
+
'duration' => @accumulated_summary[:duration],
|
|
233
|
+
'example_count' => @accumulated_summary[:example_count],
|
|
234
|
+
'failure_count' => @accumulated_summary[:failure_count],
|
|
235
|
+
'pending_count' => @accumulated_summary[:pending_count],
|
|
236
|
+
'errors_outside_of_examples_count' => @accumulated_summary[:errors_outside_of_examples_count]
|
|
237
|
+
},
|
|
238
|
+
'summary_line' => summary_line,
|
|
239
|
+
'examples' => @accumulated_examples,
|
|
240
|
+
'batch_timings' => {
|
|
241
|
+
'count' => @batch_durations.size,
|
|
242
|
+
'min' => @batch_durations.min&.round(2),
|
|
243
|
+
'avg' => @batch_durations.empty? ? 0 : (@batch_durations.sum / @batch_durations.size).round(2),
|
|
244
|
+
'max' => @batch_durations.max&.round(2),
|
|
245
|
+
'all' => @batch_durations.map { |d| d.round(2) }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
File.write(path, JSON.pretty_generate(merged))
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Print a unified summary to the output stream after all batches.
|
|
253
|
+
def print_summary
|
|
254
|
+
output.puts ''
|
|
255
|
+
output.puts '=' * 60
|
|
256
|
+
output.puts '[specbandit] Summary'
|
|
257
|
+
output.puts '=' * 60
|
|
258
|
+
output.puts " Batches: #{@batch_durations.size}"
|
|
259
|
+
output.puts " Examples: #{@accumulated_summary[:example_count]}"
|
|
260
|
+
output.puts " Failures: #{@accumulated_summary[:failure_count]}"
|
|
261
|
+
output.puts " Pending: #{@accumulated_summary[:pending_count]}"
|
|
262
|
+
|
|
263
|
+
output.puts ''
|
|
264
|
+
output.puts format(
|
|
265
|
+
' Batch timing: min %.1fs | avg %.1fs | max %.1fs',
|
|
266
|
+
@batch_durations.min || 0,
|
|
267
|
+
@batch_durations.empty? ? 0 : @batch_durations.sum / @batch_durations.size,
|
|
268
|
+
@batch_durations.max || 0
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
failed_examples = @accumulated_examples.select { |e| e['status'] == 'failed' }
|
|
272
|
+
if failed_examples.any?
|
|
273
|
+
output.puts ''
|
|
274
|
+
output.puts " Failed specs (#{failed_examples.size}):"
|
|
275
|
+
failed_examples.each do |ex|
|
|
276
|
+
location = ex.dig('file_path') || 'unknown'
|
|
277
|
+
line = ex.dig('line_number')
|
|
278
|
+
location = "#{location}:#{line}" if line
|
|
279
|
+
desc = ex.dig('full_description') || ex.dig('description') || ''
|
|
280
|
+
message = ex.dig('exception', 'message') || ''
|
|
281
|
+
# Truncate long messages
|
|
282
|
+
message = "#{message[0, 120]}..." if message.length > 120
|
|
283
|
+
output.puts " #{location} - #{desc}"
|
|
284
|
+
output.puts " #{message}" unless message.empty?
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
output.puts '=' * 60
|
|
289
|
+
output.puts ''
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def summary_line
|
|
293
|
+
parts = ["#{@accumulated_summary[:example_count]} examples"]
|
|
294
|
+
parts << "#{@accumulated_summary[:failure_count]} failures"
|
|
295
|
+
parts << "#{@accumulated_summary[:pending_count]} pending" if @accumulated_summary[:pending_count] > 0
|
|
296
|
+
parts.join(', ')
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Write a markdown summary to $GITHUB_STEP_SUMMARY for GitHub Actions.
|
|
300
|
+
def write_github_step_summary
|
|
301
|
+
path = ENV['GITHUB_STEP_SUMMARY']
|
|
302
|
+
return unless path
|
|
303
|
+
|
|
304
|
+
md = StringIO.new
|
|
305
|
+
md.puts '### 🏴☠️ Specbandit Results'
|
|
306
|
+
md.puts ''
|
|
307
|
+
md.puts '| Metric | Value |'
|
|
308
|
+
md.puts '|--------|-------|'
|
|
309
|
+
md.puts "| Batches | #{@batch_durations.size} |"
|
|
310
|
+
md.puts "| Examples | #{@accumulated_summary[:example_count]} |"
|
|
311
|
+
md.puts "| Failures | #{@accumulated_summary[:failure_count]} |"
|
|
312
|
+
md.puts "| Pending | #{@accumulated_summary[:pending_count]} |"
|
|
313
|
+
|
|
314
|
+
md.puts format('| Batch time (min) | %.1fs |', @batch_durations.min || 0)
|
|
315
|
+
md.puts format('| Batch time (avg) | %.1fs |',
|
|
316
|
+
@batch_durations.empty? ? 0 : @batch_durations.sum / @batch_durations.size)
|
|
317
|
+
md.puts format('| Batch time (max) | %.1fs |', @batch_durations.max || 0)
|
|
318
|
+
md.puts ''
|
|
319
|
+
|
|
320
|
+
failed_examples = @accumulated_examples.select { |e| e['status'] == 'failed' }
|
|
321
|
+
if failed_examples.any?
|
|
322
|
+
md.puts "<details><summary>❌ #{failed_examples.size} failed specs</summary>"
|
|
323
|
+
md.puts ''
|
|
324
|
+
md.puts '| Location | Description | Error |'
|
|
325
|
+
md.puts '|----------|-------------|-------|'
|
|
326
|
+
failed_examples.each do |ex|
|
|
327
|
+
location = ex['file_path'] || 'unknown'
|
|
328
|
+
line = ex['line_number']
|
|
329
|
+
location = "#{location}:#{line}" if line
|
|
330
|
+
desc = (ex['full_description'] || ex['description'] || '').gsub('|', '\\|')
|
|
331
|
+
message = (ex.dig('exception', 'message') || '').gsub('|', '\\|').gsub("\n", ' ')
|
|
332
|
+
message = "#{message[0, 100]}..." if message.length > 100
|
|
333
|
+
md.puts "| `#{location}` | #{desc} | #{message} |"
|
|
334
|
+
end
|
|
335
|
+
md.puts ''
|
|
336
|
+
md.puts '</details>'
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
File.open(path, 'a') { |f| f.write(md.string) }
|
|
340
|
+
rescue StandardError
|
|
341
|
+
# Never fail the build because of summary writing
|
|
342
|
+
nil
|
|
136
343
|
end
|
|
137
344
|
end
|
|
138
345
|
end
|