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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require "rake/tasklib"
5
+
6
+ module Ace
7
+ module TestRunner
8
+ # Custom Rake task that uses ace-test instead of standard Minitest runner
9
+ # This allows seamless integration with existing Rake workflows
10
+ class RakeTask < Rake::TaskLib
11
+ attr_accessor :name, :description, :libs, :pattern, :verbose, :format,
12
+ :warning, :loader, :options, :test_files
13
+
14
+ def initialize(name = :test)
15
+ @name = name
16
+ @description = "Run tests with ace-test"
17
+ @libs = %w[test lib]
18
+ @pattern = "test/**/*_test.rb"
19
+ @verbose = false
20
+ @format = nil
21
+ @warning = false
22
+ @loader = nil
23
+ @options = nil
24
+ @test_files = nil
25
+
26
+ yield self if block_given?
27
+ define
28
+ end
29
+
30
+ def define
31
+ desc @description
32
+ task @name do
33
+ run_tests
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def run_tests
40
+ # Build ace-test command
41
+ command = build_command
42
+
43
+ # Execute ace-test with sanitized environment
44
+ # Strip assignment context vars to prevent tests from resolving to wrong assignments
45
+ puts command if verbose
46
+ env = ENV.to_h.merge({
47
+ "ACE_ASSIGN_ID" => nil,
48
+ "ACE_ASSIGN_FORK_ROOT" => nil
49
+ })
50
+ success = system(env, command)
51
+
52
+ # Exit with proper code for CI/CD
53
+ exit(1) unless success
54
+ end
55
+
56
+ def build_command
57
+ cmd = ["ace-test"]
58
+
59
+ # Add format if specified
60
+ cmd << "--format" << format if format
61
+
62
+ # Add verbose flag
63
+ cmd << "--verbose" if verbose
64
+
65
+ # Handle TEST environment variable (specific test file)
66
+ if ENV["TEST"]
67
+ cmd << ENV["TEST"]
68
+ elsif test_files
69
+ # Handle explicit test files
70
+ cmd.concat(Array(test_files))
71
+ elsif pattern
72
+ # Use pattern to find files (ace-test will handle this internally)
73
+ # For now, we'll let ace-test use its default detection
74
+ end
75
+
76
+ # Handle TESTOPTS environment variable
77
+ if ENV["TESTOPTS"]
78
+ # Parse TESTOPTS and add to command
79
+ opts = ENV["TESTOPTS"].split(/\s+/)
80
+ cmd.concat(opts)
81
+ end
82
+
83
+ # Add any additional options
84
+ cmd.concat(Array(options)) if options
85
+
86
+ cmd.join(" ")
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Suite
6
+ # DisplayHelpers provides shared formatting methods for display managers.
7
+ # This module centralizes common output formatting logic used by both
8
+ # SimpleDisplayManager (line-by-line) and DisplayManager (animated).
9
+ #
10
+ # == Interface Requirements
11
+ #
12
+ # Including classes must implement:
13
+ # color(text, color_name) - Apply color to text (or return text unchanged)
14
+ # @param text [String] the text to colorize
15
+ # @param color_name [Symbol] one of :green, :red, :yellow
16
+ # @return [String] colorized text or plain text
17
+ #
18
+ # This module provides a `colorize` wrapper that delegates to `color()`.
19
+ # Internal methods use `colorize()` for consistency; including classes
20
+ # only need to implement `color()`.
21
+ #
22
+ module DisplayHelpers
23
+ # Render the overall summary section
24
+ # @param summary [Hash] summary data from orchestrator
25
+ # @param start_time [Time] when the test run started
26
+ # @param separator [String] visual separator line
27
+ def render_summary(summary, start_time, separator)
28
+ puts
29
+ puts separator
30
+
31
+ total_duration = Time.now - start_time
32
+ total_skipped = summary[:total_skipped] || 0
33
+
34
+ # Show packages with skipped tests first (least important, scrolls away)
35
+ render_skipped_packages(summary, total_skipped)
36
+
37
+ # Show failed packages (important but less than status)
38
+ render_failed_packages(summary)
39
+
40
+ # Duration
41
+ puts "Duration: #{sprintf("%.2f", total_duration)}s"
42
+
43
+ # Stats (packages, tests, assertions)
44
+ puts "Packages: #{summary[:packages_passed]} passed, #{summary[:packages_failed]} failed"
45
+ render_tests_line(summary, total_skipped)
46
+ render_assertions_line(summary)
47
+
48
+ # Overall status message LAST (most visible)
49
+ puts
50
+ render_status_message(summary[:packages_failed], total_skipped)
51
+
52
+ puts separator
53
+ end
54
+
55
+ private
56
+
57
+ def render_status_message(packages_failed, total_skipped)
58
+ if packages_failed == 0 && total_skipped == 0
59
+ puts colorize("✓ ALL TESTS PASSED", :green)
60
+ elsif packages_failed == 0 && total_skipped > 0
61
+ puts colorize("✓ ALL TESTS PASSED", :green)
62
+ else
63
+ puts colorize("✗ SOME TESTS FAILED", :red)
64
+ end
65
+ end
66
+
67
+ def render_tests_line(summary, total_skipped)
68
+ return unless summary[:total_tests] > 0
69
+
70
+ if total_skipped > 0
71
+ puts "Tests: #{summary[:total_passed]} passed, #{summary[:total_failed]} failed, #{total_skipped} skipped"
72
+ else
73
+ puts "Tests: #{summary[:total_passed]} passed, #{summary[:total_failed]} failed"
74
+ end
75
+ end
76
+
77
+ def render_assertions_line(summary)
78
+ return unless summary[:total_assertions] && summary[:total_assertions] > 0
79
+
80
+ assertions_failed = summary[:assertions_failed] || 0
81
+ assertions_passed = summary[:total_assertions] - assertions_failed
82
+ puts "Assertions: #{assertions_passed} passed, #{assertions_failed} failed"
83
+ end
84
+
85
+ def render_failed_packages(summary)
86
+ return unless summary[:failed_packages] && !summary[:failed_packages].empty?
87
+
88
+ puts
89
+ puts "Failed packages:"
90
+ summary[:failed_packages].each do |pkg|
91
+ puts " - #{pkg[:name]}: #{pkg[:failures]} failures, #{pkg[:errors]} errors"
92
+ puts Ace::TestRunner::Molecules::FailedPackageReporter.format_for_display(pkg)
93
+ end
94
+ end
95
+
96
+ def render_skipped_packages(summary, total_skipped)
97
+ return unless total_skipped > 0
98
+
99
+ packages_with_skips = summary[:results].select { |r| (r[:skipped] || 0) > 0 }
100
+ return if packages_with_skips.empty?
101
+
102
+ skip_parts = packages_with_skips.map { |pkg| "#{pkg[:package]} (#{pkg[:skipped]})" }
103
+ puts "Skipped: #{skip_parts.join(", ")}"
104
+ puts
105
+ end
106
+
107
+ # Colorize text - must be implemented by including class
108
+ # @param text [String] text to colorize
109
+ # @param color_name [Symbol] color name (:green, :red, :yellow)
110
+ # @return [String] colorized text or plain text if color disabled
111
+ def colorize(text, color_name)
112
+ color(text, color_name)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "display_helpers"
4
+
5
+ module Ace
6
+ module TestRunner
7
+ module Suite
8
+ class DisplayManager
9
+ include DisplayHelpers
10
+
11
+ attr_reader :packages, :config, :lines, :start_time
12
+
13
+ def initialize(packages, config)
14
+ @packages = packages
15
+ @config = config
16
+ @lines = {}
17
+ @package_status = {}
18
+ @start_time = Time.now
19
+ @use_color = config.dig("test_suite", "display", "color") != false
20
+ @last_refresh = Time.now
21
+ @refresh_interval = config.dig("test_suite", "display", "update_interval") || 0.1
22
+ end
23
+
24
+ def initialize_display
25
+ # Clear screen like Ctrl+L (preserves scrollback)
26
+ print "\033[H\033[J"
27
+
28
+ # Print header
29
+ puts separator
30
+ puts " ACE Test Suite Runner - Running #{@packages.size} packages"
31
+ puts separator
32
+ puts
33
+
34
+ # Reserve lines for each package
35
+ @packages.each_with_index do |package, index|
36
+ @lines[package["name"]] = index + 5 # Account for header lines
37
+ @package_status[package["name"]] = {status: :waiting}
38
+ print_package_line(package["name"])
39
+ end
40
+
41
+ # Print footer space
42
+ puts
43
+ puts
44
+ @footer_line = @lines.values.max + 3
45
+ end
46
+
47
+ def update_package(package, status, output = nil)
48
+ @package_status[package["name"]] = status
49
+ print_package_line(package["name"])
50
+ update_footer
51
+ end
52
+
53
+ def refresh
54
+ # Only refresh if enough time has passed
55
+ return if Time.now - @last_refresh < @refresh_interval
56
+
57
+ @package_status.each do |name, _status|
58
+ print_package_line(name)
59
+ end
60
+ update_footer
61
+ @last_refresh = Time.now
62
+ end
63
+
64
+ # Finalize display by moving cursor past the display area.
65
+ # In progress mode, package results are already shown inline during updates,
66
+ # so we skip redrawing the results table. The overall summary is handled by show_summary.
67
+ def finalize_display
68
+ move_to_line(@footer_line + 1)
69
+ puts
70
+ end
71
+
72
+ # Alias for backward compatibility
73
+ alias_method :show_final_results, :finalize_display
74
+
75
+ # Display the summary section using shared helpers
76
+ def show_summary(summary)
77
+ render_summary(summary, @start_time, separator)
78
+ end
79
+
80
+ private
81
+
82
+ def print_package_line(name)
83
+ status = @package_status[name]
84
+ line = @lines[name]
85
+
86
+ move_to_line(line)
87
+ print "\033[K" # Clear line
88
+
89
+ # Format package name (fixed width, no brackets)
90
+ pkg_name = name.ljust(25)
91
+
92
+ case status[:status]
93
+ when :waiting
94
+ icon = color("·", :gray)
95
+ elapsed = " 0.00s"
96
+ progress = "[············] waiting"
97
+ print "#{icon} #{elapsed} #{pkg_name} #{progress}"
98
+
99
+ when :running
100
+ icon = color("⋯", :cyan)
101
+ duration = status.dig(:results, :duration) || status[:elapsed] || 0
102
+ elapsed = sprintf("%5.2fs", duration)
103
+ progress_bar = build_progress_bar(status)
104
+ count = if status[:total] && status[:total] > 0
105
+ "#{status[:progress]}/#{status[:total]}"
106
+ else
107
+ "running"
108
+ end
109
+ print "#{icon} #{elapsed} #{pkg_name} #{progress_bar} #{count}"
110
+
111
+ when :completed
112
+ results = status[:results] || {}
113
+ duration = results[:duration] || status[:elapsed] || 0
114
+ elapsed = sprintf("%5.2fs", duration)
115
+
116
+ tests = results[:tests] || 0
117
+ assertions = results[:assertions] || 0
118
+ failures = results[:failures] || 0
119
+ errors = results[:errors] || 0
120
+ skipped = results[:skipped] || 0
121
+
122
+ if status[:success]
123
+ icon = package_status_icon(true, skipped)
124
+ failure_count = failures
125
+ else
126
+ icon = package_status_icon(false, 0)
127
+ failure_count = failures + errors
128
+ end
129
+
130
+ # Format: ICON TIME PACKAGE TESTS ASSERTS FAIL [SKIP]
131
+ tests_col = "#{tests.to_s.rjust(4)} tests"
132
+ asserts_col = "#{assertions.to_s.rjust(5)} asserts"
133
+ fail_col = "#{failure_count.to_s.rjust(3)} fail"
134
+
135
+ line_text = "#{icon} #{elapsed} #{pkg_name} #{tests_col} #{asserts_col} #{fail_col}"
136
+ line_text += " #{skipped} skip" if skipped > 0
137
+
138
+ print line_text
139
+ end
140
+ end
141
+
142
+ # Determines the appropriate status icon for a package
143
+ # Returns ? (yellow) for successful packages with skipped tests (informational)
144
+ # Returns ✓ (green) for successful packages without skipped tests
145
+ # Returns ✗ (red) for failed packages
146
+ def package_status_icon(success, skipped_count)
147
+ return color("✗", :red) unless success
148
+ (skipped_count > 0) ? color("?", :yellow) : color("✓", :green)
149
+ end
150
+
151
+ def build_progress_bar(status)
152
+ bar_width = 13
153
+ if status[:total] && status[:total] > 0
154
+ progress = status[:progress] || 0
155
+ filled = (progress.to_f / status[:total] * bar_width).round
156
+ else
157
+ # Animate based on elapsed time if no total
158
+ duration = status.dig(:results, :duration) || status[:elapsed] || 0
159
+ filled = ((duration % 3) * bar_width / 3).round
160
+ end
161
+
162
+ filled = [filled, bar_width].min
163
+ empty = bar_width - filled
164
+
165
+ "[" + color("▓" * filled, :green) + "░" * empty + "]"
166
+ end
167
+
168
+ def update_footer
169
+ move_to_line(@footer_line)
170
+ print "\033[K"
171
+
172
+ active = @package_status.count { |_, s| s[:status] == :running }
173
+ completed = @package_status.count { |_, s| s[:status] == :completed }
174
+ waiting = @package_status.count { |_, s| s[:status] == :waiting }
175
+
176
+ print "Active: #{active} | Completed: #{completed} | Waiting: #{waiting}"
177
+ end
178
+
179
+ def move_to_line(line)
180
+ print "\033[#{line};1H"
181
+ end
182
+
183
+ def separator
184
+ "═" * 65
185
+ end
186
+
187
+ def color(text, color_name)
188
+ return text unless @use_color
189
+
190
+ colors = {
191
+ green: "\033[32m",
192
+ red: "\033[31m",
193
+ yellow: "\033[33m",
194
+ cyan: "\033[36m",
195
+ gray: "\033[90m",
196
+ reset: "\033[0m"
197
+ }
198
+
199
+ "#{colors[color_name]}#{text}#{colors[:reset]}"
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module TestRunner
7
+ module Suite
8
+ # Estimates expected test duration for packages based on historical data.
9
+ # Used by Orchestrator to schedule slowest packages first, preventing
10
+ # them from becoming bottlenecks at the end of parallel test runs.
11
+ class DurationEstimator
12
+ def initialize(report_root: nil)
13
+ @report_root = report_root
14
+ end
15
+
16
+ # Read historical duration from package's latest summary.json
17
+ #
18
+ # @param package [Hash] Package config with "path" key
19
+ # @return [Float, nil] Duration in seconds, or nil if unavailable
20
+ def estimate(package)
21
+ reports_dir = Atoms::ReportPathResolver.report_directory(
22
+ package["path"],
23
+ report_root: @report_root,
24
+ package_name: package["name"]
25
+ )
26
+ return nil unless reports_dir
27
+
28
+ summary_file = File.join(reports_dir, "summary.json")
29
+ return nil unless File.exist?(summary_file)
30
+
31
+ data = JSON.parse(File.read(summary_file))
32
+ data["duration"]
33
+ rescue JSON::ParserError, Errno::ENOENT, Errno::EACCES
34
+ nil
35
+ end
36
+
37
+ # Enrich packages with expected_duration from historical data
38
+ #
39
+ # @param packages [Array<Hash>] Package configs
40
+ # @return [Array<Hash>] Same packages with expected_duration added
41
+ def enrich_packages(packages)
42
+ packages.each do |pkg|
43
+ pkg["expected_duration"] = estimate(pkg) || 0
44
+ end
45
+ packages
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+
6
+ module Ace
7
+ module TestRunner
8
+ module Suite
9
+ class Orchestrator
10
+ attr_reader :config, :packages, :results, :running_processes
11
+
12
+ def initialize(config)
13
+ @config = config
14
+ @packages = config.dig("test_suite", "packages") || []
15
+ @results = {}
16
+ @running_processes = {}
17
+ @completed_packages = []
18
+ @waiting_packages = []
19
+ @failed_packages = []
20
+
21
+ # Use ace-config's project root detection
22
+ require "ace/support/config"
23
+ @project_root = Ace::Support::Config.find_project_root
24
+
25
+ # Resolve package paths relative to project root
26
+ resolve_package_paths! if @project_root
27
+ end
28
+
29
+ def run
30
+ validate_packages!
31
+ test_options = (@config.dig("test_suite", "test_options") || {}).dup
32
+ report_root = normalize_report_root(test_options["report_dir"])
33
+ test_options["report_dir"] = report_root if report_root
34
+
35
+ display_manager = create_display_manager
36
+ process_monitor = ProcessMonitor.new(@config.dig("test_suite", "max_parallel") || 10)
37
+
38
+ # Enrich packages with historical duration data for scheduling
39
+ estimator = DurationEstimator.new(report_root: report_root)
40
+ estimator.enrich_packages(@packages)
41
+
42
+ # Sort by expected duration (descending), then priority (ascending)
43
+ # This ensures slowest packages start first, preventing end-of-run bottlenecks
44
+ sorted_packages = @packages.sort_by { |p| [-(p["expected_duration"] || 0), p["priority"] || 999] }
45
+
46
+ # Initialize display
47
+ display_manager.initialize_display
48
+
49
+ # Start processes
50
+ sorted_packages.each do |package|
51
+ process_monitor.start_package(package, test_options) do |pkg, status, output|
52
+ display_manager.update_package(pkg, status, output)
53
+
54
+ if status[:completed]
55
+ @completed_packages << pkg
56
+ @results[pkg["name"]] = status
57
+ end
58
+ end
59
+ end
60
+
61
+ # Wait for all processes to complete
62
+ while process_monitor.running?
63
+ process_monitor.check_processes
64
+ display_manager.refresh
65
+ sleep(@config.dig("test_suite", "display", "update_interval") || 0.1)
66
+ end
67
+
68
+ # Final display
69
+ display_manager.show_final_results
70
+
71
+ # Aggregate results
72
+ aggregator = ResultAggregator.new(@packages, report_root: report_root)
73
+ summary = aggregator.aggregate
74
+
75
+ display_manager.show_summary(summary)
76
+
77
+ # Return exit code based on results
78
+ (summary[:packages_failed] > 0) ? 1 : 0
79
+ end
80
+
81
+ private
82
+
83
+ def validate_packages!
84
+ @packages.each do |package|
85
+ unless Dir.exist?(package["path"])
86
+ raise "Package directory not found: #{package["path"]} for #{package["name"]}"
87
+ end
88
+ end
89
+ end
90
+
91
+ def resolve_package_paths!
92
+ @packages.each do |package|
93
+ # If path is relative (doesn't start with /), resolve it relative to project root
94
+ if package["path"] && !package["path"].start_with?("/")
95
+ package["path"] = File.join(@project_root, package["path"])
96
+ end
97
+ end
98
+ end
99
+
100
+ # Select display manager based on --progress flag.
101
+ # Default: SimpleDisplayManager (line-by-line, pipe-friendly)
102
+ # With --progress: DisplayManager (animated ANSI progress bars)
103
+ def create_display_manager
104
+ if @config.dig("test_suite", "progress")
105
+ DisplayManager.new(@packages, @config)
106
+ else
107
+ SimpleDisplayManager.new(@packages, @config)
108
+ end
109
+ end
110
+
111
+ def normalize_report_root(report_root)
112
+ return nil if report_root.nil? || report_root.to_s.empty?
113
+ return report_root if Pathname.new(report_root).absolute?
114
+
115
+ File.expand_path(report_root, @project_root || Dir.pwd)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end