tryouts 3.1.2 → 3.2.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.
@@ -0,0 +1,273 @@
1
+ # lib/tryouts/cli/formatters/tty_status_display.rb
2
+
3
+ require 'tty-cursor'
4
+ require 'tty-screen'
5
+ require 'pastel'
6
+ require 'io/console'
7
+
8
+ class Tryouts
9
+ class CLI
10
+ # Encapsulates TTY manipulation for live status display with fallback
11
+ class TTYStatusDisplay
12
+ STATUS_LINES = 5 # Lines reserved for fixed status display (4 content + 1 separator)
13
+
14
+ def initialize(io = $stdout, options = {})
15
+ @io = io
16
+ @available = check_tty_availability
17
+ @show_debug = options.fetch(:debug, false)
18
+ @cleanup_registered = false
19
+
20
+ return unless @available
21
+
22
+ @cursor = TTY::Cursor
23
+ @pastel = Pastel.new
24
+ @status_active = false
25
+ @original_cursor_position = nil
26
+
27
+ register_cleanup_handlers
28
+ end
29
+
30
+ def available?
31
+ @available
32
+ end
33
+
34
+ def reserve_status_area
35
+ return unless @available && !@status_active
36
+
37
+ with_terminal_safety do
38
+ # Store original cursor position if possible
39
+ @original_cursor_position = get_cursor_position
40
+
41
+ # Simply print empty lines to push content up and make room at bottom
42
+ STATUS_LINES.times { @io.print "\n" }
43
+
44
+ @status_active = true
45
+ end
46
+ end
47
+
48
+ def write_scrolling(text)
49
+ return @io.print(text) unless @available
50
+
51
+ # Always write content normally - the status will be updated separately
52
+ @io.print text
53
+ end
54
+
55
+ def update_status(state)
56
+ return unless @available && @status_active
57
+
58
+ with_terminal_safety do
59
+ # Move to status area (bottom of screen) and update in place
60
+ current_row, current_col = get_cursor_position
61
+
62
+ # Move to status area at bottom
63
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES + 1)
64
+
65
+ # Clear and write status content
66
+ STATUS_LINES.times do
67
+ @io.print @cursor.clear_line
68
+ @io.print @cursor.down(1) unless STATUS_LINES == 1
69
+ end
70
+
71
+ # Move back to start of status area and write content
72
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES + 1)
73
+ write_status_content(state)
74
+
75
+ # Move cursor back to where content should continue (just before status area)
76
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES)
77
+ @io.flush
78
+ end
79
+ end
80
+
81
+ def clear_status_area
82
+ return unless @available && @status_active
83
+
84
+ with_terminal_safety do
85
+ # Move to status area and clear it completely - start from first status line
86
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES + 1)
87
+
88
+ # Clear each line thoroughly
89
+ STATUS_LINES.times do |i|
90
+ @io.print @cursor.clear_line
91
+ @io.print @cursor.down(1) if i < STATUS_LINES - 1 # Don't go down after last line
92
+ end
93
+
94
+ # Move cursor to a clean area for final output - position it well above the cleared area
95
+ # This ensures no interference with the cleared status content
96
+ target_row = TTY::Screen.height - STATUS_LINES - 2 # Leave some buffer space
97
+ @io.print @cursor.move_to(0, target_row)
98
+ @io.print "\n" # Add a clean line for final output to start
99
+ @io.flush
100
+ end
101
+
102
+ @status_active = false
103
+ end
104
+
105
+ # Explicit cleanup method for manual invocation
106
+ def cleanup!
107
+ return unless @available
108
+
109
+ with_terminal_safety do
110
+ if @status_active
111
+ # Clear the status area completely
112
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES + 1)
113
+ STATUS_LINES.times do |i|
114
+ @io.print @cursor.clear_line
115
+ @io.print @cursor.down(1) if i < STATUS_LINES - 1
116
+ end
117
+ end
118
+
119
+ # Reset cursor to a safe position (start of line below cleared area)
120
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES)
121
+ @io.print "\n"
122
+ @io.flush
123
+ end
124
+
125
+ @status_active = false
126
+ end
127
+
128
+ private
129
+
130
+ def get_cursor_position
131
+ # Simple approximation - in a real terminal this would query cursor position
132
+ # For now, return reasonable defaults
133
+ [10, 0]
134
+ end
135
+
136
+ def check_tty_availability
137
+ # Check if we have a real TTY
138
+ return false unless @io.respond_to?(:tty?) && @io.tty?
139
+
140
+ # Check if we can get screen dimensions
141
+ return false unless TTY::Screen.width > 0 && TTY::Screen.height > STATUS_LINES + 5
142
+
143
+ # Check if TERM environment variable suggests terminal capabilities
144
+ term = ENV.fetch('TERM', nil)
145
+ return false if term.nil? || term == 'dumb'
146
+
147
+ # Check if we're likely in a CI environment (common CI env vars)
148
+ ci_vars = %w[CI CONTINUOUS_INTEGRATION BUILD_NUMBER JENKINS_URL GITHUB_ACTIONS]
149
+ return false if ci_vars.any? { |var| ENV[var] }
150
+
151
+ true
152
+ rescue StandardError => ex
153
+ # If any TTY detection fails, assume not available
154
+ if @show_debug
155
+ @io.puts "TTY detection failed: #{ex.message}"
156
+ end
157
+ false
158
+ end
159
+
160
+ def write_status_content(state)
161
+ return unless @available
162
+
163
+ # Line 1: Empty separator line
164
+ @io.print "\n"
165
+
166
+ # Line 2: Current progress
167
+ if state.current_file
168
+ current_info = "Running: #{state.current_file}"
169
+ current_info += " → #{state.current_test}" if state.current_test
170
+ @io.print current_info
171
+ else
172
+ @io.print 'Ready'
173
+ end
174
+ @io.print "\n"
175
+
176
+ # Line 3: Test counts
177
+ parts = []
178
+ parts << @pastel.green("#{state.passed} passed") if state.passed > 0
179
+ parts << @pastel.red("#{state.failed} failed") if state.failed > 0
180
+ parts << @pastel.yellow("#{state.errors} errors") if state.errors > 0
181
+
182
+ if parts.any?
183
+ @io.print "Tests: #{parts.join(', ')}"
184
+ else
185
+ @io.print 'Tests: 0 run'
186
+ end
187
+ @io.print "\n"
188
+
189
+ # Line 4: File progress
190
+ files_info = "Files: #{state.files_completed}"
191
+ files_info += "/#{state.total_files}" if state.total_files > 0
192
+ files_info += ' completed'
193
+ @io.print files_info
194
+ @io.print "\n"
195
+
196
+ # Line 5: Timing
197
+ @io.print "Time: #{format_timing(state.elapsed_time)}"
198
+ end
199
+
200
+ def format_timing(elapsed_time)
201
+ if elapsed_time < 0.001
202
+ "#{(elapsed_time * 1_000_000).round}μs"
203
+ elsif elapsed_time < 1
204
+ "#{(elapsed_time * 1000).round}ms"
205
+ else
206
+ "#{elapsed_time.round(2)}s"
207
+ end
208
+ end
209
+
210
+ # Register cleanup handlers for various termination scenarios
211
+ def register_cleanup_handlers
212
+ return if @cleanup_registered
213
+
214
+ # Handle common termination signals
215
+ %w[INT TERM QUIT HUP].each do |signal|
216
+ begin
217
+ Signal.trap(signal) do
218
+ cleanup!
219
+ # Re-raise the signal with default handler
220
+ Signal.trap(signal, 'DEFAULT')
221
+ Process.kill(signal, Process.pid)
222
+ end
223
+ rescue ArgumentError
224
+ # Signal not supported on this platform, skip it
225
+ debug_log("Signal #{signal} not supported, skipping cleanup handler")
226
+ end
227
+ end
228
+
229
+ # Register at_exit handler as final fallback
230
+ at_exit { cleanup! }
231
+
232
+ @cleanup_registered = true
233
+ debug_log('TTY cleanup handlers registered')
234
+ end
235
+
236
+ # Wrap terminal operations with exception handling
237
+ def with_terminal_safety
238
+ yield
239
+ rescue StandardError => e
240
+ debug_log("Terminal operation failed: #{e.message}")
241
+ # Attempt basic cleanup on any terminal operation failure
242
+ begin
243
+ @io.print "\n" if @io.respond_to?(:print)
244
+ @io.flush if @io.respond_to?(:flush)
245
+ rescue StandardError
246
+ # If even basic cleanup fails, there's nothing more we can do
247
+ end
248
+ @status_active = false
249
+ end
250
+
251
+ def debug_log(message)
252
+ return unless @show_debug
253
+
254
+ # Use stderr to avoid interfering with TTY operations on stdout
255
+ $stderr.puts "TTY DEBUG: #{message}"
256
+ end
257
+ end
258
+
259
+ # No-op implementation for when TTY is not available
260
+ class NoOpStatusDisplay
261
+ def initialize(io = $stdout, _options = {})
262
+ @io = io
263
+ end
264
+
265
+ def available? = false
266
+ def reserve_status_area; end
267
+ def write_scrolling(text) = @io.print(text)
268
+ def update_status(state); end
269
+ def clear_status_area; end
270
+ def cleanup!; end # No-op cleanup for consistency
271
+ end
272
+ end
273
+ end
@@ -7,117 +7,119 @@ class Tryouts
7
7
  include FormatterInterface
