tryouts 3.3.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
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: :result_type_expectation }]
90
+ current_block[:expectations] << token
91
+
92
+ in [_, { type: :regex_match_expectation }]
93
+ current_block[:expectations] << token
94
+
95
+ in [_, { type: :performance_time_expectation }]
96
+ current_block[:expectations] << token
97
+
98
+ in [_, { type: :output_expectation }]
99
+ current_block[:expectations] << token
100
+
101
+ in [_, { type: :comment | :blank }]
102
+ add_context_to_block(current_block, token)
103
+ end
104
+ end
105
+
106
+ blocks << current_block if block_has_content?(current_block)
107
+ classify_blocks(blocks)
108
+ end
109
+
110
+ # Find where a test case ends by looking for the last expectation
111
+ # before the next test description or end of tokens
112
+ def find_test_case_end(tokens, start_index)
113
+ last_expectation_line = nil
114
+
115
+ # Look forward from the description for expectations
116
+ (start_index + 1).upto(tokens.length - 1) do |i|
117
+ token = tokens[i]
118
+
119
+ # Stop if we hit another test description
120
+ break if token[:type] == :description
121
+
122
+ # Track the last expectation we see
123
+ if is_expectation_type?(token[:type])
124
+ last_expectation_line = token[:line]
125
+ end
126
+ end
127
+
128
+ last_expectation_line
129
+ end
130
+
131
+ # Find actual test case boundaries by looking for ## descriptions or # TEST: patterns
132
+ # followed by code and expectations
133
+ def find_test_case_boundaries(tokens)
134
+ boundaries = []
135
+
136
+ tokens.each_with_index do |token, index|
137
+ if token[:type] == :description
138
+ start_line = token[:line]
139
+ end_line = find_test_case_end(tokens, index)
140
+ boundaries << { start: start_line, end: end_line } if end_line
141
+ end
142
+ end
143
+
144
+ boundaries
145
+ end
146
+
147
+ # Convert potential_descriptions to descriptions or comments using test case boundaries
148
+ def classify_potential_descriptions_with_boundaries(tokens, test_boundaries)
149
+ tokens.map.with_index do |token, index|
150
+ if token[:type] == :potential_description
151
+ line_num = token[:line]
152
+ within_test_case = test_boundaries.any? do |boundary|
153
+ line_num >= boundary[:start] && line_num <= boundary[:end]
154
+ end
155
+
156
+ if within_test_case
157
+ token.merge(type: :comment)
158
+ else
159
+ content = token[:content].strip
160
+
161
+ looks_like_test_description = content.match?(/test|example|demonstrate|show|should|when|given/i) &&
162
+ content.length > 10
163
+
164
+ prev_token = index > 0 ? tokens[index - 1] : nil
165
+ has_code_before = prev_token && prev_token[:type] == :code
166
+
167
+ if has_code_before || !looks_like_test_description
168
+ token.merge(type: :comment)
169
+ else
170
+ following_tokens = tokens[(index + 1)..]
171
+ meaningful_following = following_tokens.reject { |t| [:blank, :comment].include?(t[:type]) }
172
+ test_window = meaningful_following.first(5)
173
+ has_code = test_window.any? { |t| t[:type] == :code }
174
+ has_expectation = test_window.any? { |t| is_expectation_type?(t[:type]) }
175
+
176
+ if has_code && has_expectation && looks_like_test_description
177
+ token.merge(type: :description)
178
+ else
179
+ token.merge(type: :comment)
180
+ end
181
+ end
182
+ end
183
+ else
184
+ token
185
+ end
186
+ end
187
+ end
188
+
189
+ # Check if token type represents any kind of expectation
190
+ def is_expectation_type?(type)
191
+ [
192
+ :expectation, :exception_expectation, :intentional_failure_expectation,
193
+ :true_expectation, :false_expectation, :boolean_expectation,
194
+ :result_type_expectation, :regex_match_expectation,
195
+ :performance_time_expectation, :output_expectation
196
+ ].include?(type)
197
+ end
198
+
199
+ # Process classified test blocks into domain objects
200
+ def process_test_blocks(classified_blocks)
201
+ setup_blocks = classified_blocks.filter { |block| block[:type] == :setup }
202
+ test_blocks = classified_blocks.filter { |block| block[:type] == :test }
203
+ teardown_blocks = classified_blocks.filter { |block| block[:type] == :teardown }
204
+
205
+ Testrun.new(
206
+ setup: build_setup(setup_blocks),
207
+ test_cases: test_blocks.map { |block| build_test_case(block) },
208
+ teardown: build_teardown(teardown_blocks),
209
+ source_file: @source_path,
210
+ metadata: { parsed_at: @parsed_at, parser: parser_type },
211
+ )
212
+ end
213
+
214
+ def build_setup(setup_blocks)
215
+ return Setup.new(code: '', line_range: 0..0, path: @source_path) if setup_blocks.empty?
216
+
217
+ Setup.new(
218
+ code: extract_pure_code_from_blocks(setup_blocks),
219
+ line_range: calculate_block_range(setup_blocks),
220
+ path: @source_path,
221
+ )
222
+ end
223
+
224
+ def build_teardown(teardown_blocks)
225
+ return Teardown.new(code: '', line_range: 0..0, path: @source_path) if teardown_blocks.empty?
226
+
227
+ Teardown.new(
228
+ code: extract_pure_code_from_blocks(teardown_blocks),
229
+ line_range: calculate_block_range(teardown_blocks),
230
+ path: @source_path,
231
+ )
232
+ end
233
+
234
+ # Modern Ruby 3.4+ pattern matching for robust code extraction
235
+ def extract_pure_code_from_blocks(blocks)
236
+ blocks
237
+ .flat_map { |block| block[:code] }
238
+ .filter_map do |token|
239
+ case token
240
+ in { type: :code, content: String => content }
241
+ content
242
+ else
243
+ nil
244
+ end
245
+ end
246
+ .join("\n")
247
+ end
248
+
249
+ def calculate_block_range(blocks)
250
+ return 0..0 if blocks.empty?
251
+
252
+ valid_blocks = blocks.filter { |block| block[:start_line] && block[:end_line] }
253
+ return 0..0 if valid_blocks.empty?
254
+
255
+ line_ranges = valid_blocks.map { |block| block[:start_line]..block[:end_line] }
256
+ line_ranges.first.first..line_ranges.last.last
257
+ end
258
+
259
+ def extract_code_content(code_tokens)
260
+ code_tokens
261
+ .filter_map do |token|
262
+ case token
263
+ in { type: :code, content: String => content }
264
+ content
265
+ else
266
+ nil
267
+ end
268
+ end
269
+ .join("\n")
270
+ end
271
+
272
+ def parse_ruby_line(line)
273
+ return nil if line.strip.empty?
274
+
275
+ result = Prism.parse(line.strip)
276
+ case result
277
+ in { errors: [] => errors, value: { body: { body: [ast] } } }
278
+ ast
279
+ in { errors: Array => errors } if errors.any?
280
+ { type: :parse_error, errors: errors, raw: line }
281
+ else
282
+ nil
283
+ end
284
+ end
285
+
286
+ def parse_expectation(expr)
287
+ parse_ruby_line(expr)
288
+ end
289
+
290
+ def new_test_block
291
+ {
292
+ description: '',
293
+ code: [],
294
+ expectations: [],
295
+ comments: [],
296
+ start_line: nil,
297
+ end_line: nil,
298
+ }
299
+ end
300
+
301
+ def block_has_content?(block)
302
+ case block
303
+ in { description: String => desc, code: Array => code, expectations: Array => exps }
304
+ !desc.empty? || !code.empty? || !exps.empty?
305
+ else
306
+ false
307
+ end
308
+ end
309
+
310
+ def add_context_to_block(block, token)
311
+ case [block[:expectations].empty?, token]
312
+ in [true, { type: :comment | :blank }]
313
+ block[:code] << token
314
+ in [false, { type: :comment | :blank }]
315
+ block[:comments] << token
316
+ end
317
+ end
318
+
319
+ # Classify blocks as setup, test, or teardown based on content
320
+ def classify_blocks(blocks)
321
+ blocks.map.with_index do |block, index|
322
+ block_type = case block
323
+ in { expectations: [] } if index == 0
324
+ :setup
325
+ in { expectations: [] } if index == blocks.size - 1
326
+ :teardown
327
+ in { expectations: Array => exps } if !exps.empty?
328
+ :test
329
+ else
330
+ :preamble
331
+ end
332
+
333
+ block.merge(type: block_type, end_line: calculate_end_line(block))
334
+ end
335
+ end
336
+
337
+ def calculate_end_line(block)
338
+ content_tokens = [*block[:code], *block[:expectations]]
339
+ return block[:start_line] if content_tokens.empty?
340
+
341
+ content_tokens.map { |token| token[:line] }.max || block[:start_line]
342
+ end
343
+
344
+ def build_test_case(block)
345
+ case block
346
+ in {
347
+ type: :test,
348
+ description: String => desc,
349
+ code: Array => code_tokens,
350
+ expectations: Array => exp_tokens,
351
+ start_line: Integer => start_line,
352
+ end_line: Integer => end_line
353
+ }
354
+ source_lines = @lines[start_line..end_line]
355
+ first_expectation_line = exp_tokens.empty? ? start_line : exp_tokens.first[:line]
356
+
357
+ TestCase.new(
358
+ description: desc,
359
+ code: extract_code_content(code_tokens),
360
+ expectations: exp_tokens.map do |token|
361
+ type = case token[:type]
362
+ when :exception_expectation then :exception
363
+ when :intentional_failure_expectation then :intentional_failure
364
+ when :true_expectation then :true # rubocop:disable Lint/BooleanSymbol
365
+ when :false_expectation then :false # rubocop:disable Lint/BooleanSymbol
366
+ when :boolean_expectation then :boolean
367
+ when :result_type_expectation then :result_type
368
+ when :regex_match_expectation then :regex_match
369
+ when :performance_time_expectation then :performance_time
370
+ when :output_expectation then :output
371
+ else :regular
372
+ end
373
+
374
+ if token[:type] == :output_expectation
375
+ OutputExpectation.new(content: token[:content], type: type, pipe: token[:pipe])
376
+ else
377
+ Expectation.new(content: token[:content], type: type)
378
+ end
379
+ end,
380
+ line_range: start_line..end_line,
381
+ path: @source_path,
382
+ source_lines: source_lines,
383
+ first_expectation_line: first_expectation_line,
384
+ )
385
+ else
386
+ raise "Invalid test block structure: #{block}"
387
+ end
388
+ end
389
+
390
+ def handle_syntax_errors
391
+ errors = @prism_result.errors.map do |error|
392
+ line_context = @lines[error.location.start_line - 1] || ''
393
+
394
+ TryoutSyntaxError.new(
395
+ error.message,
396
+ line_number: error.location.start_line,
397
+ context: line_context,
398
+ source_file: @source_path,
399
+ )
400
+ end
401
+
402
+ raise errors.first if errors.any?
403
+ end
404
+
405
+ # Parser type identification for metadata - to be overridden by subclasses
406
+ def parser_type
407
+ :shared
408
+ end
409
+
410
+ end
411
+ end
412
+ 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)
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}"
@@ -509,6 +529,11 @@ class Tryouts
509
529
  @options[:shared_context] == true
