tryouts 3.0.0 → 3.1.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +51 -115
  3. data/exe/try +25 -4
  4. data/lib/tryouts/cli/formatters/base.rb +33 -21
  5. data/lib/tryouts/cli/formatters/compact.rb +122 -84
  6. data/lib/tryouts/cli/formatters/factory.rb +1 -1
  7. data/lib/tryouts/cli/formatters/output_manager.rb +13 -2
  8. data/lib/tryouts/cli/formatters/quiet.rb +22 -16
  9. data/lib/tryouts/cli/formatters/verbose.rb +102 -61
  10. data/lib/tryouts/console.rb +53 -17
  11. data/lib/tryouts/expectation_evaluators/base.rb +101 -0
  12. data/lib/tryouts/expectation_evaluators/boolean.rb +60 -0
  13. data/lib/tryouts/expectation_evaluators/exception.rb +61 -0
  14. data/lib/tryouts/expectation_evaluators/expectation_result.rb +67 -0
  15. data/lib/tryouts/expectation_evaluators/false.rb +60 -0
  16. data/lib/tryouts/expectation_evaluators/intentional_failure.rb +74 -0
  17. data/lib/tryouts/expectation_evaluators/output.rb +101 -0
  18. data/lib/tryouts/expectation_evaluators/performance_time.rb +81 -0
  19. data/lib/tryouts/expectation_evaluators/regex_match.rb +57 -0
  20. data/lib/tryouts/expectation_evaluators/registry.rb +66 -0
  21. data/lib/tryouts/expectation_evaluators/regular.rb +67 -0
  22. data/lib/tryouts/expectation_evaluators/result_type.rb +51 -0
  23. data/lib/tryouts/expectation_evaluators/true.rb +58 -0
  24. data/lib/tryouts/prism_parser.rb +221 -20
  25. data/lib/tryouts/test_batch.rb +556 -0
  26. data/lib/tryouts/test_case.rb +192 -0
  27. data/lib/tryouts/test_executor.rb +7 -5
  28. data/lib/tryouts/test_runner.rb +2 -2
  29. data/lib/tryouts/translators/minitest_translator.rb +78 -11
  30. data/lib/tryouts/translators/rspec_translator.rb +85 -12
  31. data/lib/tryouts/version.rb +1 -1
  32. data/lib/tryouts.rb +43 -1
  33. metadata +18 -5
  34. data/lib/tryouts/testbatch.rb +0 -314
  35. data/lib/tryouts/testcase.rb +0 -51
