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,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
|