tryouts 2.4.1 → 3.0.0.pre2

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,66 @@
1
+ # lib/tryouts/file_processor.rb
2
+
3
+ require_relative 'prism_parser'
4
+ require_relative 'test_executor'
5
+ require_relative 'cli/modes/inspect'
6
+ require_relative 'cli/modes/generate'
7
+
8
+ class Tryouts
9
+ class FileProcessor
10
+ def initialize(file:, options:, output_manager:, translator:, global_tally:)
11
+ @file = file
12
+ @options = options
13
+ @output_manager = output_manager
14
+ @translator = translator
15
+ @global_tally = global_tally
16
+ end
17
+
18
+ def process
19
+ testrun = PrismParser.new(@file).parse
20
+ @global_tally[:file_count] += 1
21
+ @output_manager.file_parsed(@file, testrun.total_tests)
22
+
23
+ if @options[:inspect]
24
+ handle_inspect_mode(testrun)
25
+ elsif @options[:generate_only]
26
+ handle_generate_only_mode(testrun)
27
+ else
28
+ execute_tests(testrun)
29
+ end
30
+ rescue TryoutSyntaxError => ex
31
+ handle_syntax_error(ex)
32
+ rescue StandardError, SystemStackError, LoadError, SecurityError, NoMemoryError => ex
33
+ handle_general_error(ex)
34
+ end
35
+
36
+ private
37
+
38
+ def handle_inspect_mode(testrun)
39
+ mode = Tryouts::CLI::InspectMode.new(@file, testrun, @options, @output_manager, @translator)
40
+ mode.handle
41
+ 0
42
+ end
43
+
44
+ def handle_generate_only_mode(testrun)
45
+ mode = Tryouts::CLI::GenerateMode.new(@file, testrun, @options, @output_manager, @translator)
46
+ mode.handle
47
+ 0
48
+ end
49
+
50
+ def execute_tests(testrun)
51
+ tex = TestExecutor.new(@file, testrun, @options, @output_manager, @translator, @global_tally)
52
+ tex.execute
53
+ end
54
+
55
+ def handle_syntax_error(ex)
56
+ @output_manager.file_failure(@file, "Syntax error: #{ex.message}")
57
+ 1
58
+ end
59
+
60
+ def handle_general_error(ex)
61
+ @global_tally[:total_errors] += 1 if @global_tally
62
+ @output_manager.file_failure(@file, ex.message, ex.backtrace)
63
+ 1
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,314 @@
1
+ # Modern Ruby 3.4+ solution for the teardown bug
2
+
3
+ require 'prism'
4
+ require_relative 'testcase'
5
+
6
+ class Tryouts
7
+ # Fixed PrismParser with pattern matching for robust token filtering
8
+ class PrismParser
9
+ def initialize(source_path)
10
+ @source_path = source_path
11
+ @source = File.read(source_path)
12
+ @lines = @source.lines.map(&:chomp)
13
+ @prism_result = Prism.parse(@source)
14
+ end
15
+
16
+ def parse
17
+ return handle_syntax_errors if @prism_result.failure?
18
+
19
+ tokens = tokenize_content
20
+ test_blocks = group_into_test_blocks(tokens)
21
+ process_test_blocks(test_blocks)
22
+ end
23
+
24
+ private
25
+
26
+ # Tokenize content using pattern matching for clean line classification
27
+ def tokenize_content
28
+ tokens = []
29
+
30
+ @lines.each_with_index do |line, index|
31
+ token = case line
32
+ in /^##\s*(.*)$/ # Test description format: ## description
33
+ { type: :description, content: $1.strip, line: index }
34
+ in /^#\s*TEST\s*\d*:\s*(.*)$/ # rubocop:disable Lint/DuplicateBranch
35
+ { type: :description, content: $1.strip, line: index }
36
+ in /^#\s*=>\s*(.*)$/ # Expectation
37
+ { type: :expectation, content: $1.strip, line: index, ast: parse_expectation($1.strip) }
38
+ in /^##\s*=>\s*(.*)$/ # Commented out expectation (should be ignored)
39
+ { type: :comment, content: '=>' + $1.strip, line: index }
40
+ in /^#\s*(.*)$/ # Single hash comment - potential description
41
+ { type: :potential_description, content: $1.strip, line: index }
42
+ in /^\s*$/ # Blank line
43
+ { type: :blank, line: index }
44
+ else # Ruby code
45
+ { type: :code, content: line, line: index, ast: parse_ruby_line(line) }
46
+ end
47
+
48
+ tokens << token
49
+ end
50
+
51
+ # Post-process to convert potential_descriptions to descriptions or comments
52
+ classify_potential_descriptions(tokens)
53
+ end
54
+
55
+ # Convert potential_descriptions to descriptions or comments based on context
56
+ def classify_potential_descriptions(tokens)
57
+ tokens.map.with_index do |token, index|
58
+ if token[:type] == :potential_description
59
+ # Look ahead strictly for the pattern: [optional blanks] code expectation
60
+ following_tokens = tokens[(index + 1)..]
61
+
62
+ # Skip blanks and find next non-blank tokens
63
+ non_blank_following = following_tokens.reject { |t| t[:type] == :blank }
64
+
65
+ # Must have: code immediately followed by expectation (with possible blanks between)
66
+ if non_blank_following.size >= 2 &&
67
+ non_blank_following[0][:type] == :code &&
68
+ non_blank_following[1][:type] == :expectation
69
+ token.merge(type: :description)
70
+ else
71
+ token.merge(type: :comment)
72
+ end
73
+ else
74
+ token
75
+ end
76
+ end
77
+ end
78
+
79
+ # Group tokens into logical test blocks using pattern matching
80
+ def group_into_test_blocks(tokens)
81
+ blocks = []
82
+ current_block = new_test_block
83
+
84
+ tokens.each do |token|
85
+ case [current_block, token]
86
+ in [_, { type: :description, content: String => desc, line: Integer => line_num }]
87
+ # Only combine descriptions if current block has a description but no code/expectations yet
88
+ # Allow blank lines between multi-line descriptions
89
+ if !current_block[:description].empty? && current_block[:code].empty? && current_block[:expectations].empty?
90
+ # Multi-line description continuation
91
+ current_block[:description] = [current_block[:description], desc].join(' ').strip
92
+ else
93
+ # Start new test block on description
94
+ blocks << current_block if block_has_content?(current_block)
95
+ current_block = new_test_block.merge(description: desc, start_line: line_num)
96
+ end
97
+
98
+ in [{ expectations: [], start_line: nil }, { type: :code, content: String => code, line: Integer => line_num }]
99
+ # First code in a new block - set start_line
100
+ current_block[:code] << token
101
+ current_block[:start_line] = line_num
102
+
103
+ in [{ expectations: [] }, { type: :code, content: String => code }]
104
+ # Code before expectations - add to current block
105
+ current_block[:code] << token
106
+
107
+ in [{ expectations: Array => exps }, { type: :code }] if !exps.empty?
108
+ # Code after expectations - finalize current block and start new one
109
+ blocks << current_block
110
+ current_block = new_test_block.merge(code: [token], start_line: token[:line])
111
+
112
+ in [_, { type: :expectation }]
113
+ current_block[:expectations] << token
114
+
115
+ in [_, { type: :comment | :blank }]
116
+ add_context_to_block(current_block, token)
117
+ end
118
+ end
119
+
120
+ blocks << current_block if block_has_content?(current_block)
121
+ classify_blocks(blocks)
122
+ end
123
+
124
+ # Process classified test blocks into domain objects
125
+ def process_test_blocks(classified_blocks)
126
+ setup_blocks = classified_blocks.filter { |block| block[:type] == :setup }
127
+ test_blocks = classified_blocks.filter { |block| block[:type] == :test }
128
+ teardown_blocks = classified_blocks.filter { |block| block[:type] == :teardown }
129
+
130
+ Testrun.new(
131
+ setup: build_setup(setup_blocks),
132
+ test_cases: test_blocks.map { |block| build_test_case(block) },
133
+ teardown: build_teardown(teardown_blocks),
134
+ source_file: @source_path,
135
+ metadata: { parsed_at: Time.now, parser: :prism_v2_fixed },
136
+ )
137
+ end
138
+
139
+ def build_setup(setup_blocks)
140
+ return Setup.new(code: '', line_range: 0..0, path: @source_path) if setup_blocks.empty?
141
+
142
+ Setup.new(
143
+ code: extract_pure_code_from_blocks(setup_blocks),
144
+ line_range: calculate_block_range(setup_blocks),
145
+ path: @source_path,
146
+ )
147
+ end
148
+
149
+ def build_teardown(teardown_blocks)
150
+ return Teardown.new(code: '', line_range: 0..0, path: @source_path) if teardown_blocks.empty?
151
+
152
+ Teardown.new(
153
+ code: extract_pure_code_from_blocks(teardown_blocks),
154
+ line_range: calculate_block_range(teardown_blocks),
155
+ path: @source_path,
156
+ )
157
+ end
158
+
159
+ # Modern Ruby 3.4+ pattern matching for robust code extraction
160
+ # This filters out comments added by add_context_to_block explicitly
161
+ def extract_pure_code_from_blocks(blocks)
162
+ blocks
163
+ .flat_map { |block| block[:code] }
164
+ .filter_map do |token|
165
+ case token
166
+ in { type: :code, content: String => content }
167
+ content
168
+ else
169
+ nil
170
+ end
171
+ end
172
+ .join("\n")
173
+ end
174
+
175
+ def calculate_block_range(blocks)
176
+ return 0..0 if blocks.empty?
177
+
178
+ # Filter out blocks with nil line numbers and build valid ranges
179
+ valid_blocks = blocks.filter { |block| block[:start_line] && block[:end_line] }
180
+ return 0..0 if valid_blocks.empty?
181
+
182
+ line_ranges = valid_blocks.map { |block| block[:start_line]..block[:end_line] }
183
+ line_ranges.first.first..line_ranges.last.last
184
+ end
185
+
186
+ def extract_code_content(code_tokens)
187
+ code_tokens
188
+ .filter_map do |token|
189
+ case token
190
+ in { type: :code, content: String => content }
191
+ content
192
+ else
193
+ nil
194
+ end
195
+ end
196
+ .join("\n")
197
+ end
198
+
199
+ def parse_ruby_line(line)
200
+ return nil if line.strip.empty?
201
+
202
+ result = Prism.parse(line.strip)
203
+ case result
204
+ in { errors: [] => errors, value: { body: { body: [ast] } } }
205
+ ast
206
+ in { errors: Array => errors } if errors.any?
207
+ { type: :parse_error, errors: errors, raw: line }
208
+ else
209
+ nil
210
+ end
211
+ end
212
+
213
+ def parse_expectation(expr)
214
+ parse_ruby_line(expr)
215
+ end
216
+
217
+ def new_test_block
218
+ {
219
+ description: '',
220
+ code: [],
221
+ expectations: [],
222
+ comments: [],
223
+ start_line: nil,
224
+ end_line: nil,
225
+ }
226
+ end
227
+
228
+ def block_has_content?(block)
229
+ case block
230
+ in { description: String => desc, code: Array => code, expectations: Array => exps }
231
+ !desc.empty? || !code.empty? || !exps.empty?
232
+ else
233
+ false
234
+ end
235
+ end
236
+
237
+ def add_context_to_block(block, token)
238
+ case [block[:expectations].empty?, token]
239
+ in [true, { type: :comment | :blank }]
240
+ # Comments before expectations go with code
241
+ block[:code] << token
242
+ in [false, { type: :comment | :blank }]
243
+ # Comments after expectations are test context
244
+ block[:comments] << token
245
+ end
246
+ end
247
+
248
+ # Classify blocks as setup, test, or teardown based on content
249
+ def classify_blocks(blocks)
250
+ blocks.map.with_index do |block, index|
251
+ block_type = case block
252
+ in { expectations: [] } if index == 0
253
+ :setup
254
+ in { expectations: [] } if index == blocks.size - 1
255
+ :teardown
256
+ in { expectations: Array => exps } if !exps.empty?
257
+ :test
258
+ else
259
+ :preamble # Default fallback
260
+ end
261
+
262
+ block.merge(type: block_type, end_line: calculate_end_line(block))
263
+ end
264
+ end
265
+
266
+ def calculate_end_line(block)
267
+ last_tokens = [*block[:code], *block[:expectations], *block[:comments]]
268
+ return block[:start_line] if last_tokens.empty?
269
+
270
+ last_tokens.map { |token| token[:line] }.max || block[:start_line]
271
+ end
272
+
273
+ def build_test_case(block)
274
+ case block
275
+ in {
276
+ type: :test,
277
+ description: String => desc,
278
+ code: Array => code_tokens,
279
+ expectations: Array => exp_tokens,
280
+ start_line: Integer => start_line,
281
+ end_line: Integer => end_line
282
+ }
283
+ # Extract source lines from the original source during parsing
284
+ source_lines = @lines[start_line..end_line]
285
+
286
+ TestCase.new(
287
+ description: desc,
288
+ code: extract_code_content(code_tokens),
289
+ expectations: exp_tokens.map { |token| token[:content] },
290
+ line_range: start_line..end_line,
291
+ path: @source_path,
292
+ source_lines: source_lines,
293
+ )
294
+ else
295
+ raise "Invalid test block structure: #{block}"
296
+ end
297
+ end
298
+
299
+ def handle_syntax_errors
300
+ errors = @prism_result.errors.map do |error|
301
+ line_context = @lines[error.location.start_line - 1] || ''
302
+
303
+ TryoutSyntaxError.new(
304
+ error.message,
305
+ line_number: error.location.start_line,
306
+ context: line_context,
307
+ source_file: @source_path,
308
+ )
309
+ end
310
+
311
+ raise errors.first if errors.any?
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,82 @@
1
+ # lib/tryouts/test_executor.rb
2
+
3
+ require_relative 'testbatch'
4
+
5
+ class Tryouts
6
+ class TestExecutor
7
+ def initialize(file, testrun, options, output_manager, translator, global_tally)
8
+ @file = file
9
+ @testrun = testrun
10
+ @options = options
11
+ @output_manager = output_manager
12
+ @translator = translator
13
+ @global_tally = global_tally
14
+ end
15
+
16
+ def execute
17
+ @file_start = Time.now
18
+
19
+ case @options[:framework]
20
+ when :direct
21
+ execute_direct_mode
22
+ when :rspec
23
+ execute_rspec_mode
24
+ when :minitest
25
+ execute_minitest_mode
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def execute_direct_mode
32
+ batch = TestBatch.new(
33
+ @testrun,
34
+ shared_context: @options[:shared_context],
35
+ verbose: @options[:verbose],
36
+ fails_only: @options[:fails_only],
37
+ output_manager: @output_manager,
38
+ global_tally: @global_tally,
39
+ )
40
+
41
+ unless @options[:verbose]
42
+ context_mode = @options[:shared_context] ? 'shared' : 'fresh'
43
+ @output_manager.file_execution_start(@file, @testrun.total_tests, context_mode)
44
+ end
45
+
46
+ test_results = []
47
+ success = batch.run do
48
+ last_result = batch.results.last
49
+ test_results << last_result if last_result
50
+ end
51
+
52
+ file_failed_count = test_results.count { |r| r[:status] == :failed }
53
+ file_error_count = test_results.count { |r| r[:status] == :error }
54
+ @global_tally[:total_tests] += batch.size
55
+ @global_tally[:total_failed] += file_failed_count
56
+ @global_tally[:total_errors] += file_error_count
57
+ @global_tally[:successful_files] += 1 if success
58
+
59
+ duration = Time.now.to_f - @file_start.to_f
60
+ @output_manager.file_success(@file, batch.size, file_failed_count, file_error_count, duration)
61
+
62
+ # Combine failures and errors to determine the exit code.
63
+ success ? 0 : (file_failed_count + file_error_count)
64
+ end
65
+
66
+ def execute_rspec_mode
67
+ @output_manager.info 'Executing with RSpec framework', 2
68
+ @translator.translate(@testrun)
69
+ require 'rspec/core'
70
+ RSpec::Core::Runner.run([])
71
+ 0
72
+ end
73
+
74
+ def execute_minitest_mode
75
+ @output_manager.info 'Executing with Minitest framework', 2
76
+ @translator.translate(@testrun)
77
+ ARGV.clear
78
+ require 'minitest/autorun'
79
+ 0
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,128 @@
1
+ # lib/tryouts/test_runner.rb
2
+
3
+ require_relative 'prism_parser'
4
+ require_relative 'testbatch'
5
+ require_relative 'translators/rspec_translator'
6
+ require_relative 'translators/minitest_translator'
7
+ require_relative 'file_processor'
8
+
9
+ class Tryouts
10
+ class TestRunner
11
+ FRAMEWORKS = {
12
+ rspec: Translators::RSpecTranslator,
13
+ minitest: Translators::MinitestTranslator,
14
+ }.freeze
15
+
16
+ FRAMEWORK_DEFAULTS = {
17
+ direct: { shared_context: true, generate_only: false },
18
+ rspec: { shared_context: false, generate_only: false },
19
+ minitest: { shared_context: false, generate_only: false },
20
+ }.freeze
21
+
22
+ def initialize(files:, options:, output_manager:)
23
+ @files = files
24
+ @options = apply_framework_defaults(options)
25
+ @output_manager = output_manager
26
+ @translator = initialize_translator
27
+ @global_tally = initialize_global_tally
28
+ end
29
+
30
+ def run
31
+ log_run_info
32
+ validate_framework
33
+
34
+ result = process_files
35
+ show_grand_total if @global_tally[:file_count] > 1
36
+ result
37
+ end
38
+
39
+ private
40
+
41
+ def log_run_info
42
+ @output_manager.processing_phase(@files.size)
43
+ @output_manager.info "Framework: #{@options[:framework]}", 1
44
+ @output_manager.info "Context: #{@options[:shared_context] ? 'shared' : 'fresh'}", 1
45
+
46
+ @files.each_with_index do |file, idx|
47
+ @output_manager.info "#{idx + 1}/#{@files.size}: #{Console.pretty_path(file)}", 1
48
+ end
49
+ end
50
+
51
+ def apply_framework_defaults(options)
52
+ framework_defaults = FRAMEWORK_DEFAULTS[options[:framework]] || {}
53
+ framework_defaults.merge(options)
54
+ end
55
+
56
+ def validate_framework
57
+ unless @options[:framework] == :direct || FRAMEWORKS.key?(@options[:framework])
58
+ raise ArgumentError, "Unknown framework: #{@options[:framework]}. Available: #{FRAMEWORKS.keys.join(', ')}, direct"
59
+ end
60
+ end
61
+
62
+ def initialize_translator
63
+ return nil if @options[:framework] == :direct
64
+
65
+ FRAMEWORKS[@options[:framework]].new
66
+ end
67
+
68
+ def initialize_global_tally
69
+ {
70
+ total_tests: 0,
71
+ total_failed: 0,
72
+ total_errors: 0,
73
+ file_count: 0,
74
+ start_time: Time.now,
75
+ successful_files: 0,
76
+ }
77
+ end
78
+
79
+ def process_files
80
+ failure_count = 0
81
+
82
+ @files.each do |file|
83
+ result = process_file(file)
84
+ failure_count += result unless result.zero?
85
+ status = result.zero? ? Console.color(:green, 'PASS') : Console.color(:red, 'FAIL')
86
+ @output_manager.info "#{status} #{Console.pretty_path(file)} (#{result} failures)", 1
87
+ end
88
+
89
+ failure_count
90
+ end
91
+
92
+ def process_file(file)
93
+ file = FileProcessor.new(
94
+ file: file,
95
+ options: @options,
96
+ output_manager: @output_manager,
97
+ translator: @translator,
98
+ global_tally: @global_tally,
99
+ )
100
+ file.process
101
+ rescue StandardError => ex
102
+ handle_file_error(ex)
103
+ @global_tally[:total_errors] += 1
104
+ 1
105
+ end
106
+
107
+ def show_grand_total
108
+ elapsed_time = Time.now - @global_tally[:start_time]
109
+ @output_manager.grand_total(
110
+ @global_tally[:total_tests],
111
+ @global_tally[:total_failed],
112
+ @global_tally[:total_errors],
113
+ @global_tally[:successful_files],
114
+ @global_tally[:file_count],
115
+ elapsed_time,
116
+ )
117
+ end
118
+
119
+ def handle_file_error(exception)
120
+ @status = :error
121
+ Tryouts.debug "TestRunner#process_file: An error occurred processing #{file}: #{ex.message}"
122
+ error_message = "Batch execution failed: #{exception.message}"
123
+ backtrace = exception.respond_to?(:backtrace) ? exception.backtrace : nil
124
+
125
+ @output_manager&.error(error_message, backtrace)
126
+ end
127
+ end
128
+ end