8
8
 
9
9
  def initialize(options = {})
10
+ super
10
11
  @line_width = options.fetch(:line_width, 70)
11
12
  @show_passed = options.fetch(:show_passed, true)
12
13
  @show_debug = options.fetch(:debug, false)
13
14
  @show_trace = options.fetch(:trace, false)
14
- @current_indent = 0
15
15
  end
16
16
 
17
17
  # Phase-level output
18
- def phase_header(message, _file_count = nil, level = 0)
19
- return if level.equal?(1)
20
-
21
- separators = [
22
- { char: '=', width: @line_width }, # Major phases
23
- { char: '-', width: @line_width - 10 }, # Sub-phases
24
- { char: '.', width: @line_width - 20 }, # Details
25
- { char: '~', width: @line_width - 30 }, # Minor items
26
- ]
27
-
28
- config = separators[level] || separators.last
18
+ def phase_header(message, file_count: nil)
19
+ return if message.include?('EXECUTING') # Skip execution phase headers
29
20
 
30
- separator_line = config[:char] * config[:width]
31
- header_line = message.center(config[:width])
32
-
33
- output = case level
34
- when 0, 1
35
- [separator_line, header_line, separator_line]
36
- else
37
- [header_line, separator_line]
38
- end
21
+ header_line = message.center(@line_width)
22
+ separator_line = '=' * @line_width
39
23
 
