specbandit 0.12.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82b0e7cfc34d8564657fc313dc9f0e4432a8c7c88795690670da3f86a21acd64
4
- data.tar.gz: 321f4437b8a265664b72f30cd38f03cdd5b03f5f7814d00f608096a6fa47b19c
3
+ metadata.gz: 14a1d9a07eb3ab1b19c59c9e054aec4fb32e5da1ac3f33841feb1fb068085648
4
+ data.tar.gz: 756a15f2a9c7f9cce3db7fa06e499235fd18ec332a51d6db1900ee1a9a2d1fb8
5
5
  SHA512:
6
- metadata.gz: f3e05b5368da44a918d66432edd5985cfb461f9505f1a47ba56b4d7e599cdae48395a9e5b8da5bbd015c9d1540a567ea97d719ef40004fe36238c5afe013adc9
7
- data.tar.gz: a5cc4f6d8844a31f9f98eb6caf5928424cccf2d4fa662d06f7a853a095ff6c8a1ef155169eaea659fb7c2e57a38d1e2c76f67dd9723260fca0bc6b410ea27eb3
6
+ metadata.gz: 6bc49db9d7bd80540b17d19b15fb759a2ab0c1c867026f3234610074323237cf4cd5ee8d7e590c472b324dbc152b0db83d35fa05bbf443175efad1f5fc658c1c
7
+ data.tar.gz: '09cdd5433154bc6a35d13006beccdcba288d8538fc79585e79b8939edf1a0c2e1149d98d21cbdc8ee4eb7f756640d00575b4ebb3791217f5359c511a8ddea6a0'
data/README.md CHANGED
@@ -318,6 +318,8 @@ Specbandit detects the mode automatically based on the state of `--key-rerun`:
318
318
  | Yes | Empty | Yes | **Fail** | Exit 1 with error. Prevents silent false pass on stale re-runs. |
319
319
  | No | -- | Yes | **Error** | Validation error: `--rerun` requires `--key-rerun`. |
320
320
 
321
+ > **Empty key names count as "not provided".** A `--key-rerun` whose value is an empty string -- e.g. `--key-rerun "$VAR"` where `$VAR` is unset in CI -- is treated exactly like `--key-rerun` being absent (the **No** rows above), not as a configured-but-empty key. Combined with `--rerun`, that means an empty/unset name fails hard (exit 1) rather than silently steal-and-pass. The same applies to `--key-failed`: an empty/unset name is treated as "not configured" and no failed files are recorded.
322
+
321
323
  #### The `--rerun` safety flag
322
324
 
323
325
  Without `--rerun`, specbandit cannot distinguish a first run from a re-run when the rerun key is empty (e.g., TTL expired or Redis was flushed). In that case it silently falls back to Record mode, which may find an empty shared queue and exit 0 with zero tests -- a **silent false pass**.
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Specbandit
4
- VERSION = '0.12.0'
4
+ VERSION = '0.13.1'
5
5
  end
@@ -4,7 +4,8 @@ require 'json'
4
4
 
5
5
  module Specbandit
6
6
  class Worker
7
- 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
8
9
 
