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.
- checksums.yaml +4 -4
- data/README.md +51 -115
- data/exe/try +25 -4
- data/lib/tryouts/cli/formatters/base.rb +33 -21
- data/lib/tryouts/cli/formatters/compact.rb +122 -84
- data/lib/tryouts/cli/formatters/factory.rb +1 -1
- data/lib/tryouts/cli/formatters/output_manager.rb +13 -2
- data/lib/tryouts/cli/formatters/quiet.rb +22 -16
- data/lib/tryouts/cli/formatters/verbose.rb +102 -61
- data/lib/tryouts/console.rb +53 -17
- data/lib/tryouts/expectation_evaluators/base.rb +101 -0
- data/lib/tryouts/expectation_evaluators/boolean.rb +60 -0
- data/lib/tryouts/expectation_evaluators/exception.rb +61 -0
- data/lib/tryouts/expectation_evaluators/expectation_result.rb +67 -0
- data/lib/tryouts/expectation_evaluators/false.rb +60 -0
- data/lib/tryouts/expectation_evaluators/intentional_failure.rb +74 -0
- data/lib/tryouts/expectation_evaluators/output.rb +101 -0
- data/lib/tryouts/expectation_evaluators/performance_time.rb +81 -0
- data/lib/tryouts/expectation_evaluators/regex_match.rb +57 -0
- data/lib/tryouts/expectation_evaluators/registry.rb +66 -0
- data/lib/tryouts/expectation_evaluators/regular.rb +67 -0
- data/lib/tryouts/expectation_evaluators/result_type.rb +51 -0
- data/lib/tryouts/expectation_evaluators/true.rb +58 -0
- data/lib/tryouts/prism_parser.rb +221 -20
- data/lib/tryouts/test_batch.rb +556 -0
- data/lib/tryouts/test_case.rb +192 -0
- data/lib/tryouts/test_executor.rb +7 -5
- data/lib/tryouts/test_runner.rb +2 -2
- data/lib/tryouts/translators/minitest_translator.rb +78 -11
- data/lib/tryouts/translators/rspec_translator.rb +85 -12
- data/lib/tryouts/version.rb +1 -1
- data/lib/tryouts.rb +43 -1
- metadata +18 -5
- data/lib/tryouts/testbatch.rb +0 -314
- 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
|