specbandit 0.11.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0284e5da8c01b4c149f7dedf22165ba743acd715de01073c9dc5fda6dd35742b'
4
- data.tar.gz: 7181e9efdc91cdeb1a80675d147f12934685c73a192aad42897d080a945675d1
3
+ metadata.gz: 95622117125136b9368557368697eb0b9663983289dc7a5c53692a230ac4d7ef
4
+ data.tar.gz: 5d74e976511d86b4c8da066cc782fc358af1c69e89ae02edcc3935b077ad473e
5
5
  SHA512:
6
- metadata.gz: 7effa3ce71d27cf6cc9a4166d99529dea22ebaa488a9100369a4e2834471e5f59358812653ccf10f29872de31a35ea591c995c7ad0e4b666ef7d50c4a00494d9
7
- data.tar.gz: bf2dfea12cd23511f63ccfa811a035966f81a35c6a26a93352990872908fa6eebb899855e4e5c3b4c6019d466e6e3d12eccafa0a329f4ea7741004de84b6be91
6
+ metadata.gz: 19a2cff1aab50ad0412e515a2081b85dcd8f94addf96232bfb2be3694b02980410df1a8788d3451907a985b26fc6cfecb1dd7d45750ba108b63f84975ea36996
7
+ data.tar.gz: 9af9ad631d684be5485a43620b76dcda02e7e223e50b6ecab47def52ee24fa4ab296dff43e5eea11c03acaea5146178da9cced4740ebb1036b41edef38ba0852
@@ -123,10 +123,22 @@ module Specbandit
123
123
  Specbandit.configuration.key_rerun_ttl = v
124
124
  end
125
125
 
126
+ opts.on('--key-failed KEY', 'Redis key to record failed test files for later review') do |v|
127
+ Specbandit.configuration.key_failed = v
128
+ end
129
+
130
+ opts.on('--key-failed-ttl SECONDS', Integer, 'TTL for failed key in seconds (default: 604800 / 1 week)') do |v|
131
+ Specbandit.configuration.key_failed_ttl = v
132
+ end
133
+
126
134
  opts.on('--rerun', 'Signal this is a re-run (fail hard if rerun key is empty)') do
127
135
  Specbandit.configuration.rerun = true
128
136
  end
129
137
 
138
+ opts.on('--report FILE', 'Write JSON report with run statistics to FILE') do |v|
139
+ Specbandit.configuration.report = v
140
+ end
141
+
130
142
  opts.on('--verbose', 'Show per-batch file list and full command output (default: quiet)') do
131
143
  Specbandit.configuration.verbose = true
132
144
  end
@@ -207,9 +219,12 @@ module Specbandit
207
219
  --rspec-opts OPTS Extra options forwarded to RSpec (for rspec adapter)
208
220
  --batch-size N Files per batch (default: 5, or set SPECBANDIT_BATCH_SIZE)
