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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/test/runner.yml +35 -0
  3. data/.ace-defaults/test/suite.yml +31 -0
  4. data/.ace-defaults/test-runner/config.yml +61 -0
  5. data/CHANGELOG.md +626 -0
  6. data/LICENSE +21 -0
  7. data/README.md +42 -0
  8. data/Rakefile +14 -0
  9. data/exe/ace-test +26 -0
  10. data/exe/ace-test-suite +149 -0
  11. data/lib/ace/test_runner/atoms/command_builder.rb +165 -0
  12. data/lib/ace/test_runner/atoms/lazy_loader.rb +62 -0
  13. data/lib/ace/test_runner/atoms/line_number_resolver.rb +86 -0
  14. data/lib/ace/test_runner/atoms/report_directory_resolver.rb +48 -0
  15. data/lib/ace/test_runner/atoms/report_path_resolver.rb +67 -0
  16. data/lib/ace/test_runner/atoms/result_parser.rb +254 -0
  17. data/lib/ace/test_runner/atoms/test_detector.rb +114 -0
  18. data/lib/ace/test_runner/atoms/test_folder_detector.rb +53 -0
  19. data/lib/ace/test_runner/atoms/test_type_detector.rb +83 -0
  20. data/lib/ace/test_runner/atoms/timestamp_generator.rb +103 -0
  21. data/lib/ace/test_runner/cli/commands/test.rb +326 -0
  22. data/lib/ace/test_runner/cli.rb +16 -0
  23. data/lib/ace/test_runner/formatters/base_formatter.rb +102 -0
  24. data/lib/ace/test_runner/formatters/json_formatter.rb +90 -0
  25. data/lib/ace/test_runner/formatters/markdown_formatter.rb +91 -0
  26. data/lib/ace/test_runner/formatters/progress_file_formatter.rb +164 -0
  27. data/lib/ace/test_runner/formatters/progress_formatter.rb +328 -0
  28. data/lib/ace/test_runner/models/test_configuration.rb +165 -0
  29. data/lib/ace/test_runner/models/test_failure.rb +95 -0
  30. data/lib/ace/test_runner/models/test_group.rb +105 -0
  31. data/lib/ace/test_runner/models/test_report.rb +145 -0
  32. data/lib/ace/test_runner/models/test_result.rb +86 -0
  33. data/lib/ace/test_runner/molecules/cli_argument_parser.rb +263 -0
  34. data/lib/ace/test_runner/molecules/config_loader.rb +162 -0
  35. data/lib/ace/test_runner/molecules/deprecation_fixer.rb +204 -0
  36. data/lib/ace/test_runner/molecules/failed_package_reporter.rb +100 -0
  37. data/lib/ace/test_runner/molecules/failure_analyzer.rb +249 -0
  38. data/lib/ace/test_runner/molecules/in_process_runner.rb +249 -0
  39. data/lib/ace/test_runner/molecules/package_resolver.rb +106 -0
  40. data/lib/ace/test_runner/molecules/pattern_resolver.rb +146 -0
  41. data/lib/ace/test_runner/molecules/rake_integration.rb +218 -0
  42. data/lib/ace/test_runner/molecules/report_storage.rb +303 -0
  43. data/lib/ace/test_runner/molecules/smart_test_executor.rb +107 -0
  44. data/lib/ace/test_runner/molecules/test_executor.rb +162 -0
  45. data/lib/ace/test_runner/organisms/agent_reporter.rb +384 -0
  46. data/lib/ace/test_runner/organisms/report_generator.rb +151 -0
  47. data/lib/ace/test_runner/organisms/sequential_group_executor.rb +185 -0
  48. data/lib/ace/test_runner/organisms/test_orchestrator.rb +648 -0
  49. data/lib/ace/test_runner/rake_task.rb +90 -0
  50. data/lib/ace/test_runner/suite/display_helpers.rb +117 -0
  51. data/lib/ace/test_runner/suite/display_manager.rb +204 -0
  52. data/lib/ace/test_runner/suite/duration_estimator.rb +50 -0
  53. data/lib/ace/test_runner/suite/orchestrator.rb +120 -0
  54. data/lib/ace/test_runner/suite/process_monitor.rb +268 -0
  55. data/lib/ace/test_runner/suite/result_aggregator.rb +176 -0
  56. data/lib/ace/test_runner/suite/simple_display_manager.rb +122 -0
  57. data/lib/ace/test_runner/suite.rb +22 -0
  58. data/lib/ace/test_runner/version.rb +7 -0
  59. data/lib/ace/test_runner.rb +69 -0
  60. 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