zenspec 0.1.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.
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Demo script for Zenspec::ProgressLoader
5
+ # This script demonstrates the Docker-style progress loader
6
+
7
+ require_relative "../lib/zenspec/progress_loader"
8
+
9
+ puts "=== Zenspec ProgressLoader Demo ==="
10
+ puts
11
+ puts "This demo shows Docker-style progress bars in action."
12
+ puts
13
+
14
+ # Demo 1: Basic progress loader
15
+ puts "Demo 1: Basic file processing"
16
+ puts "-" * 50
17
+ loader = Zenspec::ProgressLoader.new(
18
+ total: 20,
19
+ description: "Processing files"
20
+ )
21
+
22
+ 20.times do |i|
23
+ loader.update(i + 1, description: "Processing file_#{i + 1}.txt")
24
+ sleep(0.1)
25
+ end
26
+
27
+ loader.finish(description: "All files processed!")
28
+ puts
29
+ puts
30
+
31
+ # Demo 2: Downloading layers (Docker-style)
32
+ puts "Demo 2: Downloading Docker layers"
33
+ puts "-" * 50
34
+ layers = 5
35
+ loader = Zenspec::ProgressLoader.new(
36
+ total: layers,
37
+ description: "Downloading image"
38
+ )
39
+
40
+ layers.times do |i|
41
+ layer_id = "sha256:#{rand(16**12).to_s(16).rjust(12, '0')}"
42
+ loader.update(i + 1, description: "Downloading layer #{i + 1}/#{layers}: #{layer_id[0..19]}...")
43
+ sleep(0.3)
44
+ end
45
+
46
+ loader.finish(description: "Image downloaded successfully!")
47
+ puts
48
+ puts
49
+
50
+ # Demo 3: Custom width progress bar
51
+ puts "Demo 3: Wide progress bar"
52
+ puts "-" * 50
53
+ loader = Zenspec::ProgressLoader.new(
54
+ total: 50,
55
+ width: 60,
56
+ description: "Running tests"
57
+ )
58
+
59
+ 50.times do |i|
60
+ loader.update(i + 1, description: "Test #{i + 1}/50")
61
+ sleep(0.05)
62
+ end
63
+
64
+ loader.finish(description: "All tests passed!")
65
+ puts
66
+ puts
67
+
68
+ # Demo 4: Increment usage
69
+ puts "Demo 4: Using increment method"
70
+ puts "-" * 50
71
+ loader = Zenspec::ProgressLoader.new(
72
+ total: 10,
73
+ description: "Building packages"
74
+ )
75
+
76
+ packages = %w[auth api web worker scheduler mailer notifications cache database utils]
77
+
78
+ packages.each_with_index do |package, i|
79
+ loader.increment(description: "Building #{package} package...")
80
+ sleep(0.2)
81
+ end
82
+
83
+ loader.finish(description: "Build complete!")
84
+ puts
85
+ puts
86
+
87
+ # Demo 5: Fast progress with many items
88
+ puts "Demo 5: Fast progress (1000 items)"
89
+ puts "-" * 50
90
+ loader = Zenspec::ProgressLoader.new(
91
+ total: 1000,
92
+ description: "Processing records"
93
+ )
94
+
95
+ 1000.times do |i|
96
+ loader.update(i + 1)
97
+ # Very fast, only update display every 10 items
98
+ sleep(0.001) if (i + 1) % 10 == 0
99
+ end
100
+
101
+ loader.finish(description: "1000 records processed!")
102
+ puts
103
+ puts
104
+
105
+ puts "=== Demo Complete ==="
106
+ puts
107
+ puts "The ProgressLoader can be used for:"
108
+ puts " • Test suite progress (RSpec formatter)"
109
+ puts " • File processing operations"
110
+ puts " • Docker-style layer downloads"
111
+ puts " • Database migrations"
112
+ puts " • Batch processing tasks"
113
+ puts " • Any long-running terminal operations"
@@ -0,0 +1,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core/formatters/base_formatter"
4
+ require "io/console"
5
+
6
+ module Zenspec
7
+ module Formatters
8
+ # Custom RSpec formatter that displays test execution with spinning animation,
9
+ # one line per file, and right-aligned progress.
10
+ #
11
+ # @example Usage in .rspec file
12
+ # --require zenspec/formatters/progress_bar_formatter
13
+ # --format Zenspec::Formatters::ProgressBarFormatter
14
+ #
15
+ # @example Command line usage
16
+ # rspec --require zenspec/formatters/progress_bar_formatter \
17
+ # --format Zenspec::Formatters::ProgressBarFormatter
18
+ #
19
+ # Format while running: [spinner] filename --> test description [percentage current/total]
20
+ # Format when done: [icon] filename [percentage current/total]
21
+ #
22
+ # Icons:
23
+ # ✔ - All tests passed (green)
24
+ # ✗ - Any test failed (red)
25
+ # ⊘ - Pending tests (cyan)
26
+ # Spinner - Running (yellow): ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
27
+ #
28
+ class ProgressBarFormatter < RSpec::Core::Formatters::BaseFormatter
29
+ RSpec::Core::Formatters.register self,
30
+ :start,
31
+ :example_started,
32
+ :example_passed,
33
+ :example_failed,
34
+ :example_pending,
35
+ :dump_summary,
36
+ :dump_failures
37
+
38
+ # ANSI color codes
39
+ COLORS = {
40
+ green: "\e[32m",
41
+ red: "\e[31m",
42
+ yellow: "\e[33m",
43
+ cyan: "\e[36m",
44
+ white: "\e[37m",
45
+ bright_white: "\e[97m",
46
+ reset: "\e[0m"
47
+ }.freeze
48
+
49
+ # Status icons
50
+ ICONS = {
51
+ passed: "✔",
52
+ failed: "✗",
53
+ pending: "⊘"
54
+ }.freeze
55
+
56
+ # Spinner frames for animation
57
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].freeze
58
+
59
+ # Spinner update interval in seconds
60
+ SPINNER_INTERVAL = 0.08
61
+
62
+ # Default terminal width if unable to detect
63
+ DEFAULT_TERMINAL_WIDTH = 120
64
+
65
+ # Minimum padding between left and right parts
66
+ MIN_PADDING = 2
67
+
68
+ def initialize(output)
69
+ super
70
+ @current_count = 0
71
+ @total_count = 0
72
+ @failed_examples = []
73
+ @pending_examples = []
74
+
75
+ # File tracking
76
+ @file_states = {} # filename -> {passed: 0, failed: 0, pending: 0, current_test: nil}
77
+ @current_file = nil
78
+ @completed_files = [] # Track files that are done
79
+
80
+ # Spinner state
81
+ @spinner_frame = 0
82
+ @spinner_thread = nil
83
+ @mutex = Mutex.new
84
+ @spinner_running = false
85
+ end
86
+
87
+ # Called at the start of the test suite
88
+ def start(notification)
89
+ @total_count = notification.count
90
+ output.puts
91
+ end
92
+
93
+ # Called when an example starts
94
+ def example_started(notification)
95
+ @mutex.synchronize do
96
+ @current_count += 1
97
+ filename = extract_filename(notification.example)
98
+
99
+ # Check if we moved to a new file
100
+ if @current_file && @current_file != filename
101
+ # Finalize the previous file
102
+ finalize_file(@current_file)
103
+ end
104
+
105
+ # Initialize file state if needed
106
+ @file_states[filename] ||= { passed: 0, failed: 0, pending: 0, current_test: nil }
107
+ @file_states[filename][:current_test] = notification.example.description
108
+ @current_file = filename
109
+
110
+ # Start spinner if not already running
111
+ start_spinner unless @spinner_running
112
+ end
113
+ end
114
+
115
+ # Called when an example passes
116
+ def example_passed(notification)
117
+ @mutex.synchronize do
118
+ filename = extract_filename(notification.example)
119
+ @file_states[filename][:passed] += 1
120
+ @file_states[filename][:current_test] = nil
121
+ end
122
+ end
123
+
124
+ # Called when an example fails
125
+ def example_failed(notification)
126
+ @mutex.synchronize do
127
+ filename = extract_filename(notification.example)
128
+ @file_states[filename][:failed] += 1
129
+ @file_states[filename][:current_test] = nil
130
+ @failed_examples << notification.example
131
+ end
132
+ end
133
+
134
+ # Called when an example is pending
135
+ def example_pending(notification)
136
+ @mutex.synchronize do
137
+ filename = extract_filename(notification.example)
138
+ @file_states[filename][:pending] += 1
139
+ @file_states[filename][:current_test] = nil
140
+ @pending_examples << notification.example
141
+ end
142
+ end
143
+
144
+ # Called at the end to print summary
145
+ def dump_summary(summary)
146
+ @mutex.synchronize do
147
+ # Finalize the last file
148
+ finalize_file(@current_file) if @current_file
149
+
150
+ # Stop spinner
151
+ stop_spinner
152
+ end
153
+
154
+ output.puts
155
+ output.puts
156
+ output.puts colorize("=" * terminal_width, :bright_white)
157
+ output.puts colorize("Test Summary", :bright_white)
158
+ output.puts colorize("=" * terminal_width, :bright_white)
159
+ output.puts
160
+
161
+ # Summary statistics
162
+ output.puts summary_line(summary)
163
+ output.puts "Duration: #{format_duration(summary.duration)}"
164
+ output.puts
165
+
166
+ # Pending examples
167
+ if @pending_examples.any?
168
+ output.puts
169
+ output.puts colorize("Pending Examples:", :cyan)
170
+ @pending_examples.each do |example|
171
+ output.puts colorize(" #{ICONS[:pending]} #{example.full_description}", :cyan)
172
+ output.puts colorize(" # #{format_location(example)}", :cyan)
173
+ end
174
+ end
175
+ end
176
+
177
+ # Called to print failure details
178
+ def dump_failures(notification)
179
+ return if notification.failed_examples.empty?
180
+
181
+ output.puts
182
+ output.puts colorize("Failures:", :red)
183
+ output.puts
184
+
185
+ notification.failed_examples.each_with_index do |example, index|
186
+ output.puts colorize(" #{index + 1}) #{example.full_description}", :red)
187
+
188
+ exception = example.execution_result.exception
189
+ if exception
190
+ output.puts colorize(" #{exception.class}: #{exception.message}", :red)
191
+
192
+ # Print backtrace (first 3 lines)
193
+ if exception.backtrace
194
+ exception.backtrace.first(3).each do |line|
195
+ output.puts colorize(" #{line}", :red)
196
+ end
197
+ end
198
+ end
199
+
200
+ output.puts colorize(" # #{format_location(example)}", :red)
201
+ output.puts
202
+ end
203
+ end
204
+
205
+ private
206
+
207
+ # Start the spinner thread
208
+ def start_spinner
209
+ return if @spinner_running
210
+
211
+ @spinner_running = true
212
+ @spinner_thread = Thread.new do
213
+ loop do
214
+ break unless @spinner_running
215
+
216
+ @mutex.synchronize do
217
+ update_current_file_line if @current_file && !@completed_files.include?(@current_file)
218
+ @spinner_frame = (@spinner_frame + 1) % SPINNER_FRAMES.length
219
+ end
220
+
221
+ sleep SPINNER_INTERVAL
222
+ end
223
+ end
224
+ end
225
+
226
+ # Stop the spinner thread
227
+ def stop_spinner
228
+ @spinner_running = false
229
+ @spinner_thread&.join
230
+ @spinner_thread = nil
231
+ end
232
+
233
+ # Update the current file's line (called by spinner thread)
234
+ def update_current_file_line
235
+ return unless @current_file
236
+ return if @completed_files.include?(@current_file)
237
+
238
+ file_state = @file_states[@current_file]
239
+ return unless file_state
240
+
241
+ # Build the line for running state
242
+ icon = SPINNER_FRAMES[@spinner_frame]
243
+ color = :yellow
244
+ filename = @current_file
245
+ description = file_state[:current_test] || "running..."
246
+ progress = format_progress
247
+
248
+ # Format: [spinner] filename --> test description [progress]
249
+ left_part = "#{icon} #{filename} --> #{description}"
250
+ right_part = progress
251
+
252
+ # Calculate padding
253
+ width = terminal_width
254
+ visual_length = strip_ansi(left_part).length + strip_ansi(right_part).length
255
+
256
+ if visual_length + MIN_PADDING >= width
257
+ # Truncate description to fit
258
+ # Format: icon(1) + " "(1) + filename(n) + " --> "(5) = 7 + n total before description
259
+ max_desc_length = width - icon.length - filename.length - 6 - right_part.length - MIN_PADDING
260
+ max_desc_length = [max_desc_length, 10].max # Minimum 10 chars for description
261
+ description = truncate_string(description, max_desc_length)
262
+ left_part = "#{icon} #{filename} --> #{description}"
263
+ visual_length = strip_ansi(left_part).length + strip_ansi(right_part).length
264
+ end
265
+
266
+ padding_length = [width - visual_length, MIN_PADDING].max
267
+
268
+ # Build the full line with colors
269
+ colored_left = "#{colorize(icon, color)} #{colorize(filename, color)} --> #{colorize(description, color)}"
270
+ colored_right = colorize(right_part, :bright_white)
271
+
272
+ full_line = "#{colored_left}#{' ' * padding_length}#{colored_right}"
273
+
274
+ # Print without newline (updates in place)
275
+ output.print "\r#{full_line}"
276
+ output.flush
277
+ end
278
+
279
+ # Finalize a file (write final status line)
280
+ def finalize_file(filename)
281
+ return unless filename
282
+ return if @completed_files.include?(filename)
283
+
284
+ @completed_files << filename
285
+
286
+ file_state = @file_states[filename]
287
+ return unless file_state
288
+
289
+ # Determine final status
290
+ status = if file_state[:failed] > 0
291
+ :failed
292
+ elsif file_state[:pending] > 0
293
+ :pending
294
+ else
295
+ :passed
296
+ end
297
+
298
+ icon = ICONS[status]
299
+ color = status_color(status)
300
+ progress = format_progress
301
+
302
+ # Format: [icon] filename [progress]
303
+ left_part = "#{icon} #{filename}"
304
+ right_part = progress
305
+
306
+ # Calculate padding
307
+ width = terminal_width
308
+ visual_length = strip_ansi(left_part).length + strip_ansi(right_part).length
309
+ padding_length = [width - visual_length, MIN_PADDING].max
310
+
311
+ # Build the full line with colors
312
+ colored_left = "#{colorize(icon, color)} #{colorize(filename, color)}"
313
+ colored_right = colorize(right_part, :bright_white)
314
+
315
+ full_line = "#{colored_left}#{' ' * padding_length}#{colored_right}"
316
+
317
+ # Clear the current line and print the final line with newline
318
+ output.print "\r#{' ' * width}\r"
319
+ output.puts full_line
320
+ end
321
+
322
+ # Get color for status
323
+ def status_color(status)
324
+ case status
325
+ when :passed then :green
326
+ when :failed then :red
327
+ when :running then :yellow
328
+ when :pending then :cyan
329
+ else :white
330
+ end
331
+ end
332
+
333
+ # Colorize text
334
+ def colorize(text, color)
335
+ return text unless color && COLORS[color]
336
+
337
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
338
+ end
339
+
340
+ # Strip ANSI color codes from text
341
+ def strip_ansi(text)
342
+ text.gsub(/\e\[[0-9;]*m/, "")
343
+ end
344
+
345
+ # Extract short filename from example
346
+ def extract_filename(example)
347
+ file_path = example.metadata[:file_path]
348
+ return "unknown" unless file_path
349
+
350
+ # Get just the filename (not the full path)
351
+ File.basename(file_path)
352
+ end
353
+
354
+ # Format progress as [percentage current/total]
355
+ def format_progress
356
+ percentage = (@current_count.to_f / @total_count * 100).round
357
+ "[#{percentage}% #{@current_count}/#{@total_count}]"
358
+ end
359
+
360
+ # Get terminal width
361
+ def terminal_width
362
+ @terminal_width ||= begin
363
+ # Only use winsize if output is actually a TTY
364
+ if output.respond_to?(:tty?) && output.tty? && output.respond_to?(:winsize)
365
+ output.winsize[1]
366
+ else
367
+ DEFAULT_TERMINAL_WIDTH
368
+ end
369
+ rescue StandardError
370
+ DEFAULT_TERMINAL_WIDTH
371
+ end
372
+ end
373
+
374
+ # Truncate string with ellipsis
375
+ def truncate_string(string, max_length)
376
+ return string if string.length <= max_length
377
+
378
+ "#{string[0...max_length - 3]}..."
379
+ end
380
+
381
+ # Build summary line
382
+ def summary_line(summary)
383
+ parts = []
384
+
385
+ # Total examples
386
+ total_text = "#{summary.example_count} examples"
387
+ parts << colorize(total_text, :bright_white)
388
+
389
+ # Failures
390
+ if summary.failure_count.positive?
391
+ failure_text = "#{summary.failure_count} failures"
392
+ parts << colorize(failure_text, :red)
393
+ end
394
+
395
+ # Pending
396
+ if summary.pending_count.positive?
397
+ pending_text = "#{summary.pending_count} pending"
398
+ parts << colorize(pending_text, :cyan)
399
+ end
400
+
401
+ # Passed
402
+ passed_count = summary.example_count - summary.failure_count - summary.pending_count
403
+ if passed_count.positive?
404
+ passed_text = "#{passed_count} passed"
405
+ parts << colorize(passed_text, :green)
406
+ end
407
+
408
+ parts.join(", ")
409
+ end
410
+
411
+ # Format duration
412
+ def format_duration(seconds)
413
+ if seconds < 1
414
+ "#{(seconds * 1000).round} milliseconds"
415
+ elsif seconds < 60
416
+ "#{seconds.round(2)} seconds"
417
+ else
418
+ minutes = (seconds / 60).floor
419
+ remaining_seconds = (seconds % 60).round
420
+ "#{minutes} minutes #{remaining_seconds} seconds"
421
+ end
422
+ end
423
+
424
+ # Format file location
425
+ def format_location(example)
426
+ "./#{example.metadata[:file_path]}:#{example.metadata[:line_number]}"
427
+ end
428
+ end
429
+ end
430
+ end
431
+
432
+ # Register the formatter as ProgressBarFormatter for easier use
433
+ ProgressBarFormatter = Zenspec::Formatters::ProgressBarFormatter
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core/formatters/base_formatter"
4
+
5
+ module Zenspec
6
+ module Formatters
7
+ # RSpec formatter that displays test progress using Docker-style progress bar
8
+ #
9
+ # @example Usage
10
+ # # In your .rspec file or command line:
11
+ # --require zenspec/formatters/progress_formatter
12
+ # --format Zenspec::Formatters::ProgressFormatter
13
+ #
14
+ # @example Command line
15
+ # rspec --require zenspec/formatters/progress_formatter \
16
+ # --format Zenspec::Formatters::ProgressFormatter
17
+ #
18
+ class ProgressFormatter < RSpec::Core::Formatters::BaseFormatter
19
+ RSpec::Core::Formatters.register self,
20
+ :start,
21
+ :example_started,
22
+ :example_passed,
23
+ :example_failed,
24
+ :example_pending,
25
+ :dump_summary,
26
+ :dump_failures
27
+
28
+ def initialize(output)
29
+ super
30
+ @loader = nil
31
+ @failed_examples = []
32
+ @pending_examples = []
33
+ end
34
+
35
+ # Called at the start of the test suite
36
+ def start(notification)
37
+ @total_count = notification.count
38
+ @loader = ProgressLoader.new(
39
+ total: @total_count,
40
+ description: "Running examples",
41
+ output: output
42
+ )
43
+ end
44
+
45
+ # Called when an example starts
46
+ def example_started(_notification)
47
+ # Can add custom logic here if needed
48
+ end
49
+
50
+ # Called when an example passes
51
+ def example_passed(notification)
52
+ @loader.increment(description: build_description(notification.example, "✓"))
53
+ end
54
+
55
+ # Called when an example fails
56
+ def example_failed(notification)
57
+ @failed_examples << notification.example
58
+ @loader.increment(description: build_description(notification.example, "✗"))
59
+ end
60
+
61
+ # Called when an example is pending
62
+ def example_pending(notification)
63
+ @pending_examples << notification.example
64
+ @loader.increment(description: build_description(notification.example, "*"))
65
+ end
66
+
67
+ # Called at the end to print summary
68
+ def dump_summary(summary)
69
+ @loader.finish(description: "Completed")
70
+
71
+ output.puts
72
+ output.puts summary_line(summary)
73
+
74
+ return unless @pending_examples.any?
75
+
76
+ output.puts
77
+ output.puts "Pending examples:"
78
+ @pending_examples.each do |example|
79
+ output.puts " #{example.full_description}"
80
+ end
81
+ end
82
+
83
+ # Called to print failure details
84
+ def dump_failures(notification)
85
+ return if notification.failed_examples.empty?
86
+
87
+ output.puts
88
+ output.puts "Failures:"
89
+ output.puts
90
+
91
+ notification.failed_examples.each_with_index do |example, index|
92
+ output.puts " #{index + 1}) #{example.full_description}"
93
+ output.puts " #{failure_message(example)}"
94
+ output.puts " # #{format_location(example)}"
95
+ output.puts
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ # Build description for current example
102
+ def build_description(example, status_symbol)
103
+ file_path = example.metadata[:file_path].split("/").last(2).join("/")
104
+ "#{status_symbol} #{file_path}:#{example.metadata[:line_number]}"
105
+ end
106
+
107
+ # Build summary line
108
+ def summary_line(summary)
109
+ parts = []
110
+ parts << "#{summary.example_count} examples"
111
+ parts << "#{summary.failure_count} failures" if summary.failure_count.positive?
112
+ parts << "#{summary.pending_count} pending" if summary.pending_count.positive?
113
+
114
+ duration = "Finished in #{format_duration(summary.duration)}"
115
+ "#{parts.join(', ')} (#{duration})"
116
+ end
117
+
118
+ # Format duration
119
+ def format_duration(seconds)
120
+ if seconds < 1
121
+ "#{(seconds * 1000).round} milliseconds"
122
+ elsif seconds < 60
123
+ "#{seconds.round(2)} seconds"
124
+ else
125
+ minutes = (seconds / 60).floor
126
+ remaining_seconds = (seconds % 60).round
127
+ "#{minutes} minutes #{remaining_seconds} seconds"
128
+ end
129
+ end
130
+
131
+ # Get failure message from example
132
+ def failure_message(example)
133
+ exception = example.execution_result.exception
134
+ return "No exception message" unless exception
135
+
136
+ message = exception.message.split("\n").first
137
+ message.length > 100 ? "#{message[0..97]}..." : message
138
+ end
139
+
140
+ # Format file location
141
+ def format_location(example)
142
+ "./#{example.metadata[:file_path]}:#{example.metadata[:line_number]}"
143
+ end
144
+ end
145
+ end
146
+ end