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,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module TestRunner
|
|
5
|
+
module Atoms
|
|
6
|
+
# Parses test output into structured data
|
|
7
|
+
class ResultParser
|
|
8
|
+
# Patterns for parsing minitest output
|
|
9
|
+
PATTERNS = {
|
|
10
|
+
summary: /(\d+) (?:tests?|runs?), (\d+) assertions?, (\d+) failures?, (\d+) errors?, (\d+) skips?/,
|
|
11
|
+
failure: /^\s+\d+\) (Failure|Error):\n(.+?)(?=^\s+\d+\) |^Finished in|\z)/m,
|
|
12
|
+
# Pattern for inline verbose failures - match test name, FAIL marker, and content until next test or EOF
|
|
13
|
+
# Matches: " test_name FAIL (0.02s)\n error details\n /path/file.rb:123..."
|
|
14
|
+
inline_failure: /^\s+(test_[\w_]+).*?FAIL.*?\([\d.]+s\)\n(.*?)(?=^\s+test_[\w_]+.*?(?:PASS|FAIL|ERROR|SKIP)|^Finished in|\z)/m,
|
|
15
|
+
# Pattern for inline errors - same structure as failures but with ERROR marker
|
|
16
|
+
inline_error: /^\s+(test_[\w_]+).*?ERROR.*?\([\d.]+s\)\n(.*?)(?=^\s+test_[\w_]+.*?(?:PASS|FAIL|ERROR|SKIP)|^Finished in|\z)/m,
|
|
17
|
+
location: /\[(.*?):(\d+)\]/,
|
|
18
|
+
duration: /Finished in ([\d.]+)s/,
|
|
19
|
+
deprecation: /DEPRECATION WARNING: (.+)/,
|
|
20
|
+
# Pattern to capture individual test times from verbose output
|
|
21
|
+
# Matches Minitest::Reporters DefaultReporter format:
|
|
22
|
+
# " test_name PASS (0.00s)"
|
|
23
|
+
test_time: /^\s+(test_[\w_]+).*?\s+(PASS|FAIL|ERROR|SKIP)\s+\(([\d.]+)s\)/,
|
|
24
|
+
# Pattern for standard Minitest verbose format:
|
|
25
|
+
# 1. "ClassName#test_name = 0.00 s = ."
|
|
26
|
+
# 2. "ClassName#test_name 0.00 = ."
|
|
27
|
+
test_time_standard: /^(\S+)#(test_[\w_]+)\s+(?:=\s+)?([\d.]+)\s*s?\s*=\s*([.FEWS])/
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def parse_output(output)
|
|
31
|
+
# Clean ANSI color codes once for all parsing methods
|
|
32
|
+
clean_output = output.gsub(/\e\[[0-9;]*m/, "")
|
|
33
|
+
|
|
34
|
+
summary = parse_summary(clean_output)
|
|
35
|
+
failures = parse_failures(clean_output)
|
|
36
|
+
duration = parse_duration(clean_output)
|
|
37
|
+
deprecations = parse_deprecations(clean_output)
|
|
38
|
+
test_times = parse_test_times(clean_output)
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
raw_output: output,
|
|
42
|
+
summary: summary,
|
|
43
|
+
failures: failures,
|
|
44
|
+
duration: duration,
|
|
45
|
+
deprecations: deprecations,
|
|
46
|
+
test_times: test_times
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_summary(clean_output)
|
|
51
|
+
# clean_output already has ANSI codes removed
|
|
52
|
+
match = clean_output.match(PATTERNS[:summary])
|
|
53
|
+
|
|
54
|
+
return default_summary unless match
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
runs: match[1].to_i,
|
|
58
|
+
assertions: match[2].to_i,
|
|
59
|
+
failures: match[3].to_i,
|
|
60
|
+
errors: match[4].to_i,
|
|
61
|
+
skips: match[5].to_i,
|
|
62
|
+
passed: match[1].to_i - match[3].to_i - match[4].to_i - match[5].to_i
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_failures(clean_output)
|
|
67
|
+
# clean_output already has ANSI codes removed
|
|
68
|
+
failures = []
|
|
69
|
+
|
|
70
|
+
# First try to parse standard format failures
|
|
71
|
+
clean_output.scan(PATTERNS[:failure]) do |type, content|
|
|
72
|
+
failure = parse_single_failure(type, content)
|
|
73
|
+
failures << failure if failure
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# If no failures found, try inline verbose format
|
|
77
|
+
# But ONLY if the output contains FAIL or ERROR to avoid expensive processing on success
|
|
78
|
+
if failures.empty? && (clean_output.include?(" FAIL ") || clean_output.include?(" ERROR "))
|
|
79
|
+
# Split by test headers and process each block
|
|
80
|
+
test_blocks = clean_output.split(/(?=^\s+test_[\w_]+)/).select { |s| s.match?(/^\s+test_/) }
|
|
81
|
+
|
|
82
|
+
test_blocks.each do |block|
|
|
83
|
+
# Check if this block contains a FAIL or ERROR
|
|
84
|
+
if block =~ /^\s+(test_[\w_]+).*?(ERROR|FAIL).*?\(([\d.]+)s\)\n(.*)/m
|
|
85
|
+
test_name = $1
|
|
86
|
+
type = ($2 == "ERROR") ? :error : :failure
|
|
87
|
+
content = $4
|
|
88
|
+
|
|
89
|
+
failure = parse_inline_failure(test_name, content, type)
|
|
90
|
+
failures << failure if failure
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
failures
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def parse_duration(clean_output)
|
|
99
|
+
# clean_output already has ANSI codes removed
|
|
100
|
+
match = clean_output.match(PATTERNS[:duration])
|
|
101
|
+
match ? match[1].to_f : 0.0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_deprecations(clean_output)
|
|
105
|
+
deprecations = []
|
|
106
|
+
|
|
107
|
+
clean_output.scan(PATTERNS[:deprecation]) do |message|
|
|
108
|
+
deprecations << message.first
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
deprecations.uniq
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_test_times(clean_output)
|
|
115
|
+
# clean_output already has ANSI codes removed
|
|
116
|
+
test_times = []
|
|
117
|
+
|
|
118
|
+
# Build location index first to avoid O(n²) complexity
|
|
119
|
+
location_index = {}
|
|
120
|
+
clean_output.scan(/(test_[\w_]+).*?\[(.*?):(\d+)\]/) do |name, file, line|
|
|
121
|
+
location_index[name] ||= "#{file}:#{line}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Try Minitest::Reporters DefaultReporter format first (most common)
|
|
125
|
+
# Format: " test_name PASS (0.00s)"
|
|
126
|
+
clean_output.scan(PATTERNS[:test_time]) do |test_name, status, time|
|
|
127
|
+
test_times << {
|
|
128
|
+
name: test_name,
|
|
129
|
+
status: status,
|
|
130
|
+
duration: time.to_f,
|
|
131
|
+
location: location_index[test_name]
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# If no matches, try standard Minitest verbose format
|
|
136
|
+
# Format: "ClassName#test_name = 0.00 s = ." or "ClassName#test_name 0.00 = ."
|
|
137
|
+
if test_times.empty?
|
|
138
|
+
clean_output.scan(PATTERNS[:test_time_standard]) do |class_name, test_name, time, status_char|
|
|
139
|
+
status = case status_char
|
|
140
|
+
when "." then "PASS"
|
|
141
|
+
when "F" then "FAIL"
|
|
142
|
+
when "E" then "ERROR"
|
|
143
|
+
when "S" then "SKIP"
|
|
144
|
+
else "UNKNOWN"
|
|
145
|
+
end
|
|
146
|
+
test_times << {
|
|
147
|
+
name: test_name,
|
|
148
|
+
class_name: class_name,
|
|
149
|
+
status: status,
|
|
150
|
+
duration: time.to_f,
|
|
151
|
+
location: location_index[test_name]
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
test_times.sort_by { |t| -t[:duration] }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def default_summary
|
|
162
|
+
{
|
|
163
|
+
runs: 0,
|
|
164
|
+
assertions: 0,
|
|
165
|
+
failures: 0,
|
|
166
|
+
errors: 0,
|
|
167
|
+
skips: 0,
|
|
168
|
+
passed: 0
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def parse_single_failure(type, content)
|
|
173
|
+
lines = content.strip.lines
|
|
174
|
+
return nil if lines.empty?
|
|
175
|
+
|
|
176
|
+
# First line is test name
|
|
177
|
+
test_name = lines.first.strip
|
|
178
|
+
|
|
179
|
+
# Find location
|
|
180
|
+
location_match = content.match(PATTERNS[:location])
|
|
181
|
+
location = if location_match
|
|
182
|
+
{
|
|
183
|
+
file: location_match[1],
|
|
184
|
+
line: location_match[2].to_i
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Extract message (everything after test name and before location/backtrace)
|
|
189
|
+
message_lines = []
|
|
190
|
+
lines[1..-1].each do |line|
|
|
191
|
+
break if line.match?(PATTERNS[:location]) || line.strip.start_with?("/")
|
|
192
|
+
message_lines << line
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
{
|
|
196
|
+
type: type.downcase.to_sym,
|
|
197
|
+
test_name: test_name,
|
|
198
|
+
message: message_lines.join.strip,
|
|
199
|
+
location: location,
|
|
200
|
+
full_content: content.strip
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def parse_inline_failure(test_name, content, type = :failure)
|
|
205
|
+
# Parse inline verbose format failures
|
|
206
|
+
# Example:
|
|
207
|
+
# test_handles_special_characters FAIL (0.00s)
|
|
208
|
+
# Expected: "path\\to\\file"
|
|
209
|
+
# Actual: nil
|
|
210
|
+
# /Users/mc/Ps/ace/ace-core/test/atoms/env_parser_test.rb:50:in 'EnvParserTest#test_handles_special_characters'
|
|
211
|
+
|
|
212
|
+
lines = content.strip.lines
|
|
213
|
+
|
|
214
|
+
# Extract location from any line that looks like a file path (first occurrence)
|
|
215
|
+
location = nil
|
|
216
|
+
location_line_idx = nil
|
|
217
|
+
lines.each_with_index do |line, idx|
|
|
218
|
+
# Match absolute paths with .rb extension
|
|
219
|
+
if line =~ %r{^\s*(/[^:]+\.rb):(\d+)}
|
|
220
|
+
location = {
|
|
221
|
+
file: $1.strip,
|
|
222
|
+
line: $2.to_i
|
|
223
|
+
}
|
|
224
|
+
location_line_idx = idx
|
|
225
|
+
break
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Extract message (all lines before location, trimmed)
|
|
230
|
+
# Skip empty lines and indentation
|
|
231
|
+
message_lines = []
|
|
232
|
+
lines[0...location_line_idx].each do |line|
|
|
233
|
+
stripped = line.strip
|
|
234
|
+
next if stripped.empty?
|
|
235
|
+
message_lines << stripped
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# If no message extracted, use first non-empty line
|
|
239
|
+
if message_lines.empty? && lines.any?
|
|
240
|
+
message_lines << lines.first.strip
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
{
|
|
244
|
+
type: type,
|
|
245
|
+
test_name: test_name,
|
|
246
|
+
message: message_lines.join("\n"),
|
|
247
|
+
location: location,
|
|
248
|
+
full_content: "#{test_name}\n#{content}".strip
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module TestRunner
|
|
5
|
+
module Atoms
|
|
6
|
+
# Detects and finds test files based on patterns
|
|
7
|
+
class TestDetector
|
|
8
|
+
DEFAULT_PATTERNS = [
|
|
9
|
+
"test/**/*_test.rb",
|
|
10
|
+
"spec/**/*_spec.rb",
|
|
11
|
+
"test/**/test_*.rb"
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(patterns: nil, root_dir: ".")
|
|
15
|
+
@patterns = patterns || DEFAULT_PATTERNS
|
|
16
|
+
@root_dir = root_dir
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def find_test_files
|
|
20
|
+
files = []
|
|
21
|
+
|
|
22
|
+
# Handle both hash (new format) and array (old format) patterns
|
|
23
|
+
patterns_to_search = if @patterns.is_a?(Hash)
|
|
24
|
+
@patterns.values
|
|
25
|
+
else
|
|
26
|
+
@patterns
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
patterns_to_search.each do |pattern|
|
|
30
|
+
full_pattern = File.join(@root_dir, pattern)
|
|
31
|
+
matched_files = Dir.glob(full_pattern).select { |f| File.file?(f) }
|
|
32
|
+
files.concat(matched_files)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Filter out helper files that aren't actual test files
|
|
36
|
+
files = files.reject { |f| f.end_with?("/test_helper.rb", "/spec_helper.rb") }
|
|
37
|
+
|
|
38
|
+
files.uniq.sort
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def filter_by_pattern(files, pattern)
|
|
42
|
+
return files unless pattern
|
|
43
|
+
|
|
44
|
+
regex = Regexp.new(pattern, Regexp::IGNORECASE)
|
|
45
|
+
files.select do |file|
|
|
46
|
+
file.match?(regex) || File.basename(file).match?(regex)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def test_file?(path)
|
|
51
|
+
return false unless File.exist?(path) && File.file?(path)
|
|
52
|
+
|
|
53
|
+
# Handle both hash (new format) and array (old format) patterns
|
|
54
|
+
patterns_to_check = if @patterns.is_a?(Hash)
|
|
55
|
+
@patterns.values
|
|
56
|
+
else
|
|
57
|
+
@patterns
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
patterns_to_check.any? do |pattern|
|
|
61
|
+
File.fnmatch?(pattern, path) ||
|
|
62
|
+
File.fnmatch?(pattern, path.sub(@root_dir + "/", ""))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def classify_file(file_path)
|
|
67
|
+
case file_path
|
|
68
|
+
when /test\/unit\/atoms\//
|
|
69
|
+
:atoms
|
|
70
|
+
when /test\/unit\/molecules\//
|
|
71
|
+
:molecules
|
|
72
|
+
when /test\/unit\/organisms\//
|
|
73
|
+
:organisms
|
|
74
|
+
when /test\/unit\/models\//
|
|
75
|
+
:models
|
|
76
|
+
when /test\/integration\//
|
|
77
|
+
:integration
|
|
78
|
+
when /test\/system\//
|
|
79
|
+
:system
|
|
80
|
+
when /test\/unit\//
|
|
81
|
+
:unit
|
|
82
|
+
when /spec\/unit\/atoms\//
|
|
83
|
+
:atoms
|
|
84
|
+
when /spec\/unit\/molecules\//
|
|
85
|
+
:molecules
|
|
86
|
+
when /spec\/unit\/organisms\//
|
|
87
|
+
:organisms
|
|
88
|
+
when /spec\/unit\/models\//
|
|
89
|
+
:models
|
|
90
|
+
when /spec\/integration\//
|
|
91
|
+
:integration
|
|
92
|
+
when /spec\/system\//
|
|
93
|
+
:system
|
|
94
|
+
when /spec\/unit\//
|
|
95
|
+
:unit
|
|
96
|
+
else
|
|
97
|
+
:other
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def group_test_files(files)
|
|
102
|
+
grouped = Hash.new { |h, k| h[k] = [] }
|
|
103
|
+
|
|
104
|
+
files.each do |file|
|
|
105
|
+
group = classify_file(file)
|
|
106
|
+
grouped[group] << file
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
grouped
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module TestRunner
|
|
5
|
+
module Atoms
|
|
6
|
+
# Detects test folder location from test file paths and calculates report directory
|
|
7
|
+
module TestFolderDetector
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Given test file paths, find the test folder and calculate report directory
|
|
11
|
+
# Returns the parent directory of the test folder + "/test-reports"
|
|
12
|
+
#
|
|
13
|
+
# Example:
|
|
14
|
+
# files = ["ace-taskflow/test/commands/tasks_command_test.rb"]
|
|
15
|
+
# detect_report_dir(files) # => "ace-taskflow/test-reports"
|
|
16
|
+
def detect_report_dir(test_files)
|
|
17
|
+
return nil if test_files.nil? || test_files.empty?
|
|
18
|
+
|
|
19
|
+
# Get the first test file and find its test folder
|
|
20
|
+
first_file = test_files.first
|
|
21
|
+
test_folder = find_test_folder(first_file)
|
|
22
|
+
|
|
23
|
+
return nil unless test_folder
|
|
24
|
+
|
|
25
|
+
# Get parent directory and append test-reports
|
|
26
|
+
parent_dir = File.dirname(test_folder)
|
|
27
|
+
File.join(parent_dir, "test-reports")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Find the test folder in a given file path
|
|
31
|
+
# Looks for "/test/" in the path and returns the path up to and including "test"
|
|
32
|
+
#
|
|
33
|
+
# Example:
|
|
34
|
+
# find_test_folder("ace-taskflow/test/commands/tasks_command_test.rb")
|
|
35
|
+
# # => "ace-taskflow/test"
|
|
36
|
+
def find_test_folder(file_path)
|
|
37
|
+
# Remove line number suffix if present (file:line format)
|
|
38
|
+
file_path = file_path.sub(/:\d+$/, "")
|
|
39
|
+
|
|
40
|
+
# Split path into parts
|
|
41
|
+
parts = file_path.split("/")
|
|
42
|
+
|
|
43
|
+
# Find index of "test" directory
|
|
44
|
+
test_index = parts.index("test")
|
|
45
|
+
return nil unless test_index
|
|
46
|
+
|
|
47
|
+
# Return path up to and including test directory
|
|
48
|
+
parts[0..test_index].join("/")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module TestRunner
|
|
5
|
+
module Atoms
|
|
6
|
+
# Detects whether tests need subprocess isolation based on their content and type
|
|
7
|
+
class TestTypeDetector
|
|
8
|
+
# Patterns that indicate a test needs subprocess isolation
|
|
9
|
+
SUBPROCESS_PATTERNS = [
|
|
10
|
+
/CommandExecutor/, # Tests that execute shell commands
|
|
11
|
+
/Open3\./, # Direct subprocess usage
|
|
12
|
+
/Process\.(spawn|fork|kill)/, # Process manipulation
|
|
13
|
+
/Signal\./, # Signal handling
|
|
14
|
+
/system\(/, # System command execution
|
|
15
|
+
/`.*`/, # Backtick command execution
|
|
16
|
+
/\$\(.*\)/, # Command substitution
|
|
17
|
+
/run_in_subprocess/, # Explicit subprocess test helpers
|
|
18
|
+
/run_in_clean_env/ # Environment isolation helpers
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Test directories that typically need isolation
|
|
22
|
+
ISOLATION_DIRS = %w[
|
|
23
|
+
integration
|
|
24
|
+
system
|
|
25
|
+
e2e
|
|
26
|
+
end_to_end
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Test directories that typically don't need isolation
|
|
30
|
+
UNIT_TEST_DIRS = %w[
|
|
31
|
+
atoms
|
|
32
|
+
molecules
|
|
33
|
+
organisms
|
|
34
|
+
models
|
|
35
|
+
unit
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
def needs_subprocess?(file_path)
|
|
39
|
+
# Check if explicitly marked as needing isolation
|
|
40
|
+
return true if isolation_directory?(file_path)
|
|
41
|
+
|
|
42
|
+
# Check if it's clearly a unit test that doesn't need isolation
|
|
43
|
+
return false if unit_test_directory?(file_path)
|
|
44
|
+
|
|
45
|
+
# Check file content for patterns that require subprocess
|
|
46
|
+
check_file_content(file_path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_type(file_path)
|
|
50
|
+
if isolation_directory?(file_path)
|
|
51
|
+
:integration
|
|
52
|
+
elsif unit_test_directory?(file_path)
|
|
53
|
+
:unit
|
|
54
|
+
elsif check_file_content(file_path)
|
|
55
|
+
:subprocess_required
|
|
56
|
+
else
|
|
57
|
+
:unit
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def isolation_directory?(file_path)
|
|
64
|
+
ISOLATION_DIRS.any? { |dir| file_path.include?("/#{dir}/") }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def unit_test_directory?(file_path)
|
|
68
|
+
UNIT_TEST_DIRS.any? { |dir| file_path.include?("/#{dir}/") }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def check_file_content(file_path)
|
|
72
|
+
return false unless File.exist?(file_path)
|
|
73
|
+
|
|
74
|
+
content = File.read(file_path)
|
|
75
|
+
SUBPROCESS_PATTERNS.any? { |pattern| content.match?(pattern) }
|
|
76
|
+
rescue
|
|
77
|
+
# If we can't read the file, assume it doesn't need isolation
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/b36ts"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module TestRunner
|
|
7
|
+
module Atoms
|
|
8
|
+
# Generates Base36 compact IDs for test reports
|
|
9
|
+
#
|
|
10
|
+
# Uses ace-b36ts to generate 6-character compact IDs (e.g., "i50jj3")
|
|
11
|
+
# for test report directories and files. Reports are temporary, so no
|
|
12
|
+
# backward compatibility with legacy timestamp format is needed.
|
|
13
|
+
#
|
|
14
|
+
# @example Generate a compact ID
|
|
15
|
+
# generator = TimestampGenerator.new
|
|
16
|
+
# generator.generate # => "i50jj3"
|
|
17
|
+
#
|
|
18
|
+
# @example Generate ISO timestamp for human-readable output
|
|
19
|
+
# generator = TimestampGenerator.new
|
|
20
|
+
# generator.iso_timestamp # => "2025-01-06T12:30:00"
|
|
21
|
+
#
|
|
22
|
+
class TimestampGenerator
|
|
23
|
+
ISO_FORMAT = "%Y-%m-%dT%H:%M:%S"
|
|
24
|
+
|
|
25
|
+
# Generate a Base36 compact ID for the given time
|
|
26
|
+
#
|
|
27
|
+
# @param time [Time] The time to encode (default: Time.now)
|
|
28
|
+
# @return [String] 6-character Base36 compact ID
|
|
29
|
+
def generate(time = Time.now)
|
|
30
|
+
Ace::B36ts.encode(time)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Generate an ISO timestamp for human-readable output
|
|
34
|
+
#
|
|
35
|
+
# @param time [Time] The time to format (default: Time.now)
|
|
36
|
+
# @return [String] ISO formatted timestamp
|
|
37
|
+
def iso_timestamp(time = Time.now)
|
|
38
|
+
time.strftime(ISO_FORMAT)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Generate a directory name (alias for generate)
|
|
42
|
+
#
|
|
43
|
+
# @param time [Time] The time to encode (default: Time.now)
|
|
44
|
+
# @return [String] 6-character Base36 compact ID
|
|
45
|
+
def directory_name(time = Time.now)
|
|
46
|
+
generate(time)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Generate a filename timestamp with optional extension
|
|
50
|
+
#
|
|
51
|
+
# @param time [Time] The time to encode (default: Time.now)
|
|
52
|
+
# @param extension [String, nil] Optional file extension
|
|
53
|
+
# @return [String] Filename with optional extension
|
|
54
|
+
def filename_timestamp(time = Time.now, extension = nil)
|
|
55
|
+
base = generate(time)
|
|
56
|
+
extension ? "#{base}#{extension}" : base
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Parse a Base36 compact ID string to Time
|
|
60
|
+
#
|
|
61
|
+
# @param id_str [String] The Base36 ID string to parse
|
|
62
|
+
# @return [Time, nil] Parsed time or nil if invalid
|
|
63
|
+
def parse(id_str)
|
|
64
|
+
Ace::B36ts.decode(id_str)
|
|
65
|
+
rescue ArgumentError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Calculate elapsed time between two times
|
|
70
|
+
#
|
|
71
|
+
# @param start_time [Time] Start time
|
|
72
|
+
# @param end_time [Time] End time (default: Time.now)
|
|
73
|
+
# @return [String] Human-readable duration
|
|
74
|
+
def elapsed_time(start_time, end_time = Time.now)
|
|
75
|
+
duration = end_time - start_time
|
|
76
|
+
format_duration(duration)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Detect the format of an ID string
|
|
80
|
+
#
|
|
81
|
+
# @param value [String] The ID string to analyze
|
|
82
|
+
# @return [Symbol, nil] :"2sec" for valid Base36 IDs, :timestamp for legacy format, or nil
|
|
83
|
+
def self.detect_format(value)
|
|
84
|
+
Ace::B36ts.detect_format(value)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def format_duration(seconds)
|
|
90
|
+
if seconds < 1
|
|
91
|
+
"#{(seconds * 1000).round(2)}ms"
|
|
92
|
+
elsif seconds < 60
|
|
93
|
+
"#{seconds.round(2)}s"
|
|
94
|
+
else
|
|
95
|
+
minutes = (seconds / 60).floor
|
|
96
|
+
remaining_seconds = (seconds % 60).round
|
|
97
|
+
"#{minutes}m #{remaining_seconds}s"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|