tryouts 3.7.1 → 4.0.0.pre1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +3 -2
- data/lib/tryouts/cli/formatters/agent.rb +2 -0
- data/lib/tryouts/cli/formatters/base.rb +2 -0
- data/lib/tryouts/cli/formatters/compact.rb +2 -0
- data/lib/tryouts/cli/formatters/factory.rb +2 -0
- data/lib/tryouts/cli/formatters/live_status_manager.rb +2 -0
- data/lib/tryouts/cli/formatters/output_manager.rb +2 -0
- data/lib/tryouts/cli/formatters/quiet.rb +2 -0
- data/lib/tryouts/cli/formatters/test_run_state.rb +2 -0
- data/lib/tryouts/cli/formatters/token_budget.rb +2 -0
- data/lib/tryouts/cli/formatters/tty_status_display.rb +2 -0
- data/lib/tryouts/cli/formatters/verbose.rb +2 -0
- data/lib/tryouts/cli/formatters.rb +2 -0
- data/lib/tryouts/cli/line_spec_parser.rb +2 -0
- data/lib/tryouts/cli/modes/generate.rb +2 -0
- data/lib/tryouts/cli/modes/inspect.rb +15 -3
- data/lib/tryouts/cli/opts.rb +2 -1
- data/lib/tryouts/cli/tty_detector.rb +2 -0
- data/lib/tryouts/cli.rb +2 -0
- data/lib/tryouts/console.rb +2 -0
- data/lib/tryouts/expectation_evaluators/base.rb +2 -0
- data/lib/tryouts/expectation_evaluators/boolean.rb +2 -0
- data/lib/tryouts/expectation_evaluators/exception.rb +2 -0
- data/lib/tryouts/expectation_evaluators/expectation_result.rb +2 -0
- data/lib/tryouts/expectation_evaluators/false.rb +2 -0
- data/lib/tryouts/expectation_evaluators/intentional_failure.rb +2 -0
- data/lib/tryouts/expectation_evaluators/non_nil.rb +2 -0
- data/lib/tryouts/expectation_evaluators/output.rb +2 -0
- data/lib/tryouts/expectation_evaluators/performance_time.rb +2 -0
- data/lib/tryouts/expectation_evaluators/regex_match.rb +2 -0
- data/lib/tryouts/expectation_evaluators/registry.rb +2 -0
- data/lib/tryouts/expectation_evaluators/regular.rb +2 -0
- data/lib/tryouts/expectation_evaluators/result_type.rb +2 -0
- data/lib/tryouts/expectation_evaluators/true.rb +2 -0
- data/lib/tryouts/failure_collector.rb +2 -0
- data/lib/tryouts/file_processor.rb +3 -4
- data/lib/tryouts/parser_warning.rb +2 -0
- data/lib/tryouts/parsers/base_parser.rb +3 -3
- data/lib/tryouts/parsers/enhanced_parser.rb +57 -3
- data/lib/tryouts/parsers/shared_methods.rb +67 -17
- data/lib/tryouts/test_batch.rb +155 -43
- data/lib/tryouts/test_case.rb +54 -6
- data/lib/tryouts/test_executor.rb +2 -0
- data/lib/tryouts/test_result_aggregator.rb +2 -0
- data/lib/tryouts/test_runner.rb +2 -1
- data/lib/tryouts/translators/minitest_translator.rb +36 -12
- data/lib/tryouts/translators/rspec_translator.rb +19 -0
- data/lib/tryouts/version.rb +3 -1
- data/lib/tryouts.rb +2 -1
- metadata +1 -2
- data/lib/tryouts/parsers/legacy_parser.rb +0 -254
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
# lib/tryouts/parsers/shared_methods.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require_relative '../parser_warning'
|
|
4
6
|
|
|
@@ -208,12 +210,21 @@ class Tryouts
|
|
|
208
210
|
# Process classified test blocks into domain objects
|
|
209
211
|
def process_test_blocks(classified_blocks)
|
|
210
212
|
setup_blocks = classified_blocks.filter { |block| block[:type] == :setup }
|
|
211
|
-
test_blocks = classified_blocks.filter { |block| block[:type] == :test }
|
|
212
213
|
teardown_blocks = classified_blocks.filter { |block| block[:type] == :teardown }
|
|
213
214
|
|
|
215
|
+
# Test cases and orphan blocks stay interleaved in source order: the
|
|
216
|
+
# executor runs them sequentially and an orphan executed out of position
|
|
217
|
+
# would corrupt the shared-context local-variable timeline.
|
|
218
|
+
test_cases = classified_blocks.filter_map do |block|
|
|
219
|
+
case block[:type]
|
|
220
|
+
when :test then build_test_case(block)
|
|
221
|
+
when :orphan then build_orphan_block(block)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
214
225
|
testrun = Testrun.new(
|
|
215
226
|
setup: build_setup(setup_blocks),
|
|
216
|
-
test_cases:
|
|
227
|
+
test_cases: test_cases,
|
|
217
228
|
teardown: build_teardown(teardown_blocks),
|
|
218
229
|
source_file: @source_path,
|
|
219
230
|
metadata: { parsed_at: @parsed_at, parser: parser_type },
|
|
@@ -233,6 +244,7 @@ class Tryouts
|
|
|
233
244
|
code: extract_pure_code_from_blocks(setup_blocks),
|
|
234
245
|
line_range: calculate_block_range(setup_blocks),
|
|
235
246
|
path: @source_path,
|
|
247
|
+
first_code_line: first_code_line(collect_code_tokens(setup_blocks)),
|
|
236
248
|
)
|
|
237
249
|
end
|
|
238
250
|
|
|
@@ -243,22 +255,29 @@ class Tryouts
|
|
|
243
255
|
code: extract_pure_code_from_blocks(teardown_blocks),
|
|
244
256
|
line_range: calculate_block_range(teardown_blocks),
|
|
245
257
|
path: @source_path,
|
|
258
|
+
first_code_line: first_code_line(collect_code_tokens(teardown_blocks)),
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Orphan blocks (code with no description and no expectations, sandwiched
|
|
263
|
+
# between real test cases) are collected for execution instead of being
|
|
264
|
+
# silently dropped. Comment-only blocks carry no executable code and
|
|
265
|
+
# produce nothing.
|
|
266
|
+
def build_orphan_block(block)
|
|
267
|
+
code = extract_pure_code_from_blocks([block])
|
|
268
|
+
return nil if code.strip.empty?
|
|
269
|
+
|
|
270
|
+
OrphanBlock.new(
|
|
271
|
+
code: code,
|
|
272
|
+
line_range: calculate_block_range([block]),
|
|
273
|
+
path: @source_path,
|
|
274
|
+
first_code_line: first_code_line(collect_code_tokens([block])),
|
|
246
275
|
)
|
|
247
276
|
end
|
|
248
277
|
|
|
249
278
|
# Modern Ruby 3.4+ pattern matching for robust code extraction
|
|
250
279
|
def extract_pure_code_from_blocks(blocks)
|
|
251
|
-
blocks
|
|
252
|
-
.flat_map { |block| block[:code] }
|
|
253
|
-
.filter_map do |token|
|
|
254
|
-
case token
|
|
255
|
-
in { type: :code, content: String => content }
|
|
256
|
-
content
|
|
257
|
-
else
|
|
258
|
-
nil
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
.join("\n")
|
|
280
|
+
extract_code_content(collect_code_tokens(blocks))
|
|
262
281
|
end
|
|
263
282
|
|
|
264
283
|
def calculate_block_range(blocks)
|
|
@@ -271,17 +290,47 @@ class Tryouts
|
|
|
271
290
|
line_ranges.first.first..line_ranges.last.last
|
|
272
291
|
end
|
|
273
292
|
|
|
293
|
+
# Code keeps the spacing it had in the source: every comment or blank line
|
|
294
|
+
# between two statements becomes an empty line here. Paired with
|
|
295
|
+
# `first_code_line` as the eval start line, that makes each statement in a
|
|
296
|
+
# multi-line block report its true source location in a backtrace. Joining
|
|
297
|
+
# the code lines alone would collapse the gaps and drift by however many
|
|
298
|
+
# lines were dropped.
|
|
274
299
|
def extract_code_content(code_tokens)
|
|
300
|
+
positioned = positioned_code_tokens(code_tokens)
|
|
301
|
+
return '' if positioned.empty?
|
|
302
|
+
|
|
303
|
+
base = positioned.first.first
|
|
304
|
+
lines = []
|
|
305
|
+
|
|
306
|
+
positioned.each { |line, content| lines[line - base] = content }
|
|
307
|
+
lines.map { |content| content || '' }.join("\n")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# 0-based source line of the block's first executable line, which is not
|
|
311
|
+
# the same as its start_line: a block opens at its description or leading
|
|
312
|
+
# comment, and only the code lines are ever evaluated.
|
|
313
|
+
def first_code_line(code_tokens)
|
|
314
|
+
positioned_code_tokens(code_tokens).first&.first
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def collect_code_tokens(blocks)
|
|
318
|
+
blocks.flat_map { |block| block[:code] }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# [line, content] pairs for executable lines only, in source order. The
|
|
322
|
+
# tokenizer emits at most one :code token per physical line.
|
|
323
|
+
def positioned_code_tokens(code_tokens)
|
|
275
324
|
code_tokens
|
|
276
325
|
.filter_map do |token|
|
|
277
326
|
case token
|
|
278
|
-
in { type: :code, content: String => content }
|
|
279
|
-
content
|
|
327
|
+
in { type: :code, content: String => content, line: Integer => line }
|
|
328
|
+
[line, content]
|
|
280
329
|
else
|
|
281
330
|
nil
|
|
282
331
|
end
|
|
283
332
|
end
|
|
284
|
-
.
|
|
333
|
+
.sort_by(&:first)
|
|
285
334
|
end
|
|
286
335
|
|
|
287
336
|
def parse_ruby_line(line)
|
|
@@ -342,7 +391,7 @@ class Tryouts
|
|
|
342
391
|
in { expectations: Array => exps } if !exps.empty?
|
|
343
392
|
:test
|
|
344
393
|
else
|
|
345
|
-
:
|
|
394
|
+
:orphan
|
|
346
395
|
end
|
|
347
396
|
|
|
348
397
|
block.merge(type: block_type, end_line: calculate_end_line(block))
|
|
@@ -375,6 +424,7 @@ class Tryouts
|
|
|
375
424
|
TestCase.new(
|
|
376
425
|
description: desc,
|
|
377
426
|
code: extract_code_content(code_tokens),
|
|
427
|
+
first_code_line: first_code_line(code_tokens),
|
|
378
428
|
expectations: exp_tokens.map do |token|
|
|
379
429
|
type = case token[:type]
|
|
380
430
|
when :exception_expectation then :exception
|
data/lib/tryouts/test_batch.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
# lib/tryouts/
|
|
1
|
+
# lib/tryouts/test_batch.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
5
|
+
require 'monitor'
|
|
3
6
|
require 'stringio'
|
|
4
7
|
require_relative 'expectation_evaluators/registry'
|
|
5
8
|
|
|
@@ -22,6 +25,17 @@ class Tryouts
|
|
|
22
25
|
|
|
23
26
|
# Modern TestBatch using Ruby 3.4+ patterns and formatter system
|
|
24
27
|
class TestBatch
|
|
28
|
+
# $stdout/$stderr are process-global, not Fiber- or thread-local. Under
|
|
29
|
+
# --parallel (Concurrent::ThreadPoolExecutor), concurrent redirects would
|
|
30
|
+
# clobber each other and could leave $stdout pointing at a dead StringIO
|
|
31
|
+
# process-wide. Every redirect path (capture_output for setup/teardown and
|
|
32
|
+
# each test, plus execute_with_output_capture) synchronizes on this lock so
|
|
33
|
+
# the redirect/execute/restore window is serialized across threads.
|
|
34
|
+
#
|
|
35
|
+
# A Monitor (reentrant) is required because execute_with_output_capture runs
|
|
36
|
+
# nested inside capture_output; a plain Mutex would self-deadlock.
|
|
37
|
+
OUTPUT_CAPTURE_MONITOR = Monitor.new
|
|
38
|
+
|
|
25
39
|
attr_reader :testrun, :failed_count, :container, :status, :results, :formatter, :output_manager
|
|
26
40
|
|
|
27
41
|
def initialize(testrun, **options)
|
|
@@ -37,6 +51,12 @@ class Tryouts
|
|
|
37
51
|
@start_time = nil
|
|
38
52
|
@test_case_count = 0
|
|
39
53
|
@setup_failed = false
|
|
54
|
+
@orphan_failed = false
|
|
55
|
+
|
|
56
|
+
# Shared-context mode evaluates every block against this one reused
|
|
57
|
+
# Binding so local variables persist across blocks like a plain Ruby
|
|
58
|
+
# script. Fresh-context mode keeps per-container instance_eval isolation.
|
|
59
|
+
@binding = options[:shared_context] ? acquire_container_binding(@container) : nil
|
|
40
60
|
@line_spec = options[:line_spec] # For output filtering only
|
|
41
61
|
|
|
42
62
|
# Setup container for fresh context mode - preserves @instance_variables from setup
|
|
@@ -60,7 +80,7 @@ class Tryouts
|
|
|
60
80
|
return false if empty?
|
|
61
81
|
|
|
62
82
|
@start_time = Time.now
|
|
63
|
-
@test_case_count =
|
|
83
|
+
@test_case_count = @testrun.total_tests
|
|
64
84
|
|
|
65
85
|
@output_manager&.execution_phase(@test_case_count)
|
|
66
86
|
@output_manager&.info("Context: #{@options[:shared_context] ? 'shared' : 'fresh'}", 1)
|
|
@@ -93,6 +113,16 @@ class Tryouts
|
|
|
93
113
|
|
|
94
114
|
idx = 0
|
|
95
115
|
execution_results = test_cases.map do |test_case|
|
|
116
|
+
# Orphan blocks run for side effects only, in source order. A raise
|
|
117
|
+
# aborts the batch like a setup failure: every subsequent test's
|
|
118
|
+
# context is suspect.
|
|
119
|
+
if test_case.is_a?(OrphanBlock)
|
|
120
|
+
execute_orphan_block(test_case)
|
|
121
|
+
break if @orphan_failed
|
|
122
|
+
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
|
|
96
126
|
@output_manager&.trace("Test #{idx + 1}/#{@test_case_count}: #{test_case.description}", 2)
|
|
97
127
|
idx += 1
|
|
98
128
|
|
|
@@ -132,6 +162,14 @@ class Tryouts
|
|
|
132
162
|
error_result
|
|
133
163
|
end
|
|
134
164
|
|
|
165
|
+
if @orphan_failed
|
|
166
|
+
@output_manager&.error('Stopping batch execution due to orphan code block failure')
|
|
167
|
+
@status = :failed
|
|
168
|
+
@failed_count += 1
|
|
169
|
+
finalize_results(execution_results || [])
|
|
170
|
+
return false
|
|
171
|
+
end
|
|
172
|
+
|
|
135
173
|
# Used for a separate purpose then execution_phase.
|
|
136
174
|
# e.g. the quiet formatter prints a newline after all test dots
|
|
137
175
|
@output_manager&.file_end(path, context: @options[:shared_context] ? :shared : :fresh)
|
|
@@ -171,6 +209,29 @@ class Tryouts
|
|
|
171
209
|
|
|
172
210
|
private
|
|
173
211
|
|
|
212
|
+
# Must be its own method, never inlined at a call site, and must use
|
|
213
|
+
# instance_eval on a STRING, not a block. A block literal fixes
|
|
214
|
+
# Module.nesting to wherever it is lexically written ([Tryouts::TestBatch]
|
|
215
|
+
# here), which would make every container's `class Foo` definitions land in
|
|
216
|
+
# one shared namespace across files. instance_eval(string) instead sets the
|
|
217
|
+
# default definee to the receiver's own singleton class, preserving the
|
|
218
|
+
# per-container class/def isolation that instance_eval(code_string) gives.
|
|
219
|
+
def acquire_container_binding(container)
|
|
220
|
+
container.instance_eval('binding', __FILE__, __LINE__)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Evaluate a block of test code against the given container. Shared-context
|
|
224
|
+
# mode reuses the one Binding acquired at initialization; each block is
|
|
225
|
+
# still its own eval call (individually rescuable and timeoutable), only
|
|
226
|
+
# the state that persists between calls changes.
|
|
227
|
+
def eval_code(container, code, path, line)
|
|
228
|
+
if @binding && container.equal?(@container)
|
|
229
|
+
@binding.eval(code, path, line)
|
|
230
|
+
else
|
|
231
|
+
container.instance_eval(code, path, line)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
174
235
|
# Pattern matching for execution strategy selection
|
|
175
236
|
def execute_single_test(test_case, before_test_hook = nil)
|
|
176
237
|
before_test_hook&.call(test_case)
|
|
@@ -244,8 +305,7 @@ class Tryouts
|
|
|
244
305
|
begin
|
|
245
306
|
code = test_case.code
|
|
246
307
|
path = test_case.path
|
|
247
|
-
|
|
248
|
-
container.instance_eval(code, path, range.first + 1)
|
|
308
|
+
eval_code(container, code, path, test_case.eval_start_line)
|
|
249
309
|
rescue SystemStackError, NoMemoryError, SecurityError, ScriptError => ex
|
|
250
310
|
# Handle system-level exceptions that don't inherit from StandardError
|
|
251
311
|
# ScriptError includes: LoadError, SyntaxError, NotImplementedError
|
|
@@ -260,9 +320,9 @@ class Tryouts
|
|
|
260
320
|
build_test_result(test_case, caught_exception, expectations_result)
|
|
261
321
|
else
|
|
262
322
|
# Regular execution for non-exception tests with timing and output capture
|
|
263
|
-
code
|
|
264
|
-
path
|
|
265
|
-
|
|
323
|
+
code = test_case.code
|
|
324
|
+
path = test_case.path
|
|
325
|
+
start_line = test_case.eval_start_line
|
|
266
326
|
|
|
267
327
|
# Check if we need output capture for any expectations
|
|
268
328
|
needs_output_capture = test_case.expectations.any?(&:output?)
|
|
@@ -270,9 +330,9 @@ class Tryouts
|
|
|
270
330
|
result_value, _, _, _, expectations_result =
|
|
271
331
|
execute_with_timeout(test_timeout, test_case) do
|
|
272
332
|
if needs_output_capture
|
|
273
|
-
# Execute with output capture
|
|
333
|
+
# Execute with output capture (serialized process-global redirect)
|
|
274
334
|
result_value, execution_time_ns, stdout_content, stderr_content =
|
|
275
|
-
execute_with_output_capture(container, code, path,
|
|
335
|
+
execute_with_output_capture(container, code, path, start_line)
|
|
276
336
|
|
|
277
337
|
expectations_result = evaluate_expectations(
|
|
278
338
|
test_case, result_value, container, execution_time_ns, stdout_content, stderr_content
|
|
@@ -281,7 +341,7 @@ class Tryouts
|
|
|
281
341
|
else
|
|
282
342
|
# Regular execution with timing capture only
|
|
283
343
|
execution_start_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
284
|
-
result_value = container
|
|
344
|
+
result_value = eval_code(container, code, path, test_case.eval_start_line)
|
|
285
345
|
execution_end_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
286
346
|
execution_time_ns = execution_end_ns - execution_start_ns
|
|
287
347
|
|
|
@@ -306,37 +366,37 @@ class Tryouts
|
|
|
306
366
|
build_error_result(test_case, StandardError.new("Test terminated by #{ex.class}: #{ex.message}"))
|
|
307
367
|
end
|
|
308
368
|
|
|
309
|
-
# Execute test code
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
369
|
+
# Execute test code while capturing its stdout/stderr.
|
|
370
|
+
#
|
|
371
|
+
# $stdout/$stderr are process-global. The redirect below mutates them for the
|
|
372
|
+
# whole process, so OUTPUT_CAPTURE_MONITOR serializes this section to keep
|
|
373
|
+
# concurrent --parallel runs from corrupting each other's output or leaving a
|
|
374
|
+
# dangling StringIO behind. See OUTPUT_CAPTURE_MONITOR for details.
|
|
375
|
+
def execute_with_output_capture(container, code, path, start_line)
|
|
315
376
|
# Create StringIO objects for capturing output
|
|
316
377
|
captured_stdout = StringIO.new
|
|
317
378
|
captured_stderr = StringIO.new
|
|
318
379
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
380
|
+
OUTPUT_CAPTURE_MONITOR.synchronize do
|
|
381
|
+
original_stdout = $stdout
|
|
382
|
+
original_stderr = $stderr
|
|
383
|
+
|
|
384
|
+
begin
|
|
322
385
|
$stdout = captured_stdout
|
|
323
386
|
$stderr = captured_stderr
|
|
324
387
|
|
|
325
388
|
# Execute with timing capture
|
|
326
389
|
execution_start_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
327
|
-
result_value = container
|
|
390
|
+
result_value = eval_code(container, code, path, start_line)
|
|
328
391
|
execution_end_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
329
392
|
execution_time_ns = execution_end_ns - execution_start_ns
|
|
330
393
|
|
|
331
|
-
[result_value, execution_time_ns]
|
|
332
|
-
|
|
333
|
-
#
|
|
334
|
-
|
|
394
|
+
[result_value, execution_time_ns, captured_stdout.string, captured_stderr.string]
|
|
395
|
+
ensure
|
|
396
|
+
# Always restore original streams
|
|
397
|
+
$stdout = original_stdout
|
|
398
|
+
$stderr = original_stderr
|
|
335
399
|
end
|
|
336
|
-
ensure
|
|
337
|
-
# Always restore original streams
|
|
338
|
-
$stdout = original_stdout
|
|
339
|
-
$stderr = original_stderr
|
|
340
400
|
end
|
|
341
401
|
end
|
|
342
402
|
|
|
@@ -426,6 +486,49 @@ class Tryouts
|
|
|
426
486
|
end
|
|
427
487
|
end
|
|
428
488
|
|
|
489
|
+
# Orphan blocks are setup-like glue between test cases: silent on success
|
|
490
|
+
# (no pass/fail, not counted in tallies), a file-level error on failure.
|
|
491
|
+
# In fresh-context mode they run against the setup container so their
|
|
492
|
+
# @instance_variables propagate to subsequent tests' fresh copies.
|
|
493
|
+
def execute_orphan_block(orphan)
|
|
494
|
+
return if orphan.code.strip.empty?
|
|
495
|
+
|
|
496
|
+
target = if shared_context?
|
|
497
|
+
@container
|
|
498
|
+
else
|
|
499
|
+
@setup_container ||= Object.new
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
@output_manager&.info("Running orphan code block (#{orphan.path}:#{orphan.line_range.first + 1})", 2)
|
|
503
|
+
|
|
504
|
+
captured_output = capture_output do
|
|
505
|
+
eval_code(target, orphan.code, orphan.path, orphan.eval_start_line)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
@output_manager&.setup_output(captured_output) if captured_output && !captured_output.empty?
|
|
509
|
+
rescue StandardError => ex
|
|
510
|
+
@orphan_failed = true
|
|
511
|
+
if @global_tally && @global_tally[:aggregator]
|
|
512
|
+
@global_tally[:aggregator].add_infrastructure_failure(
|
|
513
|
+
:orphan, @testrun.source_file, ex.message, ex
|
|
514
|
+
)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
Tryouts.debug "Orphan code block failed (#{ex.class}): #{ex.message}"
|
|
518
|
+
Tryouts.trace ex.backtrace
|
|
519
|
+
|
|
520
|
+
@output_manager&.error("Orphan code block failed (#{orphan.path}:#{orphan.line_range.first + 1}): #{ex.message}")
|
|
521
|
+
rescue SystemExit, SignalException => ex
|
|
522
|
+
@orphan_failed = true
|
|
523
|
+
if @global_tally && @global_tally[:aggregator]
|
|
524
|
+
@global_tally[:aggregator].add_infrastructure_failure(
|
|
525
|
+
:orphan, @testrun.source_file, "Orphan block terminated by #{ex.class}: #{ex.message}", ex
|
|
526
|
+
)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
@output_manager&.error("Orphan code block terminated by #{ex.class}: #{ex.message}")
|
|
530
|
+
end
|
|
531
|
+
|
|
429
532
|
# Global setup execution for shared context mode
|
|
430
533
|
def execute_global_setup
|
|
431
534
|
setup = @testrun.setup
|
|
@@ -435,7 +538,7 @@ class Tryouts
|
|
|
435
538
|
|
|
436
539
|
# Capture setup output instead of letting it print directly
|
|
437
540
|
captured_output = capture_output do
|
|
438
|
-
@container
|
|
541
|
+
eval_code(@container, setup.code, setup.path, setup.eval_start_line)
|
|
439
542
|
end
|
|
440
543
|
|
|
441
544
|
@output_manager&.setup_output(captured_output) if captured_output && !captured_output.empty?
|
|
@@ -487,7 +590,7 @@ class Tryouts
|
|
|
487
590
|
|
|
488
591
|
# Capture setup output instead of letting it print directly
|
|
489
592
|
captured_output = capture_output do
|
|
490
|
-
@setup_container.instance_eval(setup.code, setup.path, setup.
|
|
593
|
+
@setup_container.instance_eval(setup.code, setup.path, setup.eval_start_line)
|
|
491
594
|
end
|
|
492
595
|
|
|
493
596
|
@output_manager&.setup_output(captured_output) if captured_output && !captured_output.empty?
|
|
@@ -536,7 +639,7 @@ class Tryouts
|
|
|
536
639
|
|
|
537
640
|
# Capture teardown output instead of letting it print directly
|
|
538
641
|
captured_output = capture_output do
|
|
539
|
-
@container
|
|
642
|
+
eval_code(@container, teardown.code, teardown.path, teardown.eval_start_line)
|
|
540
643
|
end
|
|
541
644
|
|
|
542
645
|
@output_manager&.teardown_output(captured_output) if captured_output && !captured_output.empty?
|
|
@@ -612,19 +715,28 @@ class Tryouts
|
|
|
612
715
|
Tryouts::CLI::LineSpecParser.matches?(test_case, @line_spec)
|
|
613
716
|
end
|
|
614
717
|
|
|
718
|
+
# Redirects the process-global $stdout/$stderr to StringIO for the duration of
|
|
719
|
+
# the block. This is the outer capture used by setup, teardown, and every test
|
|
720
|
+
# (execute_with_output_capture nests inside it for output-expectation tests).
|
|
721
|
+
# Synchronizes on OUTPUT_CAPTURE_MONITOR so the redirect/restore window is
|
|
722
|
+
# serialized across --parallel threads that share these process-global streams.
|
|
615
723
|
def capture_output
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
724
|
+
OUTPUT_CAPTURE_MONITOR.synchronize do
|
|
725
|
+
old_stdout = $stdout
|
|
726
|
+
old_stderr = $stderr
|
|
727
|
+
$stdout = StringIO.new
|
|
728
|
+
$stderr = StringIO.new
|
|
729
|
+
|
|
730
|
+
begin
|
|
731
|
+
yield
|
|
732
|
+
|
|
733
|
+
captured = $stdout.string + $stderr.string
|
|
734
|
+
captured.empty? ? nil : captured
|
|
735
|
+
ensure
|
|
736
|
+
$stdout = old_stdout
|
|
737
|
+
$stderr = old_stderr
|
|
738
|
+
end
|
|
739
|
+
end
|
|
628
740
|
end
|
|
629
741
|
|
|
630
742
|
def handle_batch_error(exception)
|
data/lib/tryouts/test_case.rb
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
|
-
# lib/tryouts/
|
|
1
|
+
# lib/tryouts/test_case.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
# Modern data structures using Ruby 3.2+ Data classes
|
|
4
6
|
class Tryouts
|
|
7
|
+
# A block is evaluated starting at its first executable line, not at its
|
|
8
|
+
# line_range.first: a block opens at its description or a leading comment,
|
|
9
|
+
# while the extracted code begins at the first statement. Getting this wrong
|
|
10
|
+
# misattributes every backtrace line in the block. Hand-built blocks (e.g.
|
|
11
|
+
# translator fixtures) may omit first_code_line and fall back to the start.
|
|
12
|
+
module EvalAnchor
|
|
13
|
+
def eval_start_line
|
|
14
|
+
(first_code_line || line_range.first) + 1
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
5
18
|
# Core data structures
|
|
6
|
-
TestCase = Data.define(:description, :code, :expectations, :line_range, :path, :source_lines, :first_expectation_line) do
|
|
19
|
+
TestCase = Data.define(:description, :code, :expectations, :line_range, :path, :source_lines, :first_expectation_line, :first_code_line) do
|
|
20
|
+
include EvalAnchor
|
|
21
|
+
|
|
22
|
+
def initialize(first_code_line: nil, **rest)
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
7
26
|
def empty?
|
|
8
27
|
code.empty?
|
|
9
28
|
end
|
|
@@ -55,25 +74,54 @@ class Tryouts
|
|
|
55
74
|
def stderr? = pipe == 2
|
|
56
75
|
end
|
|
57
76
|
|
|
58
|
-
Setup = Data.define(:code, :line_range, :path) do
|
|
77
|
+
Setup = Data.define(:code, :line_range, :path, :first_code_line) do
|
|
78
|
+
include EvalAnchor
|
|
79
|
+
|
|
80
|
+
def initialize(first_code_line: nil, **rest)
|
|
81
|
+
super
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def empty?
|
|
85
|
+
code.empty?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Code with no description and no expectations that sits between test cases.
|
|
90
|
+
# Executed in source order for its side effects, but never evaluated against
|
|
91
|
+
# expectations and never counted in test tallies.
|
|
92
|
+
OrphanBlock = Data.define(:code, :line_range, :path, :first_code_line) do
|
|
93
|
+
include EvalAnchor
|
|
94
|
+
|
|
95
|
+
def initialize(first_code_line: nil, **rest)
|
|
96
|
+
super
|
|
97
|
+
end
|
|
98
|
+
|
|
59
99
|
def empty?
|
|
60
100
|
code.empty?
|
|
61
101
|
end
|
|
62
102
|
end
|
|
63
103
|
|
|
64
|
-
Teardown = Data.define(:code, :line_range, :path) do
|
|
104
|
+
Teardown = Data.define(:code, :line_range, :path, :first_code_line) do
|
|
105
|
+
include EvalAnchor
|
|
106
|
+
|
|
107
|
+
def initialize(first_code_line: nil, **rest)
|
|
108
|
+
super
|
|
109
|
+
end
|
|
110
|
+
|
|
65
111
|
def empty?
|
|
66
112
|
code.empty?
|
|
67
113
|
end
|
|
68
114
|
end
|
|
69
115
|
|
|
116
|
+
# test_cases is a single ordered array of TestCase | OrphanBlock — the
|
|
117
|
+
# interleaving order is load-bearing for shared-context local-variable state.
|
|
70
118
|
Testrun = Data.define(:setup, :test_cases, :teardown, :source_file, :metadata, :warnings) do
|
|
71
119
|
def total_tests
|
|
72
|
-
test_cases.
|
|
120
|
+
test_cases.count { |tc| tc.is_a?(TestCase) }
|
|
73
121
|
end
|
|
74
122
|
|
|
75
123
|
def empty?
|
|
76
|
-
|
|
124
|
+
total_tests.zero?
|
|
77
125
|
end
|
|
78
126
|
end
|
|
79
127
|
|
data/lib/tryouts/test_runner.rb
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
# lib/tryouts/translators/minitest_translator.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
class Tryouts
|
|
4
6
|
module Translators
|
|
@@ -41,6 +43,15 @@ class Tryouts
|
|
|
41
43
|
# Recommendation: Write tryouts tests that work in fresh context mode
|
|
42
44
|
# if you plan to use Minitest translation.
|
|
43
45
|
class MinitestTranslator
|
|
46
|
+
# Simple string parameterization for method names.
|
|
47
|
+
#
|
|
48
|
+
# Defined on the class because `translate` calls it from inside a
|
|
49
|
+
# `Class.new(Minitest::Test) do ... end` block, where `self` is the
|
|
50
|
+
# anonymous test class rather than the translator instance.
|
|
51
|
+
def self.parameterize(string)
|
|
52
|
+
string.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/^_|_$/, '')
|
|
53
|
+
end
|
|
54
|
+
|
|
44
55
|
def initialize
|
|
45
56
|
require 'minitest/test'
|
|
46
57
|
rescue LoadError
|
|
@@ -61,18 +72,29 @@ class Tryouts
|
|
|
61
72
|
|
|
62
73
|
# Generate test methods
|
|
63
74
|
testrun.test_cases.each_with_index do |test_case, index|
|
|
75
|
+
# Orphan blocks become plain statements in the class body, in source order
|
|
76
|
+
if test_case.is_a?(Tryouts::OrphanBlock)
|
|
77
|
+
unless test_case.empty?
|
|
78
|
+
class_eval(test_case.code, testrun.source_file, test_case.eval_start_line)
|
|
79
|
+
end
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
|
|
64
83
|
next if test_case.empty? || !test_case.expectations?
|
|
65
84
|
|
|
66
|
-
method_name = "test_#{index.to_s.rjust(3, '0')}_#{parameterize(test_case.description)}"
|
|
85
|
+
method_name = "test_#{index.to_s.rjust(3, '0')}_#{MinitestTranslator.parameterize(test_case.description)}"
|
|
67
86
|
define_method(method_name) do
|
|
68
87
|
if test_case.exception_expectations?
|
|
69
|
-
# Handle exception expectations
|
|
70
|
-
|
|
88
|
+
# Handle exception expectations. The raised exception is bound to
|
|
89
|
+
# a local named `error`, which is what #=!> expectations refer to,
|
|
90
|
+
# so the expectation is eval'd against this binding rather than
|
|
91
|
+
# instance_eval'd.
|
|
92
|
+
error = assert_raises(StandardError) do
|
|
71
93
|
instance_eval(test_case.code) unless test_case.code.strip.empty?
|
|
72
94
|
end
|
|
73
95
|
|
|
74
96
|
test_case.exception_expectations.each do |expectation|
|
|
75
|
-
result =
|
|
97
|
+
result = eval(expectation.content, binding) # rubocop:disable Security/Eval
|
|
76
98
|
assert result, "Exception expectation failed: #{expectation.content}"
|
|
77
99
|
end
|
|
78
100
|
else
|
|
@@ -119,9 +141,18 @@ class Tryouts
|
|
|
119
141
|
end
|
|
120
142
|
|
|
121
143
|
testrun.test_cases.each_with_index do |test_case, index|
|
|
144
|
+
# Orphan blocks become plain statements in the class body, in source order
|
|
145
|
+
if test_case.is_a?(Tryouts::OrphanBlock)
|
|
146
|
+
unless test_case.empty?
|
|
147
|
+
test_case.code.lines.each { |line| lines << " #{line.chomp}" }
|
|
148
|
+
lines << ''
|
|
149
|
+
end
|
|
150
|
+
next
|
|
151
|
+
end
|
|
152
|
+
|
|
122
153
|
next if test_case.empty? || !test_case.expectations?
|
|
123
154
|
|
|
124
|
-
method_name = "test_#{index.to_s.rjust(3, '0')}_#{parameterize(test_case.description)}"
|
|
155
|
+
method_name = "test_#{index.to_s.rjust(3, '0')}_#{MinitestTranslator.parameterize(test_case.description)}"
|
|
125
156
|
lines << " def #{method_name}"
|
|
126
157
|
|
|
127
158
|
if test_case.exception_expectations?
|
|
@@ -161,13 +192,6 @@ class Tryouts
|
|
|
161
192
|
lines << 'end'
|
|
162
193
|
lines.join("\n")
|
|
163
194
|
end
|
|
164
|
-
|
|
165
|
-
private
|
|
166
|
-
|
|
167
|
-
# Simple string parameterization for method names
|
|
168
|
-
def parameterize(string)
|
|
169
|
-
string.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/^_|_$/, '')
|
|
170
|
-
end
|
|
171
195
|
end
|
|
172
196
|
end
|
|
173
197
|
end
|