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.
- checksums.yaml +4 -4
- data/exe/try +1 -1
- data/lib/tryouts/cli/formatters/compact.rb +8 -4
- data/lib/tryouts/cli/formatters/quiet.rb +4 -3
- data/lib/tryouts/cli/formatters/verbose.rb +8 -4
- data/lib/tryouts/cli/opts.rb +14 -4
- data/lib/tryouts/console.rb +32 -4
- data/lib/tryouts/expectation_evaluators/exception.rb +8 -2
- data/lib/tryouts/expectation_evaluators/non_nil.rb +77 -0
- data/lib/tryouts/expectation_evaluators/regex_match.rb +11 -3
- data/lib/tryouts/expectation_evaluators/registry.rb +2 -0
- data/lib/tryouts/expectation_evaluators/result_type.rb +9 -1
- data/lib/tryouts/file_processor.rb +9 -5
- data/lib/tryouts/parsers/base_parser.rb +23 -0
- data/lib/tryouts/parsers/enhanced_parser.rb +115 -0
- data/lib/tryouts/parsers/prism_parser.rb +122 -0
- data/lib/tryouts/parsers/shared_methods.rb +416 -0
- data/lib/tryouts/test_batch.rb +54 -13
- data/lib/tryouts/test_case.rb +3 -3
- data/lib/tryouts/test_executor.rb +6 -4
- data/lib/tryouts/test_result_aggregator.rb +138 -0
- data/lib/tryouts/test_runner.rb +76 -20
- data/lib/tryouts/version.rb +1 -1
- data/lib/tryouts.rb +7 -2
- metadata +21 -3
- data/lib/tryouts/enhanced_parser.rb +0 -461
- data/lib/tryouts/prism_parser.rb +0 -516
@@ -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
|
data/lib/tryouts/test_batch.rb
CHANGED
@@ -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
|
-
#
|
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,
|
247
|
+
evaluate_expectations(test_case, caught_exception, container, nil, nil, nil, caught_exception)
|
234
248
|
end
|
235
|
-
build_test_result(test_case,
|
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
|
-
|
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
|
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
|
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
|
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
|
data/lib/tryouts/test_case.rb
CHANGED
@@ -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
|
158
|
-
error_display = if error && Tryouts.
|
159
|
-
backtrace_preview = error.backtrace
|
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}"
|