40
- with_indent(level) do
41
- puts output.join("\n")
42
- end
24
+ puts(separator_line)
25
+ puts(header_line)
26
+ puts(separator_line)
43
27
  end
44
28
 
45
29
  # File-level operations
46
- def file_start(file_path, _context_info = {})
47
- puts file_header_visual(file_path)
30
+ def file_start(file_path, context_info: {})
31
+ puts(file_header_visual(file_path))
48
32
  end
49
33
 
50
- def file_end(_file_path, _context_info = {})
51
- # No output in verbose mode
52
- end
53
-
54
- def file_parsed(_file_path, _test_count, setup_present: false, teardown_present: false)
34
+ def file_parsed(_file_path, test_count:, setup_present: false, teardown_present: false)
55
35
  message = ''
56
36
 
57
- extras = []
37
+ extras = []
58
38
  extras << 'setup' if setup_present
59
39
  extras << 'teardown' if teardown_present
60
40
  message += " (#{extras.join(', ')})" unless extras.empty?
61
41
 
62
- puts indent_text(message, 2)
42
+ puts(indent_text(message, 2))
63
43
  end
64
44
 
65
- def file_execution_start(_file_path, test_count, context_mode)
45
+ def file_execution_start(_file_path, test_count:, context_mode:)
66
46
  message = "Running #{test_count} tests with #{context_mode} context"
67
- puts indent_text(message, 1)
47
+ puts(indent_text(message, 1))
68
48
  end
69
49
 
