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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 737b47ec80d9081b78036ca433c88b8a98ce7c7fbcb42dc44317c9143130745c
4
- data.tar.gz: f572472d8569456a2a5296c70daa674125c72dfc2a9a19ad1753cb47712e96eb
3
+ metadata.gz: 4626e822d15950006f1df84000e1e964163355052b9e4167bcccb963202bbd02
4
+ data.tar.gz: cd10d616e61501c57556f3227ec5f635ddd34534fc31ee7a7cbe4e1a6667e869
5
5
  SHA512:
6
- metadata.gz: bba645e19d66f65b131a4c13ad0b7674c461797a8b88157d3e38a4ca4d768c32ffa32baadc1eb120d27eefb67f625bb73bc5b21e422eb41e381399cd3a1cb9c3
7
- data.tar.gz: fa9ba7f05016bac56e7d99daab178b1550603c48e1f2454cbe7fb5f37ce4c72c25b9973efafff370ab2ab0d1fd65527f9af0a3bc5183ed016a8b01c22c497bc4
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: ".ace/test/suite.yml",
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
- # Load the config using namespace method
91
- config = Ace::Core.get("test", file: "suite")
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: Configuration file not found: .ace/test/suite.yml"
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
- elsif @configuration && @configuration[:save_reports]
28
- lines << "Details: #{@configuration[:report_dir] || ".ace-local/test/reports"}/latest/"
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
- if result.has_failures?
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
- report_path = @report_path || "#{@configuration[:report_dir] || ".ace-local/test/reports"}/latest"
55
- lines << "FAILURES (#{failures_to_show.size}/#{total_failures}) → #{report_path}/failures.json:"
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
- report_path = @report_path || "#{@configuration[:report_dir] || ".ace-local/test/reports"}/latest"
91
- lines << " ... and #{remaining} more #{(remaining == 1) ? "failure" : "failures"}. See full report: #{report_path}/failures.json"
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
- elsif @configuration && @configuration[:save_reports]
33
- lines << "Details: #{@configuration[:report_dir] || ".ace-local/test/reports"}/latest/"
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
- if result.has_failures?
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
- report_path = @report_path || "#{@configuration[:report_dir] || ".ace-local/test/reports"}/latest"
68
- lines << "#{label} (#{failures_to_show.size}/#{total_failures}) → #{report_path}/failures.json:"
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
- report_path = @report_path || "#{@configuration[:report_dir] || ".ace-local/test/reports"}/latest"
104
- lines << " ... and #{remaining} more #{(remaining == 1) ? "failure" : "failures"}. See full report: #{report_path}/failures.json"
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
- failed > 0 || errors > 0
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
- path = extract_path(package)
14
- report_root = extract_report_root(package)
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
- path = extract_path(package)
44
- report_root = extract_report_root(package)
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
- # Reporter already set up before loading test files
260
- # Run Minitest
261
- Minitest.run(args)
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", timestamp_generator: nil)
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 save_report(report, format: :json)
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 = create_report_directory
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.write(summary_file, JSON.pretty_generate(summary_data))
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
- # Remove existing symlink if present
226
- FileUtils.rm_f(latest_link) if File.exist?(latest_link)
227
-
228
- # Create new symlink to the latest report
229
- FileUtils.ln_s(File.basename(report_dir), latest_link)
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.write(report_file, report.to_json)
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[:stdout], @result_parser)
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(combined_output, parser)
85
- # Split output by test file executions
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 = total_summary[:failures] == 0 && total_summary[:errors] == 0
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,