510
530
  end
511
531
 
532
+ # Check if test case has expectations that can handle exceptions gracefully
533
+ def can_handle_exception?(test_case, _exception)
534
+ test_case.expectations.any? { |exp| exp.result_type? || exp.regex_match? }
535
+ end
536
+
512
537
  def capture_output
513
538
  old_stdout = $stdout
514
539
  old_stderr = $stderr
@@ -1,6 +1,7 @@
1
1
  # lib/tryouts/test_runner.rb
2
2
 
3
- require_relative 'prism_parser'
3
+ require_relative 'parsers/prism_parser'
4
+ require_relative 'parsers/enhanced_parser'
4
5
  require_relative 'test_batch'
5
6
  require_relative 'translators/rspec_translator'
6
7
  require_relative 'translators/minitest_translator'
@@ -1,5 +1,5 @@
1
1
  # lib/tryouts/version.rb
2
2
 
3
3
  class Tryouts
4
- VERSION = '3.3.0'
4
+ VERSION = '3.3.1'
5
5
  end
data/lib/tryouts.rb CHANGED
@@ -8,7 +8,8 @@ TRYOUTS_LIB_HOME = __dir__ unless defined?(TRYOUTS_LIB_HOME)
8
8
  require_relative 'tryouts/console'
9
9
  require_relative 'tryouts/test_batch'