70
- # Summary operations
71
- #
72
- # Called right before file_result
73
- def batch_summary(total_tests, failed_count, elapsed_time)
74
- # No output in verbose mode
50
+ # Summary operations - show detailed failure summary
51
+ def batch_summary(failure_collector)
52
+ return unless failure_collector.any_failures?
53
+
54
+ puts
55
+ write '=' * 50
56
+ puts Console.color(:red, 'Failed Tests:')
57
+
58
+ failure_collector.failures_by_file.each do |file_path, failures|
59
+ failures.each_with_index do |failure, index|
60
+ pretty_path = Console.pretty_path(file_path)
61
+
62
+ # Include line number with file path for easy copying/clicking
63
+ if failure.line_number > 0
64
+ location = "#{pretty_path}:#{failure.line_number}"
65
+ else
66
+ location = pretty_path
67
+ end
68
+
69
+ puts
70
+ puts Console.color(:yellow, location)
71
+ puts " #{index + 1}) #{failure.description}"
72
+ puts " #{Console.color(:red, 'Failure:')} #{failure.failure_reason}"
73
+
74
+ # Show source context in verbose mode
75
+ if failure.source_context.any?
76
+ puts " #{Console.color(:cyan, 'Source:')}"
77
+ failure.source_context.each do |line|
78
+ puts " #{line.strip}"
79
+ end
80
+ end
81
+ puts
82
+ end
83
+ end
75
84
  end
76
85
 
77
- def file_result(_file_path, total_tests, failed_count, error_count, elapsed_time)
86
+ def file_result(_file_path, total_tests:, failed_count:, error_count:, elapsed_time: nil)
78
87
  issues_count = failed_count + error_count
79
88
  passed_count = total_tests - issues_count
80
- details = [
81
- "#{passed_count} passed",
82
- ]
89
+ details = ["#{passed_count} passed"]
90
+
83
91
  puts
84
92
  if issues_count > 0
85
93
  details << "#{failed_count} failed" if failed_count > 0
86
94
  details << "#{error_count} errors" if error_count > 0
87
95
  details_str = details.join(', ')
88
- color = :red
96
+ color = :red
89
97
 
90
98
  time_str = elapsed_time ? " (#{elapsed_time.round(2)}s)" : ''
91
- message = "✗ Out of #{total_tests} tests: #{details_str}#{time_str}"
99
+ message = "✗ Out of #{total_tests} tests: #{details_str}#{time_str}"
92
100
  puts indent_text(Console.color(color, message), 2)
93
101
  else
94
102
  message = "#{total_tests} tests passed"
95
- color = :green
103
+ color = :green
96
104
  puts indent_text(Console.color(color, "✓ #{message}"), 2)
97
105
  end
98
106
 
99
107
  return unless elapsed_time
100
108
 
101
109
  time_msg = "Completed in #{format_timing(elapsed_time).strip.tr('()', '')}"
102
-
103
110
  puts indent_text(Console.color(:dim, time_msg), 2)
104
111
  end
105
112
 
106
113
  # Test-level operations
107
- def test_start(test_case, index, total)
108
- desc = test_case.description.to_s
109
- desc = 'Unnamed test' if desc.empty?
114
+ def test_start(test_case:, index:, total:)
115
+ desc = test_case.description.to_s
116
+ desc = 'Unnamed test' if desc.empty?
110
117
  message = "Test #{index}/#{total}: #{desc}"
111
118
  puts indent_text(Console.color(:dim, message), 2)
112
119
  end
113
120
 
114
- def test_end(_test_case, _index, _total)
115
- # No output in verbose mode
116
- end
117
-
118
121
  def test_result(result_packet)
119
122
  should_show = @show_passed || !result_packet.passed?
120
-
121
123
  return unless should_show
122
124
 
123
125
  status_line = case result_packet.status
@@ -134,7 +136,7 @@ class Tryouts
134
136
  end
135
137
 
136
138
  test_case = result_packet.test_case
137
- location = "#{Console.pretty_path(test_case.path)}:#{test_case.first_expectation_line + 1}"
139
+ location = "#{Console.pretty_path(test_case.path)}:#{test_case.first_expectation_line + 1}"
138
140
  puts
139
141
  puts indent_text("#{status_line} @ #{location}", 2)
140
142
 
@@ -150,7 +152,7 @@ class Tryouts
150
152
  end