209
221
  --redis-url URL Redis URL (default: redis://localhost:6379)
210
- --key-rerun KEY Per-runner rerun key for re-run support
211
- --key-rerun-ttl N TTL for rerun key (default: 604800 / 1 week)
212
- --rerun Signal this is a re-run (fail hard if rerun key is empty)
222
+ --key-rerun KEY Per-runner rerun key for re-run support
223
+ --key-rerun-ttl N TTL for rerun key (default: 604800 / 1 week)
224
+ --key-failed KEY Redis key to record failed test files
225
+ --key-failed-ttl N TTL for failed key (default: 604800 / 1 week)
226
+ --rerun Signal this is a re-run (fail hard if rerun key is empty)
227
+ --report FILE Write JSON report to FILE after run
213
228
  --verbose Show per-batch file list and full command output
214
229
 
215
230
  Arguments after -- are forwarded to the adapter (rspec opts, command opts, etc.).
@@ -226,8 +241,11 @@ module Specbandit
226
241
  SPECBANDIT_RSPEC_OPTS RSpec options (rspec adapter)
227
242
  SPECBANDIT_KEY_RERUN Per-runner rerun key
228
243
  SPECBANDIT_KEY_RERUN_TTL Rerun key TTL in seconds (default: 604800)
244
+ SPECBANDIT_KEY_FAILED Redis key for failed test files
245
+ SPECBANDIT_KEY_FAILED_TTL Failed key TTL in seconds (default: 604800)
229
246
  SPECBANDIT_RERUN Signal re-run mode (1/true/yes)
230
247
  SPECBANDIT_VERBOSE Enable verbose output (1/true/yes)
248
+ SPECBANDIT_REPORT Path to write JSON report file
231
249
 
232
250
  File input priority for push:
233
251
  1. stdin (piped) echo "spec/a_spec.rb" | specbandit push --key KEY
@@ -4,12 +4,14 @@ module Specbandit
4
4
  class Configuration
5
5
  attr_accessor :redis_url, :batch_size, :key, :rspec_opts, :key_ttl,
6
6
  :key_rerun, :key_rerun_ttl, :rerun, :verbose,
7
- :adapter, :command, :command_opts
7
+ :adapter, :command, :command_opts,
8
+ :key_failed, :key_failed_ttl, :report
8
9
 
9
10
  DEFAULT_REDIS_URL = 'redis://localhost:6379'
10
11
  DEFAULT_BATCH_SIZE = 5
11
12
  DEFAULT_KEY_TTL = 21_600 # 6 hours in seconds
12
13
  DEFAULT_KEY_RERUN_TTL = 604_800 # 1 week in seconds
14
+ DEFAULT_KEY_FAILED_TTL = 604_800 # 1 week in seconds
13
15
  DEFAULT_ADAPTER = 'cli'
14
16
 
15
17
  def initialize
@@ -25,6 +27,9 @@ module Specbandit
25
27
  @adapter = ENV.fetch('SPECBANDIT_ADAPTER', DEFAULT_ADAPTER)
26
28
  @command = ENV.fetch('SPECBANDIT_COMMAND', nil)
27
29
  @command_opts = parse_space_separated(ENV.fetch('SPECBANDIT_COMMAND_OPTS', nil))
30
+ @key_failed = ENV.fetch('SPECBANDIT_KEY_FAILED', nil)
31
+ @key_failed_ttl = Integer(ENV.fetch('SPECBANDIT_KEY_FAILED_TTL', DEFAULT_KEY_FAILED_TTL))
32
+ @report = ENV.fetch('SPECBANDIT_REPORT', nil)
28
33
  end
29
34
 
30
35
  def validate!
@@ -32,6 +37,7 @@ module Specbandit
32
37
  raise Error, 'batch_size must be a positive integer' unless batch_size.positive?
33
38
  raise Error, 'key_ttl must be a positive integer' unless key_ttl.positive?
34
39
  raise Error, 'key_rerun_ttl must be a positive integer' unless key_rerun_ttl.positive?
40
+ raise Error, 'key_failed_ttl must be a positive integer' unless key_failed_ttl.positive?
35
41
  raise Error, '--rerun requires --key-rerun to be set' if rerun && (key_rerun.nil? || key_rerun.empty?)
36
42
  end
37
43
 
@@ -65,10 +65,10 @@ module Specbandit
65
65
  ensure
66
66
  # Print RSpec output through our output stream
67
67
  rspec_output = out&.string
68
- output.print(rspec_output) if verbose && rspec_output && !rspec_output.empty?
68
+ output.print(rspec_output) if rspec_output && !rspec_output.empty?
69
69
 
70
70
  rspec_err = err&.string
71
- output.print(rspec_err) if verbose && rspec_err && !rspec_err.empty?
71
+ output.print(rspec_err) if rspec_err && !rspec_err.empty?
72
72
 
73
73
  # Don't unlink the tempfile here — the Worker needs to read it.
74
74
  # The Worker is responsible for cleanup after accumulation.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Specbandit
4
- VERSION = '0.11.0'
4
+ VERSION = '0.13.0'
5
5
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'stringio'
4
3
  require 'json'
5
4
 
6
5
  module Specbandit
7
6
  class Worker
8
- attr_reader :queue, :key, :batch_size, :adapter, :key_rerun, :key_rerun_ttl, :rerun, :output, :verbose
7
+ attr_reader :queue, :key, :batch_size, :adapter, :key_rerun, :key_rerun_ttl, :key_failed, :key_failed_ttl, :rerun,
8
+ :output, :verbose, :report
9
9
 
10
10
  def initialize(
11
11
  key: Specbandit.configuration.key,
@@ -13,8 +13,11 @@ module Specbandit
13
13
  adapter: nil,
14
14
  key_rerun: Specbandit.configuration.key_rerun,
15
15
  key_rerun_ttl: Specbandit.configuration.key_rerun_ttl,
16
+ key_failed: Specbandit.configuration.key_failed,
17
+ key_failed_ttl: Specbandit.configuration.key_failed_ttl,
16
18
  rerun: Specbandit.configuration.rerun,
17
19
  verbose: Specbandit.configuration.verbose,
20
+ report: Specbandit.configuration.report,
18
21
  queue: nil,
19
22
  output: $stdout,
20
23
  # Legacy parameter for backward compatibility.
@@ -25,12 +28,16 @@ module Specbandit
25
28
  @batch_size = batch_size
26
29
  @key_rerun = key_rerun
27
30
  @key_rerun_ttl = key_rerun_ttl
31
+ @key_failed = key_failed
32
+ @key_failed_ttl = key_failed_ttl
28
33
  @rerun = rerun
29
34
  @verbose = verbose
35
+ @report = report
30
36
  @queue = queue || RedisQueue.new
31
37
  @output = output
32
38
  @batch_results = []
33
39
  @accumulated_examples = []
40
+ @accumulated_failed_files = []
34
41
  @accumulated_summary = { duration: 0.0, example_count: 0, failure_count: 0, pending_count: 0,
35
42
  errors_outside_of_examples_count: 0 }
36
43
 
@@ -47,6 +54,7 @@ module Specbandit
47
54
  #
48
55
  # Returns 0 if all batches passed (or nothing to do), 1 if any batch failed.
49
56
  def run
57
+ @run_start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
50
58
  adapter.setup
51
59
 
52
60
  exit_code = if key_rerun
@@ -67,8 +75,7 @@ module Specbandit
67
75
 
68
76
  print_summary if @batch_results.any?
69
77
  merge_json_results
70
- write_github_step_summary if ENV['GITHUB_STEP_SUMMARY']
71
-
78
+ write_report
72
79
  exit_code
73
80
  ensure
74
81
  adapter.teardown
@@ -80,29 +87,32 @@ module Specbandit
80
87
  # Used when re-running a failed CI job -- the rerun key already
81
88
  # contains the exact files this runner executed previously.
82
89
  def run_replay(files)
83
- output.puts "[specbandit] Replay mode: found #{files.size} files in rerun key '#{key_rerun}'."
84
- output.puts '[specbandit] Running previously recorded files (not touching shared queue).'
90
+ output.puts "[specbandit] Replay mode: found #{files.size} files in rerun key '#{key_rerun}'." if verbose
91
+ output.puts '[specbandit] Running previously recorded files (not touching shared queue).' if verbose
85
92
 
86
93
  failed = false
87
94
  batch_num = 0
88
95
 
89
96
  files.each_slice(batch_size) do |batch|
90
97
  batch_num += 1
91
- output.puts "[specbandit] Batch ##{batch_num}: running #{batch.size} files"
98
+ output.puts "[specbandit] Batch ##{batch_num}: running #{batch.size} files" if verbose
92
99
  batch.each { |f| output.puts " #{f}" } if verbose
93
100
 
94
101
  result = adapter.run_batch(batch, batch_num)
102
+ record_failed_files(batch, result)
95
103
  process_batch_result(result)
96
104
 
97
105
  if result.exit_code != 0
98
- output.puts "[specbandit] Batch ##{batch_num} FAILED (exit code: #{result.exit_code})"
106
+ output.puts "[specbandit] Batch ##{batch_num} FAILED (exit code: #{result.exit_code})" if verbose
99
107
  failed = true
100
- else
108
+ elsif verbose
101
109
  output.puts "[specbandit] Batch ##{batch_num} passed."
102
110
  end
103
111
  end
104
112
 
105
- output.puts "[specbandit] Replay finished: #{batch_num} batches. #{failed ? 'SOME FAILED' : 'All passed.'}"
113
+ if verbose
114
+ output.puts "[specbandit] Replay finished: #{batch_num} batches. #{failed ? 'SOME FAILED' : 'All passed.'}"
115
+ end
106
116
  failed ? 1 : 0
107
117
  end
108
118
 
@@ -111,8 +121,8 @@ module Specbandit
111
121
  # rerun key so this runner can replay them on a re-run.
112
122
  def run_steal(record:)
113
123
  mode_label = record ? 'Record' : 'Steal'
114
- output.puts "[specbandit] #{mode_label} mode: stealing batches from '#{key}'."
115
- output.puts "[specbandit] Recording stolen files to rerun key '#{key_rerun}'." if record
124
+ output.puts "[specbandit] #{mode_label} mode: stealing batches from '#{key}'." if verbose
125
+ output.puts "[specbandit] Recording stolen files to rerun key '#{key_rerun}'." if verbose && record
116
126
 
117
127
  failed = false
118
128
  batch_num = 0
@@ -121,7 +131,7 @@ module Specbandit
121
131
  files = queue.steal(key, batch_size)
122
132
 
123
133
  if files.empty?
124
- output.puts '[specbandit] Queue exhausted. No more files to run.'
134
+ output.puts '[specbandit] Queue exhausted. No more files to run.' if verbose
125
135
  break
126
136
  end
127
137
 
@@ -129,34 +139,74 @@ module Specbandit
129
139
  queue.push(key_rerun, files, ttl: key_rerun_ttl) if record
130
140
 
131
141
  batch_num += 1
132
- output.puts "[specbandit] Batch ##{batch_num}: running #{files.size} files"
142
+ output.puts "[specbandit] Batch ##{batch_num}: running #{files.size} files" if verbose
133
143
  files.each { |f| output.puts " #{f}" } if verbose
134
144
 
135
145
  result = adapter.run_batch(files, batch_num)
146
+ record_failed_files(files, result)
136
147
  process_batch_result(result)
137
148
 
138
149
  if result.exit_code != 0
139
- output.puts "[specbandit] Batch ##{batch_num} FAILED (exit code: #{result.exit_code})"
150
+ output.puts "[specbandit] Batch ##{batch_num} FAILED (exit code: #{result.exit_code})" if verbose
140
151
  failed = true
141
- else
152
+ elsif verbose
142
153
  output.puts "[specbandit] Batch ##{batch_num} passed."
143
154
  end
144
155
  end
145
156
 
146
157
  if batch_num.zero?
147
- output.puts '[specbandit] Nothing to do (queue was empty).'
148
- else
158
+ output.puts '[specbandit] Nothing to do (queue was empty).' if verbose
159
+ elsif verbose
149
160
  output.puts "[specbandit] Finished #{batch_num} batches. #{failed ? 'SOME FAILED' : 'All passed.'}"
150
161
  end
151
162
 
152
163
  failed ? 1 : 0
153
164
  end
154
165
 
166
+ # Record failed files to the failed key in Redis for later review.
167
+ # Called after each batch; only pushes when key_failed is configured
168
+ # and the batch had a non-zero exit code.
169
+ #
170
+ # For RSpec batches with JSON output, only the individual failed file
171
+ # paths are recorded (not the entire batch). For CLI adapter batches
172
+ # (no per-file granularity), the whole batch is recorded as fallback.
173
+ def record_failed_files(files, result)
174
+ return unless key_failed
175
+ return if result.exit_code.zero?
176
+
177
+ failed_files = extract_failed_files(result) || files
178
+ return if failed_files.empty?
179
+
180
+ queue.push(key_failed, failed_files, ttl: key_failed_ttl)
181
+ end
182
+
183
+ # Extract individual failed file paths from an RspecBatchResult's JSON output.
184
+ # Returns nil when per-file data is not available (CLI adapter).
185
+ def extract_failed_files(result)
186
+ return nil unless result.is_a?(RspecBatchResult) && result.json_path && File.exist?(result.json_path)
187
+
188
+ data = JSON.parse(File.read(result.json_path))
189
+ failed = data.fetch('examples', [])
190
+ .select { |e| e['status'] == 'failed' }
191
+ .filter_map { |e| e['file_path'] }
192
+ .uniq
193
+
194
+ failed.empty? ? nil : failed
195
+ rescue JSON::ParserError
196
+ nil
197
+ end
198
+
155
199
  # Process a BatchResult: store it, and for RSpec batches,
156
200
  # read the JSON output for rich reporting.
157
201
  def process_batch_result(result)
158
202
  @batch_results << result
159
203
 
204
+ # Accumulate failed files for the report while JSON data is still available.
205
+ if result.exit_code != 0
206
+ per_file = extract_failed_files(result)
207
+ @accumulated_failed_files.concat(per_file || result.files)
208
+ end
209
+
160
210
  # If the adapter returned an RspecBatchResult with a json_path,
161
211
  # accumulate the structured results for rich reporting.
162
212
  return unless result.is_a?(RspecBatchResult) && result.json_path
@@ -243,6 +293,48 @@ module Specbandit
243
293
  File.write(path, JSON.pretty_generate(merged))
244
294
  end
245
295
 
296
+ # Write a JSON report file with run statistics when --report is set.
297
+ def write_report
298
+ return unless report
299
+ return if @batch_results.empty?
300
+
301
+ wall_time = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_start_time).round(2)
302
+ durations = batch_durations
303
+ failed_batches_count = @batch_results.count { |r| r.exit_code != 0 }
304
+ passed_batches_count = @batch_results.count { |r| r.exit_code == 0 }
305
+
306
+ data = {
307
+ specbandit_version: Specbandit::VERSION,
308
+ summary: {
309
+ total_files: @batch_results.sum { |r| r.files.size },
310
+ total_batches: @batch_results.size,
311
+ passed_batches: passed_batches_count,
312
+ failed_batches: failed_batches_count,
313
+ passed: failed_batches_count == 0
314
+ },
315
+ failed_files: @accumulated_failed_files.uniq,
316
+ total_wall_time: wall_time,
317
+ batch_timings: {
318
+ count: durations.size,
319
+ min: format('%.2f', durations.min || 0),
320
+ avg: format('%.2f', durations.empty? ? 0 : durations.sum / durations.size),
321
+ max: format('%.2f', durations.max || 0),
322
+ all: durations.map { |d| d.round(2) }
323
+ },
324
+ batches: @batch_results.map do |r|
325
+ {
326
+ batch_num: r.batch_num,
327
+ files: r.files,
328
+ exit_code: r.exit_code,
329
+ duration: r.duration.round(2),
330
+ passed: r.exit_code == 0
331
+ }
332
+ end
333
+ }
334
+
335
+ File.write(report, JSON.pretty_generate(data))
336
+ end
337
+
246
338
  # Print a unified summary to the output stream after all batches.
247
339
  def print_summary
248
340
  output.puts ''
@@ -310,92 +402,5 @@ module Specbandit
310
402
  parts << "#{@accumulated_summary[:pending_count]} pending" if @accumulated_summary[:pending_count] > 0
311
403
  parts.join(', ')
312
404
  end
313
-
314
- # Write a markdown summary to $GITHUB_STEP_SUMMARY for GitHub Actions.
315
- def write_github_step_summary
316
- path = ENV['GITHUB_STEP_SUMMARY']
317
- return unless path
318
-
319
- md = StringIO.new
320
-
321
- if has_rspec_results?
322
- write_rspec_github_summary(md)
323
- else
324
- write_generic_github_summary(md)
325
- end
326
-
327
- File.open(path, 'a') { |f| f.write(md.string) }
328
- rescue StandardError
329
- # Never fail the build because of summary writing
330
- nil
331
- end
332
-
333
- def write_rspec_github_summary(md)
334
- md.puts '### Specbandit Results'
335
- md.puts ''
336
- md.puts '| Metric | Value |'
337
- md.puts '|--------|-------|'
338
- md.puts "| Batches | #{batch_durations.size} |"
339
- md.puts "| Examples | #{@accumulated_summary[:example_count]} |"
340
- md.puts "| Failures | #{@accumulated_summary[:failure_count]} |"
341
- md.puts "| Pending | #{@accumulated_summary[:pending_count]} |"
342
-
343
- md.puts format('| Batch time (min) | %.1fs |', batch_durations.min || 0)
344
- md.puts format('| Batch time (avg) | %.1fs |',
345
- batch_durations.empty? ? 0 : batch_durations.sum / batch_durations.size)
346
- md.puts format('| Batch time (max) | %.1fs |', batch_durations.max || 0)
347
- md.puts ''
348
-
349
- failed_examples = @accumulated_examples.select { |e| e['status'] == 'failed' }
350
- return unless failed_examples.any?
351
-
352
- md.puts "<details><summary>#{failed_examples.size} failed specs</summary>"
353
- md.puts ''
354
- md.puts '| Location | Description | Error |'
355
- md.puts '|----------|-------------|-------|'
356
- failed_examples.each do |ex|
357
- location = ex['file_path'] || 'unknown'
358
- line = ex['line_number']
359
- location = "#{location}:#{line}" if line
360
- desc = (ex['full_description'] || ex['description'] || '').gsub('|', '\\|')
361
- message = (ex.dig('exception', 'message') || '').gsub('|', '\\|').gsub("\n", ' ')
362
- message = "#{message[0, 100]}..." if message.length > 100
363
- md.puts "| `#{location}` | #{desc} | #{message} |"
364
- end
365
- md.puts ''
366
- md.puts '</details>'
367
- end
368
-
369
- def write_generic_github_summary(md)
370
- total_files = @batch_results.sum { |r| r.files.size }
371
- failed_batch_results = @batch_results.select { |r| r.exit_code != 0 }
372
-
373
- md.puts '### Specbandit Results'
374
- md.puts ''
375
- md.puts '| Metric | Value |'
376
- md.puts '|--------|-------|'
377
- md.puts "| Batches | #{batch_durations.size} |"
378
- md.puts "| Files | #{total_files} |"
379
- md.puts "| Failed batches | #{failed_batch_results.size} |"
380
-
381
- md.puts format('| Batch time (min) | %.1fs |', batch_durations.min || 0)
382
- md.puts format('| Batch time (avg) | %.1fs |',
383
- batch_durations.empty? ? 0 : batch_durations.sum / batch_durations.size)
384
- md.puts format('| Batch time (max) | %.1fs |', batch_durations.max || 0)
385
- md.puts ''
386
-
387
- return unless failed_batch_results.any?
388
-
389
- md.puts "<details><summary>#{failed_batch_results.size} failed batches</summary>"
390
- md.puts ''
391
- md.puts '| Batch | Exit Code | Files |'
392
- md.puts '|-------|-----------|-------|'
393
- failed_batch_results.each do |r|
394
- files_str = r.files.map { |f| "`#{f}`" }.join(', ')
395
- md.puts "| ##{r.batch_num} | #{r.exit_code} | #{files_str} |"
396
- end
397
- md.puts ''
398
- md.puts '</details>'
399
- end
400
405
  end
401
406
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: specbandit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ferran Basora