@@ -0,0 +1,556 @@
1
+ # lib/tryouts/testbatch.rb
2
+
3
+ require 'stringio'
4
+ require_relative 'expectation_evaluators/registry'
5
+
6
+ class Tryouts
7
+ # Factory for creating fresh context containers for each test
8
+ class FreshContextFactory
9
+ def initialize
10
+ @containers_created = 0
11
+ end
12
+
13
+ def create_container
14
+ @containers_created += 1
15
+ Object.new
16
+ end
17
+
18
+ def containers_created_count
19
+ @containers_created
20
+ end
21
+ end
22
+
23
+ # Modern TestBatch using Ruby 3.4+ patterns and formatter system
24
+ class TestBatch
25
+ attr_reader :testrun, :failed_count, :container, :status, :results, :formatter, :output_manager
26
+
27
+ def initialize(testrun, **options)
28
+ @testrun = testrun
29
+ @container = Object.new
30
+ @options = options
31
+ @formatter = Tryouts::CLI::FormatterFactory.create_formatter(options)
32
+ @output_manager = options[:output_manager]
33
+ @global_tally = options[:global_tally]
34
+ @failed_count = 0
35
+ @status = :pending
36
+ @results = []
37
+ @start_time = nil
38
+ @test_case_count = 0
39
+ @setup_failed = false
40
+
41
+ # Setup container for fresh context mode - preserves @instance_variables from setup
42
+ @setup_container = nil
43
+
44
+ # Circuit breaker for batch-level failure protection
45
+ @consecutive_failures = 0
46
+ @max_consecutive_failures = options[:max_consecutive_failures] || 10
47
+ @circuit_breaker_active = false
48
+
49
+ # Expose context objects for testing - different strategies for each mode
50
+ @shared_context = if options[:shared_context]
51
+ @container # Shared mode: single container reused across tests
52
+ else
53
+ FreshContextFactory.new # Fresh mode: factory that creates new containers
54
+ end
55
+ end
56
+
57
+ # Main execution pipeline using functional composition
58
+ def run(before_test_hook = nil, &)
59
+ return false if empty?
60
+
61
+ @start_time = Time.now
62
+ @test_case_count = test_cases.size
63
+
64
+ @output_manager&.execution_phase(@test_case_count)
65
+ @output_manager&.info("Context: #{@options[:shared_context] ? 'shared' : 'fresh'}", 1)
66
+ @output_manager&.file_start(path, context: @options[:shared_context] ? :shared : :fresh)
67
+
68
+ if shared_context?
69
+ @output_manager&.info('Running global setup...', 2)
70
+ execute_global_setup
71
+
72
+ # Stop execution if setup failed
73
+ if @setup_failed
74
+ @output_manager&.error('Stopping batch execution due to setup failure')
75
+ @status = :failed
76
+ finalize_results([])
77
+ return false
78
+ end
79
+ else
80
+ # Fresh context mode: execute setup once to establish shared @instance_variables
81
+ @output_manager&.info('Running setup for fresh context...', 2)
82
+ execute_fresh_context_setup
83
+
84
+ # Stop execution if setup failed
85
+ if @setup_failed
86
+ @output_manager&.error('Stopping batch execution due to setup failure')
87
+ @status = :failed
88
+ finalize_results([])
89
+ return false
90
+ end
91
+ end
92
+
93
+ idx = 0
94
+ execution_results = test_cases.map do |test_case|
95
+ @output_manager&.trace("Test #{idx + 1}/#{@test_case_count}: #{test_case.description}", 2)
96
+ idx += 1
97
+
98
+ # Check circuit breaker before executing test
99
+ if @circuit_breaker_active
100
+ @output_manager&.error("Circuit breaker active - skipping remaining tests after #{@consecutive_failures} consecutive failures")
101
+ break
102
+ end
103
+
104
+ @output_manager&.test_start(test_case, idx, @test_case_count)
105
+ result = execute_single_test(test_case, before_test_hook, &) # runs the test code
106
+ @output_manager&.test_end(test_case, idx, @test_case_count)
107
+
108
+ # Update circuit breaker state based on result
109
+ update_circuit_breaker(result)
110
+
111
+ result
112
+ rescue StandardError => ex
113
+ @output_manager&.test_end(test_case, idx, @test_case_count, status: :failed, error: ex)
114
+ # Create error result packet to maintain consistent data flow
115
+ error_result = build_error_result(test_case, ex)
116
+ process_test_result(error_result)
117
+
118
+ # Update circuit breaker for exception cases
119
+ update_circuit_breaker(error_result)
120
+
121
+ error_result
122
+ end
123
+
124
+ # Used for a separate purpose then execution_phase.
125
+ # e.g. the quiet formatter prints a newline after all test dots
126
+ @output_manager&.file_end(path, context: @options[:shared_context] ? :shared : :fresh)
127
+
128
+ @output_manager&.execution_phase(test_cases.size)
129
+
130
+ execute_global_teardown
131
+ finalize_results(execution_results)
132
+
133
+ @status = :completed
134
+ !failed?
135
+ end
136
+
137
+ def empty?
138
+ @testrun.empty?
139
+ end
140
+
141
+ def size
142
+ @testrun.total_tests
143
+ end
144
+
145
+ def test_cases
146
+ @testrun.test_cases
147
+ end
148
+
149
+ def path
150
+ @testrun.source_file
151
+ end
152
+
153
+ def failed?
154
+ @failed_count > 0
155
+ end
156
+
157
+ def completed?
158
+ @status == :completed
159
+ end
160
+
161
+ private
162
+
163
+ # Pattern matching for execution strategy selection
164
+ def execute_single_test(test_case, before_test_hook = nil)
165
+ before_test_hook&.call(test_case)
166
+
167
+ # Capture output during test execution
168
+ result = nil
169
+ captured_output = capture_output do
170
+ result = case @options[:shared_context]
171
+ when true
172
+ execute_with_shared_context(test_case)
173
+ when false, nil
174
+ execute_with_fresh_context(test_case)
175
+ else
176
+ raise 'Invalid execution context configuration'
177
+ end
178
+ end
179
+
180
+ # Add captured output to the result if any exists
181
+ if captured_output && !captured_output.empty?
182
+ # Create new result packet with captured output
183
+ result = result.class.new(
184
+ test_case: result.test_case,
185
+ status: result.status,
186
+ result_value: result.result_value,
187
+ actual_results: result.actual_results,
188
+ expected_results: result.expected_results,
189
+ error: result.error,
190
+ captured_output: captured_output,
191
+ elapsed_time: result.elapsed_time,
192
+ metadata: result.metadata,
193
+ )
194
+ end
195
+
196
+ process_test_result(result)
197
+ yield(test_case) if block_given?
198
+ result
199
+ end
200
+
201
+ # Shared context execution - setup runs once, all tests share state
202
+ def execute_with_shared_context(test_case)
203
+ execute_test_case_with_container(test_case, @container)
204
+ end
205
+
206
+ # Fresh context execution - tests run in isolated state but inherit setup @instance_variables
207
+ def execute_with_fresh_context(test_case)
208
+ fresh_container = if @shared_context.is_a?(FreshContextFactory)
209
+ @shared_context.create_container
210
+ else
211
+ Object.new # Fallback for backwards compatibility
212
+ end
213
+
214
+ # Copy @instance_variables from setup container to fresh container
215
+ if @setup_container
216
+ @setup_container.instance_variables.each do |var|
217
+ value = @setup_container.instance_variable_get(var)
218
+ fresh_container.instance_variable_set(var, value)
219
+ end
220
+ end
221
+
222
+ execute_test_case_with_container(test_case, fresh_container)
223
+ end
224
+
225
+ # Common test execution logic shared by both context modes
226
+ def execute_test_case_with_container(test_case, container)
227
+ # Individual test timeout protection
228
+ test_timeout = @options[:test_timeout] || 30 # 30 second default
229
+
230
+ if test_case.exception_expectations?
231
+ # For exception tests, don't execute code here - let evaluate_expectations handle it
232
+ expectations_result = execute_with_timeout(test_timeout, test_case) do
233
+ evaluate_expectations(test_case, nil, container)
234
+ end
235
+ build_test_result(test_case, nil, expectations_result)
236
+ else
237
+ # Regular execution for non-exception tests with timing and output capture
238
+ code = test_case.code
239
+ path = test_case.path
240
+ range = test_case.line_range
241
+
242
+ # Check if we need output capture for any expectations
243
+ needs_output_capture = test_case.expectations.any?(&:output?)
244
+
245
+ result_value, _, _, _, expectations_result =
246
+ execute_with_timeout(test_timeout, test_case) do
247
+ if needs_output_capture
248
+ # Execute with output capture using Fiber-local isolation
249
+ result_value, execution_time_ns, stdout_content, stderr_content =
250
+ execute_with_output_capture(container, code, path, range)
251
+
252
+ expectations_result = evaluate_expectations(
253
+ test_case, result_value, container, execution_time_ns, stdout_content, stderr_content
254
+ )
255
+ [result_value, execution_time_ns, stdout_content, stderr_content, expectations_result]
256
+ else
257
+ # Regular execution with timing capture only
258
+ execution_start_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
259
+ result_value = container.instance_eval(code, path, range.first + 1)
260
+ execution_end_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
261
+ execution_time_ns = execution_end_ns - execution_start_ns
262
+
263
+ expectations_result = evaluate_expectations(test_case, result_value, container, execution_time_ns)
264
+ [result_value, execution_time_ns, nil, nil, expectations_result]
265
+ end
266
+ end
267
+
268
+ build_test_result(test_case, result_value, expectations_result)
269
+ end
270
+ rescue StandardError => ex
271
+ build_error_result(test_case, ex)
272
+ rescue SystemExit, SignalException => ex
273
+ # Handle process control exceptions gracefully
274
+ Tryouts.debug "Test received #{ex.class}: #{ex.message}"
275
+ build_error_result(test_case, StandardError.new("Test terminated by #{ex.class}: #{ex.message}"))
276
+ end
277
+
278
+ # Execute test code with Fiber-based stdout/stderr capture
279
+ def execute_with_output_capture(container, code, path, range)
280
+ # Fiber-local storage for output redirection
281
+ original_stdout = $stdout
282
+ original_stderr = $stderr
283
+
284
+ # Create StringIO objects for capturing output
285
+ captured_stdout = StringIO.new
286
+ captured_stderr = StringIO.new
287
+
288
+ begin
289
+ # Redirect output streams using Fiber-local variables
290
+ Fiber.new do
291
+ $stdout = captured_stdout
292
+ $stderr = captured_stderr
293
+
294
+ # Execute with timing capture
295
+ execution_start_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
296
+ result_value = container.instance_eval(code, path, range.first + 1)
297
+ execution_end_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
298
+ execution_time_ns = execution_end_ns - execution_start_ns
299
+
300
+ [result_value, execution_time_ns]
301
+ end.resume.tap do |result_value, execution_time_ns|
302
+ # Return captured content along with result
303
+ return [result_value, execution_time_ns, captured_stdout.string, captured_stderr.string]
304
+ end
305
+ ensure
306
+ # Always restore original streams
307
+ $stdout = original_stdout
308
+ $stderr = original_stderr
309
+ end
310
+ end
311
+
312
+ # Evaluate expectations using new object-oriented evaluation system
313
+ def evaluate_expectations(test_case, actual_result, context, execution_time_ns = nil, stdout_content = nil, stderr_content = nil)
314
+ return { passed: true, actual_results: [], expected_results: [] } if test_case.expectations.empty?
315
+
316
+ evaluation_results = test_case.expectations.map do |expectation|
317
+ evaluator = ExpectationEvaluators::Registry.evaluator_for(expectation, test_case, context)
318
+
319
+ # Pass appropriate data to different evaluator types
320
+ if expectation.performance_time? && execution_time_ns
321
+ evaluator.evaluate(actual_result, execution_time_ns)
322
+ elsif expectation.output? && (stdout_content || stderr_content)
323
+ evaluator.evaluate(actual_result, stdout_content, stderr_content)
324
+ else
325
+ evaluator.evaluate(actual_result)
326
+ end
327
+ end
328
+
329
+ aggregate_evaluation_results(evaluation_results)
330
+ end
331
+
332
+ # Aggregate individual evaluation results into the expected format
333
+ def aggregate_evaluation_results(evaluation_results)
334
+ {
335
+ passed: evaluation_results.all? { |r| r[:passed] },
336
+ actual_results: evaluation_results.map { |r| r[:actual] },
337
+ expected_results: evaluation_results.map { |r| r[:expected] },
338
+ }
339
+ end
340
+
341
+ # Build structured test results using TestCaseResultPacket
342
+ def build_test_result(test_case, result_value, expectations_result)
343
+ if expectations_result[:passed]
344
+ TestCaseResultPacket.from_success(
345
+ test_case,
346
+ result_value,
347
+ expectations_result[:actual_results],
348
+ expectations_result[:expected_results],
349
+ )
350
+ else
351
+ TestCaseResultPacket.from_failure(
352
+ test_case,
353
+ result_value,
354
+ expectations_result[:actual_results],
355
+ expectations_result[:expected_results],
356
+ )
357
+ end
358
+ end
359
+
360
+ def build_error_result(test_case, exception)
361
+ TestCaseResultPacket.from_error(test_case, exception)
362
+ end
363
+
364
+ # Process and display test results using formatter
365
+ def process_test_result(result)
366
+ @results << result
367
+
368
+ if result.failed? || result.error?
369
+ @failed_count += 1
370
+ end
371
+
372
+ show_test_result(result)
373
+
374
+ # Show captured output if any exists
375
+ if result.has_output?
376
+ @output_manager&.test_output(result.test_case, result.captured_output)
377
+ end
378
+ end
379
+
380
+ # Global setup execution for shared context mode
381
+ def execute_global_setup
382
+ setup = @testrun.setup
383
+
384
+ if setup && !setup.code.empty? && @options[:shared_context]
385
+ @output_manager&.setup_start(setup.line_range)
386
+
387
+ # Capture setup output instead of letting it print directly
388
+ captured_output = capture_output do
389
+ @container.instance_eval(setup.code, setup.path, setup.line_range.first + 1)
390
+ end
391
+
392
+ @output_manager&.setup_output(captured_output) if captured_output && !captured_output.empty?
393
+ end
394
+ rescue StandardError => ex
395
+ @setup_failed = true
396
+ @global_tally[:total_errors] += 1 if @global_tally
397
+
398
+ # Classify error and handle appropriately
399
+ error_type = Tryouts.classify_error(ex)
400
+
401
+ Tryouts.debug "Setup failed with #{error_type} error: (#{ex.class}): #{ex.message}"
402
+ Tryouts.trace ex.backtrace
403
+
404
+ # For non-catastrophic errors, we still stop batch execution
405
+ unless Tryouts.batch_stopping_error?(ex)
406
+ @output_manager&.error("Global setup failed: #{ex.message}")
407
+ return
408
+ end
409
+
410
+ # For catastrophic errors, still raise to stop execution
411
+ raise "Global setup failed (#{ex.class}): #{ex.message}"
412
+ end
413
+
414
+ # Setup execution for fresh context mode - creates @setup_container with @instance_variables
415
+ def execute_fresh_context_setup
416
+ setup = @testrun.setup
417
+
418
+ if setup && !setup.code.empty? && !@options[:shared_context]
419
+ @output_manager&.setup_start(setup.line_range)
420
+
421
+ # Create setup container to hold @instance_variables
422
+ @setup_container = Object.new
423
+
424
+ # Capture setup output instead of letting it print directly
425
+ captured_output = capture_output do
426
+ @setup_container.instance_eval(setup.code, setup.path, setup.line_range.first + 1)
427
+ end
428
+
429
+ @output_manager&.setup_output(captured_output) if captured_output && !captured_output.empty?
430
+ end
431
+ rescue StandardError => ex
432
+ @setup_failed = true
433
+ @global_tally[:total_errors] += 1 if @global_tally
434
+
435
+ # Classify error and handle appropriately
436
+ error_type = Tryouts.classify_error(ex)
437
+
438
+ Tryouts.debug "Setup failed with #{error_type} error: (#{ex.class}): #{ex.message}"
439
+ Tryouts.trace ex.backtrace
440
+
441
+ # For non-catastrophic errors, we still stop batch execution
442
+ unless Tryouts.batch_stopping_error?(ex)
443
+ @output_manager&.error("Fresh context setup failed: #{ex.message}")
444
+ return
445
+ end
446
+
447
+ # For catastrophic errors, still raise to stop execution
448
+ raise "Fresh context setup failed (#{ex.class}): #{ex.message}"
449
+ end
450
+
451
+ # Global teardown execution
452
+ def execute_global_teardown
453
+ teardown = @testrun.teardown
454
+
455
+ if teardown && !teardown.code.empty?
456
+ @output_manager&.teardown_start(teardown.line_range)
457
+
458
+ # Capture teardown output instead of letting it print directly
459
+ captured_output = capture_output do
460
+ @container.instance_eval(teardown.code, teardown.path, teardown.line_range.first + 1)
461
+ end
462
+
463
+ @output_manager&.teardown_output(captured_output) if captured_output && !captured_output.empty?
464
+ end
465
+ rescue StandardError => ex
466
+ @global_tally[:total_errors] += 1 if @global_tally
467
+
468
+ # Classify error and handle appropriately
469
+ error_type = Tryouts.classify_error(ex)
470
+
471
+ Tryouts.debug "Teardown failed with #{error_type} error: (#{ex.class}): #{ex.message}"
472
+ Tryouts.trace ex.backtrace
473
+
474
+ @output_manager&.error("Teardown failed: #{ex.message}")
475
+
476
+ # Teardown failures are generally non-fatal - log and continue
477
+ if Tryouts.batch_stopping_error?(ex)
478
+ # Only catastrophic errors should potentially affect batch completion
479
+ @output_manager&.error('Teardown failure may affect subsequent operations')
480
+ else
481
+ @output_manager&.error('Continuing despite teardown failure')
482
+ end
483
+ end
484
+
485
+ # Result finalization and summary display
486
+ def finalize_results(_execution_results)
487
+ @status = :completed
488
+ elapsed_time = Time.now - @start_time
489
+ show_summary(elapsed_time)
490
+ end
491
+
492
+ def show_test_result(result)
493
+ @output_manager&.test_result(result)
494
+ end
495
+
496
+ def show_summary(elapsed_time)
497
+ # Use actual executed test count, not total tests in file
498
+ executed_count = @results.size
499
+ @output_manager&.batch_summary(executed_count, @failed_count, elapsed_time)
500
+ end
501
+
502
+ # Helper methods using pattern matching
503
+
504
+ def shared_context?
505
+ @options[:shared_context] == true
506
+ end
507
+
508
+ def capture_output
509
+ old_stdout = $stdout
510
+ old_stderr = $stderr
511
+ $stdout = StringIO.new
512
+ $stderr = StringIO.new
513
+
514
+ yield
515
+
516
+ captured = $stdout.string + $stderr.string
517
+ captured.empty? ? nil : captured
518
+ ensure
519
+ $stdout = old_stdout
520
+ $stderr = old_stderr
521
+ end
522
+
523
+ def handle_batch_error(exception)
524
+ @status = :error
525
+ @failed_count = 1
526
+
527
+ error_message = "Batch execution failed: #{exception.message}"
528
+ backtrace = exception.respond_to?(:backtrace) ? exception.backtrace : nil
529
+
530
+ @output_manager&.error(error_message, backtrace)
531
+ end
532
+
533
+ # Timeout protection for individual test execution
534
+ def execute_with_timeout(timeout_seconds, test_case, &)
535
+ Timeout.timeout(timeout_seconds, &)
536
+ rescue Timeout::Error
537
+ Tryouts.debug "Test timeout after #{timeout_seconds}s: #{test_case.description}"
538
+ raise StandardError.new("Test execution timeout (#{timeout_seconds}s)")
539
+ end
540
+
541
+ # Circuit breaker pattern for batch-level failure protection
542
+ def update_circuit_breaker(result)
543
+ if result.failed? || result.error?
544
+ @consecutive_failures += 1
545
+ if @consecutive_failures >= @max_consecutive_failures
546
+ @circuit_breaker_active = true
547
+ Tryouts.debug "Circuit breaker activated after #{@consecutive_failures} consecutive failures"
548
+ end
549
+ else
550
+ # Reset on success
551
+ @consecutive_failures = 0
552
+ @circuit_breaker_active = false
553
+ end
554
+ end
555
+ end
556
+ end