151
153
  end
152
154
 
153
- def test_output(_test_case, output_text)
155
+ def test_output(test_case:, output_text:, result_packet:)
154
156
  return if output_text.nil? || output_text.strip.empty?
155
157
 
156
158
  puts indent_text('Test Output:', 3)
@@ -165,7 +167,7 @@ class Tryouts
165
167
  end
166
168
 
167
169
  # Setup/teardown operations
168
- def setup_start(line_range)
170
+ def setup_start(line_range:)
169
171
  message = "Executing global setup (lines #{line_range.first}..#{line_range.last})"
170
172
  puts indent_text(Console.color(:cyan, message), 2)
171
173
  end
@@ -178,7 +180,7 @@ class Tryouts
178
180
  end
179
181
  end
180
182
 
181
- def teardown_start(line_range)
183
+ def teardown_start(line_range:)
182
184
  message = "Executing teardown (lines #{line_range.first}..#{line_range.last})"
183
185
  puts indent_text(Console.color(:cyan, message), 2)
184
186
  puts
@@ -192,21 +194,20 @@ class Tryouts
192
194
  end
193
195
  end
194
196
 
195
- def grand_total(total_tests, failed_count, error_count, successful_files, total_files, elapsed_time)
197
+ def grand_total(total_tests:, failed_count:, error_count:, successful_files:, total_files:, elapsed_time:)
196
198
  puts
197
199
  puts '=' * @line_width
198
200
  puts 'Grand Total:'
199
201
 
200
202
  issues_count = failed_count + error_count
201
- time_str =
202
- if elapsed_time < 2.0
203
- " (#{(elapsed_time * 1000).round}ms)"
204
- else
205
- " (#{elapsed_time.round(2)}s)"
206
- end
203
+ time_str = if elapsed_time < 2.0
204
+ " (#{(elapsed_time * 1000).round}ms)"
205
+ else
206
+ " (#{elapsed_time.round(2)}s)"
207
+ end
207
208
 
208
209
  if issues_count > 0
209
- passed = [total_tests - issues_count, 0].max # Ensure passed never goes negative
210
+ passed = [total_tests - issues_count, 0].max # Ensure passed never goes negative
210
211
  details = []
211
212
  details << "#{failed_count} failed" if failed_count > 0
212
213
  details << "#{error_count} errors" if error_count > 0
@@ -215,12 +216,12 @@ class Tryouts
215
216
  puts "#{total_tests} tests passed#{time_str}"
216
217
  end
217
218
 
218
- puts "Files processed: #{successful_files} of #{total_files} successful"
219
+ puts "Files: #{successful_files} of #{total_files} successful"
219
220
  puts '=' * @line_width
220
221
  end
221
222
 
222
223
  # Debug and diagnostic output
223
- def debug_info(message, level = 0)
224
+ def debug_info(message, level: 0)
224
225
  return unless @show_debug
225
226
 
226
227
  prefix = Console.color(:cyan, 'INFO ')
@@ -228,14 +229,14 @@ class Tryouts
228
229
  puts indent_text("#{prefix} #{message}", level + 1)
229
230
  end
230
231
 
231
- def trace_info(message, level = 0)
232
+ def trace_info(message, level: 0)
232
233
  return unless @show_trace
233
234
 
234
235
  prefix = Console.color(:dim, 'TRACE')
235
236
  puts indent_text("#{prefix} #{message}", level + 1)
236
237
  end
237
238
 
238
- def error_message(message, backtrace = nil)
239
+ def error_message(message, backtrace: nil)
239
240
  error_msg = Console.color(:red, "ERROR: #{message}")
240
241
  puts indent_text(error_msg, 1)
241
242
 
@@ -249,22 +250,12 @@ class Tryouts
249
250
  puts indent_text("... (#{backtrace.length - 10} more lines)", 3) if backtrace.length > 10
250
251
  end
251
252
 
