ace-test-runner 0.27.0 → 0.28.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 +4 -4
- data/CHANGELOG.md +19 -0
- data/exe/ace-test-suite +13 -4
- data/lib/ace/test_runner/cli/commands/test.rb +1 -3
- data/lib/ace/test_runner/formatters/json_formatter.rb +3 -1
- data/lib/ace/test_runner/formatters/progress_file_formatter.rb +10 -7
- data/lib/ace/test_runner/formatters/progress_formatter.rb +10 -7
- data/lib/ace/test_runner/models/test_report.rb +1 -0
- data/lib/ace/test_runner/models/test_result.rb +12 -3
- data/lib/ace/test_runner/molecules/execution_evidence.rb +102 -0
- data/lib/ace/test_runner/molecules/failed_package_reporter.rb +4 -65
- data/lib/ace/test_runner/molecules/in_process_runner.rb +27 -5
- data/lib/ace/test_runner/molecules/report_storage.rb +24 -20
- data/lib/ace/test_runner/molecules/test_executor.rb +1 -0
- data/lib/ace/test_runner/organisms/sequential_target_executor.rb +5 -7
- data/lib/ace/test_runner/organisms/test_orchestrator.rb +41 -21
- data/lib/ace/test_runner/suite/display_manager.rb +7 -6
- data/lib/ace/test_runner/suite/orchestrator.rb +5 -3
- data/lib/ace/test_runner/suite/process_monitor.rb +46 -123
- data/lib/ace/test_runner/suite/result_aggregator.rb +17 -89
- data/lib/ace/test_runner/suite/simple_display_manager.rb +2 -1
- data/lib/ace/test_runner/version.rb +1 -1
- data/lib/ace/test_runner.rb +1 -0
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4626e822d15950006f1df84000e1e964163355052b9e4167bcccb963202bbd02
|
|
4
|
+
data.tar.gz: cd10d616e61501c57556f3227ec5f635ddd34534fc31ee7a7cbe4e1a6667e869
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5f6bb28ba6b56c6a84cb1b4dfda1f3d6cea951d204333a31ef8ed8fc57af97494c247e81f907a23ec295fa6f5a4cb517721bbdf6ad01fcf3ba495fdd3e2f461b
|
|
7
|
+
data.tar.gz: 15d0a4980512f6007f1468525111875826a8a57b5251bcd5fe53455552402b51cb776138c7a0426143c082e0fa0cb20052cf36947aad7d5ed72cd1dd11cbabdd
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.28.0] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Honor explicit `ace-test-suite --config FILE` without merging the default cascade, and refuse unreadable or malformed selected files. Default namespace discovery remains unchanged.
|
|
15
|
+
|
|
16
|
+
- Bind suite verdicts, counts and report links to each exact child invocation; preserve duplicate entries and no-save completion evidence during concurrent runs.
|
|
17
|
+
|
|
18
|
+
- Preserve unsuccessful execution through target aggregation and saved/CLI verdicts even after passing partial output; distinguish operator interruption from completed test errors.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Declare the required direct dependencies and minimum producer versions for this coordinated release: `ace-support-fs ~> 0.3`.
|
|
23
|
+
|
|
24
|
+
## [0.27.1] - 2026-09-29
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- Suite children now launch with the suite's own Ruby binary plus `-rbundler/setup`, so bundler discovers the checkout Gemfile by walking up from each package directory. This gives hermetic children a deterministic source-of-truth dependency surface instead of silently relying on PATH-resolved interpreters (which can fail through manager shims under fixture HOME/XDG overrides) or on globally installed ace gems. Closes the runner-level workspace LOAD_PATH follow-up from the hermetic-tests line.
|
|
28
|
+
|
|
10
29
|
## [0.27.0] - 2026-09-28
|
|
11
30
|
|
|
12
31
|
### Added
|
data/exe/ace-test-suite
CHANGED
|
@@ -7,7 +7,7 @@ require "ace/test_runner"
|
|
|
7
7
|
require "ace/test_runner/suite"
|
|
8
8
|
|
|
9
9
|
options = {
|
|
10
|
-
config:
|
|
10
|
+
config: nil,
|
|
11
11
|
parallel: nil,
|
|
12
12
|
group: nil,
|
|
13
13
|
verbose: false,
|
|
@@ -87,11 +87,20 @@ rescue OptionParser::InvalidArgument => e
|
|
|
87
87
|
exit 1
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
#
|
|
91
|
-
|
|
90
|
+
# Explicit files replace the cascade; omitting --config keeps namespace discovery.
|
|
91
|
+
begin
|
|
92
|
+
config = if options[:config]
|
|
93
|
+
Ace::Support::Config::Molecules::YamlLoader.load_file(options[:config]).data
|
|
94
|
+
else
|
|
95
|
+
Ace::Core.get("test", file: "suite")
|
|
96
|
+
end
|
|
97
|
+
rescue Ace::Support::Config::Error, Psych::Exception => e
|
|
98
|
+
puts "Error: Configuration file #{options[:config] || '.ace/test/suite.yml'}: #{e.message}"
|
|
99
|
+
exit 1
|
|
100
|
+
end
|
|
92
101
|
|
|
93
102
|
unless config.is_a?(Hash) && config["test_suite"].is_a?(Hash)
|
|
94
|
-
puts "Error:
|
|
103
|
+
puts "Error: Invalid or missing test suite configuration: #{options[:config] || '.ace/test/suite.yml'}"
|
|
95
104
|
puts
|
|
96
105
|
puts "Create a test suite configuration at .ace/test/suite.yml in your project root"
|
|
97
106
|
puts
|
|
@@ -221,10 +221,8 @@ module Ace
|
|
|
221
221
|
config_data = config_loader.load(options[:config_path])
|
|
222
222
|
config = config_loader.merge_with_options(config_data, options)
|
|
223
223
|
|
|
224
|
-
timestamp_generator = Atoms::TimestampGenerator.new
|
|
225
224
|
storage = Molecules::ReportStorage.new(
|
|
226
|
-
base_dir: config[:defaults][:report_dir] || ".ace-local/test/reports"
|
|
227
|
-
timestamp_generator: timestamp_generator
|
|
225
|
+
base_dir: config[:defaults][:report_dir] || ".ace-local/test/reports"
|
|
228
226
|
)
|
|
229
227
|
|
|
230
228
|
keep = options[:cleanup_keep] || 10
|
|
@@ -53,7 +53,9 @@ module Ace
|
|
|
53
53
|
assertions: result.assertions,
|
|
54
54
|
duration: result.duration,
|
|
55
55
|
pass_rate: result.pass_rate,
|
|
56
|
-
success: result.success
|
|
56
|
+
success: result.success?,
|
|
57
|
+
execution_success: result.execution_success,
|
|
58
|
+
execution_error: result.execution_error
|
|
57
59
|
},
|
|
58
60
|
timing: {
|
|
59
61
|
start_time: result.start_time&.iso8601,
|
|
@@ -24,8 +24,8 @@ module Ace
|
|
|
24
24
|
# Report directory - use actual report path if available
|
|
25
25
|
if @report_path
|
|
26
26
|
lines << "Details: #{@report_path}/"
|
|
27
|
-
|
|
28
|
-
lines << "
|
|
27
|
+
else
|
|
28
|
+
lines << "No saved report"
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# Compact single-line summary with emoji status
|
|
@@ -42,7 +42,9 @@ module Ace
|
|
|
42
42
|
lines << summary
|
|
43
43
|
|
|
44
44
|
# Add failure details if there are any
|
|
45
|
-
|
|
45
|
+
lines << "Execution failed: #{result.execution_error}" unless result.execution_success
|
|
46
|
+
|
|
47
|
+
if result.failed > 0 || result.errors > 0
|
|
46
48
|
lines << ""
|
|
47
49
|
total_failures = result.failed + result.errors
|
|
48
50
|
|
|
@@ -51,8 +53,8 @@ module Ace
|
|
|
51
53
|
|
|
52
54
|
# Show failure count header with reference to full report if needed
|
|
53
55
|
if total_failures > @max_failures_to_display
|
|
54
|
-
|
|
55
|
-
lines << "FAILURES (#{failures_to_show.size}/#{total_failures})
|
|
56
|
+
reference = @report_path ? "→ #{@report_path}/failures.json" : "(no saved report)"
|
|
57
|
+
lines << "FAILURES (#{failures_to_show.size}/#{total_failures}) #{reference}:"
|
|
56
58
|
else
|
|
57
59
|
lines << "FAILURES (#{total_failures}):"
|
|
58
60
|
end
|
|
@@ -87,8 +89,9 @@ module Ace
|
|
|
87
89
|
# If there are more failures than displayed
|
|
88
90
|
if result.failures_detail.size > @max_failures_to_display
|
|
89
91
|
remaining = result.failures_detail.size - @max_failures_to_display
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
reference = @report_path ? "See full report: #{@report_path}/failures.json" : "No saved report"
|
|
93
|
+
noun = remaining == 1 ? "failure" : "failures"
|
|
94
|
+
lines << " ... and #{remaining} more #{noun}. #{reference}"
|
|
92
95
|
end
|
|
93
96
|
end
|
|
94
97
|
|
|
@@ -29,8 +29,8 @@ module Ace
|
|
|
29
29
|
# Report directory - use actual report path if available
|
|
30
30
|
if @report_path
|
|
31
31
|
lines << "Details: #{@report_path}/"
|
|
32
|
-
|
|
33
|
-
lines << "
|
|
32
|
+
else
|
|
33
|
+
lines << "No saved report"
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
# Compact single-line summary with emoji status
|
|
@@ -43,7 +43,9 @@ module Ace
|
|
|
43
43
|
lines << summary
|
|
44
44
|
|
|
45
45
|
# Add failure details if there are any
|
|
46
|
-
|
|
46
|
+
lines << "Execution failed: #{result.execution_error}" unless result.execution_success
|
|
47
|
+
|
|
48
|
+
if result.failed > 0 || result.errors > 0
|
|
47
49
|
lines << ""
|
|
48
50
|
total_failures = result.failed + result.errors
|
|
49
51
|
|
|
@@ -64,8 +66,8 @@ module Ace
|
|
|
64
66
|
|
|
65
67
|
# Show failure count header with reference to full report if needed
|
|
66
68
|
if total_failures > @max_failures_to_display
|
|
67
|
-
|
|
68
|
-
lines << "#{label} (#{failures_to_show.size}/#{total_failures})
|
|
69
|
+
reference = @report_path ? "→ #{@report_path}/failures.json" : "(no saved report)"
|
|
70
|
+
lines << "#{label} (#{failures_to_show.size}/#{total_failures}) #{reference}:"
|
|
69
71
|
else
|
|
70
72
|
lines << "#{label} (#{total_failures}):"
|
|
71
73
|
end
|
|
@@ -100,8 +102,9 @@ module Ace
|
|
|
100
102
|
# If there are more failures than displayed
|
|
101
103
|
if result.failures_detail.size > @max_failures_to_display
|
|
102
104
|
remaining = result.failures_detail.size - @max_failures_to_display
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
reference = @report_path ? "See full report: #{@report_path}/failures.json" : "No saved report"
|
|
106
|
+
noun = remaining == 1 ? "failure" : "failures"
|
|
107
|
+
lines << " ... and #{remaining} more #{noun}. #{reference}"
|
|
105
108
|
end
|
|
106
109
|
end
|
|
107
110
|
|
|
@@ -76,6 +76,7 @@ module Ace
|
|
|
76
76
|
lines << ""
|
|
77
77
|
lines << "**Generated:** #{timestamp.strftime("%Y-%m-%d %H:%M:%S")}"
|
|
78
78
|
lines << "**Status:** #{success? ? "✅ Success" : "❌ Failed"}"
|
|
79
|
+
lines << "**Execution failure:** #{result.execution_error}" unless result.execution_success
|
|
79
80
|
lines << ""
|
|
80
81
|
|
|
81
82
|
lines << "## Summary"
|
|
@@ -7,7 +7,7 @@ module Ace
|
|
|
7
7
|
class TestResult
|
|
8
8
|
attr_accessor :passed, :failed, :skipped, :errors, :assertions,
|
|
9
9
|
:duration, :start_time, :end_time, :failures_detail,
|
|
10
|
-
:deprecations, :raw_output, :stderr
|
|
10
|
+
:deprecations, :raw_output, :stderr, :execution_success
|
|
11
11
|
|
|
12
12
|
def initialize(attributes = {})
|
|
13
13
|
@passed = attributes[:passed] || 0
|
|
@@ -22,6 +22,7 @@ module Ace
|
|
|
22
22
|
@deprecations = attributes[:deprecations] || []
|
|
23
23
|
@raw_output = attributes[:raw_output] || ""
|
|
24
24
|
@stderr = attributes[:stderr] || ""
|
|
25
|
+
@execution_success = attributes.fetch(:execution_success, true) == true
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def total_tests
|
|
@@ -29,11 +30,16 @@ module Ace
|
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def success?
|
|
32
|
-
failed == 0 && errors == 0
|
|
33
|
+
execution_success && failed == 0 && errors == 0
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def has_failures?
|
|
36
|
-
|
|
37
|
+
!success?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def execution_error
|
|
41
|
+
return nil if execution_success
|
|
42
|
+
stderr.to_s.strip.empty? ? "Test execution did not complete successfully" : stderr.to_s.strip
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
def has_skips?
|
|
@@ -55,6 +61,7 @@ module Ace
|
|
|
55
61
|
parts << "❌ #{failed} failed" if failed > 0
|
|
56
62
|
parts << "💥 #{errors} errors" if errors > 0
|
|
57
63
|
parts << "⚠️ #{skipped} skipped" if skipped > 0
|
|
64
|
+
parts << "❌ execution failed" unless execution_success
|
|
58
65
|
|
|
59
66
|
parts.empty? ? "No tests executed" : parts.join(", ")
|
|
60
67
|
end
|
|
@@ -70,6 +77,8 @@ module Ace
|
|
|
70
77
|
duration: duration,
|
|
71
78
|
pass_rate: pass_rate,
|
|
72
79
|
success: success?,
|
|
80
|
+
execution_success: execution_success,
|
|
81
|
+
execution_error: execution_error,
|
|
73
82
|
start_time: start_time&.iso8601,
|
|
74
83
|
end_time: end_time&.iso8601,
|
|
75
84
|
failures: failures_detail.map(&:to_h),
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module TestRunner
|
|
9
|
+
module Molecules
|
|
10
|
+
# The completion file is published last, after the optional report is complete.
|
|
11
|
+
# A suite reads this exact file once; latest is only a navigation convenience.
|
|
12
|
+
class ExecutionEvidence
|
|
13
|
+
CHANNEL = "ACE_TEST_EXECUTION"
|
|
14
|
+
COUNTS = %w[total passed failed errors skipped assertions].freeze
|
|
15
|
+
attr_reader :identity, :path, :package, :entry_id, :package_path
|
|
16
|
+
|
|
17
|
+
def initialize(package_path:, package: nil, entry_id: nil, identity: SecureRandom.uuid, path: nil)
|
|
18
|
+
@identity = identity
|
|
19
|
+
@package_path = File.realpath(package_path)
|
|
20
|
+
@package = package || File.basename(@package_path)
|
|
21
|
+
@entry_id = entry_id || identity
|
|
22
|
+
@path = path || File.join(@package_path, ".ace-local/test/completions", "#{identity}.json")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.for_invocation(package_path)
|
|
26
|
+
channel = ENV[CHANNEL]
|
|
27
|
+
return new(package_path: package_path) unless channel
|
|
28
|
+
|
|
29
|
+
data = JSON.parse(channel, symbolize_names: true)
|
|
30
|
+
raise Error, "Execution package path mismatch" unless File.realpath(package_path) == data[:package_path]
|
|
31
|
+
|
|
32
|
+
new(**data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def channel
|
|
36
|
+
JSON.generate(identity: identity, path: path, package: package, entry_id: entry_id, package_path: package_path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def publish(result, files:, report_dir: nil)
|
|
40
|
+
data = {
|
|
41
|
+
execution_id: identity, entry_id: entry_id, package: package, package_path: package_path,
|
|
42
|
+
pid: Process.pid, completed: true, selected_files: files, report_dir: report_dir,
|
|
43
|
+
total: result.total_tests, passed: result.passed, failed: result.failed, errors: result.errors,
|
|
44
|
+
skipped: result.skipped, assertions: result.assertions, duration: result.duration,
|
|
45
|
+
success: result.success?, error: result.execution_error
|
|
46
|
+
}
|
|
47
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
48
|
+
temporary = "#{path}.#{SecureRandom.uuid}.tmp"
|
|
49
|
+
File.write(temporary, JSON.generate(data))
|
|
50
|
+
# Exclusive publication: a completed invocation may never be replaced.
|
|
51
|
+
File.link(temporary, path)
|
|
52
|
+
data
|
|
53
|
+
ensure
|
|
54
|
+
FileUtils.rm_f(temporary) if temporary
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def read(pid:, save_reports:)
|
|
58
|
+
data = JSON.parse(File.read(path))
|
|
59
|
+
valid = data.is_a?(Hash) && data["completed"] == true &&
|
|
60
|
+
data["execution_id"] == identity && data["entry_id"] == entry_id &&
|
|
61
|
+
data["package"] == package && data["package_path"] == package_path && data["pid"] == pid &&
|
|
62
|
+
data["selected_files"].is_a?(Array) && data["selected_files"].all? { |file| file.is_a?(String) } &&
|
|
63
|
+
COUNTS.all? { |key| data[key].is_a?(Integer) && data[key] >= 0 } &&
|
|
64
|
+
[true, false].include?(data["success"]) && data["duration"].is_a?(Numeric) &&
|
|
65
|
+
data["duration"].finite? && data["duration"] >= 0 &&
|
|
66
|
+
data["total"] == data.values_at("passed", "failed", "errors", "skipped").sum &&
|
|
67
|
+
(!data["success"] || data["failed"] + data["errors"] == 0) &&
|
|
68
|
+
(!data["selected_files"].empty? || data["total"] == 0) &&
|
|
69
|
+
(!data["success"] || data["total"] > 0 || data["selected_files"].empty?)
|
|
70
|
+
raise Error, "Invalid or mismatched execution completion evidence" unless valid
|
|
71
|
+
|
|
72
|
+
if save_reports
|
|
73
|
+
validate_report!(data)
|
|
74
|
+
elsif data["report_dir"]
|
|
75
|
+
raise Error, "Unexpected saved report in no-save execution"
|
|
76
|
+
end
|
|
77
|
+
data.transform_keys(&:to_sym)
|
|
78
|
+
rescue SystemCallError, JSON::ParserError, TypeError => e
|
|
79
|
+
raise Error, "Unverifiable execution completion evidence: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def validate_report!(data)
|
|
85
|
+
directory = data["report_dir"]
|
|
86
|
+
raise Error, "Missing concrete execution report directory" unless directory.is_a?(String) &&
|
|
87
|
+
File.absolute_path(directory) == directory && File.basename(directory) == identity && !File.symlink?(directory)
|
|
88
|
+
|
|
89
|
+
summary = JSON.parse(File.read(File.join(directory, "summary.json")))
|
|
90
|
+
report = JSON.parse(File.read(File.join(directory, "report.json")))
|
|
91
|
+
raise Error, "Mismatched execution summary/report" unless summary.is_a?(Hash) && report.is_a?(Hash) &&
|
|
92
|
+
summary["execution_id"] == identity && report.dig("metadata", "execution_id") == identity &&
|
|
93
|
+
report["report_path"] == directory && report["files_tested"] == data["selected_files"] &&
|
|
94
|
+
report.dig("result", "total_tests") == data["total"] &&
|
|
95
|
+
COUNTS.all? { |key| summary[key] == data[key] } && summary["success"] == data["success"] &&
|
|
96
|
+
summary["duration"] == data["duration"] &&
|
|
97
|
+
%w[passed failed errors skipped assertions duration success].all? { |key| report.dig("result", key) == data[key] }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -10,79 +10,18 @@ module Ace
|
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
12
|
def format_for_display(package)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
package_name = extract_name(package) || File.basename(path)
|
|
16
|
-
report_path = Atoms::ReportPathResolver.call(
|
|
17
|
-
path,
|
|
18
|
-
report_root: report_root,
|
|
19
|
-
package_name: package_name
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
if report_path
|
|
23
|
-
begin
|
|
24
|
-
relative_path = Pathname.new(report_path).relative_path_from(Dir.pwd)
|
|
25
|
-
" → See #{relative_path}"
|
|
26
|
-
rescue => e
|
|
27
|
-
warn "Failed to calculate relative path: #{e.message}" if debug_mode?
|
|
28
|
-
" → See #{report_path}"
|
|
29
|
-
end
|
|
30
|
-
else
|
|
31
|
-
reports_path = fallback_reports_path(path, report_root, package_name)
|
|
32
|
-
begin
|
|
33
|
-
relative_path = Pathname.new(reports_path).relative_path_from(Dir.pwd)
|
|
34
|
-
" → Check #{relative_path}/ for details"
|
|
35
|
-
rescue => e
|
|
36
|
-
warn "Failed to calculate relative path: #{e.message}" if debug_mode?
|
|
37
|
-
" → Check #{reports_path}/ for details"
|
|
38
|
-
end
|
|
39
|
-
end
|
|
13
|
+
directory = package[:report_dir] || package["report_dir"]
|
|
14
|
+
directory ? " → See #{relative_or_absolute(directory)}" : " → No saved report"
|
|
40
15
|
end
|
|
41
16
|
|
|
42
17
|
def format_for_markdown(package)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
package_name = extract_name(package) || File.basename(path)
|
|
46
|
-
report_path = Atoms::ReportPathResolver.call(
|
|
47
|
-
path,
|
|
48
|
-
report_root: report_root,
|
|
49
|
-
package_name: package_name
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
if report_path
|
|
53
|
-
relative_report_path = relative_or_absolute(report_path)
|
|
54
|
-
"- Report: `#{relative_report_path}`"
|
|
55
|
-
else
|
|
56
|
-
reports_path = fallback_reports_path(path, report_root, package_name)
|
|
57
|
-
relative_reports_path = relative_or_absolute(reports_path)
|
|
58
|
-
"- Report: Check `#{relative_reports_path}/` for details"
|
|
59
|
-
end
|
|
18
|
+
directory = package[:report_dir] || package["report_dir"]
|
|
19
|
+
directory ? "- Report: `#{relative_or_absolute(directory)}`" : "- No saved report"
|
|
60
20
|
end
|
|
61
21
|
|
|
62
22
|
class << self
|
|
63
23
|
private
|
|
64
24
|
|
|
65
|
-
def extract_path(package)
|
|
66
|
-
package[:path] || package["path"]
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def extract_name(package)
|
|
70
|
-
package[:name] || package["name"]
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def extract_report_root(package)
|
|
74
|
-
package[:report_root] || package["report_root"]
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def fallback_reports_path(path, report_root, package_name)
|
|
78
|
-
if report_root
|
|
79
|
-
short_name = package_name.to_s.sub(/\Aace-/, "")
|
|
80
|
-
return File.join(report_root, short_name) unless short_name.empty?
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
File.join(path, "test-reports")
|
|
84
|
-
end
|
|
85
|
-
|
|
86
25
|
def debug_mode?
|
|
87
26
|
ENV["DEBUG"]
|
|
88
27
|
end
|
|
@@ -10,6 +10,16 @@ module Ace
|
|
|
10
10
|
# Runs tests directly in the current Ruby process without spawning subprocesses
|
|
11
11
|
# This provides significantly faster execution for unit tests that don't need isolation
|
|
12
12
|
class InProcessRunner
|
|
13
|
+
# Minitest converts ordinary timeout exceptions into completed test
|
|
14
|
+
# errors. Its pass-through SystemExit boundary preserves execution
|
|
15
|
+
# deadlines; this owner catches the private control-flow exception.
|
|
16
|
+
class ExecutionTimeout < SystemExit
|
|
17
|
+
def initialize(*)
|
|
18
|
+
super(124)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
private_constant :ExecutionTimeout
|
|
22
|
+
|
|
13
23
|
def initialize(timeout: nil, launch_env:)
|
|
14
24
|
@timeout = timeout
|
|
15
25
|
@launch_env = launch_env # Hermetic fixture environment applied for the test run
|
|
@@ -121,7 +131,7 @@ module Ace
|
|
|
121
131
|
# Run Minitest with captured output
|
|
122
132
|
# Suppress Minitest's own output by using null reporter
|
|
123
133
|
exit_code = if @timeout
|
|
124
|
-
Timeout.timeout(@timeout) do
|
|
134
|
+
Timeout.timeout(@timeout, ExecutionTimeout) do
|
|
125
135
|
run_minitest_silent(options)
|
|
126
136
|
end
|
|
127
137
|
else
|
|
@@ -129,7 +139,7 @@ module Ace
|
|
|
129
139
|
end
|
|
130
140
|
|
|
131
141
|
success = exit_code == true || exit_code == 0
|
|
132
|
-
rescue Timeout::Error
|
|
142
|
+
rescue ExecutionTimeout, Timeout::Error
|
|
133
143
|
stderr_io.puts "Test execution timed out after #{@timeout} seconds"
|
|
134
144
|
success = false
|
|
135
145
|
exit_code = 124
|
|
@@ -256,9 +266,21 @@ module Ace
|
|
|
256
266
|
args << "--name" << "/#{pattern}/"
|
|
257
267
|
end
|
|
258
268
|
|
|
259
|
-
#
|
|
260
|
-
#
|
|
261
|
-
|
|
269
|
+
# Minitest swallows Interrupt and returns a normal failure. Preserve
|
|
270
|
+
# operator SIGINT as interruption so the owner never writes a completed
|
|
271
|
+
# report for this invocation.
|
|
272
|
+
interrupted = false
|
|
273
|
+
previous_handler = Signal.trap("INT") do
|
|
274
|
+
interrupted = true
|
|
275
|
+
raise Interrupt
|
|
276
|
+
end
|
|
277
|
+
begin
|
|
278
|
+
result = Minitest.run(args)
|
|
279
|
+
raise Interrupt if interrupted
|
|
280
|
+
result
|
|
281
|
+
ensure
|
|
282
|
+
Signal.trap("INT", previous_handler)
|
|
283
|
+
end
|
|
262
284
|
end
|
|
263
285
|
end
|
|
264
286
|
end
|
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Ace
|
|
4
6
|
module TestRunner
|
|
5
7
|
module Molecules
|
|
6
8
|
# Handles storage of test reports to filesystem
|
|
7
9
|
class ReportStorage
|
|
8
|
-
def initialize(base_dir: ".ace-local/test/reports"
|
|
10
|
+
def initialize(base_dir: ".ace-local/test/reports")
|
|
9
11
|
@base_dir = base_dir
|
|
10
|
-
@timestamp_generator = timestamp_generator || Atoms::TimestampGenerator.new
|
|
11
12
|
end
|
|
12
13
|
|
|
13
|
-
def
|
|
14
|
+
def reserve(execution_id = SecureRandom.uuid)
|
|
15
|
+
ensure_base_directory
|
|
16
|
+
directory = File.join(File.expand_path(@base_dir), execution_id)
|
|
17
|
+
Dir.mkdir(directory)
|
|
18
|
+
directory
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def save_report(report, format: :json, report_dir: nil)
|
|
14
22
|
ensure_base_directory
|
|
15
|
-
report_dir
|
|
23
|
+
report_dir ||= reserve
|
|
24
|
+
report.report_path = report_dir
|
|
25
|
+
report.metadata[:execution_id] = File.basename(report_dir)
|
|
16
26
|
|
|
17
27
|
case format
|
|
18
28
|
when :json
|
|
19
29
|
save_json_report(report, report_dir)
|
|
30
|
+
save_summary(report.result, report_dir)
|
|
20
31
|
when :markdown
|
|
21
32
|
save_markdown_report(report, report_dir)
|
|
22
33
|
when :all
|
|
@@ -50,6 +61,8 @@ module Ace
|
|
|
50
61
|
summary_file = File.join(report_dir, "summary.json")
|
|
51
62
|
|
|
52
63
|
summary_data = {
|
|
64
|
+
execution_id: File.basename(report_dir),
|
|
65
|
+
assertions: result.assertions,
|
|
53
66
|
passed: result.passed,
|
|
54
67
|
failed: result.failed,
|
|
55
68
|
errors: result.errors,
|
|
@@ -61,7 +74,7 @@ module Ace
|
|
|
61
74
|
timestamp: Time.now.iso8601
|
|
62
75
|
}
|
|
63
76
|
|
|
64
|
-
File.
|
|
77
|
+
File.open(summary_file, "wx") { |file| file.write(JSON.pretty_generate(summary_data)) }
|
|
65
78
|
summary_file
|
|
66
79
|
end
|
|
67
80
|
|
|
@@ -211,27 +224,18 @@ module Ace
|
|
|
211
224
|
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
212
225
|
end
|
|
213
226
|
|
|
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
227
|
def create_latest_symlink(report_dir)
|
|
223
228
|
latest_link = File.join(@base_dir, "latest")
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
FileUtils.ln_s(File.basename(report_dir), latest_link)
|
|
229
|
+
temporary = "#{latest_link}.#{SecureRandom.uuid}"
|
|
230
|
+
File.symlink(File.basename(report_dir), temporary)
|
|
231
|
+
File.rename(temporary, latest_link)
|
|
232
|
+
ensure
|
|
233
|
+
FileUtils.rm_f(temporary) if temporary
|
|
230
234
|
end
|
|
231
235
|
|
|
232
236
|
def save_json_report(report, report_dir)
|
|
233
237
|
report_file = File.join(report_dir, "report.json")
|
|
234
|
-
File.
|
|
238
|
+
File.open(report_file, "wx") { |file| file.write(report.to_json) }
|
|
235
239
|
report_file
|
|
236
240
|
end
|
|
237
241
|
|
|
@@ -139,6 +139,7 @@ module Ace
|
|
|
139
139
|
stdout: results.map { |r| r[:stdout] }.join("\n"),
|
|
140
140
|
stderr: results.map { |r| r[:stderr] }.join("\n"),
|
|
141
141
|
commands: results.map { |r| r[:command] },
|
|
142
|
+
execution_outputs: results.map { |r| r[:stdout] },
|
|
142
143
|
start_time: results.first[:start_time],
|
|
143
144
|
end_time: results.last[:end_time],
|
|
144
145
|
duration: results.sum { |r| r[:duration] },
|
|
@@ -37,7 +37,7 @@ module Ace
|
|
|
37
37
|
# Parse result
|
|
38
38
|
parsed = if result[:commands] && result[:commands].is_a?(Array)
|
|
39
39
|
# Multiple commands executed (per-file)
|
|
40
|
-
aggregate_results(result
|
|
40
|
+
aggregate_results(result.fetch(:execution_outputs), @result_parser)
|
|
41
41
|
else
|
|
42
42
|
@result_parser.parse_output(result[:stdout])
|
|
43
43
|
end
|
|
@@ -81,10 +81,8 @@ module Ace
|
|
|
81
81
|
|
|
82
82
|
private
|
|
83
83
|
|
|
84
|
-
def aggregate_results(
|
|
85
|
-
#
|
|
86
|
-
individual_outputs = combined_output.split(/^Started with run options/)
|
|
87
|
-
individual_outputs.shift if individual_outputs.first && individual_outputs.first.empty?
|
|
84
|
+
def aggregate_results(individual_outputs, parser)
|
|
85
|
+
# Preserve exact executor boundaries, including headerless partial output.
|
|
88
86
|
|
|
89
87
|
aggregated = {
|
|
90
88
|
summary: {
|
|
@@ -102,7 +100,6 @@ module Ace
|
|
|
102
100
|
}
|
|
103
101
|
|
|
104
102
|
individual_outputs.each do |output|
|
|
105
|
-
output = "Started with run options" + output
|
|
106
103
|
parsed = parser.parse_output(output)
|
|
107
104
|
|
|
108
105
|
aggregated[:summary][:runs] += parsed[:summary][:runs]
|
|
@@ -160,7 +157,8 @@ module Ace
|
|
|
160
157
|
combined_stderr = all_results.map { |r| r[:stderr] }.join("\n")
|
|
161
158
|
|
|
162
159
|
# Determine success
|
|
163
|
-
success =
|
|
160
|
+
success = all_results.all? { |result| result[:success] == true } &&
|
|
161
|
+
total_summary[:failures] == 0 && total_summary[:errors] == 0
|
|
164
162
|
|
|
165
163
|
{
|
|
166
164
|
stdout: combined_stdout,
|