10
10
  require_relative 'tryouts/version'
11
- require_relative 'tryouts/prism_parser'
11
+ require_relative 'tryouts/parsers/prism_parser'
12
+ require_relative 'tryouts/parsers/enhanced_parser'
12
13
  require_relative 'tryouts/cli'
13
14
 
14
15
  class Tryouts
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tryouts
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.0
4
+ version: 3.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -137,7 +137,6 @@ files:
137
137
  - lib/tryouts/cli/opts.rb
138
138
  - lib/tryouts/cli/tty_detector.rb
139
139
  - lib/tryouts/console.rb
140
- - lib/tryouts/enhanced_parser.rb
141
140
  - lib/tryouts/expectation_evaluators/base.rb
142
141
  - lib/tryouts/expectation_evaluators/boolean.rb
143
142
  - lib/tryouts/expectation_evaluators/exception.rb
@@ -153,7 +152,10 @@ files:
153
152
  - lib/tryouts/expectation_evaluators/true.rb
154
153
  - lib/tryouts/failure_collector.rb
155
154
  - lib/tryouts/file_processor.rb
156
- - lib/tryouts/prism_parser.rb
155
+ - lib/tryouts/parsers/base_parser.rb
156
+ - lib/tryouts/parsers/enhanced_parser.rb
157
+ - lib/tryouts/parsers/prism_parser.rb
158
+ - lib/tryouts/parsers/shared_methods.rb
157
159
  - lib/tryouts/test_batch.rb
158
160
  - lib/tryouts/test_case.rb
159
161
  - lib/tryouts/test_executor.rb