ace-test-runner 0.18.0
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 +7 -0
- data/.ace-defaults/test/runner.yml +35 -0
- data/.ace-defaults/test/suite.yml +31 -0
- data/.ace-defaults/test-runner/config.yml +61 -0
- data/CHANGELOG.md +626 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-test +26 -0
- data/exe/ace-test-suite +149 -0
- data/lib/ace/test_runner/atoms/command_builder.rb +165 -0
- data/lib/ace/test_runner/atoms/lazy_loader.rb +62 -0
- data/lib/ace/test_runner/atoms/line_number_resolver.rb +86 -0
- data/lib/ace/test_runner/atoms/report_directory_resolver.rb +48 -0
- data/lib/ace/test_runner/atoms/report_path_resolver.rb +67 -0
- data/lib/ace/test_runner/atoms/result_parser.rb +254 -0
- data/lib/ace/test_runner/atoms/test_detector.rb +114 -0
- data/lib/ace/test_runner/atoms/test_folder_detector.rb +53 -0
- data/lib/ace/test_runner/atoms/test_type_detector.rb +83 -0
- data/lib/ace/test_runner/atoms/timestamp_generator.rb +103 -0
- data/lib/ace/test_runner/cli/commands/test.rb +326 -0
- data/lib/ace/test_runner/cli.rb +16 -0
- data/lib/ace/test_runner/formatters/base_formatter.rb +102 -0
- data/lib/ace/test_runner/formatters/json_formatter.rb +90 -0
- data/lib/ace/test_runner/formatters/markdown_formatter.rb +91 -0
- data/lib/ace/test_runner/formatters/progress_file_formatter.rb +164 -0
- data/lib/ace/test_runner/formatters/progress_formatter.rb +328 -0
- data/lib/ace/test_runner/models/test_configuration.rb +165 -0
- data/lib/ace/test_runner/models/test_failure.rb +95 -0
- data/lib/ace/test_runner/models/test_group.rb +105 -0
- data/lib/ace/test_runner/models/test_report.rb +145 -0
- data/lib/ace/test_runner/models/test_result.rb +86 -0
- data/lib/ace/test_runner/molecules/cli_argument_parser.rb +263 -0
- data/lib/ace/test_runner/molecules/config_loader.rb +162 -0
- data/lib/ace/test_runner/molecules/deprecation_fixer.rb +204 -0
- data/lib/ace/test_runner/molecules/failed_package_reporter.rb +100 -0
- data/lib/ace/test_runner/molecules/failure_analyzer.rb +249 -0
- data/lib/ace/test_runner/molecules/in_process_runner.rb +249 -0
- data/lib/ace/test_runner/molecules/package_resolver.rb +106 -0
- data/lib/ace/test_runner/molecules/pattern_resolver.rb +146 -0
- data/lib/ace/test_runner/molecules/rake_integration.rb +218 -0
- data/lib/ace/test_runner/molecules/report_storage.rb +303 -0
- data/lib/ace/test_runner/molecules/smart_test_executor.rb +107 -0
- data/lib/ace/test_runner/molecules/test_executor.rb +162 -0
- data/lib/ace/test_runner/organisms/agent_reporter.rb +384 -0
- data/lib/ace/test_runner/organisms/report_generator.rb +151 -0
- data/lib/ace/test_runner/organisms/sequential_group_executor.rb +185 -0
- data/lib/ace/test_runner/organisms/test_orchestrator.rb +648 -0
- data/lib/ace/test_runner/rake_task.rb +90 -0
- data/lib/ace/test_runner/suite/display_helpers.rb +117 -0
- data/lib/ace/test_runner/suite/display_manager.rb +204 -0
- data/lib/ace/test_runner/suite/duration_estimator.rb +50 -0
- data/lib/ace/test_runner/suite/orchestrator.rb +120 -0
- data/lib/ace/test_runner/suite/process_monitor.rb +268 -0
- data/lib/ace/test_runner/suite/result_aggregator.rb +176 -0
- data/lib/ace/test_runner/suite/simple_display_manager.rb +122 -0
- data/lib/ace/test_runner/suite.rb +22 -0
- data/lib/ace/test_runner/version.rb +7 -0
- data/lib/ace/test_runner.rb +69 -0
- metadata +246 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formatters/base_formatter"
|
|
4
|
+
require_relative "../molecules/config_loader"
|
|
5
|
+
require_relative "../molecules/pattern_resolver"
|
|
6
|
+
require_relative "../atoms/report_directory_resolver"
|
|
7
|
+
require_relative "sequential_group_executor"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module TestRunner
|
|
11
|
+
module Organisms
|
|
12
|
+
# Main orchestrator that coordinates the entire test execution flow
|
|
13
|
+
class TestOrchestrator
|
|
14
|
+
attr_reader :configuration, :result
|
|
15
|
+
|
|
16
|
+
def initialize(options = {})
|
|
17
|
+
@package_dir = options[:package_dir]
|
|
18
|
+
@original_dir = Dir.pwd
|
|
19
|
+
@options = options
|
|
20
|
+
|
|
21
|
+
# Fail fast: validate package_dir exists before proceeding
|
|
22
|
+
if @package_dir && !Dir.exist?(@package_dir)
|
|
23
|
+
raise Error, "Package directory not found: #{@package_dir}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Track if user explicitly specified --report-dir to avoid auto-detection override
|
|
27
|
+
@report_dir_override = options[:report_dir] ? :user_specified : nil
|
|
28
|
+
|
|
29
|
+
# Component initialization strategy:
|
|
30
|
+
# - Package mode: defer setup to run() when we're in the correct directory
|
|
31
|
+
# - Non-package mode: set up immediately for backward compatibility
|
|
32
|
+
# (callers may access @configuration after initialize)
|
|
33
|
+
if @package_dir
|
|
34
|
+
@configuration = nil
|
|
35
|
+
@components_initialized = false
|
|
36
|
+
else
|
|
37
|
+
setup_components
|
|
38
|
+
@components_initialized = true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run
|
|
43
|
+
# Change to package directory if specified - this is done in run to ensure
|
|
44
|
+
# the ensure block always restores the directory, even on initialization errors
|
|
45
|
+
Dir.chdir(@package_dir) if @package_dir
|
|
46
|
+
|
|
47
|
+
# Initialize components if not already done (package mode)
|
|
48
|
+
setup_components unless @components_initialized
|
|
49
|
+
|
|
50
|
+
run_with_package_context
|
|
51
|
+
ensure
|
|
52
|
+
# Restore original directory if we changed it
|
|
53
|
+
Dir.chdir(@original_dir) if @package_dir && Dir.pwd != @original_dir
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def run_with_package_context
|
|
57
|
+
validate_configuration!
|
|
58
|
+
|
|
59
|
+
start_time = Time.now
|
|
60
|
+
|
|
61
|
+
# Print package context if running in different directory
|
|
62
|
+
if @package_dir
|
|
63
|
+
puts "Running tests in #{File.basename(@package_dir)}..."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if sequential group execution should be used
|
|
67
|
+
if should_execute_sequentially?
|
|
68
|
+
# Use default "all" group if no target specified in grouped mode
|
|
69
|
+
@configuration.target ||= "all"
|
|
70
|
+
return run_sequential_groups(start_time)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Find test files
|
|
74
|
+
test_files = find_test_files
|
|
75
|
+
if test_files.empty?
|
|
76
|
+
return handle_no_tests
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
resolve_and_set_report_dir_context(test_files)
|
|
80
|
+
|
|
81
|
+
# Count total available test files (before filtering)
|
|
82
|
+
total_available = count_total_test_files
|
|
83
|
+
|
|
84
|
+
# Notify start with both counts
|
|
85
|
+
if @formatter.respond_to?(:on_start_with_totals)
|
|
86
|
+
@formatter.on_start_with_totals(test_files.size, total_available)
|
|
87
|
+
else
|
|
88
|
+
@formatter.on_start(test_files.size)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Execute tests
|
|
92
|
+
execution_result = execute_tests(test_files)
|
|
93
|
+
|
|
94
|
+
# Check if execution failed with LoadError (stdout is empty means tests didn't run)
|
|
95
|
+
@parsed_result = if !execution_result[:success] && execution_result[:stdout].to_s.empty? && execution_result[:stderr] && !execution_result[:stderr].empty?
|
|
96
|
+
# Handle load errors or other failures that prevented test execution
|
|
97
|
+
{
|
|
98
|
+
summary: {
|
|
99
|
+
runs: 0,
|
|
100
|
+
assertions: 0,
|
|
101
|
+
failures: 0,
|
|
102
|
+
errors: test_files.size, # Count all test files as errors
|
|
103
|
+
skips: 0,
|
|
104
|
+
passed: 0
|
|
105
|
+
},
|
|
106
|
+
failures: [],
|
|
107
|
+
errors: [{
|
|
108
|
+
message: execution_result[:stderr].strip,
|
|
109
|
+
type: "LoadError",
|
|
110
|
+
files: test_files
|
|
111
|
+
}],
|
|
112
|
+
deprecations: [],
|
|
113
|
+
duration: execution_result[:duration]
|
|
114
|
+
}
|
|
115
|
+
elsif execution_result[:commands] && execution_result[:commands].is_a?(Array)
|
|
116
|
+
# Each file was executed separately, parse and sum them all
|
|
117
|
+
aggregate_individual_results(execution_result[:stdout])
|
|
118
|
+
else
|
|
119
|
+
# Single command execution (grouped)
|
|
120
|
+
@result_parser.parse_output(execution_result[:stdout])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Build result object
|
|
124
|
+
@result = build_result(@parsed_result, execution_result, start_time)
|
|
125
|
+
|
|
126
|
+
# Analyze failures and errors
|
|
127
|
+
if @result.has_failures?
|
|
128
|
+
# Collect both failures and errors for analysis
|
|
129
|
+
all_failures = @parsed_result[:failures] || []
|
|
130
|
+
|
|
131
|
+
# Convert errors to failure format if present
|
|
132
|
+
if @parsed_result[:errors] && @parsed_result[:errors].any?
|
|
133
|
+
error_failures = @parsed_result[:errors].map do |error|
|
|
134
|
+
{
|
|
135
|
+
type: :error,
|
|
136
|
+
test_name: error[:type] || "LoadError",
|
|
137
|
+
message: error[:message] || "Unknown error",
|
|
138
|
+
location: nil,
|
|
139
|
+
full_content: error[:message] || "Unknown error",
|
|
140
|
+
files: error[:files]
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
all_failures += error_failures
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
analyzed_failures = @failure_analyzer.analyze_all(
|
|
147
|
+
all_failures,
|
|
148
|
+
stderr: @result.stderr
|
|
149
|
+
)
|
|
150
|
+
@result.failures_detail = analyzed_failures
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Generate and save report
|
|
154
|
+
report = @report_generator.generate(@result, test_files)
|
|
155
|
+
|
|
156
|
+
# Save reports if configured
|
|
157
|
+
if @configuration.save_reports
|
|
158
|
+
report_path = save_reports(report)
|
|
159
|
+
# Pass report path to formatter before outputting
|
|
160
|
+
@formatter.report_path = report_path if @formatter.respond_to?(:report_path=)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Output to stdout
|
|
164
|
+
@formatter.on_finish(@result)
|
|
165
|
+
|
|
166
|
+
# Display profile results if requested
|
|
167
|
+
if @configuration.profile && @parsed_result[:test_times] && !@parsed_result[:test_times].empty?
|
|
168
|
+
display_profile(@parsed_result[:test_times], @configuration.profile)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Return exit code
|
|
172
|
+
@result.success? ? 0 : 1
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def setup_components
|
|
178
|
+
@configuration = build_configuration(@options)
|
|
179
|
+
@pattern_resolver = Molecules::PatternResolver.new(@configuration)
|
|
180
|
+
@test_detector = Atoms::TestDetector.new(patterns: @configuration.patterns)
|
|
181
|
+
|
|
182
|
+
# Use SmartTestExecutor for intelligent subprocess/direct execution choice
|
|
183
|
+
require_relative "../molecules/smart_test_executor"
|
|
184
|
+
force_mode = if @options[:direct]
|
|
185
|
+
:direct
|
|
186
|
+
else
|
|
187
|
+
(@options[:subprocess] ? :subprocess : nil)
|
|
188
|
+
end
|
|
189
|
+
@test_executor = Molecules::SmartTestExecutor.new(
|
|
190
|
+
timeout: @configuration.timeout,
|
|
191
|
+
force_mode: force_mode
|
|
192
|
+
)
|
|
193
|
+
@result_parser = Atoms::ResultParser.new
|
|
194
|
+
@failure_analyzer = Molecules::FailureAnalyzer.new
|
|
195
|
+
@report_generator = ReportGenerator.new(@configuration)
|
|
196
|
+
|
|
197
|
+
# Initialize formatter - will be updated after pattern resolution
|
|
198
|
+
formatter_options = @configuration.to_h
|
|
199
|
+
if @configuration.failure_limits
|
|
200
|
+
formatter_options[:max_failures_to_display] = @configuration.failure_limits[:max_display]
|
|
201
|
+
end
|
|
202
|
+
# Disable group headers in on_test_complete to avoid duplicates with on_group_start/complete
|
|
203
|
+
formatter_options[:show_groups] = false
|
|
204
|
+
@formatter = @configuration.formatter_class.new(formatter_options)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def run_sequential_groups(start_time)
|
|
208
|
+
# Resolve groups sequentially
|
|
209
|
+
groups = @pattern_resolver.resolve_group_sequential(@configuration.target)
|
|
210
|
+
|
|
211
|
+
if groups.empty?
|
|
212
|
+
return handle_no_tests
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Count total files
|
|
216
|
+
all_files = groups.flat_map { |g| g[:files] }
|
|
217
|
+
|
|
218
|
+
resolve_and_set_report_dir_context(all_files)
|
|
219
|
+
|
|
220
|
+
total_available = count_total_test_files
|
|
221
|
+
|
|
222
|
+
# Notify start
|
|
223
|
+
if @formatter.respond_to?(:on_start_with_totals)
|
|
224
|
+
@formatter.on_start_with_totals(all_files.size, total_available)
|
|
225
|
+
else
|
|
226
|
+
@formatter.on_start(all_files.size)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Create sequential executor
|
|
230
|
+
executor = SequentialGroupExecutor.new(
|
|
231
|
+
test_executor: @test_executor,
|
|
232
|
+
result_parser: @result_parser,
|
|
233
|
+
formatter: @formatter
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Execute groups sequentially
|
|
237
|
+
execution_result = executor.execute_groups(groups, sequential_options) do |event|
|
|
238
|
+
case event[:type]
|
|
239
|
+
when :stdout
|
|
240
|
+
@formatter.on_test_stdout(event[:content]) if @formatter.respond_to?(:on_test_stdout)
|
|
241
|
+
when :complete
|
|
242
|
+
if @formatter.respond_to?(:on_test_complete)
|
|
243
|
+
@formatter.on_test_complete(
|
|
244
|
+
event[:file],
|
|
245
|
+
event[:success],
|
|
246
|
+
event[:duration]
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Use the parsed result from sequential executor
|
|
253
|
+
@parsed_result = execution_result[:parsed_result]
|
|
254
|
+
|
|
255
|
+
# Build result object
|
|
256
|
+
@result = build_result(@parsed_result, execution_result, start_time)
|
|
257
|
+
|
|
258
|
+
# Analyze failures
|
|
259
|
+
if @result.has_failures?
|
|
260
|
+
all_failures = @parsed_result[:failures] || []
|
|
261
|
+
|
|
262
|
+
if @parsed_result[:errors] && @parsed_result[:errors].any?
|
|
263
|
+
error_failures = @parsed_result[:errors].map do |error|
|
|
264
|
+
{
|
|
265
|
+
type: :error,
|
|
266
|
+
test_name: error[:type] || "LoadError",
|
|
267
|
+
message: error[:message] || "Unknown error",
|
|
268
|
+
location: nil,
|
|
269
|
+
full_content: error[:message] || "Unknown error",
|
|
270
|
+
files: error[:files]
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
all_failures += error_failures
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
analyzed_failures = @failure_analyzer.analyze_all(
|
|
277
|
+
all_failures,
|
|
278
|
+
stderr: @result.stderr
|
|
279
|
+
)
|
|
280
|
+
@result.failures_detail = analyzed_failures
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Generate and save report
|
|
284
|
+
report = @report_generator.generate(@result, all_files)
|
|
285
|
+
|
|
286
|
+
if @configuration.save_reports
|
|
287
|
+
report_path = save_reports(report)
|
|
288
|
+
@formatter.report_path = report_path if @formatter.respond_to?(:report_path=)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Output to stdout
|
|
292
|
+
@formatter.on_finish(@result)
|
|
293
|
+
|
|
294
|
+
# Display profile results if requested
|
|
295
|
+
if @configuration.profile && @parsed_result[:test_times] && !@parsed_result[:test_times].empty?
|
|
296
|
+
display_profile(@parsed_result[:test_times], @configuration.profile)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Show stopped message if execution was stopped
|
|
300
|
+
if execution_result[:stopped_at_group]
|
|
301
|
+
puts "\nSTOPPED: Group '#{execution_result[:stopped_at_group]}' failed (--fail-fast enabled)"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Return exit code
|
|
305
|
+
@result.success? ? 0 : 1
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def should_execute_sequentially?
|
|
309
|
+
# If explicit files are provided via CLI, bypass group execution
|
|
310
|
+
# This ensures that commands like `ace-test test/atoms/foo_test.rb` run only that file
|
|
311
|
+
return false if @configuration.files && !@configuration.files.empty?
|
|
312
|
+
|
|
313
|
+
# Only use sequential groups if execution_mode is "grouped"
|
|
314
|
+
return false unless @configuration.execution_mode == "grouped"
|
|
315
|
+
|
|
316
|
+
# Explicit --run-in-single-batch bypasses grouped execution
|
|
317
|
+
return false if @configuration.run_in_single_batch
|
|
318
|
+
|
|
319
|
+
# When profiling without a specific target, run as single batch for accurate timing
|
|
320
|
+
# Profiling requires verbose output which works better with all-at-once execution
|
|
321
|
+
if @configuration.profile && !@configuration.target
|
|
322
|
+
return false
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# If no target specified, default to "all" group for grouped mode
|
|
326
|
+
target = @configuration.target || "all"
|
|
327
|
+
|
|
328
|
+
target_str = target.to_s
|
|
329
|
+
target_sym = target.to_sym
|
|
330
|
+
|
|
331
|
+
# Check both string and symbol keys for compatibility
|
|
332
|
+
@configuration.groups&.key?(target_str) || @configuration.groups&.key?(target_sym)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def sequential_options
|
|
336
|
+
{
|
|
337
|
+
fail_fast: @configuration.fail_fast,
|
|
338
|
+
verbose: @configuration.verbose,
|
|
339
|
+
per_file: @configuration.per_file,
|
|
340
|
+
profile: @configuration.profile,
|
|
341
|
+
group_fail_fast: @configuration.execution&.[](:group_fail_fast),
|
|
342
|
+
group_isolation: @configuration.group_isolation
|
|
343
|
+
}
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def build_configuration(options)
|
|
347
|
+
# Load configuration from file
|
|
348
|
+
config_loader = Molecules::ConfigLoader.new
|
|
349
|
+
config_data = config_loader.load(options[:config_path])
|
|
350
|
+
|
|
351
|
+
# Merge with command-line options
|
|
352
|
+
config_with_options = config_loader.merge_with_options(config_data, options)
|
|
353
|
+
|
|
354
|
+
# Create TestConfiguration with merged data
|
|
355
|
+
Models::TestConfiguration.new(
|
|
356
|
+
format: config_with_options.defaults[:reporter] || options[:format],
|
|
357
|
+
report_dir: options[:report_dir] || config_with_options.defaults[:report_dir],
|
|
358
|
+
save_reports: config_with_options.defaults[:save_reports] != false && options[:save_reports] != false,
|
|
359
|
+
fail_fast: config_with_options.defaults[:fail_fast] || options[:fail_fast],
|
|
360
|
+
verbose: options[:verbose],
|
|
361
|
+
filter: options[:filter],
|
|
362
|
+
fix_deprecations: options[:fix_deprecations],
|
|
363
|
+
patterns: config_with_options.patterns,
|
|
364
|
+
groups: config_with_options.groups,
|
|
365
|
+
target: options[:target],
|
|
366
|
+
config_path: options[:config_path],
|
|
367
|
+
timeout: options[:timeout],
|
|
368
|
+
parallel: options[:parallel],
|
|
369
|
+
color: (config_with_options.defaults[:color] == "auto") ? true : config_with_options.defaults[:color],
|
|
370
|
+
per_file: options[:per_file],
|
|
371
|
+
failure_limits: config_with_options.failure_limits,
|
|
372
|
+
profile: options[:profile],
|
|
373
|
+
execution: config_with_options.execution || {},
|
|
374
|
+
files: options[:files],
|
|
375
|
+
run_in_single_batch: options[:run_in_single_batch]
|
|
376
|
+
)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def validate_configuration!
|
|
380
|
+
@configuration.validate!
|
|
381
|
+
rescue ArgumentError => e
|
|
382
|
+
puts "Configuration error: #{e.message}"
|
|
383
|
+
exit 1
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def find_test_files
|
|
387
|
+
# If specific files are provided (e.g., from command line), use them directly
|
|
388
|
+
if @configuration.files && !@configuration.files.empty?
|
|
389
|
+
files = @configuration.files
|
|
390
|
+
|
|
391
|
+
# Don't apply filter when files contain line numbers (file:line format)
|
|
392
|
+
has_line_numbers = files.any? { |f| f.match?(/:\d+$/) }
|
|
393
|
+
|
|
394
|
+
# Apply filter only if no line numbers and filter is provided
|
|
395
|
+
if @configuration.filter && !has_line_numbers
|
|
396
|
+
files = @test_detector.filter_by_pattern(files, @configuration.filter)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
return files
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Otherwise, use PatternResolver for consistency
|
|
403
|
+
begin
|
|
404
|
+
files = if @configuration.target
|
|
405
|
+
@pattern_resolver.resolve_target(@configuration.target)
|
|
406
|
+
else
|
|
407
|
+
# When no target specified, resolve all files
|
|
408
|
+
@pattern_resolver.resolve_target(nil)
|
|
409
|
+
end
|
|
410
|
+
rescue ArgumentError => e
|
|
411
|
+
puts "Error: #{e.message}"
|
|
412
|
+
puts "Available targets: #{@pattern_resolver.available_targets.join(", ")}"
|
|
413
|
+
exit 1
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# If using catch-all pattern, reinitialize formatter without groups
|
|
417
|
+
if @pattern_resolver.using_catch_all
|
|
418
|
+
formatter_options = @configuration.to_h.merge(show_groups: false)
|
|
419
|
+
if @configuration.failure_limits
|
|
420
|
+
formatter_options[:max_failures_to_display] = @configuration.failure_limits[:max_display]
|
|
421
|
+
end
|
|
422
|
+
@formatter = @configuration.formatter_class.new(formatter_options)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Apply filter if provided
|
|
426
|
+
if @configuration.filter
|
|
427
|
+
files = @test_detector.filter_by_pattern(files, @configuration.filter)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
files
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def handle_no_tests
|
|
434
|
+
@result = Models::TestResult.new
|
|
435
|
+
|
|
436
|
+
message = if @configuration.filter
|
|
437
|
+
"No test files found matching pattern '#{@configuration.filter}'"
|
|
438
|
+
else
|
|
439
|
+
"No test files found"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
puts message
|
|
443
|
+
0
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def count_total_test_files
|
|
447
|
+
# Count all test files in test directory regardless of configuration
|
|
448
|
+
Dir.glob("test/**/*_test.rb").select { |f| File.file?(f) }.size
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def execute_tests(test_files)
|
|
452
|
+
options = {
|
|
453
|
+
fail_fast: @configuration.fail_fast,
|
|
454
|
+
verbose: @configuration.verbose,
|
|
455
|
+
per_file: @configuration.per_file, # Allow per-file execution if needed for debugging
|
|
456
|
+
profile: @configuration.profile, # Add profile option for verbose timing
|
|
457
|
+
group_isolation: @configuration.group_isolation # Pass through for execution mode selection
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
# Always use execute_with_progress for consistent interface
|
|
461
|
+
# The method internally decides whether to run per-file or grouped
|
|
462
|
+
@test_executor.execute_with_progress(test_files, options) do |event|
|
|
463
|
+
case event[:type]
|
|
464
|
+
when :stdout
|
|
465
|
+
# Pass stdout to formatter for per-test progress
|
|
466
|
+
@formatter.on_test_stdout(event[:content]) if @formatter.respond_to?(:on_test_stdout)
|
|
467
|
+
when :complete
|
|
468
|
+
if @formatter.respond_to?(:on_test_complete)
|
|
469
|
+
@formatter.on_test_complete(
|
|
470
|
+
event[:file],
|
|
471
|
+
event[:success],
|
|
472
|
+
event[:duration]
|
|
473
|
+
)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def build_result(parsed_result, execution_result, start_time)
|
|
480
|
+
Models::TestResult.new(
|
|
481
|
+
passed: parsed_result[:summary][:passed],
|
|
482
|
+
failed: parsed_result[:summary][:failures],
|
|
483
|
+
errors: parsed_result[:summary][:errors],
|
|
484
|
+
skipped: parsed_result[:summary][:skips],
|
|
485
|
+
assertions: parsed_result[:summary][:assertions],
|
|
486
|
+
duration: parsed_result[:duration] || execution_result[:duration],
|
|
487
|
+
start_time: start_time,
|
|
488
|
+
end_time: Time.now,
|
|
489
|
+
deprecations: parsed_result[:deprecations],
|
|
490
|
+
raw_output: execution_result[:stdout],
|
|
491
|
+
stderr: execution_result[:stderr]
|
|
492
|
+
)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def aggregate_individual_results(combined_output)
|
|
496
|
+
# Split output by test file executions
|
|
497
|
+
individual_outputs = combined_output.split(/^Started with run options/)
|
|
498
|
+
individual_outputs.shift if individual_outputs.first && individual_outputs.first.empty?
|
|
499
|
+
|
|
500
|
+
aggregated = {
|
|
501
|
+
raw_output: combined_output,
|
|
502
|
+
summary: {
|
|
503
|
+
runs: 0,
|
|
504
|
+
assertions: 0,
|
|
505
|
+
failures: 0,
|
|
506
|
+
errors: 0,
|
|
507
|
+
skips: 0,
|
|
508
|
+
passed: 0
|
|
509
|
+
},
|
|
510
|
+
failures: [],
|
|
511
|
+
duration: 0.0,
|
|
512
|
+
deprecations: [],
|
|
513
|
+
test_times: []
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
individual_outputs.each do |output|
|
|
517
|
+
output = "Started with run options" + output # Restore the split text
|
|
518
|
+
parsed = @result_parser.parse_output(output)
|
|
519
|
+
|
|
520
|
+
# Sum up the counts
|
|
521
|
+
aggregated[:summary][:runs] += parsed[:summary][:runs]
|
|
522
|
+
aggregated[:summary][:assertions] += parsed[:summary][:assertions]
|
|
523
|
+
aggregated[:summary][:failures] += parsed[:summary][:failures]
|
|
524
|
+
aggregated[:summary][:errors] += parsed[:summary][:errors]
|
|
525
|
+
aggregated[:summary][:skips] += parsed[:summary][:skips]
|
|
526
|
+
aggregated[:summary][:passed] += parsed[:summary][:passed]
|
|
527
|
+
|
|
528
|
+
# Collect failures and deprecations
|
|
529
|
+
aggregated[:failures].concat(parsed[:failures])
|
|
530
|
+
aggregated[:deprecations].concat(parsed[:deprecations])
|
|
531
|
+
aggregated[:duration] += parsed[:duration]
|
|
532
|
+
|
|
533
|
+
# Collect test times if available
|
|
534
|
+
aggregated[:test_times].concat(parsed[:test_times]) if parsed[:test_times]
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Re-sort test times by duration
|
|
538
|
+
aggregated[:test_times].sort_by! { |t| -t[:duration] } if aggregated[:test_times]
|
|
539
|
+
|
|
540
|
+
aggregated
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def resolve_and_set_report_dir_context(test_files)
|
|
544
|
+
explicit_cli_override = @report_dir_override == :user_specified
|
|
545
|
+
|
|
546
|
+
report_root_start_path = Dir.pwd
|
|
547
|
+
if explicit_cli_override && @package_dir
|
|
548
|
+
report_root_start_path = @original_dir
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
report_root = Atoms::ReportDirectoryResolver.resolve_report_root(
|
|
552
|
+
@configuration.report_dir,
|
|
553
|
+
explicit_cli_override: explicit_cli_override,
|
|
554
|
+
start_path: report_root_start_path
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if explicit_cli_override
|
|
558
|
+
@configuration.report_dir = report_root
|
|
559
|
+
return
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
package_name = Atoms::ReportDirectoryResolver.infer_package_name(
|
|
563
|
+
package_dir: @package_dir,
|
|
564
|
+
test_files: test_files,
|
|
565
|
+
cwd: Dir.pwd
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
@configuration.report_dir = Atoms::ReportDirectoryResolver.resolve_package_report_dir(
|
|
569
|
+
report_root: report_root,
|
|
570
|
+
package_name: package_name
|
|
571
|
+
)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def save_reports(report)
|
|
575
|
+
timestamp_generator = Atoms::TimestampGenerator.new
|
|
576
|
+
storage = Molecules::ReportStorage.new(
|
|
577
|
+
base_dir: @configuration.report_dir,
|
|
578
|
+
timestamp_generator: timestamp_generator
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
# Save in appropriate format
|
|
582
|
+
report_path = case @configuration.format
|
|
583
|
+
when "json"
|
|
584
|
+
storage.save_report(report, format: :json)
|
|
585
|
+
else
|
|
586
|
+
storage.save_report(report, format: :all)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Always save raw output
|
|
590
|
+
storage.save_raw_output(@result.raw_output, report_path)
|
|
591
|
+
|
|
592
|
+
# Save stderr if present
|
|
593
|
+
storage.save_stderr(@result.stderr, report_path) if @result.stderr && !@result.stderr.empty?
|
|
594
|
+
|
|
595
|
+
# Save individual failure reports if there are failures
|
|
596
|
+
if @result.has_failures? && @configuration.format != "json"
|
|
597
|
+
markdown_formatter_class = Atoms::LazyLoader.load_formatter("markdown")
|
|
598
|
+
markdown_formatter = markdown_formatter_class.new(@configuration.to_h)
|
|
599
|
+
max_display = @configuration.failure_limits ? @configuration.failure_limits[:max_display] : nil
|
|
600
|
+
storage.save_individual_failure_reports(
|
|
601
|
+
@result.failures_detail,
|
|
602
|
+
report_path,
|
|
603
|
+
markdown_formatter,
|
|
604
|
+
max_display: max_display
|
|
605
|
+
)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
report.report_path = report_path
|
|
609
|
+
report_path
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def display_profile(test_times, count)
|
|
613
|
+
return if test_times.empty?
|
|
614
|
+
|
|
615
|
+
# Take only the slowest N tests
|
|
616
|
+
slowest = test_times.first(count)
|
|
617
|
+
|
|
618
|
+
puts "\n" + "=" * 60
|
|
619
|
+
puts "Slowest Tests (Top #{[count, test_times.size].min})"
|
|
620
|
+
puts "=" * 60
|
|
621
|
+
|
|
622
|
+
slowest.each_with_index do |test, index|
|
|
623
|
+
# Format duration nicely
|
|
624
|
+
duration = format("%.3fs", test[:duration])
|
|
625
|
+
|
|
626
|
+
# Try to shorten the file path if location is available
|
|
627
|
+
location = if test[:location]
|
|
628
|
+
test[:location].gsub(/^.*\/test\//, "test/")
|
|
629
|
+
else
|
|
630
|
+
"unknown location"
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
puts format("%2d. %-50s %8s",
|
|
634
|
+
index + 1,
|
|
635
|
+
test[:name][0..49], # Truncate long test names
|
|
636
|
+
duration)
|
|
637
|
+
puts " #{location}" if test[:location]
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Show total time for all tests
|
|
641
|
+
total_time = test_times.sum { |t| t[:duration] }
|
|
642
|
+
puts "-" * 60
|
|
643
|
+
puts format("Total time in tests: %.3fs", total_time)
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
end
|