specbandit 0.5.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 +199 -17
- 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
|
|
@@ -118,18 +132,31 @@ module Specbandit
|
|
|
118
132
|
def run_rspec_batch(files)
|
|
119
133
|
reset_rspec_state
|
|
120
134
|
|
|
121
|
-
|
|
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]
|
|
139
|
+
|
|
122
140
|
err = StringIO.new
|
|
123
141
|
out = StringIO.new
|
|
124
142
|
|
|
125
|
-
|
|
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
|
|
126
150
|
ensure
|
|
127
151
|
# Print RSpec output through our output stream
|
|
128
152
|
rspec_output = out&.string
|
|
129
|
-
output.print(rspec_output)
|
|
153
|
+
output.print(rspec_output) if verbose && rspec_output && !rspec_output.empty?
|
|
130
154
|
|
|
131
155
|
rspec_err = err&.string
|
|
132
|
-
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
|
|
133
160
|
end
|
|
134
161
|
|
|
135
162
|
# Reset RSpec state between batches so each batch runs cleanly.
|
|
@@ -159,5 +186,160 @@ module Specbandit
|
|
|
159
186
|
RSpec.world.non_example_failure = false
|
|
160
187
|
RSpec.configuration.output_stream = $stdout
|
|
161
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
|
|
343
|
+
end
|
|
162
344
|
end
|
|
163
345
|
end
|