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,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module TestRunner
|
|
5
|
+
module Molecules
|
|
6
|
+
# Analyzes test failures and provides insights
|
|
7
|
+
class FailureAnalyzer
|
|
8
|
+
COMMON_PATTERNS = {
|
|
9
|
+
assertion: {
|
|
10
|
+
pattern: /Expected: (.+)\n\s+Actual: (.+)/m,
|
|
11
|
+
suggestion: "Check the assertion values. Expected and actual don't match."
|
|
12
|
+
},
|
|
13
|
+
nil_error: {
|
|
14
|
+
pattern: /undefined method .+ for nil:NilClass/,
|
|
15
|
+
suggestion: "Object is nil. Add nil check or ensure object is initialized."
|
|
16
|
+
},
|
|
17
|
+
missing_constant: {
|
|
18
|
+
pattern: /uninitialized constant (\S+)/,
|
|
19
|
+
suggestion: "Class or module not found. Check requires/imports or class name spelling."
|
|
20
|
+
},
|
|
21
|
+
argument_error: {
|
|
22
|
+
pattern: /wrong number of arguments \(given (\d+), expected (\d+)\)/,
|
|
23
|
+
suggestion: "Method called with wrong number of arguments. Check method signature."
|
|
24
|
+
},
|
|
25
|
+
file_not_found: {
|
|
26
|
+
pattern: /No such file or directory/,
|
|
27
|
+
suggestion: "File doesn't exist. Check file path or create the file."
|
|
28
|
+
},
|
|
29
|
+
syntax_error: {
|
|
30
|
+
pattern: /syntax error/,
|
|
31
|
+
suggestion: "Ruby syntax error. Check for missing brackets, quotes, or keywords."
|
|
32
|
+
},
|
|
33
|
+
timeout: {
|
|
34
|
+
pattern: /execution expired|timeout/i,
|
|
35
|
+
suggestion: "Operation timed out. Consider increasing timeout or optimizing slow code."
|
|
36
|
+
}
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
def analyze_failure(failure_data)
|
|
40
|
+
return failure_data unless failure_data.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
message = failure_data[:message] || failure_data[:full_content] || ""
|
|
43
|
+
|
|
44
|
+
# Try to extract test class and method
|
|
45
|
+
if failure_data[:test_name] && failure_data[:test_name].include?("#")
|
|
46
|
+
parts = failure_data[:test_name].split("#")
|
|
47
|
+
failure_data[:test_class] = parts[0]
|
|
48
|
+
failure_data[:test_name] = parts[1]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Analyze the error message for common patterns
|
|
52
|
+
suggestion = find_suggestion(message)
|
|
53
|
+
failure_data[:fix_suggestion] = suggestion if suggestion
|
|
54
|
+
|
|
55
|
+
# Extract file and line from location if available
|
|
56
|
+
if failure_data[:location] && failure_data[:location].is_a?(Hash)
|
|
57
|
+
failure_data[:file_path] = failure_data[:location][:file]
|
|
58
|
+
failure_data[:line_number] = failure_data[:location][:line]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Extract code context if file and line are available
|
|
62
|
+
if failure_data[:file_path] && failure_data[:line_number] && File.exist?(failure_data[:file_path])
|
|
63
|
+
failure_data[:code_context] = extract_code_context(
|
|
64
|
+
failure_data[:file_path],
|
|
65
|
+
failure_data[:line_number]
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Clean and format backtrace
|
|
70
|
+
if failure_data[:backtrace]
|
|
71
|
+
failure_data[:formatted_backtrace] = format_backtrace(failure_data[:backtrace])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Models::TestFailure.new(failure_data)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def analyze_all(failures, stderr: nil)
|
|
78
|
+
return [] unless failures.is_a?(Array)
|
|
79
|
+
|
|
80
|
+
failures.map do |failure|
|
|
81
|
+
analyzed = analyze_failure(failure)
|
|
82
|
+
# Associate stderr with all failures if present
|
|
83
|
+
analyzed.stderr_warnings = stderr if stderr && !stderr.empty?
|
|
84
|
+
analyzed
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def group_by_type(failures)
|
|
89
|
+
failures.group_by(&:type)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def group_by_file(failures)
|
|
93
|
+
failures.group_by(&:file_path).compact
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def find_common_issues(failures)
|
|
97
|
+
issues = {}
|
|
98
|
+
|
|
99
|
+
# Count occurrences of each type of error
|
|
100
|
+
failures.each do |failure|
|
|
101
|
+
COMMON_PATTERNS.each do |issue_type, config|
|
|
102
|
+
if failure.message&.match?(config[:pattern])
|
|
103
|
+
issues[issue_type] ||= {count: 0, failures: [], suggestion: config[:suggestion]}
|
|
104
|
+
issues[issue_type][:count] += 1
|
|
105
|
+
issues[issue_type][:failures] << failure
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
issues.sort_by { |_, v| -v[:count] }.to_h
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def generate_fix_script(failures)
|
|
114
|
+
fixes = []
|
|
115
|
+
|
|
116
|
+
failures.each do |failure|
|
|
117
|
+
if failure.message&.match?(/DEPRECATION WARNING/)
|
|
118
|
+
fixes << generate_deprecation_fix(failure)
|
|
119
|
+
elsif failure.message&.match?(/undefined method/)
|
|
120
|
+
fixes << generate_method_fix(failure)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
fixes.compact
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def extract_code_context(file_path, line_number, radius = 5)
|
|
128
|
+
return nil unless File.exist?(file_path)
|
|
129
|
+
|
|
130
|
+
lines = File.readlines(file_path)
|
|
131
|
+
total_lines = lines.size
|
|
132
|
+
center_line = line_number.to_i
|
|
133
|
+
|
|
134
|
+
# Calculate the range of lines to include
|
|
135
|
+
start_line = [center_line - radius, 1].max
|
|
136
|
+
end_line = [center_line + radius, total_lines].min
|
|
137
|
+
|
|
138
|
+
context = {
|
|
139
|
+
file: file_path,
|
|
140
|
+
center_line: center_line,
|
|
141
|
+
lines: {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
(start_line..end_line).each do |line_num|
|
|
145
|
+
line_content = lines[line_num - 1]
|
|
146
|
+
context[:lines][line_num] = {
|
|
147
|
+
content: line_content.chomp,
|
|
148
|
+
highlighted: line_num == center_line
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
context
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def format_backtrace(backtrace)
|
|
156
|
+
return [] unless backtrace
|
|
157
|
+
|
|
158
|
+
# Convert to array if it's a string
|
|
159
|
+
trace_lines = backtrace.is_a?(String) ? backtrace.split("\n") : backtrace
|
|
160
|
+
|
|
161
|
+
formatted = []
|
|
162
|
+
project_root = Dir.pwd
|
|
163
|
+
|
|
164
|
+
trace_lines.each do |line|
|
|
165
|
+
# Clean up the line
|
|
166
|
+
clean_line = line.strip
|
|
167
|
+
|
|
168
|
+
# Skip minitest internal frames unless in verbose mode
|
|
169
|
+
next if clean_line.include?("/minitest/") && !@verbose
|
|
170
|
+
next if clean_line.include?("/bundler/") && !@verbose
|
|
171
|
+
|
|
172
|
+
# Parse the backtrace line
|
|
173
|
+
if clean_line =~ /^(.+):(\d+):in `(.+)'$/
|
|
174
|
+
file = $1
|
|
175
|
+
line_num = $2
|
|
176
|
+
method = $3
|
|
177
|
+
|
|
178
|
+
# Make paths relative to project root
|
|
179
|
+
relative_file = file.start_with?(project_root) ?
|
|
180
|
+
file.sub(project_root + "/", "") : file
|
|
181
|
+
|
|
182
|
+
formatted << {
|
|
183
|
+
file: relative_file,
|
|
184
|
+
line: line_num.to_i,
|
|
185
|
+
method: method,
|
|
186
|
+
in_project: file.start_with?(project_root)
|
|
187
|
+
}
|
|
188
|
+
else
|
|
189
|
+
# Keep unparseable lines as-is
|
|
190
|
+
formatted << {raw: clean_line}
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
formatted
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
def find_suggestion(message)
|
|
200
|
+
COMMON_PATTERNS.each do |_, config|
|
|
201
|
+
return config[:suggestion] if message.match?(config[:pattern])
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Generic suggestions based on keywords
|
|
205
|
+
case message
|
|
206
|
+
when /permission denied/i
|
|
207
|
+
"Check file/directory permissions"
|
|
208
|
+
when /connection refused/i
|
|
209
|
+
"Service not running or wrong connection settings"
|
|
210
|
+
when /invalid/i
|
|
211
|
+
"Check input validation and data format"
|
|
212
|
+
when /not found/i
|
|
213
|
+
"Resource doesn't exist. Check paths and names"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def generate_deprecation_fix(failure)
|
|
218
|
+
{
|
|
219
|
+
file: failure.file_path,
|
|
220
|
+
line: failure.line_number,
|
|
221
|
+
type: :deprecation,
|
|
222
|
+
fix: extract_deprecation_fix(failure.message)
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def generate_method_fix(failure)
|
|
227
|
+
if failure.message =~ /undefined method `(.+)' for/
|
|
228
|
+
method = $1
|
|
229
|
+
{
|
|
230
|
+
file: failure.file_path,
|
|
231
|
+
line: failure.line_number,
|
|
232
|
+
type: :missing_method,
|
|
233
|
+
fix: "Define method '#{method}' or check spelling"
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def extract_deprecation_fix(message)
|
|
239
|
+
# Look for "use X instead" patterns
|
|
240
|
+
if message =~ /use (.+) instead/i
|
|
241
|
+
"Replace with: #{$1}"
|
|
242
|
+
else
|
|
243
|
+
"Update deprecated code"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "ostruct"
|
|
5
|
+
require "minitest"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module TestRunner
|
|
9
|
+
module Molecules
|
|
10
|
+
# Runs tests directly in the current Ruby process without spawning subprocesses
|
|
11
|
+
# This provides significantly faster execution for unit tests that don't need isolation
|
|
12
|
+
class InProcessRunner
|
|
13
|
+
def initialize(timeout: nil)
|
|
14
|
+
@timeout = timeout
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def execute_tests(files, options = {})
|
|
18
|
+
return empty_result if files.empty?
|
|
19
|
+
|
|
20
|
+
start_time = Time.now
|
|
21
|
+
|
|
22
|
+
# Capture stdout/stderr
|
|
23
|
+
original_stdout = $stdout
|
|
24
|
+
original_stderr = $stderr
|
|
25
|
+
stdout_io = StringIO.new
|
|
26
|
+
stderr_io = StringIO.new
|
|
27
|
+
|
|
28
|
+
# Store original verbose setting
|
|
29
|
+
original_verbose = $VERBOSE
|
|
30
|
+
original_mt_no_autorun = ENV["MT_NO_AUTORUN"]
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
$stdout = stdout_io
|
|
34
|
+
$stderr = stderr_io
|
|
35
|
+
$VERBOSE = nil if options[:suppress_warnings]
|
|
36
|
+
|
|
37
|
+
# Prevent Minitest from auto-running
|
|
38
|
+
ENV["MT_NO_AUTORUN"] = "1"
|
|
39
|
+
|
|
40
|
+
# Add test directory to load path if not already there
|
|
41
|
+
test_dir = File.expand_path("test")
|
|
42
|
+
lib_dir = File.expand_path("lib")
|
|
43
|
+
$LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
|
|
44
|
+
$LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
|
|
45
|
+
|
|
46
|
+
# Only require minitest/autorun if not already loaded
|
|
47
|
+
# This prevents double runs when ace/test_support is loaded
|
|
48
|
+
unless defined?(Minitest.autorun)
|
|
49
|
+
require "minitest/autorun"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# First pass: check for line numbers and resolve test names
|
|
53
|
+
# This must be done before loading files
|
|
54
|
+
test_names_to_run = []
|
|
55
|
+
files_to_load = []
|
|
56
|
+
|
|
57
|
+
files.each do |file|
|
|
58
|
+
# Check if file has line number (file:line format)
|
|
59
|
+
if file =~ /^(.+):(\d+)$/
|
|
60
|
+
actual_file = $1
|
|
61
|
+
line_number = $2.to_i
|
|
62
|
+
|
|
63
|
+
# Resolve line number to test name
|
|
64
|
+
require_relative "../atoms/line_number_resolver"
|
|
65
|
+
test_name = Ace::TestRunner::Atoms::LineNumberResolver.resolve_test_at_line(actual_file, line_number)
|
|
66
|
+
test_names_to_run << test_name if test_name
|
|
67
|
+
|
|
68
|
+
files_to_load << actual_file
|
|
69
|
+
else
|
|
70
|
+
files_to_load << file
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Store test names in options to pass to run_minitest_with_args
|
|
75
|
+
options = options.merge(test_names_filter: test_names_to_run) if test_names_to_run.any?
|
|
76
|
+
|
|
77
|
+
# Clear previously loaded test classes to avoid accumulation between groups
|
|
78
|
+
# This is crucial for in-process execution where tests from previous groups
|
|
79
|
+
# would otherwise be re-run in subsequent groups
|
|
80
|
+
Minitest::Runnable.runnables.clear
|
|
81
|
+
|
|
82
|
+
# Setup Minitest::Reporters BEFORE loading test files
|
|
83
|
+
# For in-process mode, we need to handle reporter state carefully
|
|
84
|
+
# because Minitest::Reporters.use! only works properly on first call
|
|
85
|
+
require "minitest/reporters"
|
|
86
|
+
|
|
87
|
+
# Create a fresh reporter for this group
|
|
88
|
+
reporter = Minitest::Reporters::DefaultReporter.new(io: $stdout)
|
|
89
|
+
|
|
90
|
+
# If this isn't the first group, we need to replace the existing reporter
|
|
91
|
+
if Minitest.reporter && Minitest.reporter.reporters
|
|
92
|
+
$stdout.flush
|
|
93
|
+
Minitest.reporter.reporters.clear
|
|
94
|
+
Minitest.reporter.reporters << reporter
|
|
95
|
+
# Reset reporter state for the new group
|
|
96
|
+
reporter.start_time = nil
|
|
97
|
+
# NOTE: Known limitation - progress dots don't show for subsequent groups
|
|
98
|
+
# in in-process mode. This appears to be a Minitest::Reporters limitation
|
|
99
|
+
# where some internal state prevents proper output after the first run.
|
|
100
|
+
# Test counts and results are still accurate.
|
|
101
|
+
else
|
|
102
|
+
# First group - use the standard setup
|
|
103
|
+
Minitest::Reporters.use! reporter
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Load the test files
|
|
107
|
+
files_to_load.uniq.each do |file|
|
|
108
|
+
file_path = File.expand_path(file)
|
|
109
|
+
begin
|
|
110
|
+
load file_path
|
|
111
|
+
rescue LoadError => e
|
|
112
|
+
stderr_io.puts "Failed to load #{file}: #{e.message}"
|
|
113
|
+
# Re-raise to fail the entire test run
|
|
114
|
+
raise
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Run Minitest with captured output
|
|
119
|
+
# Suppress Minitest's own output by using null reporter
|
|
120
|
+
exit_code = if @timeout
|
|
121
|
+
Timeout.timeout(@timeout) do
|
|
122
|
+
run_minitest_silent(options)
|
|
123
|
+
end
|
|
124
|
+
else
|
|
125
|
+
run_minitest_silent(options)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
success = exit_code == true || exit_code == 0
|
|
129
|
+
rescue Timeout::Error
|
|
130
|
+
stderr_io.puts "Test execution timed out after #{@timeout} seconds"
|
|
131
|
+
success = false
|
|
132
|
+
exit_code = 124
|
|
133
|
+
rescue LoadError
|
|
134
|
+
# LoadError already logged in the loop above
|
|
135
|
+
stderr_io.puts "Test run aborted due to load error"
|
|
136
|
+
success = false
|
|
137
|
+
exit_code = 1
|
|
138
|
+
rescue => e
|
|
139
|
+
stderr_io.puts "Error running tests: #{e.message}"
|
|
140
|
+
stderr_io.puts e.backtrace.join("\n") if options[:verbose]
|
|
141
|
+
success = false
|
|
142
|
+
exit_code = 1
|
|
143
|
+
ensure
|
|
144
|
+
$stdout = original_stdout
|
|
145
|
+
$stderr = original_stderr
|
|
146
|
+
$VERBOSE = original_verbose
|
|
147
|
+
|
|
148
|
+
# Restore original MT_NO_AUTORUN value
|
|
149
|
+
if original_mt_no_autorun
|
|
150
|
+
ENV["MT_NO_AUTORUN"] = original_mt_no_autorun
|
|
151
|
+
else
|
|
152
|
+
ENV.delete("MT_NO_AUTORUN")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
end_time = Time.now
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
stdout: stdout_io.string,
|
|
160
|
+
stderr: stderr_io.string,
|
|
161
|
+
status: OpenStruct.new(success?: success, exitstatus: if exit_code.is_a?(Integer)
|
|
162
|
+
exit_code
|
|
163
|
+
else
|
|
164
|
+
(success ? 0 : 1)
|
|
165
|
+
end),
|
|
166
|
+
command: "in-process:#{files.join(",")}",
|
|
167
|
+
start_time: start_time,
|
|
168
|
+
end_time: end_time,
|
|
169
|
+
duration: end_time - start_time,
|
|
170
|
+
success: success
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def execute_single_file(file, options = {})
|
|
175
|
+
execute_tests([file], options)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def execute_with_progress(files, options = {}, &block)
|
|
179
|
+
# For in-process execution, we run all tests together for best performance
|
|
180
|
+
result = execute_tests(files, options)
|
|
181
|
+
|
|
182
|
+
# Send stdout event for per-test progress parsing
|
|
183
|
+
if block_given? && result[:stdout]
|
|
184
|
+
yield({type: :stdout, content: result[:stdout]})
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Simulate progress callbacks for compatibility
|
|
188
|
+
if block_given?
|
|
189
|
+
files.each { |file| yield({type: :start, file: file}) }
|
|
190
|
+
files.each { |file| yield({type: :complete, file: file, success: result[:success], duration: result[:duration] / files.size}) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
result
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def empty_result
|
|
199
|
+
{
|
|
200
|
+
stdout: "",
|
|
201
|
+
stderr: "No test files found",
|
|
202
|
+
status: OpenStruct.new(success?: true, exitstatus: 0),
|
|
203
|
+
command: "",
|
|
204
|
+
start_time: Time.now,
|
|
205
|
+
end_time: Time.now,
|
|
206
|
+
duration: 0.0,
|
|
207
|
+
success: true
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def run_minitest_with_args(options)
|
|
212
|
+
# Build Minitest arguments
|
|
213
|
+
args = []
|
|
214
|
+
args << "--seed" << options[:seed].to_s if options[:seed]
|
|
215
|
+
args << "--verbose" if options[:verbose]
|
|
216
|
+
|
|
217
|
+
# Add test name filter if line numbers were provided
|
|
218
|
+
if options[:test_names_filter] && options[:test_names_filter].any?
|
|
219
|
+
pattern = options[:test_names_filter].map { |name| Regexp.escape(name) }.join("|")
|
|
220
|
+
args << "--name" << "/#{pattern}/"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Run Minitest
|
|
224
|
+
# Returns true on success, false on failure
|
|
225
|
+
Minitest.run(args)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def run_minitest_silent(options)
|
|
229
|
+
# Minitest uses reporters that can bypass $stdout redirection
|
|
230
|
+
# We need to suppress Minitest's own output completely
|
|
231
|
+
|
|
232
|
+
# Build Minitest arguments
|
|
233
|
+
args = []
|
|
234
|
+
args << "--seed" << options[:seed].to_s if options[:seed]
|
|
235
|
+
|
|
236
|
+
# Add test name filter if line numbers were provided
|
|
237
|
+
if options[:test_names_filter] && options[:test_names_filter].any?
|
|
238
|
+
pattern = options[:test_names_filter].map { |name| Regexp.escape(name) }.join("|")
|
|
239
|
+
args << "--name" << "/#{pattern}/"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Reporter already set up before loading test files
|
|
243
|
+
# Run Minitest
|
|
244
|
+
Minitest.run(args)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "ace/support/fs"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module TestRunner
|
|
8
|
+
module Molecules
|
|
9
|
+
# Resolves package names or paths to absolute package directories
|
|
10
|
+
# Supports: package name (ace-bundle), relative path (./ace-bundle), absolute path
|
|
11
|
+
#
|
|
12
|
+
# Note: This class depends on ace-support-fs which provides ProjectRootFinder.
|
|
13
|
+
class PackageResolver
|
|
14
|
+
# Initialize resolver
|
|
15
|
+
# @param project_root [String, nil] Override project root (for testing)
|
|
16
|
+
def initialize(project_root: nil)
|
|
17
|
+
@project_root = project_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Resolve a package name or path to an absolute directory path
|
|
21
|
+
# @param name_or_path [String] Package name, relative path, or absolute path
|
|
22
|
+
# @return [String, nil] Absolute path to package directory, or nil if not found
|
|
23
|
+
def resolve(name_or_path)
|
|
24
|
+
return nil if name_or_path.nil? || name_or_path.empty?
|
|
25
|
+
|
|
26
|
+
path = if absolute_path?(name_or_path)
|
|
27
|
+
resolve_absolute(name_or_path)
|
|
28
|
+
elsif relative_path?(name_or_path)
|
|
29
|
+
resolve_relative(name_or_path)
|
|
30
|
+
else
|
|
31
|
+
resolve_by_name(name_or_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Validate the resolved path has a test directory
|
|
35
|
+
return nil unless path && valid_package?(path)
|
|
36
|
+
|
|
37
|
+
path
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# List all available packages in the mono-repo.
|
|
41
|
+
# Results are memoized since filesystem glob operations are relatively expensive.
|
|
42
|
+
# @return [Array<String>] List of package names
|
|
43
|
+
def available_packages
|
|
44
|
+
return [] unless @project_root
|
|
45
|
+
|
|
46
|
+
@available_packages ||= Dir.glob(File.join(@project_root, "ace-*"))
|
|
47
|
+
.select { |path| File.directory?(path) && has_test_directory?(path) }
|
|
48
|
+
.map { |path| File.basename(path) }
|
|
49
|
+
.sort
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get the project root
|
|
53
|
+
# @return [String, nil] Project root path
|
|
54
|
+
attr_reader :project_root
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def absolute_path?(path)
|
|
59
|
+
path.start_with?("/")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def relative_path?(path)
|
|
63
|
+
path == "." || path == ".." || path.start_with?("./") || path.start_with?("../")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resolve_absolute(path)
|
|
67
|
+
return nil unless Dir.exist?(path)
|
|
68
|
+
|
|
69
|
+
File.realpath(path)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def resolve_relative(path)
|
|
73
|
+
expanded = File.expand_path(path, Dir.pwd)
|
|
74
|
+
return nil unless Dir.exist?(expanded)
|
|
75
|
+
|
|
76
|
+
File.realpath(expanded)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resolve_by_name(name)
|
|
80
|
+
return nil unless @project_root
|
|
81
|
+
|
|
82
|
+
# Try exact match first (ace-bundle)
|
|
83
|
+
exact_path = File.join(@project_root, name)
|
|
84
|
+
return File.realpath(exact_path) if Dir.exist?(exact_path)
|
|
85
|
+
|
|
86
|
+
# Try with ace- prefix (bundle -> ace-bundle)
|
|
87
|
+
prefixed_path = File.join(@project_root, "ace-#{name}")
|
|
88
|
+
return File.realpath(prefixed_path) if Dir.exist?(prefixed_path)
|
|
89
|
+
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def valid_package?(path)
|
|
94
|
+
return false unless Dir.exist?(path)
|
|
95
|
+
|
|
96
|
+
has_test_directory?(path)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def has_test_directory?(path)
|
|
100
|
+
test_dir = File.join(path, "test")
|
|
101
|
+
Dir.exist?(test_dir)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|