tryouts 3.3.0 → 3.3.2

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,122 @@
1
+ # lib/tryouts/parsers/prism_parser.rb
2
+
3
+ require_relative '../test_case'
4
+ require_relative 'base_parser'
5
+
6
+ class Tryouts
7
+ # Fixed PrismParser with pattern matching for robust token filtering
8
+ class PrismParser < Tryouts::Parsers::BaseParser
9
+
10
+ def parse
11
+ return handle_syntax_errors if @prism_result.failure?
12
+
13
+ tokens = tokenize_content
14
+ test_boundaries = find_test_case_boundaries(tokens)
15
+ tokens = classify_potential_descriptions_with_boundaries(tokens, test_boundaries)
16
+ test_blocks = group_into_test_blocks(tokens)
17
+ process_test_blocks(test_blocks)
18
+ end
19
+
20
+ private
21
+
22
+ # Tokenize content using pattern matching for clean line classification
23
+ def tokenize_content
24
+ tokens = []
25
+
26
+ @lines.each_with_index do |line, index|
27
+ token = case line
28
+ in /^##\s*(.*)$/ # Test description format: ## description
29
+ { type: :description, content: $1.strip, line: index }
30
+ in /^#\s*TEST\s*\d*:\s*(.*)$/ # rubocop:disable Lint/DuplicateBranch
31
+ { type: :description, content: $1.strip, line: index }
32
+ in /^#\s*=!>\s*(.*)$/ # Exception expectation (updated for consistency)
33
+ { type: :exception_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
34
+ in /^#\s*=<>\s*(.*)$/ # Intentional failure expectation
35
+ { type: :intentional_failure_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
36
+ in /^#\s*==>\s*(.*)$/ # Boolean true expectation
37
+ { type: :true_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
38
+ in %r{^#\s*=/=>\s*(.*)$} # Boolean false expectation
39
+ { type: :false_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
40
+ in /^#\s*=\|>\s*(.*)$/ # Boolean (true or false) expectation
41
+ { type: :boolean_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
42
+ in /^#\s*=\*>\s*(.*)$/ # Non-nil expectation
43
+ { type: :non_nil_expectation, content: $1.strip, line: index }
44
+ in /^#\s*=:>\s*(.*)$/ # Result type expectation
45
+ { type: :result_type_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
46
+ in /^#\s*=~>\s*(.*)$/ # Regex match expectation
47
+ { type: :regex_match_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
48
+ in /^#\s*=%>\s*(.*)$/ # Performance time expectation
49
+ { type: :performance_time_expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
50
+ in /^#\s*=(\d+)>\s*(.*)$/ # Output expectation (stdout/stderr with pipe number)
51
+ { type: :output_expectation, content: $2.strip, pipe: $1.to_i, line: index, ast: parse_expectation($2.strip) }
52
+ in /^#\s*=>\s*(.*)$/ # Regular expectation
53
+ { type: :expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
54
+ in /^##\s*=>\s*(.*)$/ # Commented out expectation (should be ignored)
55
+ { type: :comment, content: '=>' + $1.strip, line: index }
56
+ in /^#\s*(.*)$/ # Single hash comment - potential description
57
+ { type: :potential_description, content: $1.strip, line: index }
58
+ in /^\s*$/ # Blank line
59
+ { type: :blank, line: index }
60
+ else # Ruby code
61
+ { type: :code, content: line, line: index, ast: parse_ruby_line(line) }
62
+ end
63
+
64
+ tokens << token
65
+ end
66
+
67
+ # Return tokens with potential_descriptions - they'll be classified later with test boundaries
68
+ tokens
69
+ end
70
+
71
+
72
+ # Convert potential_descriptions to descriptions or comments based on context
73
+ def classify_potential_descriptions(tokens)
74
+ tokens.map.with_index do |token, index|
75
+ if token[:type] == :potential_description
76
+ # Check if this looks like a test description based on content and context
77
+ content = token[:content].strip
78
+
79
+ # Skip if it's clearly just a regular comment (short, lowercase, etc.)
80
+ # Test descriptions are typically longer and more descriptive
81
+ looks_like_regular_comment = content.length < 20 &&
82
+ content.downcase == content &&
83
+ !content.match?(/test|example|demonstrate|show/i)
84
+
85
+ # Check if there's code immediately before this (suggesting it's mid-test)
86
+ prev_token = index > 0 ? tokens[index - 1] : nil
87
+ has_code_before = prev_token && prev_token[:type] == :code
88
+
89
+ if looks_like_regular_comment || has_code_before
90
+ # Treat as regular comment
91
+ token.merge(type: :comment)
92
+ else
93
+ # Look ahead for test pattern: code + at least one expectation within reasonable distance
94
+ following_tokens = tokens[(index + 1)..]
95
+
96
+ # Skip blanks and comments to find meaningful content
97
+ meaningful_following = following_tokens.reject { |t| [:blank, :comment].include?(t[:type]) }
98
+
99
+ # Look for test pattern: at least one code token followed by at least one expectation
100
+ # within the next 10 meaningful tokens (to avoid matching setup/teardown)
101
+ test_window = meaningful_following.first(10)
102
+ has_code = test_window.any? { |t| t[:type] == :code }
103
+ has_expectation = test_window.any? { |t| is_expectation_type?(t[:type]) }
104
+
105
+ if has_code && has_expectation
106
+ token.merge(type: :description)
107
+ else
108
+ token.merge(type: :comment)
109
+ end
110
+ end
111
+ else
112
+ token
113
+ end
114
+ end
115
+ end
116
+
117
+ # Parser type identification for metadata
118
+ def parser_type
119
+ :prism_v2_fixed
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,416 @@
1
+ # lib/tryouts/parsers/shared_methods.rb
2
+
3
+ class Tryouts
4
+ module Parsers
5
+ module SharedMethods
6
+ # Check if a description token at given index is followed by actual test content
7
+ # (code + expectations), indicating it's a real test case vs just a comment
8
+ def has_following_test_pattern?(tokens, desc_index)
9
+ return false if desc_index >= tokens.length - 1
10
+
11
+ # Look ahead for code and expectation tokens after this description
12
+ has_code = false
13
+ has_expectation = false
14
+
15
+ (desc_index + 1...tokens.length).each do |i|
16
+ token = tokens[i]
17
+
18
+ case token[:type]
19
+ when :code
20
+ has_code = true
21
+ when :description
22
+ # If we hit another description before finding expectations,
23
+ # this description doesn't have a complete test pattern
24
+ break
25
+ else
26
+ if is_expectation_type?(token[:type])
27
+ has_expectation = true
28
+ break if has_code # Found both code and expectation
29
+ end
30
+ end
31
+ end
32
+
33
+ has_code && has_expectation
34
+ end
35
+
36
+ def group_into_test_blocks(tokens)
37
+ blocks = []
38
+ current_block = new_test_block
39
+
40
+ tokens.each_with_index do |token, index|
41
+ case [current_block, token]
42
+ in [_, { type: :description, content: String => desc, line: Integer => line_num }]
43
+ # Only combine descriptions if current block has a description but no code/expectations yet
44
+ # Allow blank lines between multi-line descriptions
45
+ if !current_block[:description].empty? && current_block[:code].empty? && current_block[:expectations].empty?
46
+ # Multi-line description continuation
47
+ current_block[:description] = [current_block[:description], desc].join(' ').strip
48
+ elsif has_following_test_pattern?(tokens, index)
49
+ # Only create new block if description is followed by actual test pattern
50
+ blocks << current_block if block_has_content?(current_block)
51
+ current_block = new_test_block.merge(description: desc, start_line: line_num)
52
+ else
53
+ # Treat as regular comment - don't create new block
54
+ current_block[:comments] << token
55
+ end
56
+
57
+ in [{ expectations: [], start_line: nil }, { type: :code, content: String => code, line: Integer => line_num }]
58
+ # First code in a new block - set start_line
59
+ current_block[:code] << token
60
+ current_block[:start_line] = line_num
61
+
62
+ in [{ expectations: [] }, { type: :code, content: String => code }]
63
+ # Code before expectations - add to current block
64
+ current_block[:code] << token
65
+
66
+ in [{ expectations: Array => exps }, { type: :code }] if !exps.empty?
67
+ # Code after expectations - finalize current block and start new one
68
+ blocks << current_block
69
+ current_block = new_test_block.merge(code: [token], start_line: token[:line])
70
+
71
+ in [_, { type: :expectation }]
72
+ current_block[:expectations] << token
73
+
74
+ in [_, { type: :exception_expectation }]
75
+ current_block[:expectations] << token
76
+
77
+ in [_, { type: :intentional_failure_expectation }]
78
+ current_block[:expectations] << token
79
+
80
+ in [_, { type: :true_expectation }]
81
+ current_block[:expectations] << token
82
+
83
+ in [_, { type: :false_expectation }]
84
+ current_block[:expectations] << token
85
+
86
+ in [_, { type: :boolean_expectation }]
87
+ current_block[:expectations] << token
88
+
89
+ in [_, { type: :non_nil_expectation }]
90
+ current_block[:expectations] << token
91
+
92
+ in [_, { type: :result_type_expectation }]
93
+ current_block[:expectations] << token
94
+
95
+ in [_, { type: :regex_match_expectation }]
96
+ current_block[:expectations] << token
97
+
98
+ in [_, { type: :performance_time_expectation }]
99
+ current_block[:expectations] << token
100
+
101
+ in [_, { type: :output_expectation }]
102
+ current_block[:expectations] << token
103
+
104
+ in [_, { type: :comment | :blank }]
105
+ add_context_to_block(current_block, token)
106
+ end
107
+ end
108
+
109
+ blocks << current_block if block_has_content?(current_block)
110
+ classify_blocks(blocks)
111
+ end
112
+
113
+ # Find where a test case ends by looking for the last expectation
114
+ # before the next test description or end of tokens
115
+ def find_test_case_end(tokens, start_index)
116
+ last_expectation_line = nil
117
+
118
+ # Look forward from the description for expectations
119
+ (start_index + 1).upto(tokens.length - 1) do |i|
120
+ token = tokens[i]
121
+
122
+ # Stop if we hit another test description
123
+ break if token[:type] == :description
124
+
125
+ # Track the last expectation we see
126
+ if is_expectation_type?(token[:type])
127
+ last_expectation_line = token[:line]
128
+ end
129
+ end
130
+
131
+ last_expectation_line
132
+ end
133
+
134
+ # Find actual test case boundaries by looking for ## descriptions or # TEST: patterns
135
+ # followed by code and expectations
136
+ def find_test_case_boundaries(tokens)
137
+ boundaries = []
138
+
139
+ tokens.each_with_index do |token, index|
140
+ if token[:type] == :description
141
+ start_line = token[:line]
142
+ end_line = find_test_case_end(tokens, index)
143
+ boundaries << { start: start_line, end: end_line } if end_line
144
+ end
145
+ end
146
+
147
+ boundaries
148
+ end
149
+
150
+ # Convert potential_descriptions to descriptions or comments using test case boundaries
151
+ def classify_potential_descriptions_with_boundaries(tokens, test_boundaries)
152
+ tokens.map.with_index do |token, index|
153
+ if token[:type] == :potential_description
154
+ line_num = token[:line]
155
+ within_test_case = test_boundaries.any? do |boundary|
156
+ line_num >= boundary[:start] && line_num <= boundary[:end]
157
+ end
158
+
159
+ if within_test_case
160
+ token.merge(type: :comment)
161
+ else
162
+ content = token[:content].strip
163
+
164
+ looks_like_test_description = content.match?(/test|example|demonstrate|show|should|when|given/i) &&
165
+ content.length > 10
166
+
167
+ prev_token = index > 0 ? tokens[index - 1] : nil
168
+ has_code_before = prev_token && prev_token[:type] == :code
169
+
170
+ if has_code_before || !looks_like_test_description
171
+ token.merge(type: :comment)
172
+ else
173
+ following_tokens = tokens[(index + 1)..]
174
+ meaningful_following = following_tokens.reject { |t| [:blank, :comment].include?(t[:type]) }
175
+ test_window = meaningful_following.first(5)
176
+ has_code = test_window.any? { |t| t[:type] == :code }
177
+ has_expectation = test_window.any? { |t| is_expectation_type?(t[:type]) }
178
+
179
+ if has_code && has_expectation && looks_like_test_description
180
+ token.merge(type: :description)
181
+ else
182
+ token.merge(type: :comment)
183
+ end
184
+ end
185
+ end
186
+ else
187
+ token
188
+ end
189
+ end
190
+ end
191
+
192
+ # Check if token type represents any kind of expectation
193
+ def is_expectation_type?(type)
194
+ [
195
+ :expectation, :exception_expectation, :intentional_failure_expectation,
196
+ :true_expectation, :false_expectation, :boolean_expectation,
197
+ :result_type_expectation, :regex_match_expectation,
198
+ :performance_time_expectation, :output_expectation, :non_nil_expectation
199
+ ].include?(type)
200
+ end
201
+
202
+ # Process classified test blocks into domain objects
203
+ def process_test_blocks(classified_blocks)
204
+ setup_blocks = classified_blocks.filter { |block| block[:type] == :setup }
205
+ test_blocks = classified_blocks.filter { |block| block[:type] == :test }
206
+ teardown_blocks = classified_blocks.filter { |block| block[:type] == :teardown }
207
+
208
+ Testrun.new(
209
+ setup: build_setup(setup_blocks),
210
+ test_cases: test_blocks.map { |block| build_test_case(block) },
211
+ teardown: build_teardown(teardown_blocks),
212
+ source_file: @source_path,
213
+ metadata: { parsed_at: @parsed_at, parser: parser_type },
214
+ )
215
+ end
216
+
217
+ def build_setup(setup_blocks)
218
+ return Setup.new(code: '', line_range: 0..0, path: @source_path) if setup_blocks.empty?
219
+
220
+ Setup.new(
221
+ code: extract_pure_code_from_blocks(setup_blocks),
222
+ line_range: calculate_block_range(setup_blocks),
223
+ path: @source_path,
224
+ )
225
+ end
226
+
227
+ def build_teardown(teardown_blocks)
228
+ return Teardown.new(code: '', line_range: 0..0, path: @source_path) if teardown_blocks.empty?
229
+
230
+ Teardown.new(
231
+ code: extract_pure_code_from_blocks(teardown_blocks),
232
+ line_range: calculate_block_range(teardown_blocks),
233
+ path: @source_path,
234
+ )
235
+ end
236
+
237
+ # Modern Ruby 3.4+ pattern matching for robust code extraction
238
+ def extract_pure_code_from_blocks(blocks)
239
+ blocks
240
+ .flat_map { |block| block[:code] }
241
+ .filter_map do |token|
242
+ case token
243
+ in { type: :code, content: String => content }
244
+ content
245
+ else
246
+ nil
247
+ end
248
+ end
249
+ .join("\n")
250
+ end
251
+
252
+ def calculate_block_range(blocks)
253
+ return 0..0 if blocks.empty?
254
+
255
+ valid_blocks = blocks.filter { |block| block[:start_line] && block[:end_line] }
256
+ return 0..0 if valid_blocks.empty?
257
+
258
+ line_ranges = valid_blocks.map { |block| block[:start_line]..block[:end_line] }
259
+ line_ranges.first.first..line_ranges.last.last
260
+ end
261
+
262
+ def extract_code_content(code_tokens)
263
+ code_tokens
264
+ .filter_map do |token|
265
+ case token
266
+ in { type: :code, content: String => content }
267
+ content
268
+ else
269
+ nil
270
+ end
271
+ end
272
+ .join("\n")
273
+ end
274
+
275
+ def parse_ruby_line(line)
276
+ return nil if line.strip.empty?
277
+
278
+ result = Prism.parse(line.strip)
279
+ case result
280
+ in { errors: [] => errors, value: { body: { body: [ast] } } }
281
+ ast
282
+ in { errors: Array => errors } if errors.any?
283
+ { type: :parse_error, errors: errors, raw: line }
284
+ else
285
+ nil
286
+ end
287
+ end
288
+
289
+ def parse_expectation(expr)
290
+ parse_ruby_line(expr)
291
+ end
292
+
293
+ def new_test_block
294
+ {
295
+ description: '',
296
+ code: [],
297
+ expectations: [],
298
+ comments: [],
299
+ start_line: nil,
300
+ end_line: nil,
301
+ }
302
+ end
303
+
304
+ def block_has_content?(block)
305
+ case block
306
+ in { description: String => desc, code: Array => code, expectations: Array => exps }
307
+ !desc.empty? || !code.empty? || !exps.empty?
308
+ else
309
+ false
310
+ end
311
+ end
312
+
313
+ def add_context_to_block(block, token)
314
+ case [block[:expectations].empty?, token]
315
+ in [true, { type: :comment | :blank }]
316
+ block[:code] << token
317
+ in [false, { type: :comment | :blank }]
318
+ block[:comments] << token
319
+ end
320
+ end
321
+
322
+ # Classify blocks as setup, test, or teardown based on content
323
+ def classify_blocks(blocks)
324
+ blocks.map.with_index do |block, index|
325
+ block_type = case block
326
+ in { expectations: [] } if index == 0
327
+ :setup
328
+ in { expectations: [] } if index == blocks.size - 1
329
+ :teardown
330
+ in { expectations: Array => exps } if !exps.empty?
331
+ :test
332
+ else
333
+ :preamble
334
+ end
335
+
336
+ block.merge(type: block_type, end_line: calculate_end_line(block))
337
+ end
338
+ end
339
+
340
+ def calculate_end_line(block)
341
+ content_tokens = [*block[:code], *block[:expectations]]
342
+ return block[:start_line] if content_tokens.empty?
343
+
344
+ content_tokens.map { |token| token[:line] }.max || block[:start_line]
345
+ end
346
+
347
+ def build_test_case(block)
348
+ case block
349
+ in {
350
+ type: :test,
351
+ description: String => desc,
352
+ code: Array => code_tokens,
353
+ expectations: Array => exp_tokens,
354
+ start_line: Integer => start_line,
355
+ end_line: Integer => end_line
356
+ }
357
+ source_lines = @lines[start_line..end_line]
358
+ first_expectation_line = exp_tokens.empty? ? start_line : exp_tokens.first[:line]
359
+
360
+ TestCase.new(
361
+ description: desc,
362
+ code: extract_code_content(code_tokens),
363
+ expectations: exp_tokens.map do |token|
364
+ type = case token[:type]
365
+ when :exception_expectation then :exception
366
+ when :intentional_failure_expectation then :intentional_failure
367
+ when :true_expectation then :true # rubocop:disable Lint/BooleanSymbol
368
+ when :false_expectation then :false # rubocop:disable Lint/BooleanSymbol
369
+ when :boolean_expectation then :boolean
370
+ when :result_type_expectation then :result_type
371
+ when :regex_match_expectation then :regex_match
372
+ when :performance_time_expectation then :performance_time
373
+ when :output_expectation then :output
374
+ when :non_nil_expectation then :non_nil
375
+ else :regular
376
+ end
377
+
378
+ if token[:type] == :output_expectation
379
+ OutputExpectation.new(content: token[:content], type: type, pipe: token[:pipe])
380
+ else
381
+ Expectation.new(content: token[:content], type: type)
382
+ end
383
+ end,
384
+ line_range: start_line..end_line,
385
+ path: @source_path,
386
+ source_lines: source_lines,
387
+ first_expectation_line: first_expectation_line,
388
+ )
389
+ else
390
+ raise "Invalid test block structure: #{block}"
391
+ end
392
+ end
393
+
394
+ def handle_syntax_errors
395
+ errors = @prism_result.errors.map do |error|
396
+ line_context = @lines[error.location.start_line - 1] || ''
397
+
398
+ TryoutSyntaxError.new(
399
+ error.message,
400
+ line_number: error.location.start_line,
401
+ context: line_context,
402
+ source_file: @source_path,
403
+ )
404
+ end
405
+
406
+ raise errors.first if errors.any?
407
+ end
408
+
409
+ # Parser type identification for metadata - to be overridden by subclasses
410
+ def parser_type
411
+ :shared
412
+ end
413
+
414
+ end
415
+ end
416
+ end
@@ -228,11 +228,25 @@ class Tryouts
228
228
  test_timeout = @options[:test_timeout] || 30 # 30 second default
229
229
 
230
230
  if test_case.exception_expectations?
231
- # For exception tests, don't execute code here - let evaluate_expectations handle it
231
+ # Execute test code and catch exception to pass to evaluators
232
+ caught_exception = nil
233
+ begin
234
+ code = test_case.code
235
+ path = test_case.path
236
+ range = test_case.line_range
237
+ container.instance_eval(code, path, range.first + 1)
238
+ rescue SystemStackError, NoMemoryError, SecurityError, ScriptError => ex
239
+ # Handle system-level exceptions that don't inherit from StandardError
240
+ # ScriptError includes: LoadError, SyntaxError, NotImplementedError
241
+ caught_exception = ex
242
+ rescue StandardError => ex
243
+ caught_exception = ex
244
+ end
245
+
232
246
  expectations_result = execute_with_timeout(test_timeout, test_case) do
233
- evaluate_expectations(test_case, nil, container)
247
+ evaluate_expectations(test_case, caught_exception, container, nil, nil, nil, caught_exception)
234
248
  end
235
- build_test_result(test_case, nil, expectations_result)
249
+ build_test_result(test_case, caught_exception, expectations_result)
236
250
  else
237
251
  # Regular execution for non-exception tests with timing and output capture
238
252
  code = test_case.code
@@ -268,7 +282,13 @@ class Tryouts
268
282
  build_test_result(test_case, result_value, expectations_result)
269
283
  end
270
284
  rescue StandardError => ex
271
- build_error_result(test_case, ex)
285
+ # Check if this exception can be handled by result_type or regex_match expectations
286
+ if can_handle_exception?(test_case, ex)
287
+ expectations_result = evaluate_expectations(test_case, ex, container)
288
+ build_test_result(test_case, ex, expectations_result)
289
+ else
290
+ build_error_result(test_case, ex)
291
+ end
272
292
  rescue SystemExit, SignalException => ex
273
293
  # Handle process control exceptions gracefully
274
294
  Tryouts.debug "Test received #{ex.class}: #{ex.message}"
@@ -310,7 +330,7 @@ class Tryouts
310
330
  end
311
331
 
312
332
  # 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)
333
+ def evaluate_expectations(test_case, actual_result, context, execution_time_ns = nil, stdout_content = nil, stderr_content = nil, caught_exception = nil)
314
334
  return { passed: true, actual_results: [], expected_results: [] } if test_case.expectations.empty?
315
335
 
316
336
  evaluation_results = test_case.expectations.map do |expectation|
@@ -321,6 +341,9 @@ class Tryouts
321
341
  evaluator.evaluate(actual_result, execution_time_ns)
322
342
  elsif expectation.output? && (stdout_content || stderr_content)
323
343
  evaluator.evaluate(actual_result, stdout_content, stderr_content)
344
+ elsif expectation.exception? && caught_exception
345
+ # Pass caught exception to avoid double execution
346
+ evaluator.evaluate(actual_result, caught_exception: caught_exception)
324
347
  else
325
348
  evaluator.evaluate(actual_result)
326
349
  end
@@ -365,13 +388,14 @@ class Tryouts
365
388
  def process_test_result(result)
366
389
  @results << result
367
390
 
391
+ # Add all test results to the aggregator for centralized counting
392
+ if @global_tally && @global_tally[:aggregator]
393
+ @global_tally[:aggregator].add_test_result(@testrun.source_file, result)
394
+ end
395
+
396
+ # Update local batch counters for batch-level logic
368
397
  if result.failed? || result.error?
369
398
  @failed_count += 1
370
-
371
- # Collect failure details for end-of-run summary
372
- if @global_tally && @global_tally[:failure_collector]
373
- @global_tally[:failure_collector].add_failure(@testrun.source_file, result)
374
- end
375
399
  end
376
400
 
377
401
  show_test_result(result)
@@ -398,7 +422,11 @@ class Tryouts
398
422
  end
399
423
  rescue StandardError => ex
400
424
  @setup_failed = true
401
- @global_tally[:total_errors] += 1 if @global_tally
425
+ if @global_tally && @global_tally[:aggregator]
426
+ @global_tally[:aggregator].add_infrastructure_failure(
427
+ :setup, @testrun.source_file, ex.message, ex
428
+ )
429
+ end
402
430
 
403
431
  # Classify error and handle appropriately
404
432
  error_type = Tryouts.classify_error(ex)
@@ -435,7 +463,11 @@ class Tryouts
435
463
  end
436
464
  rescue StandardError => ex
437
465
  @setup_failed = true
438
- @global_tally[:total_errors] += 1 if @global_tally
466
+ if @global_tally && @global_tally[:aggregator]
467
+ @global_tally[:aggregator].add_infrastructure_failure(
468
+ :setup, @testrun.source_file, ex.message, ex
469
+ )
470
+ end
439
471
 
440
472
  # Classify error and handle appropriately
441
473
  error_type = Tryouts.classify_error(ex)
@@ -468,7 +500,11 @@ class Tryouts
468
500
  @output_manager&.teardown_output(captured_output) if captured_output && !captured_output.empty?
469
501
  end
470
502
  rescue StandardError => ex
471
- @global_tally[:total_errors] += 1 if @global_tally
503
+ if @global_tally && @global_tally[:aggregator]
504
+ @global_tally[:aggregator].add_infrastructure_failure(
505
+ :teardown, @testrun.source_file, ex.message, ex
506
+ )
507
+ end
472
508
 
473
509
  # Classify error and handle appropriately
474
510
  error_type = Tryouts.classify_error(ex)
@@ -509,6 +545,11 @@ class Tryouts
509
545
  @options[:shared_context] == true
510
546
  end
511
547
 
548
+ # Check if test case has expectations that can handle exceptions gracefully
549
+ def can_handle_exception?(test_case, _exception)
550
+ test_case.expectations.any? { |exp| exp.result_type? || exp.regex_match? }
551
+ end
552
+
512
553
  def capture_output
513
554
  old_stdout = $stdout
514
555
  old_stderr = $stderr
@@ -154,9 +154,9 @@ class Tryouts
154
154
  def self.from_error(test_case, error, captured_output: nil, elapsed_time: nil, metadata: {})
155
155
  error_message = error ? error.message : '<exception is nil>'
156
156
 
157
- # Include backtrace in error message when in debug/verbose mode
158
- error_display = if error && Tryouts.debug?
159
- backtrace_preview = error.backtrace&.first(3)&.join("\n ")
157
+ # Include backtrace in error message when stack traces are enabled
158
+ error_display = if error && Tryouts.stack_traces?
159
+ backtrace_preview = Console.pretty_backtrace(error.backtrace, limit: 3).join("\n ")
160
160
  "(#{error.class}) #{error_message}\n #{backtrace_preview}"
161
161
  else
162
162
  "(#{error.class}) #{error_message}"