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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +637 -0
- data/Rakefile +12 -0
- data/examples/progress_loader_demo.rb +113 -0
- data/lib/zenspec/formatters/progress_bar_formatter.rb +433 -0
- data/lib/zenspec/formatters/progress_formatter.rb +146 -0
- data/lib/zenspec/helpers/graphql_helpers.rb +103 -0
- data/lib/zenspec/helpers.rb +10 -0
- data/lib/zenspec/matchers/graphql_matchers.rb +358 -0
- data/lib/zenspec/matchers/graphql_type_matchers.rb +554 -0
- data/lib/zenspec/matchers/interactor_matchers.rb +216 -0
- data/lib/zenspec/matchers.rb +12 -0
- data/lib/zenspec/progress_loader.rb +155 -0
- data/lib/zenspec/railtie.rb +26 -0
- data/lib/zenspec/shoulda_config.rb +17 -0
- data/lib/zenspec/version.rb +5 -0
- data/lib/zenspec.rb +14 -0
- data/sig/zenspec.rbs +4 -0
- metadata +121 -0
|
@@ -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
|