252
- # Utility methods
253
- def raw_output(text)
254
- puts text
255
- end
256
-
257
- def separator(style = :light)
258
- case style
259
- when :heavy
260
- puts '=' * @line_width
261
- when :light
262
- puts '-' * @line_width
263
- when :dotted
264
- puts '.' * @line_width
265
- else # rubocop:disable Lint/DuplicateBranch
266
- puts '-' * @line_width
267
- end
253
+ def live_status_capabilities
254
+ {
255
+ supports_coordination: true, # Verbose can work with coordinated output
256
+ output_frequency: :high, # Outputs frequently for each test
257
+ requires_tty: false, # Works without TTY
258
+ }
268
259
  end
269
260
 
270
261
  private
@@ -279,7 +270,7 @@ class Tryouts
279
270
  puts indent_text('Exception Details:', 4)
280
271
 
281
272
  actual_results.each_with_index do |actual, idx|
282
- expected = expected_results[idx] if expected_results && idx < expected_results.length
273
+ expected = expected_results[idx] if expected_results && idx < expected_results.length
283
274
  expectation = test_case.expectations[idx] if test_case.expectations
284
275
 
285
276
  if expectation&.type == :exception
@@ -296,7 +287,7 @@ class Tryouts
296
287
  start_line = test_case.line_range.first
297
288
 
298
289
  test_case.source_lines.each_with_index do |line_content, index|
299
- line_num = start_line + index
290
+ line_num = start_line + index
300
291
  line_display = format('%3d: %s', line_num + 1, line_content)
301
292
 
302
293
  # Highlight expectation lines by checking if this line contains any expectation syntax
@@ -313,7 +304,7 @@ class Tryouts
313
304
  return if actual_results.empty?
314
305
 
315
306
  actual_results.each_with_index do |actual, idx|
316
- expected = expected_results[idx] if expected_results && idx < expected_results.length
307
+ expected = expected_results[idx] if expected_results && idx < expected_results.length
317
308
  expected_line = test_case.expectations[idx] if test_case.expectations
318
309
 
319
310
  if !expected.nil?
@@ -347,10 +338,10 @@ class Tryouts
347
338
  end
348
339
 
349
340
  def file_header_visual(file_path)
350
- pretty_path = Console.pretty_path(file_path)
341
+ pretty_path = Console.pretty_path(file_path)
351
342
  header_content = ">>>>> #{pretty_path} "
352
343
  padding_length = [@line_width - header_content.length, 0].max
353
- padding = '<' * padding_length
344
+ padding = '<' * padding_length
354
345
 
355
346
  [
356
347
  indent_text('-' * @line_width, 1),
@@ -358,16 +349,6 @@ class Tryouts
358
349
  indent_text('-' * @line_width, 1),
359
350
  ].join("\n")
360
351
  end
361
-
362
- def format_timing(elapsed_time)
363
- if elapsed_time < 0.001
364
- " (#{(elapsed_time * 1_000_000).round}μs)"
365
- elsif elapsed_time < 1
366
- " (#{(elapsed_time * 1000).round}ms)"
367
- else
368
- " (#{elapsed_time.round(2)}s)"
369
- end
370
- end
371
352
  end
372
353
 
373
354
  # Verbose formatter that only shows failures and errors
@@ -376,12 +357,27 @@ class Tryouts
376
357
  super(options.merge(show_passed: false))
377
358
  end
378
359
 
360
+ def test_output(test_case:, output_text:, result_packet:)
361
+ # Only show output for failed tests
362
+ return if result_packet.passed?
363
+
364
+ super
365
+ end
366
+
379
367
  def test_result(result_packet)
380
368
  # Only show failed/error tests, but with full source code
381
369
  return if result_packet.passed?
382
370
 
383
371
  super
384
372
  end
373
+
374
+ def live_status_capabilities
375
+ {
376
+ supports_coordination: true, # Verbose can work with coordinated output
377
+ output_frequency: :high, # Outputs frequently for each test
378
+ requires_tty: false, # Works without TTY
379
+ }
380
+ end
385
381
  end
386
382
  end
387
383
  end
@@ -4,3 +4,6 @@ require_relative 'formatters/base'
4
4
  require_relative 'formatters/compact'
5
5
  require_relative 'formatters/quiet'
6
6
  require_relative 'formatters/verbose'
7
+ require_relative 'formatters/test_run_state'
8
+ require_relative 'formatters/tty_status_display'
9
+ require_relative 'formatters/factory'