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,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module TestRunner
|
|
5
|
+
module Molecules
|
|
6
|
+
class PatternResolver
|
|
7
|
+
attr_reader :using_catch_all
|
|
8
|
+
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config
|
|
11
|
+
@patterns = normalize_keys(config.patterns || {})
|
|
12
|
+
@groups = normalize_keys(config.groups || {})
|
|
13
|
+
@using_catch_all = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resolve_target(target)
|
|
17
|
+
return resolve_all_files if target.nil? || target == "all"
|
|
18
|
+
return [target] if File.exist?(target)
|
|
19
|
+
|
|
20
|
+
# Normalize target to string for consistent lookup
|
|
21
|
+
target_key = target.to_s
|
|
22
|
+
|
|
23
|
+
if @groups.key?(target_key)
|
|
24
|
+
resolve_group(target_key)
|
|
25
|
+
elsif @patterns.key?(target_key)
|
|
26
|
+
expand_pattern(@patterns[target_key])
|
|
27
|
+
elsif looks_like_file_path?(target)
|
|
28
|
+
raise ArgumentError, "File not found: #{target}. " \
|
|
29
|
+
"Make sure you're running from the correct directory or use an absolute path."
|
|
30
|
+
else
|
|
31
|
+
raise ArgumentError, "Unknown target: #{target}. Available targets: #{available_targets.join(", ")}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolve_multiple_targets(targets)
|
|
36
|
+
targets.flat_map { |target| resolve_target(target) }.uniq
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve_group_sequential(group_name)
|
|
40
|
+
group_key = group_name.to_s
|
|
41
|
+
group_members = @groups[group_key]
|
|
42
|
+
return [] unless group_members
|
|
43
|
+
|
|
44
|
+
group_members.flat_map do |member|
|
|
45
|
+
member_key = member.to_s
|
|
46
|
+
|
|
47
|
+
if @groups.key?(member_key)
|
|
48
|
+
# Recursively expand nested groups
|
|
49
|
+
resolve_group_sequential(member_key)
|
|
50
|
+
elsif @patterns.key?(member_key)
|
|
51
|
+
# Pattern found - return as a group
|
|
52
|
+
files = expand_pattern(@patterns[member_key])
|
|
53
|
+
files.empty? ? [] : [{name: member_key, files: files}]
|
|
54
|
+
else
|
|
55
|
+
# Direct pattern - expand and wrap
|
|
56
|
+
files = expand_pattern(member)
|
|
57
|
+
files.empty? ? [] : [{name: "other", files: files}]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def available_targets
|
|
63
|
+
(@groups.keys + @patterns.keys).map(&:to_s).sort
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def classify_file(file_path)
|
|
67
|
+
@patterns.each do |name, pattern|
|
|
68
|
+
# Use File::FNM_PATHNAME to handle ** correctly
|
|
69
|
+
return name.to_s if File.fnmatch?(pattern, file_path, File::FNM_PATHNAME)
|
|
70
|
+
end
|
|
71
|
+
"other"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def looks_like_file_path?(target)
|
|
77
|
+
target.include?("/") || target.end_with?(".rb")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def normalize_keys(hash)
|
|
81
|
+
hash.transform_keys(&:to_s)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolve_group(group_name)
|
|
85
|
+
# Normalize to string
|
|
86
|
+
group_key = group_name.to_s
|
|
87
|
+
group_members = @groups[group_key]
|
|
88
|
+
return [] unless group_members
|
|
89
|
+
|
|
90
|
+
group_members.flat_map do |member|
|
|
91
|
+
member_key = member.to_s
|
|
92
|
+
|
|
93
|
+
if @groups.key?(member_key)
|
|
94
|
+
resolve_group(member_key) # Recursive expansion
|
|
95
|
+
elsif @patterns.key?(member_key)
|
|
96
|
+
expand_pattern(@patterns[member_key])
|
|
97
|
+
else
|
|
98
|
+
# Direct pattern or file
|
|
99
|
+
expand_pattern(member)
|
|
100
|
+
end
|
|
101
|
+
end.uniq
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def expand_pattern(pattern)
|
|
105
|
+
Dir.glob(pattern).select { |f| File.file?(f) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve_all_files
|
|
109
|
+
# Always start by finding ALL test files that exist
|
|
110
|
+
all_test_files = expand_pattern("test/**/*_test.rb")
|
|
111
|
+
@using_catch_all = false
|
|
112
|
+
|
|
113
|
+
# First check if "all" group is defined
|
|
114
|
+
if @groups.key?("all")
|
|
115
|
+
pattern_files = resolve_group("all")
|
|
116
|
+
# If patterns miss any files, use the complete scan
|
|
117
|
+
if pattern_files.size < all_test_files.size
|
|
118
|
+
@using_catch_all = true
|
|
119
|
+
return all_test_files
|
|
120
|
+
end
|
|
121
|
+
return pattern_files
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# If no "all" group, try all defined patterns
|
|
125
|
+
if @patterns && !@patterns.empty?
|
|
126
|
+
pattern_files = []
|
|
127
|
+
@patterns.each_value do |pattern|
|
|
128
|
+
pattern_files.concat(expand_pattern(pattern))
|
|
129
|
+
end
|
|
130
|
+
pattern_files = pattern_files.uniq
|
|
131
|
+
# If patterns miss any files, use the complete scan
|
|
132
|
+
if pattern_files.size < all_test_files.size
|
|
133
|
+
@using_catch_all = true
|
|
134
|
+
return all_test_files
|
|
135
|
+
end
|
|
136
|
+
return pattern_files
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# No configuration - using catch-all pattern
|
|
140
|
+
@using_catch_all = true
|
|
141
|
+
all_test_files
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "erb"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module TestRunner
|
|
8
|
+
module Molecules
|
|
9
|
+
# Manages Rakefile integration for ace-test
|
|
10
|
+
class RakeIntegration
|
|
11
|
+
BACKUP_EXTENSION = ".ace-backup"
|
|
12
|
+
MARKER_COMMENT = "# ace-test-runner integration"
|
|
13
|
+
|
|
14
|
+
def initialize(rakefile_path = "Rakefile")
|
|
15
|
+
@rakefile_path = rakefile_path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Set ace-test as the default rake test runner
|
|
19
|
+
def set_default
|
|
20
|
+
validate_environment!
|
|
21
|
+
|
|
22
|
+
if already_integrated?
|
|
23
|
+
return {success: true, message: "ace-test is already set as default rake test runner"}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
backup_rakefile
|
|
27
|
+
inject_ace_test_config
|
|
28
|
+
|
|
29
|
+
{success: true, message: "Successfully set ace-test as default rake test runner"}
|
|
30
|
+
rescue => e
|
|
31
|
+
restore_from_backup if backup_exists?
|
|
32
|
+
{success: false, message: "Failed to set ace-test as default: #{e.message}"}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Remove ace-test as the default rake test runner
|
|
36
|
+
def unset_default
|
|
37
|
+
unless already_integrated?
|
|
38
|
+
return {success: true, message: "ace-test is not currently set as default"}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if backup_exists?
|
|
42
|
+
restore_from_backup
|
|
43
|
+
{success: true, message: "Successfully restored original Rakefile"}
|
|
44
|
+
else
|
|
45
|
+
remove_ace_test_config
|
|
46
|
+
{success: true, message: "Successfully removed ace-test configuration"}
|
|
47
|
+
end
|
|
48
|
+
rescue => e
|
|
49
|
+
{success: false, message: "Failed to unset ace-test as default: #{e.message}"}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Check if ace-test is currently set as default
|
|
53
|
+
def check_status
|
|
54
|
+
if !rakefile_exists?
|
|
55
|
+
{
|
|
56
|
+
integrated: false,
|
|
57
|
+
message: "No Rakefile found in current directory",
|
|
58
|
+
rakefile_exists: false
|
|
59
|
+
}
|
|
60
|
+
elsif already_integrated?
|
|
61
|
+
{
|
|
62
|
+
integrated: true,
|
|
63
|
+
message: "ace-test is currently set as default rake test runner",
|
|
64
|
+
rakefile_exists: true,
|
|
65
|
+
backup_exists: backup_exists?
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
{
|
|
69
|
+
integrated: false,
|
|
70
|
+
message: "ace-test is not set as default rake test runner",
|
|
71
|
+
rakefile_exists: true,
|
|
72
|
+
has_test_task: has_test_task?
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def validate_environment!
|
|
80
|
+
unless rakefile_exists?
|
|
81
|
+
# Create a basic Rakefile if it doesn't exist
|
|
82
|
+
create_basic_rakefile
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def rakefile_exists?
|
|
87
|
+
File.exist?(@rakefile_path)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def backup_exists?
|
|
91
|
+
File.exist?(backup_path)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def backup_path
|
|
95
|
+
"#{@rakefile_path}#{BACKUP_EXTENSION}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def already_integrated?
|
|
99
|
+
return false unless rakefile_exists?
|
|
100
|
+
|
|
101
|
+
content = File.read(@rakefile_path)
|
|
102
|
+
content.include?(MARKER_COMMENT) && content.include?("Ace::TestRunner::RakeTask")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def has_test_task?
|
|
106
|
+
return false unless rakefile_exists?
|
|
107
|
+
|
|
108
|
+
content = File.read(@rakefile_path)
|
|
109
|
+
content.match?(/task\s+:test|Rake::TestTask\.new/)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def backup_rakefile
|
|
113
|
+
FileUtils.cp(@rakefile_path, backup_path)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def restore_from_backup
|
|
117
|
+
FileUtils.mv(backup_path, @rakefile_path, force: true)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def inject_ace_test_config
|
|
121
|
+
original_content = File.read(@rakefile_path)
|
|
122
|
+
|
|
123
|
+
# Check if there's an existing test task
|
|
124
|
+
if has_test_task?
|
|
125
|
+
# Comment out existing test task and add ace-test task
|
|
126
|
+
modified_content = comment_out_existing_test_task(original_content)
|
|
127
|
+
modified_content += "\n\n" + ace_test_rake_config
|
|
128
|
+
else
|
|
129
|
+
# Just append ace-test configuration
|
|
130
|
+
modified_content = original_content + "\n\n" + ace_test_rake_config
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
File.write(@rakefile_path, modified_content)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def remove_ace_test_config
|
|
137
|
+
content = File.read(@rakefile_path)
|
|
138
|
+
|
|
139
|
+
# Remove ace-test configuration block
|
|
140
|
+
content = content.gsub(/#{Regexp.escape(MARKER_COMMENT)}.*?# End of ace-test-runner integration/mo, "")
|
|
141
|
+
|
|
142
|
+
# Uncomment original test task if it was commented
|
|
143
|
+
content = uncomment_original_test_task(content)
|
|
144
|
+
|
|
145
|
+
File.write(@rakefile_path, content.strip + "\n")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def comment_out_existing_test_task(content)
|
|
149
|
+
# Comment out existing Rake::TestTask
|
|
150
|
+
content = content.gsub(/^(require\s+["']rake\/testtask["'])/, '# \1 # Commented by ace-test')
|
|
151
|
+
content = content.gsub(/^(Rake::TestTask\.new.*?)^end/m) do |match|
|
|
152
|
+
match.split("\n").map { |line| "# #{line}" }.join("\n")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Comment out simple task :test definitions
|
|
156
|
+
content.gsub(/^(task\s+:test\s+do.*?)^end/m) do |match|
|
|
157
|
+
match.split("\n").map { |line| "# #{line}" }.join("\n")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def uncomment_original_test_task(content)
|
|
162
|
+
# Uncomment lines that were commented by ace-test
|
|
163
|
+
content.gsub(/^# (.*) # Commented by ace-test$/, '\1')
|
|
164
|
+
|
|
165
|
+
# Uncomment task blocks (more complex - would need proper parsing)
|
|
166
|
+
# For now, just leave them commented as user can manually uncomment if needed
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def create_basic_rakefile
|
|
170
|
+
File.write(@rakefile_path, basic_rakefile_template)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def basic_rakefile_template
|
|
174
|
+
<<~RAKEFILE
|
|
175
|
+
# frozen_string_literal: true
|
|
176
|
+
|
|
177
|
+
require "bundler/gem_tasks" if File.exist?("Gemfile")
|
|
178
|
+
|
|
179
|
+
# Default task
|
|
180
|
+
task default: :test
|
|
181
|
+
RAKEFILE
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def ace_test_rake_config
|
|
185
|
+
<<~CONFIG
|
|
186
|
+
#{MARKER_COMMENT}
|
|
187
|
+
begin
|
|
188
|
+
require "ace/test_runner/rake_task"
|
|
189
|
+
|
|
190
|
+
# Use ace-test as the default test runner
|
|
191
|
+
Ace::TestRunner::RakeTask.new(:test) do |t|
|
|
192
|
+
t.description = "Run tests with ace-test"
|
|
193
|
+
t.libs << "test" << "lib"
|
|
194
|
+
t.pattern = ENV["PATTERN"] || "test/**/*_test.rb"
|
|
195
|
+
t.verbose = ENV["VERBOSE"] == "true"
|
|
196
|
+
t.format = ENV["FORMAT"] || ENV["ACE_TEST_FORMAT"]
|
|
197
|
+
end
|
|
198
|
+
rescue LoadError
|
|
199
|
+
# Fallback to standard Rake::TestTask if ace-test-runner is not available
|
|
200
|
+
require "rake/testtask"
|
|
201
|
+
|
|
202
|
+
Rake::TestTask.new(:test) do |t|
|
|
203
|
+
t.libs << "test" << "lib"
|
|
204
|
+
t.test_files = FileList[ENV["PATTERN"] || "test/**/*_test.rb"]
|
|
205
|
+
t.verbose = ENV["VERBOSE"] == "true"
|
|
206
|
+
t.warning = ENV["WARNING"] == "true"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
puts "Warning: ace-test-runner not found. Using standard Rake::TestTask."
|
|
210
|
+
puts "Install ace-test-runner gem to use advanced test features."
|
|
211
|
+
end
|
|
212
|
+
# End of ace-test-runner integration
|
|
213
|
+
CONFIG
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module TestRunner
|
|
5
|
+
module Molecules
|
|
6
|
+
# Handles storage of test reports to filesystem
|
|
7
|
+
class ReportStorage
|
|
8
|
+
def initialize(base_dir: ".ace-local/test/reports", timestamp_generator: nil)
|
|
9
|
+
@base_dir = base_dir
|
|
10
|
+
@timestamp_generator = timestamp_generator || Atoms::TimestampGenerator.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def save_report(report, format: :json)
|
|
14
|
+
ensure_base_directory
|
|
15
|
+
report_dir = create_report_directory
|
|
16
|
+
|
|
17
|
+
case format
|
|
18
|
+
when :json
|
|
19
|
+
save_json_report(report, report_dir)
|
|
20
|
+
when :markdown
|
|
21
|
+
save_markdown_report(report, report_dir)
|
|
22
|
+
when :all
|
|
23
|
+
save_all_formats(report, report_dir)
|
|
24
|
+
else
|
|
25
|
+
raise ArgumentError, "Unknown report format: #{format}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
create_latest_symlink(report_dir)
|
|
29
|
+
report_dir
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def save_raw_output(output, report_dir)
|
|
33
|
+
ensure_directory(report_dir)
|
|
34
|
+
output_file = File.join(report_dir, "raw_output.txt")
|
|
35
|
+
File.write(output_file, output)
|
|
36
|
+
output_file
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def save_stderr(stderr, report_dir)
|
|
40
|
+
return nil if stderr.nil? || stderr.empty?
|
|
41
|
+
|
|
42
|
+
ensure_directory(report_dir)
|
|
43
|
+
stderr_file = File.join(report_dir, "raw_stderr.txt")
|
|
44
|
+
File.write(stderr_file, stderr)
|
|
45
|
+
stderr_file
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def save_summary(result, report_dir)
|
|
49
|
+
ensure_directory(report_dir)
|
|
50
|
+
summary_file = File.join(report_dir, "summary.json")
|
|
51
|
+
|
|
52
|
+
summary_data = {
|
|
53
|
+
passed: result.passed,
|
|
54
|
+
failed: result.failed,
|
|
55
|
+
errors: result.errors,
|
|
56
|
+
skipped: result.skipped,
|
|
57
|
+
total: result.total_tests,
|
|
58
|
+
pass_rate: result.pass_rate,
|
|
59
|
+
duration: result.duration,
|
|
60
|
+
success: result.success?,
|
|
61
|
+
timestamp: Time.now.iso8601
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
File.write(summary_file, JSON.pretty_generate(summary_data))
|
|
65
|
+
summary_file
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def save_failures(failures, report_dir)
|
|
69
|
+
return nil if failures.empty?
|
|
70
|
+
|
|
71
|
+
ensure_directory(report_dir)
|
|
72
|
+
failures_file = File.join(report_dir, "failures.json")
|
|
73
|
+
|
|
74
|
+
failures_data = failures.map(&:to_h)
|
|
75
|
+
File.write(failures_file, JSON.pretty_generate(failures_data))
|
|
76
|
+
failures_file
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def save_individual_failure_reports(failures, report_dir, formatter, max_display: nil)
|
|
80
|
+
return [] if failures.empty?
|
|
81
|
+
|
|
82
|
+
failures_dir = File.join(report_dir, "failures")
|
|
83
|
+
ensure_directory(failures_dir)
|
|
84
|
+
|
|
85
|
+
# Limit number of individual .md files to max_display if specified
|
|
86
|
+
failures_to_save = max_display ? failures.take(max_display) : failures
|
|
87
|
+
|
|
88
|
+
report_files = []
|
|
89
|
+
failures_to_save.each_with_index do |failure, index|
|
|
90
|
+
filename = generate_failure_filename(failure, index + 1)
|
|
91
|
+
filepath = File.join(failures_dir, filename)
|
|
92
|
+
|
|
93
|
+
content = formatter.generate_failure_report(failure, index + 1)
|
|
94
|
+
File.write(filepath, content)
|
|
95
|
+
report_files << filepath
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Create an index file for ALL failures (not just the limited ones)
|
|
99
|
+
create_failure_index(failures, failures_dir)
|
|
100
|
+
|
|
101
|
+
report_files
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def list_reports(limit: 10)
|
|
105
|
+
ensure_base_directory
|
|
106
|
+
|
|
107
|
+
report_directories
|
|
108
|
+
.map { |d| report_info(d) }
|
|
109
|
+
.compact
|
|
110
|
+
.sort_by { |r| r[:timestamp] }
|
|
111
|
+
.reverse
|
|
112
|
+
.take(limit)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def latest_report_path
|
|
116
|
+
latest_link = File.join(@base_dir, "latest")
|
|
117
|
+
return nil unless File.exist?(latest_link) && File.symlink?(latest_link)
|
|
118
|
+
|
|
119
|
+
File.readlink(latest_link)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def load_report(report_dir)
|
|
123
|
+
return nil unless Dir.exist?(report_dir)
|
|
124
|
+
|
|
125
|
+
summary_file = File.join(report_dir, "summary.json")
|
|
126
|
+
return nil unless File.exist?(summary_file)
|
|
127
|
+
|
|
128
|
+
summary = JSON.parse(File.read(summary_file), symbolize_names: true)
|
|
129
|
+
|
|
130
|
+
# Load additional data if available
|
|
131
|
+
failures_file = File.join(report_dir, "failures.json")
|
|
132
|
+
failures = if File.exist?(failures_file)
|
|
133
|
+
JSON.parse(File.read(failures_file), symbolize_names: true)
|
|
134
|
+
else
|
|
135
|
+
[]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
{
|
|
139
|
+
summary: summary,
|
|
140
|
+
failures: failures,
|
|
141
|
+
report_dir: report_dir
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def cleanup_old_reports(keep: 10, max_age_days: 30)
|
|
146
|
+
ensure_base_directory
|
|
147
|
+
|
|
148
|
+
cutoff_time = Time.now - (max_age_days * 24 * 60 * 60)
|
|
149
|
+
deleted = []
|
|
150
|
+
|
|
151
|
+
# In centralized mode, base_dir contains package subfolders; in legacy mode it may contain reports directly.
|
|
152
|
+
reports_by_scope = report_directories
|
|
153
|
+
.map { |d| report_info(d) }
|
|
154
|
+
.compact
|
|
155
|
+
.group_by { |r| File.dirname(r[:path]) }
|
|
156
|
+
|
|
157
|
+
reports_by_scope.each_value do |reports|
|
|
158
|
+
sorted = reports.sort_by { |r| r[:timestamp] }.reverse
|
|
159
|
+
|
|
160
|
+
to_keep = sorted.take(keep)
|
|
161
|
+
to_keep += sorted.select { |r| r[:timestamp] > cutoff_time }
|
|
162
|
+
to_keep_paths = to_keep.map { |r| r[:path] }.uniq
|
|
163
|
+
|
|
164
|
+
sorted.each do |report|
|
|
165
|
+
next if to_keep_paths.include?(report[:path])
|
|
166
|
+
|
|
167
|
+
FileUtils.rm_rf(report[:path])
|
|
168
|
+
deleted << report[:path]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
deleted
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def generate_failure_filename(failure, index)
|
|
178
|
+
# Create a safe filename from the test name
|
|
179
|
+
test_name = failure.full_test_name.gsub(/\W+/, "_").downcase
|
|
180
|
+
test_name = test_name[0...50] if test_name.length > 50
|
|
181
|
+
format("%03d-%s.md", index, test_name)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def create_failure_index(failures, failures_dir)
|
|
185
|
+
index_file = File.join(failures_dir, "index.md")
|
|
186
|
+
|
|
187
|
+
lines = []
|
|
188
|
+
lines << "# Test Failures Index"
|
|
189
|
+
lines << ""
|
|
190
|
+
lines << "Total failures: #{failures.size}"
|
|
191
|
+
lines << ""
|
|
192
|
+
lines << "## Failures"
|
|
193
|
+
lines << ""
|
|
194
|
+
|
|
195
|
+
failures.each_with_index do |failure, index|
|
|
196
|
+
filename = generate_failure_filename(failure, index + 1)
|
|
197
|
+
lines << "#{index + 1}. [#{failure.full_test_name}](./#{filename})"
|
|
198
|
+
lines << " - **Type:** #{failure.type}"
|
|
199
|
+
lines << " - **Location:** `#{failure.location}`"
|
|
200
|
+
lines << ""
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
File.write(index_file, lines.join("\n"))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def ensure_base_directory
|
|
207
|
+
FileUtils.mkdir_p(@base_dir) unless Dir.exist?(@base_dir)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def ensure_directory(dir)
|
|
211
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def create_report_directory
|
|
215
|
+
ensure_base_directory
|
|
216
|
+
timestamp = @timestamp_generator.directory_name
|
|
217
|
+
report_dir = File.join(@base_dir, timestamp)
|
|
218
|
+
FileUtils.mkdir_p(report_dir)
|
|
219
|
+
report_dir
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def create_latest_symlink(report_dir)
|
|
223
|
+
latest_link = File.join(@base_dir, "latest")
|
|
224
|
+
|
|
225
|
+
# Remove existing symlink if present
|
|
226
|
+
FileUtils.rm_f(latest_link) if File.exist?(latest_link)
|
|
227
|
+
|
|
228
|
+
# Create new symlink to the latest report
|
|
229
|
+
FileUtils.ln_s(File.basename(report_dir), latest_link)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def save_json_report(report, report_dir)
|
|
233
|
+
report_file = File.join(report_dir, "report.json")
|
|
234
|
+
File.write(report_file, report.to_json)
|
|
235
|
+
report_file
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def save_markdown_report(report, report_dir)
|
|
239
|
+
report_file = File.join(report_dir, "report.md")
|
|
240
|
+
File.write(report_file, report.to_markdown)
|
|
241
|
+
report_file
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def save_all_formats(report, report_dir)
|
|
245
|
+
files = [
|
|
246
|
+
save_json_report(report, report_dir),
|
|
247
|
+
save_markdown_report(report, report_dir),
|
|
248
|
+
save_summary(report.result, report_dir)
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
if report.result.has_failures?
|
|
252
|
+
files << save_failures(report.result.failures_detail, report_dir)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
files.compact
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def report_info(dir)
|
|
259
|
+
return nil unless Dir.exist?(dir)
|
|
260
|
+
|
|
261
|
+
summary_file = File.join(dir, "summary.json")
|
|
262
|
+
return nil unless File.exist?(summary_file)
|
|
263
|
+
|
|
264
|
+
summary = JSON.parse(File.read(summary_file), symbolize_names: true)
|
|
265
|
+
|
|
266
|
+
{
|
|
267
|
+
path: dir,
|
|
268
|
+
name: File.basename(dir),
|
|
269
|
+
timestamp: Time.parse(summary[:timestamp]),
|
|
270
|
+
success: summary[:success],
|
|
271
|
+
stats: {
|
|
272
|
+
passed: summary[:passed],
|
|
273
|
+
failed: summary[:failed],
|
|
274
|
+
total: summary[:total]
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
rescue JSON::ParserError, ArgumentError
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def report_directories
|
|
282
|
+
top_level = Dir.glob(File.join(@base_dir, "*"))
|
|
283
|
+
.select { |d| File.directory?(d) && !File.symlink?(d) }
|
|
284
|
+
|
|
285
|
+
dirs = []
|
|
286
|
+
top_level.each do |entry|
|
|
287
|
+
if File.exist?(File.join(entry, "summary.json"))
|
|
288
|
+
dirs << entry
|
|
289
|
+
next
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
nested = Dir.glob(File.join(entry, "*"))
|
|
293
|
+
.select { |d| File.directory?(d) && !File.symlink?(d) }
|
|
294
|
+
.select { |d| File.exist?(File.join(d, "summary.json")) }
|
|
295
|
+
dirs.concat(nested)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
dirs
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|