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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd0cef232a7dfa2dd4109d150bd63dbdaac27f65c87e9a9c4a8c495678fdc8f0
4
- data.tar.gz: c0a7505268abdc305f6d095fd75788ee5180c4e911972af8f95397bb57241358
3
+ metadata.gz: 6e9239010371ceb4ec5c34c959413de19ecb62d31004010c4cccd74a7f558f96
4
+ data.tar.gz: 740235b2c5e7174b0b0b5aba2f0da1632db3b789a9deca1800ee3f61568bf0be
5
5
  SHA512:
6
- metadata.gz: 93c598ec5a9e0a542c0f86159ba3a70f7d7712b4c7f1c0bdf264cd9316363b483470986af3f6b619d326a52ce6d14cbd4c3ffff91e681d8c7eb7ddb8f1a2e150
7
- data.tar.gz: 7974be5fd63f662d6db8a3b99cf00aeb7a0f75b0742c74a585c8cb7388686673507e2b23e0eb836cf1ec5c6b698585ef5eba0ec106b9436f6927409cbc59c85e
6
+ metadata.gz: 7446e60aee84c46d4ea8e1c5c59307c7bd9593b2ba082b9f30dc4835e531b5343291cd1f42be8015f48c808faf8f65f93cd43610a799133ebffda4723b7e1a92
7
+ data.tar.gz: 6a698f1ccb2e6fd7840171f97ad1ffaad5b2f1e00a66349e6bcc4ea79b34768c821516bd3552ef82188c6d8cb6abc2b1218859c7981850e39ac52950b81444df
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Specbandit
4
- VERSION = '0.5.0'
4
+ VERSION = '0.7.0'
5
5
  end
@@ -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
- rerun_files = queue.read_all(key_rerun)
34
- if rerun_files.any?
35
- run_replay(rerun_files)
36
- else
37
- run_steal(record: true)
38
- end
39
- else
40
- run_steal(record: false)
41
- end
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
- args = files + rspec_opts
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
- RSpec::Core::Runner.run(args, err, out)
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) unless rspec_output.nil? || rspec_output.empty?
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) unless rspec_err.nil? || rspec_err.empty?
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
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.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ferran Basora