9
10
  def initialize(
10
11
  key: Specbandit.configuration.key,
@@ -12,8 +13,11 @@ module Specbandit
12
13
  adapter: nil,
13
14
  key_rerun: Specbandit.configuration.key_rerun,
14
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,
15
18
  rerun: Specbandit.configuration.rerun,
16
19
  verbose: Specbandit.configuration.verbose,
20
+ report: Specbandit.configuration.report,
17
21
  queue: nil,
18
22
  output: $stdout,
19
23
  # Legacy parameter for backward compatibility.
@@ -24,12 +28,16 @@ module Specbandit
24
28
  @batch_size = batch_size
25
29
  @key_rerun = key_rerun
26
30
  @key_rerun_ttl = key_rerun_ttl
31
+ @key_failed = key_failed
32
+ @key_failed_ttl = key_failed_ttl
27
33
  @rerun = rerun
28
34
  @verbose = verbose
35
+ @report = report
29
36
  @queue = queue || RedisQueue.new
30
37
  @output = output
31
38
  @batch_results = []
32
39
  @accumulated_examples = []
40
+ @accumulated_failed_files = []
33
41
  @accumulated_summary = { duration: 0.0, example_count: 0, failure_count: 0, pending_count: 0,
34
42
  errors_outside_of_examples_count: 0 }
35
43
 
@@ -46,26 +54,14 @@ module Specbandit
46
54
  #
47
55
  # Returns 0 if all batches passed (or nothing to do), 1 if any batch failed.
48
56
  def run
57
+ @run_start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
58
  adapter.setup
50
59
 
51
- exit_code = if key_rerun
52
- rerun_files = queue.read_all(key_rerun)
53
- if rerun_files.any?
54
- run_replay(rerun_files)
55
- elsif rerun
56
- output.puts "[specbandit] ERROR: --rerun flag is set but rerun key '#{key_rerun}' is empty."
57
- output.puts '[specbandit] The rerun key may have expired (TTL) or Redis was flushed.'
58
- output.puts '[specbandit] Cannot replay — failing to prevent silent false pass.'
59
- 1
60
- else
61
- run_steal(record: true)
62
- end
63
- else
64
- run_steal(record: false)
65
- end
60
+ exit_code = determine_exit_code
66
61
 
67
62
  print_summary if @batch_results.any?
68
63
  merge_json_results
64
+ write_report
69
65
  exit_code
70
66
  ensure
71
67
  adapter.teardown
@@ -73,6 +69,43 @@ module Specbandit
73
69
 
74
70
  private
75
71
 
72
+ # Decide the operating mode and execute it, returning the exit code.
73
+ # - no usable rerun key → steal mode (or crash if --rerun was requested)
74
+ # - rerun key has data → replay mode
75
+ # - rerun key but empty → record mode (or crash if --rerun was requested)
76
+ def determine_exit_code
77
+ unless key_present?(key_rerun)
78
+ return fail_stale_rerun if rerun
79
+
80
+ return run_steal(record: false)
81
+ end
82
+
83
+ rerun_files = queue.read_all(key_rerun)
84
+ return run_replay(rerun_files) if rerun_files.any?
85
+ return fail_stale_rerun if rerun
86
+
87
+ run_steal(record: true)
88
+ end
89
+
90
+ # A Redis key name is usable only when it is a non-nil, non-empty string.
91
+ # Guards the Ruby gotcha where "" is truthy (e.g. --key-rerun "$UNSET_VAR"),
92
+ # which would otherwise read/write against an empty key name.
93
+ def key_present?(value)
94
+ !value.nil? && !value.empty?
95
+ end
96
+
97
+ # Emit the stale/missing-rerun error and return exit code 1.
98
+ def fail_stale_rerun
99
+ if key_present?(key_rerun)
100
+ output.puts "[specbandit] ERROR: --rerun flag is set but rerun key '#{key_rerun}' is empty."
101
+ else
102
+ output.puts '[specbandit] ERROR: --rerun flag is set but no rerun key is configured.'
103
+ end
104
+ output.puts '[specbandit] The rerun key may have expired (TTL) or Redis was flushed.'
105
+ output.puts '[specbandit] Cannot replay — failing to prevent silent false pass.'
106
+ 1
107
+ end
108
+
76
109
  # Replay mode: run a known list of files in local batches.
77
110
  # Used when re-running a failed CI job -- the rerun key already
78
111
  # contains the exact files this runner executed previously.
@@ -89,6 +122,7 @@ module Specbandit
89
122
  batch.each { |f| output.puts " #{f}" } if verbose
90
123
 
91
124
  result = adapter.run_batch(batch, batch_num)
125
+ record_failed_files(batch, result)
92
126
  process_batch_result(result)
93
127
 
94
128
  if result.exit_code != 0
@@ -132,6 +166,7 @@ module Specbandit
132
166
  files.each { |f| output.puts " #{f}" } if verbose
133
167
 
134
168
  result = adapter.run_batch(files, batch_num)
169
+ record_failed_files(files, result)
135
170
  process_batch_result(result)
136
171
 
137
172
  if result.exit_code != 0
@@ -151,11 +186,50 @@ module Specbandit
151
186
  failed ? 1 : 0
152
187
  end
153
188
 
189
+ # Record failed files to the failed key in Redis for later review.
190
+ # Called after each batch; only pushes when key_failed is configured
191
+ # and the batch had a non-zero exit code.
192
+ #
193
+ # For RSpec batches with JSON output, only the individual failed file
194
+ # paths are recorded (not the entire batch). For CLI adapter batches
195
+ # (no per-file granularity), the whole batch is recorded as fallback.
196
+ def record_failed_files(files, result)
197
+ return unless key_present?(key_failed)
198
+ return if result.exit_code.zero?
199
+
200
+ failed_files = extract_failed_files(result) || files
201
+ return if failed_files.empty?
202
+
203
+ queue.push(key_failed, failed_files, ttl: key_failed_ttl)
204
+ end
205
+
206
+ # Extract individual failed file paths from an RspecBatchResult's JSON output.
207
+ # Returns nil when per-file data is not available (CLI adapter).
208
+ def extract_failed_files(result)
209
+ return nil unless result.is_a?(RspecBatchResult) && result.json_path && File.exist?(result.json_path)
210
+
211
+ data = JSON.parse(File.read(result.json_path))
212
+ failed = data.fetch('examples', [])
213
+ .select { |e| e['status'] == 'failed' }
214
+ .filter_map { |e| e['file_path'] }
215
+ .uniq
216
+
217
+ failed.empty? ? nil : failed
218
+ rescue JSON::ParserError
219
+ nil
220
+ end
221
+
154
222
  # Process a BatchResult: store it, and for RSpec batches,
155
223
  # read the JSON output for rich reporting.
156
224
  def process_batch_result(result)
157
225
  @batch_results << result
158
226
 
227
+ # Accumulate failed files for the report while JSON data is still available.
228
+ if result.exit_code != 0
229
+ per_file = extract_failed_files(result)
230
+ @accumulated_failed_files.concat(per_file || result.files)
231
+ end
232
+
159
233
  # If the adapter returned an RspecBatchResult with a json_path,
160
234
  # accumulate the structured results for rich reporting.
161
235
  return unless result.is_a?(RspecBatchResult) && result.json_path
@@ -242,6 +316,48 @@ module Specbandit
242
316
  File.write(path, JSON.pretty_generate(merged))
243
317
  end
244
318
 
319
+ # Write a JSON report file with run statistics when --report is set.
320
+ def write_report
321
+ return unless report
322
+ return if @batch_results.empty?
323
+
324
+ wall_time = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_start_time).round(2)
325
+ durations = batch_durations
326
+ failed_batches_count = @batch_results.count { |r| r.exit_code != 0 }
327
+ passed_batches_count = @batch_results.count { |r| r.exit_code == 0 }
328
+
329
+ data = {
330
+ specbandit_version: Specbandit::VERSION,
331
+ summary: {
332
+ total_files: @batch_results.sum { |r| r.files.size },
333
+ total_batches: @batch_results.size,
334
+ passed_batches: passed_batches_count,
335
+ failed_batches: failed_batches_count,
336
+ passed: failed_batches_count == 0
337
+ },
338
+ failed_files: @accumulated_failed_files.uniq,
339
+ total_wall_time: wall_time,
340
+ batch_timings: {
341
+ count: durations.size,
342
+ min: format('%.2f', durations.min || 0),
343
+ avg: format('%.2f', durations.empty? ? 0 : durations.sum / durations.size),
344
+ max: format('%.2f', durations.max || 0),
345
+ all: durations.map { |d| d.round(2) }
346
+ },
347
+ batches: @batch_results.map do |r|
348
+ {
349
+ batch_num: r.batch_num,
350
+ files: r.files,
351
+ exit_code: r.exit_code,
352
+ duration: r.duration.round(2),
353
+ passed: r.exit_code == 0
354
+ }
355
+ end
356
+ }
357
+
358
+ File.write(report, JSON.pretty_generate(data))
359
+ end
360
+
245
361
  # Print a unified summary to the output stream after all batches.
246
362
  def print_summary
247
363
  output.puts ''
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.12.0
4
+ version: 0.13.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ferran Basora
@@ -109,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
109
  - !ruby/object:Gem::Version
110
110
  version: '0'
111
111
  requirements: []
112
- rubygems_version: 4.0.6
112
+ rubygems_version: 4.0.10
113
113
  specification_version: 4
114
114
  summary: Distributed test runner using Redis as a work queue
115
115
  test_files: []