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
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ desc "Run tests using ace-test"
7
+ task :test do
8
+ sh "ace-test"
9
+ end
10
+
11
+ desc "Run tests directly (CI mode)"
12
+ Minitest::TestTask.create(:ci)
13
+
14
+ task default: :test
data/exe/ace-test ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Prevent Minitest from autorunning when ace-test exits
5
+ ENV["MT_NO_AUTORUN"] = "1"
6
+
7
+ require_relative "../lib/ace/test_runner"
8
+
9
+ # Start ace-support-cli with exception-based exit code handling (per ADR-023)
10
+ # IMPORTANT: Use exit! (not exit) to skip Ruby's at_exit handlers.
11
+ # This prevents Minitest from auto-running again via its at_exit hook.
12
+ # When ace-test uses in-process (direct) execution, test files are loaded
13
+ # into the current process. Minitest registers an at_exit handler that would
14
+ # re-run all tests on normal exit. Using exit! bypasses this.
15
+ # See guide://testable-code-patterns for details on this pattern.
16
+ begin
17
+ exit_code = Ace::Support::Cli::Runner.new(Ace::TestRunner::CLI::Commands::Test).call(args: ARGV)
18
+ $stdout.flush
19
+ $stderr.flush
20
+ exit!(exit_code.respond_to?(:to_i) ? exit_code.to_i : 0)
21
+ rescue Ace::Support::Cli::Error => e
22
+ warn e.message
23
+ $stdout.flush
24
+ $stderr.flush
25
+ exit!(e.exit_code)
26
+ end
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "ace/core"
6
+ require "ace/test_runner"
7
+ require "ace/test_runner/suite"
8
+
9
+ options = {
10
+ config: ".ace/test/suite.yml",
11
+ parallel: nil,
12
+ group: nil,
13
+ verbose: false,
14
+ progress: false
15
+ }
16
+
17
+ parser = OptionParser.new do |opts|
18
+ # Custom banner matching ace-support-cli ALL-CAPS style
19
+ opts.banner = <<~BANNER.chomp
20
+ NAME
21
+ ace-test-suite - Run all tests across the monorepo
22
+
23
+ USAGE
24
+ ace-test-suite [OPTIONS]
25
+
26
+ OPTIONS
27
+ BANNER
28
+
29
+ opts.on("-c", "--config FILE", "Configuration file (default: .ace/test/suite.yml)") do |config|
30
+ options[:config] = config
31
+ end
32
+
33
+ opts.on("-p", "--parallel N", Integer, "Maximum parallel processes") do |n|
34
+ options[:parallel] = n
35
+ end
36
+
37
+ opts.on("-g", "--group GROUP", "Run only packages in specified group") do |group|
38
+ options[:group] = group
39
+ end
40
+
41
+ opts.on("-v", "--verbose", "Show verbose output") do
42
+ options[:verbose] = true
43
+ end
44
+
45
+ opts.on("--progress", "Enable live animated display with progress bars") do
46
+ options[:progress] = true
47
+ end
48
+
49
+ opts.on("--no-color", "Disable colored output") do
50
+ options[:no_color] = true
51
+ end
52
+
53
+ opts.on("-h", "--help", "Show this help") do
54
+ puts opts
55
+ puts
56
+ puts "EXAMPLES"
57
+ puts " $ ace-test-suite Run all (simple line-by-line output)"
58
+ puts " $ ace-test-suite --progress Show live animated progress bars"
59
+ puts " $ ace-test-suite --parallel 16 Run with 16 parallel processes"
60
+ puts " $ ace-test-suite --group foundation Run only foundation group"
61
+ puts " $ ace-test-suite --verbose Show detailed test output"
62
+ exit 0
63
+ end
64
+
65
+ opts.on("--version", "Show version") do
66
+ puts "ace-test-suite #{Ace::TestRunner::VERSION}"
67
+ exit 0
68
+ end
69
+ end
70
+
71
+ begin
72
+ parser.parse!(ARGV)
73
+ rescue OptionParser::InvalidArgument => e
74
+ puts "Error: #{e.message}"
75
+ puts
76
+ puts parser
77
+ exit 1
78
+ end
79
+
80
+ # Load the config using namespace method
81
+ config = Ace::Core.get("test", file: "suite")
82
+
83
+ unless config.is_a?(Hash) && config["test_suite"].is_a?(Hash)
84
+ puts "Error: Configuration file not found: .ace/test/suite.yml"
85
+ puts
86
+ puts "Create a test suite configuration at .ace/test/suite.yml in your project root"
87
+ puts
88
+ puts "Example configuration:"
89
+ puts <<~YAML
90
+ test_suite:
91
+ max_parallel: 10
92
+ packages:
93
+ - name: ace-support-core
94
+ path: ./ace-support-core
95
+ group: foundation
96
+ priority: 1
97
+ - name: ace-test-runner
98
+ path: ./ace-test-runner
99
+ group: foundation
100
+ priority: 1
101
+ test_options:
102
+ format: compact
103
+ save_reports: true
104
+ YAML
105
+ exit 1
106
+ end
107
+
108
+ # Override config with command line options if provided
109
+ if options[:parallel]
110
+ config["test_suite"]["max_parallel"] = options[:parallel]
111
+ end
112
+
113
+ if options[:group]
114
+ config["test_suite"]["packages"].select! { |p| p["group"] == options[:group] }
115
+ if config["test_suite"]["packages"].empty?
116
+ puts "Error: No packages found in group '#{options[:group]}'"
117
+ exit 1
118
+ end
119
+ end
120
+
121
+ if options[:no_color]
122
+ config["test_suite"]["display"] ||= {}
123
+ config["test_suite"]["display"]["color"] = false
124
+ end
125
+
126
+ if options[:verbose]
127
+ config["test_suite"]["test_options"] ||= {}
128
+ config["test_suite"]["test_options"]["verbose"] = true
129
+ end
130
+
131
+ if options[:progress]
132
+ config["test_suite"]["progress"] = true
133
+ end
134
+
135
+ # Run the test suite with the config
136
+ begin
137
+ exit_code = Ace::TestRunner::Suite.run(config)
138
+ exit exit_code
139
+ rescue Ace::TestRunner::Suite::Error => e
140
+ puts "Error: #{e.message}"
141
+ exit 1
142
+ rescue Interrupt
143
+ puts "\n\nTest suite execution interrupted"
144
+ exit 130
145
+ rescue => e
146
+ puts "Unexpected error: #{e.message}"
147
+ puts e.backtrace if options[:verbose]
148
+ exit 1
149
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "line_number_resolver"
4
+
5
+ module Ace
6
+ module TestRunner
7
+ module Atoms
8
+ # Builds test execution commands
9
+ class CommandBuilder
10
+ def initialize(ruby_command: "ruby", bundler: true)
11
+ @ruby_command = ruby_command
12
+ @bundler = bundler
13
+ end
14
+
15
+ def build_test_command(files, options = {})
16
+ cmd_parts = []
17
+
18
+ # Use bundler if available and requested
19
+ cmd_parts << "bundle exec" if @bundler && bundler_available?
20
+
21
+ # Ruby command
22
+ cmd_parts << @ruby_command
23
+
24
+ # Add test framework options
25
+ cmd_parts << "-Ilib:test" unless options[:no_load_path]
26
+
27
+ # Note: fail_fast is handled by test executor, not minitest
28
+ # We don't use minitest/fail_fast gem to avoid extra dependencies
29
+
30
+ # Add the test files
31
+ if files.is_a?(Array)
32
+ # Check if any file has a line number (file:line format)
33
+ has_line_numbers = files.any? { |f| f.match?(/:\d+$/) }
34
+
35
+ if has_line_numbers
36
+ # For files with line numbers, resolve to test names and filter
37
+ build_line_number_command(cmd_parts, files, options)
38
+ else
39
+ # Build a Ruby script that requires each file and fails on LoadError
40
+ requires_script = files.map do |f|
41
+ # Add ./ prefix if it's a relative path without one
42
+ path = f.start_with?("/", "./") ? f : "./#{f}"
43
+ # Escape the path for shell safety
44
+ escaped_path = path.gsub("'", "\\\\'")
45
+ "begin; require '#{escaped_path}'; rescue LoadError => e; STDERR.puts \\\"Failed to load #{escaped_path}: \\\" + e.message; exit(1); end"
46
+ end.join("; ")
47
+
48
+ # Build the script parts
49
+ script_parts = []
50
+ # Inject ARGV with --verbose for Minitest if profile is requested
51
+ # (Ruby's --verbose flag only sets $VERBOSE, doesn't enable Minitest verbose mode)
52
+ script_parts << "ARGV.replace(['--verbose'])" if options[:profile]
53
+ script_parts << requires_script
54
+ script_parts << "exit_code = Minitest.autorun"
55
+ script_parts << "exit(exit_code)"
56
+
57
+ # Execute the requires and then run Minitest
58
+ cmd_parts << "-e"
59
+ # Use double quotes to wrap the entire script
60
+ cmd_parts << "\"#{script_parts.join("; ")}\""
61
+ end
62
+ elsif files.match?(/:\d+$/)
63
+ # Check if single file has line number
64
+ build_line_number_command(cmd_parts, [files], options)
65
+ elsif options[:profile]
66
+ # Single file without line number
67
+ cmd_parts << "-e"
68
+ escaped_path = files.gsub("'", "\\\\'")
69
+ path = files.start_with?("/", "./") ? escaped_path : "./#{escaped_path}"
70
+ cmd_parts << "\"ARGV.replace(['--verbose']); require '#{path}'; exit_code = Minitest.autorun; exit(exit_code)\""
71
+ # Inject ARGV with --verbose for Minitest profiling
72
+ else
73
+ # Just pass file as argument (Minitest autoruns)
74
+ cmd_parts << files
75
+ end
76
+
77
+ # Add any extra arguments
78
+ if options[:args]
79
+ cmd_parts.concat(Array(options[:args]))
80
+ end
81
+
82
+ cmd_parts.join(" ")
83
+ end
84
+
85
+ def build_single_file_command(file, options = {})
86
+ # Single file uses the same logic as multiple files
87
+ build_test_command(file, options)
88
+ end
89
+
90
+ def build_pattern_command(pattern)
91
+ cmd = []
92
+ cmd << "bundle exec" if @bundler && bundler_available?
93
+ cmd << @ruby_command
94
+ cmd << "-Ilib:test"
95
+ cmd << "-e"
96
+ cmd << %{'Dir.glob("#{pattern}").each { |f| require f }'}
97
+
98
+ cmd.join(" ")
99
+ end
100
+
101
+ private
102
+
103
+ def build_line_number_command(cmd_parts, files, options = {})
104
+ # For files with line numbers, we need to:
105
+ # 1. Load each file
106
+ # 2. Resolve line numbers to test names
107
+ # 3. Filter using --name option
108
+
109
+ file_requires = []
110
+ test_names = []
111
+
112
+ files.each do |file_with_line|
113
+ parsed = LineNumberResolver.parse_file_with_line(file_with_line)
114
+ file_path = parsed[:file]
115
+ line_number = parsed[:line]
116
+
117
+ # Add ./ prefix if it's a relative path without one
118
+ path = file_path.start_with?("/", "./") ? file_path : "./#{file_path}"
119
+ escaped_path = path.gsub("'", "\\\\'")
120
+
121
+ # Always require the file
122
+ file_requires << "require '#{escaped_path}'"
123
+
124
+ # If there's a line number, resolve it to a test name
125
+ if line_number
126
+ test_name = LineNumberResolver.resolve_test_at_line(file_path, line_number)
127
+ if test_name
128
+ test_names << test_name
129
+ end
130
+ end
131
+ end
132
+
133
+ # Build the command
134
+ script_parts = []
135
+
136
+ # Require all files
137
+ script_parts << file_requires.join("; ")
138
+
139
+ # Set up ARGV with --name filter and --verbose if needed
140
+ argv_args = []
141
+ argv_args << "--verbose" if options[:profile]
142
+ if test_names.any?
143
+ # Create a regex pattern that matches any of the test names
144
+ pattern = test_names.map { |name| Regexp.escape(name) }.join("|")
145
+ argv_args << "--name"
146
+ argv_args << "/#{pattern}/"
147
+ end
148
+ script_parts << "ARGV.replace([#{argv_args.map { |a| "'#{a}'" }.join(", ")}])" if argv_args.any?
149
+
150
+ # Run Minitest
151
+ script_parts << "exit_code = Minitest.autorun"
152
+ script_parts << "exit(exit_code)"
153
+
154
+ # Add to command
155
+ cmd_parts << "-e"
156
+ cmd_parts << "\"#{script_parts.join("; ")}\""
157
+ end
158
+
159
+ def bundler_available?
160
+ @bundler_available ||= system("which bundle > /dev/null 2>&1")
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Atoms
6
+ class LazyLoader
7
+ class << self
8
+ def load_formatter(format)
9
+ case format
10
+ when "json"
11
+ require_relative "../formatters/json_formatter"
12
+ Formatters::JsonFormatter
13
+ when "markdown"
14
+ require_relative "../formatters/markdown_formatter"
15
+ Formatters::MarkdownFormatter
16
+ when "progress"
17
+ require_relative "../formatters/progress_formatter"
18
+ Formatters::ProgressFormatter
19
+ when "progress-file"
20
+ require_relative "../formatters/progress_file_formatter"
21
+ Formatters::ProgressFileFormatter
22
+ else
23
+ raise ArgumentError, "Unknown format: #{format}"
24
+ end
25
+ end
26
+
27
+ def load_molecule(name)
28
+ case name
29
+ when :pattern_resolver
30
+ require_relative "../molecules/pattern_resolver"
31
+ Molecules::PatternResolver
32
+ when :config_loader
33
+ require_relative "../molecules/config_loader"
34
+ Molecules::ConfigLoader
35
+ when :deprecation_fixer
36
+ require_relative "../molecules/deprecation_fixer"
37
+ Molecules::DeprecationFixer
38
+ when :rake_integration
39
+ require_relative "../molecules/rake_integration"
40
+ Molecules::RakeIntegration
41
+ else
42
+ raise ArgumentError, "Unknown molecule: #{name}"
43
+ end
44
+ end
45
+
46
+ def load_organism(name)
47
+ case name
48
+ when :report_generator
49
+ require_relative "../organisms/report_generator"
50
+ Organisms::ReportGenerator
51
+ when :agent_reporter
52
+ require_relative "../organisms/agent_reporter"
53
+ Organisms::AgentReporter
54
+ else
55
+ raise ArgumentError, "Unknown organism: #{name}"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Atoms
6
+ # Resolves line numbers to test method names
7
+ module LineNumberResolver
8
+ module_function
9
+
10
+ # Given a file and line number, find the test method name
11
+ # Returns the test name or nil if not found
12
+ def resolve_test_at_line(file_path, line_number)
13
+ return nil unless File.exist?(file_path)
14
+
15
+ content = File.read(file_path)
16
+ lines = content.split("\n")
17
+
18
+ # Find all test methods and their line ranges
19
+ test_methods = extract_test_methods(lines)
20
+
21
+ # Find the test that contains the specified line
22
+ test_methods.find do |test|
23
+ line_number >= test[:start_line] && line_number <= test[:end_line]
24
+ end&.fetch(:name)
25
+ end
26
+
27
+ # Extract test method names and their line ranges from file content
28
+ def extract_test_methods(lines)
29
+ test_methods = []
30
+ current_test = nil
31
+
32
+ lines.each_with_index do |line, index|
33
+ line_number = index + 1
34
+
35
+ # Match test method definitions: def test_something or test "something"
36
+ if line =~ /^\s*(def\s+(test_\w+)|test\s+["'](.+)["']\s+do)/
37
+ test_name = $2 || $3 # Either def test_name or test "name"
38
+
39
+ # Convert test "name" to test_name format for minitest --name option
40
+ test_name = test_name.gsub(/\s+/, "_") if test_name && test_name.include?(" ")
41
+
42
+ # Close previous test if any
43
+ if current_test
44
+ current_test[:end_line] = line_number - 1
45
+ test_methods << current_test
46
+ end
47
+
48
+ current_test = {
49
+ name: test_name,
50
+ start_line: line_number,
51
+ end_line: lines.size # Default to end of file
52
+ }
53
+ elsif line =~ /^\s*end\s*(#.*)?$/ && current_test
54
+ # Found an end keyword - could be end of test method
55
+ # Simple heuristic: if we're at the same or less indentation level, close the test
56
+ current_indent = line[/^\s*/].length
57
+
58
+ if current_indent <= 2 # Assuming test methods are indented at most 2 spaces
59
+ current_test[:end_line] = line_number
60
+ test_methods << current_test
61
+ current_test = nil
62
+ end
63
+ end
64
+ end
65
+
66
+ # Close last test if still open
67
+ if current_test
68
+ current_test[:end_line] = lines.size
69
+ test_methods << current_test
70
+ end
71
+
72
+ test_methods
73
+ end
74
+
75
+ # Given "file.rb:123", split into file and line number
76
+ def parse_file_with_line(file_with_line)
77
+ if file_with_line =~ /^(.+):(\d+)$/
78
+ {file: $1, line: $2.to_i}
79
+ else
80
+ {file: file_with_line, line: nil}
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/config"
4
+
5
+ module Ace
6
+ module TestRunner
7
+ module Atoms
8
+ # Resolves canonical test report directories.
9
+ module ReportDirectoryResolver
10
+ module_function
11
+
12
+ DEFAULT_REPORT_ROOT = ".ace-local/test/reports"
13
+
14
+ def resolve_report_root(raw_report_dir, explicit_cli_override:, start_path:)
15
+ root = raw_report_dir.to_s.strip
16
+ root = DEFAULT_REPORT_ROOT if root.empty?
17
+
18
+ return File.expand_path(root, start_path) if explicit_cli_override
19
+
20
+ project_root = Ace::Support::Config.find_project_root(start_path: start_path)
21
+ base = project_root || start_path
22
+ File.expand_path(root, base)
23
+ end
24
+
25
+ def infer_package_name(package_dir:, test_files:, cwd:)
26
+ if package_dir && !package_dir.to_s.empty?
27
+ return File.basename(File.expand_path(package_dir))
28
+ end
29
+
30
+ first = Array(test_files).first
31
+ if first && (match = first.match(%r{\A(.+?)/test/}))
32
+ return File.basename(match[1])
33
+ end
34
+
35
+ File.basename(File.expand_path(cwd))
36
+ end
37
+
38
+ def short_package_name(package_name)
39
+ package_name.to_s.sub(/\Aace-/, "")
40
+ end
41
+
42
+ def resolve_package_report_dir(report_root:, package_name:)
43
+ File.join(report_root, short_package_name(package_name))
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Atoms
6
+ module ReportPathResolver
7
+ module_function
8
+
9
+ REPORT_PRIORITY = [
10
+ "failures.json",
11
+ "summary.json",
12
+ "report.md",
13
+ "report.json",
14
+ "raw_output.txt"
15
+ ].freeze
16
+
17
+ # Resolves the best available report file path for a package
18
+ #
19
+ # The resolver checks for report files in the following priority order:
20
+ # 1. failures.json - Detailed failure information
21
+ # 2. summary.json - Summary of test results
22
+ # 3. report.md - Markdown formatted report
23
+ # 4. report.json - JSON formatted report
24
+ # 5. raw_output.txt - Raw test output
25
+ #
26
+ # @param package_path [String] The root path of the package
27
+ # @param report_root [String, nil] Centralized report root
28
+ # @param package_name [String, nil] Package name used for centralized lookup
29
+ # @return [String, nil] The absolute path to the best available report file, or nil if none exist
30
+ def call(package_path, report_root: nil, package_name: nil)
31
+ return nil unless package_path && Dir.exist?(package_path)
32
+
33
+ reports_dir = report_directory(package_path, report_root: report_root, package_name: package_name)
34
+ return nil unless reports_dir
35
+
36
+ REPORT_PRIORITY.each do |filename|
37
+ path = File.join(reports_dir, filename)
38
+ return path if File.exist?(path)
39
+ end
40
+
41
+ nil
42
+ end
43
+
44
+ def report_directory(package_path, report_root: nil, package_name: nil)
45
+ candidates(package_path, report_root, package_name).each do |dir|
46
+ return dir if Dir.exist?(dir)
47
+ end
48
+
49
+ nil
50
+ end
51
+
52
+ def candidates(package_path, report_root, package_name)
53
+ dirs = []
54
+
55
+ if report_root
56
+ short_name = package_name.to_s.sub(/\Aace-/, "")
57
+ short_name = File.basename(package_path).sub(/\Aace-/, "") if short_name.empty?
58
+ dirs << File.join(report_root, short_name, "latest")
59
+ end
60
+
61
+ dirs << File.join(package_path, "test-reports", "latest")
62
+ dirs.uniq
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end