ace-test-runner 0.27.1 → 0.28.1

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/test-runner/config.yml +2 -0
  3. data/CHANGELOG.md +27 -0
  4. data/exe/ace-test-suite +13 -4
  5. data/lib/ace/test_runner/atoms/command_builder.rb +60 -86
  6. data/lib/ace/test_runner/atoms/line_number_resolver.rb +70 -63
  7. data/lib/ace/test_runner/atoms/result_parser.rb +1 -1
  8. data/lib/ace/test_runner/cli/commands/test.rb +2 -3
  9. data/lib/ace/test_runner/formatters/json_formatter.rb +3 -1
  10. data/lib/ace/test_runner/formatters/progress_file_formatter.rb +10 -7
  11. data/lib/ace/test_runner/formatters/progress_formatter.rb +10 -7
  12. data/lib/ace/test_runner/models/test_report.rb +1 -0
  13. data/lib/ace/test_runner/models/test_result.rb +12 -3
  14. data/lib/ace/test_runner/models/test_selection_plan.rb +44 -0
  15. data/lib/ace/test_runner/molecules/cli_argument_parser.rb +9 -1
  16. data/lib/ace/test_runner/molecules/execution_evidence.rb +102 -0
  17. data/lib/ace/test_runner/molecules/failed_package_reporter.rb +4 -65
  18. data/lib/ace/test_runner/molecules/in_process_runner.rb +48 -37
  19. data/lib/ace/test_runner/molecules/pattern_resolver.rb +8 -5
  20. data/lib/ace/test_runner/molecules/report_storage.rb +24 -20
  21. data/lib/ace/test_runner/molecules/selection_resolver.rb +45 -0
  22. data/lib/ace/test_runner/molecules/selection_verifier.rb +78 -0
  23. data/lib/ace/test_runner/molecules/smart_test_executor.rb +4 -0
  24. data/lib/ace/test_runner/molecules/test_executor.rb +11 -4
  25. data/lib/ace/test_runner/organisms/sequential_target_executor.rb +6 -7
  26. data/lib/ace/test_runner/organisms/test_orchestrator.rb +44 -21
  27. data/lib/ace/test_runner/suite/display_manager.rb +7 -6
  28. data/lib/ace/test_runner/suite/orchestrator.rb +5 -3
  29. data/lib/ace/test_runner/suite/process_monitor.rb +37 -119
  30. data/lib/ace/test_runner/suite/result_aggregator.rb +17 -89
  31. data/lib/ace/test_runner/suite/simple_display_manager.rb +2 -1
  32. data/lib/ace/test_runner/version.rb +1 -1
  33. data/lib/ace/test_runner.rb +1 -0
  34. metadata +34 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b9e68c09f883cccd0d324be3b33f54de394293a8b0a4e7421ed697b8dffff7e
4
- data.tar.gz: 92f8e49cccbf575821c3195bcdc92b7cf393c728474d7d116f77f3f94ad3fcae
3
+ metadata.gz: 8fc2c5132ba01f77ad703fc44426139e83ae2f930eb95a3ab26293f237bd13c7
4
+ data.tar.gz: b998e891eeecc4dd6ff6be3cdfcfb15c5d14a17333b306febecbd1db4dee4532
5
5
  SHA512:
6
- metadata.gz: 0dd0f2a17a9bf8f99bc4fd6c3a7004d293a0f43a1838c0654bd98743e2277d734f42043cb2a56a3c7e0e994aa863c4ab3fac0771ae7709f28dadb7ed3ebd46fb
7
- data.tar.gz: cca845831f57a5b706dd547560c351ea30d19e17b99d838a8f0e1f7fadb157fd14eb35aa08a60715a49d3a24e02193e638135c075ecbb79e52fa23e493a4a9ac
6
+ metadata.gz: d70486feda75375aaf8e5057adc1c6b9a10cf2692a7b2b7cd5d337c6375ba53a894c353de62b3178ca33e95460a208b367a9c283256b9ae8ee8127891f18e2bb
7
+ data.tar.gz: 2545709874b877010ad45bb538d05d03167b0a994a81119503a85c9f8666a13c689a2b971c738d8107d9f312785ec5a329de40718ac796baebb975f13f8421fa
@@ -26,6 +26,7 @@ patterns:
26
26
  core: "test/{fast/,}core/**/*_test.rb"
27
27
  lifecycle: "test/{fast/,}lifecycle/**/*_test.rb"
28
28
  edge: "test/{fast/,}edge/**/*_test.rb"
29
+ fast_tests: "test/fast/**/*_test.rb"
29
30
  feat_tests: "test/feat/**/*_test.rb"
30
31
 
31
32
  # Test targets that combine multiple patterns
@@ -45,6 +46,7 @@ targets:
45
46
  - contract
46
47
  - core
47
48
  - lifecycle
49
+ - fast_tests
48
50
  feat:
49
51
  - feat_tests
50
52
  - edge
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.28.1] - 2026-10-08
11
+
12
+ ### Fixed
13
+
14
+ - Reject missing explicit Ruby test files before loading tests or running selectors. Preserve exact unambiguous file-line selection.
15
+
16
+ ### Fixed
17
+
18
+ - Refuse invalid or ambiguous file-line selections and execute exact class/method identities in direct and subprocess modes. Reject mixed whole-file/line selections before loading tests.
19
+
20
+ - Include uncategorized fast test directories in default fast/all selection without executing overlapping matches twice or widening explicit target overrides.
21
+ - Exclude undispatched groups from sequential execution reports after target fail-fast.
22
+
23
+ ## [0.28.0] - 2026-10-05
24
+
25
+ ### Fixed
26
+
27
+ - Honor explicit `ace-test-suite --config FILE` without merging the default cascade, and refuse unreadable or malformed selected files. Default namespace discovery remains unchanged.
28
+
29
+ - Bind suite verdicts, counts and report links to each exact child invocation; preserve duplicate entries and no-save completion evidence during concurrent runs.
30
+
31
+ - Preserve unsuccessful execution through target aggregation and saved/CLI verdicts even after passing partial output; distinguish operator interruption from completed test errors.
32
+
33
+ ### Changed
34
+
35
+ - Declare the required direct dependencies and minimum producer versions for this coordinated release: `ace-support-fs ~> 0.3`.
36
+
10
37
  ## [0.27.1] - 2026-09-29
11
38
 
12
39
  ### Fixed
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
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "line_number_resolver"
4
+ require_relative "../molecules/selection_resolver"
5
+ require "shellwords"
4
6
 
5
7
  module Ace
6
8
  module TestRunner
@@ -13,6 +15,8 @@ module Ace
13
15
  end
14
16
 
15
17
  def build_test_command(files, options = {})
18
+ selection = options[:selection_plan] || Molecules::SelectionResolver.resolve(Array(files))
19
+ return exact_selection_command(selection, options) if selection.qualified?
16
20
  cmd_parts = []
17
21
 
18
22
  # Use bundler if available and requested
@@ -29,39 +33,28 @@ module Ace
29
33
 
30
34
  # Add the test files
31
35
  if files.is_a?(Array)
32
- # Check if any file has a line number (file:line format)
33
- has_line_numbers = files.any? { |f| f.match?(/:\d+$/) }
34
-
35
- if has_line_numbers
36
- # For files with line numbers, resolve to test names and filter
37
- build_line_number_command(cmd_parts, files, options)
38
- else
39
- # Build a Ruby script that requires each file and fails on LoadError
40
- requires_script = files.map do |f|
41
- # Add ./ prefix if it's a relative path without one
42
- path = f.start_with?("/", "./") ? f : "./#{f}"
43
- # Escape the path for shell safety
44
- escaped_path = path.gsub("'", "\\\\'")
45
- "begin; require '#{escaped_path}'; rescue LoadError => e; STDERR.puts \\\"Failed to load #{escaped_path}: \\\" + e.message; exit(1); end"
46
- end.join("; ")
47
-
48
- # Build the script parts
49
- script_parts = []
50
- # Inject ARGV with --verbose for Minitest if profile is requested
51
- # (Ruby's --verbose flag only sets $VERBOSE, doesn't enable Minitest verbose mode)
52
- script_parts << "ARGV.replace(['--verbose'])" if options[:profile]
53
- script_parts << requires_script
54
- script_parts << "exit_code = Minitest.autorun"
55
- script_parts << "exit(exit_code)"
56
-
57
- # Execute the requires and then run Minitest
58
- cmd_parts << "-e"
59
- # Use double quotes to wrap the entire script
60
- cmd_parts << "\"#{script_parts.join("; ")}\""
61
- end
62
- elsif files.match?(/:\d+$/)
63
- # Check if single file has line number
64
- build_line_number_command(cmd_parts, [files], options)
36
+ # Build a Ruby script that requires each file and fails on LoadError
37
+ requires_script = files.map do |f|
38
+ # Add ./ prefix if it's a relative path without one
39
+ path = f.start_with?("/", "./") ? f : "./#{f}"
40
+ # Escape the path for shell safety
41
+ escaped_path = path.gsub("'", "\\\\'")
42
+ "begin; require '#{escaped_path}'; rescue LoadError => e; STDERR.puts \\\"Failed to load #{escaped_path}: \\\" + e.message; exit(1); end"
43
+ end.join("; ")
44
+
45
+ # Build the script parts
46
+ script_parts = []
47
+ # Inject ARGV with --verbose for Minitest if profile is requested
48
+ # (Ruby's --verbose flag only sets $VERBOSE, doesn't enable Minitest verbose mode)
49
+ script_parts << "ARGV.replace(['--verbose'])" if options[:profile]
50
+ script_parts << requires_script
51
+ script_parts << "exit_code = Minitest.autorun"
52
+ script_parts << "exit(exit_code)"
53
+
54
+ # Execute the requires and then run Minitest
55
+ cmd_parts << "-e"
56
+ # Use double quotes to wrap the entire script
57
+ cmd_parts << "\"#{script_parts.join("; ")}\""
65
58
  elsif options[:profile]
66
59
  # Single file without line number
67
60
  cmd_parts << "-e"
@@ -100,60 +93,41 @@ module Ace
100
93
 
101
94
  private
102
95
 
103
- def build_line_number_command(cmd_parts, files, options = {})
104
- # For files with line numbers, we need to:
105
- # 1. Load each file
106
- # 2. Resolve line numbers to test names
107
- # 3. Filter using --name option
108
-
109
- file_requires = []
110
- test_names = []
111
-
112
- files.each do |file_with_line|
113
- parsed = LineNumberResolver.parse_file_with_line(file_with_line)
114
- file_path = parsed[:file]
115
- line_number = parsed[:line]
116
-
117
- # Add ./ prefix if it's a relative path without one
118
- path = file_path.start_with?("/", "./") ? file_path : "./#{file_path}"
119
- escaped_path = path.gsub("'", "\\\\'")
120
-
121
- # Always require the file
122
- file_requires << "require '#{escaped_path}'"
123
-
124
- # If there's a line number, resolve it to a test name
125
- if line_number
126
- test_name = LineNumberResolver.resolve_test_at_line(file_path, line_number)
127
- if test_name
128
- test_names << test_name
129
- end
96
+ def exact_selection_command(plan, options)
97
+ argv = []
98
+ argv.concat(["bundle", "exec"]) if @bundler && bundler_available?
99
+ argv.concat(Shellwords.split(@ruby_command))
100
+ argv << "-Ilib:test" unless options[:no_load_path]
101
+ model = File.expand_path("../models/test_selection_plan", __dir__)
102
+ verifier = File.expand_path("../molecules/selection_verifier", __dir__)
103
+ script = <<~RUBY
104
+ begin
105
+ require #{model.inspect}
106
+ require #{verifier.inspect}
107
+ plan = Ace::TestRunner::Models::TestSelectionPlan.new(
108
+ files: #{plan.files.inspect}, identities: #{plan.identities.inspect},
109
+ source_digests: #{plan.source_digests.inspect})
110
+ verifier = Ace::TestRunner::Molecules::SelectionVerifier
111
+ verifier.verify_sources!(plan)
112
+ require "minitest/autorun"
113
+ plan.files.each { |file| require file }
114
+ pattern = verifier.verify_loaded!(plan)
115
+ args = ["--name", "/" + pattern + "/"]
116
+ args << "--verbose" if #{!!options[:profile]}
117
+ Minitest::Reporters.use! Minitest::Reporters::DefaultReporter.new
118
+ success = verifier.verify_execution!(plan) { Minitest.run(args) }
119
+ STDOUT.flush
120
+ STDERR.flush
121
+ exit!(success ? 0 : 1)
122
+ rescue Exception => error
123
+ STDERR.puts(error.message)
124
+ STDERR.flush
125
+ exit!(1)
130
126
  end
131
- end
132
-
133
- # Build the command
134
- script_parts = []
135
-
136
- # Require all files
137
- script_parts << file_requires.join("; ")
138
-
139
- # Set up ARGV with --name filter and --verbose if needed
140
- argv_args = []
141
- argv_args << "--verbose" if options[:profile]
142
- if test_names.any?
143
- # Create a regex pattern that matches any of the test names
144
- pattern = test_names.map { |name| Regexp.escape(name) }.join("|")
145
- argv_args << "--name"
146
- argv_args << "/#{pattern}/"
147
- end
148
- script_parts << "ARGV.replace([#{argv_args.map { |a| "'#{a}'" }.join(", ")}])" if argv_args.any?
149
-
150
- # Run Minitest
151
- script_parts << "exit_code = Minitest.autorun"
152
- script_parts << "exit(exit_code)"
153
-
154
- # Add to command
155
- cmd_parts << "-e"
156
- cmd_parts << "\"#{script_parts.join("; ")}\""
127
+ RUBY
128
+ argv.concat(["-e", script])
129
+ argv.concat(Array(options[:args])) if options[:args]
130
+ argv
157
131
  end
158
132
 
159
133
  def bundler_available?
@@ -1,85 +1,92 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  module Ace
4
6
  module TestRunner
5
7
  module Atoms
6
- # Resolves line numbers to test method names
7
8
  module LineNumberResolver
9
+ class SelectionError < ArgumentError; end
8
10
  module_function
9
11
 
10
- # Given a file and line number, find the test method name
11
- # Returns the test name or nil if not found
12
- def resolve_test_at_line(file_path, line_number)
13
- return nil unless File.exist?(file_path)
14
-
15
- content = File.read(file_path)
16
- lines = content.split("\n")
17
-
18
- # Find all test methods and their line ranges
19
- test_methods = extract_test_methods(lines)
20
-
21
- # Find the test that contains the specified line
22
- test_methods.find do |test|
23
- line_number >= test[:start_line] && line_number <= test[:end_line]
24
- end&.fetch(:name)
12
+ def parse_file_with_line(selector)
13
+ if (match = /\A(.+\.rb):(.+)\z/.match(selector))
14
+ unless match[2].match?(/\A[1-9]\d*\z/)
15
+ raise SelectionError, "Invalid test selector: #{selector}"
16
+ end
17
+ {file: match[1], line: Integer(match[2], 10)}
18
+ else
19
+ {file: selector, line: nil}
20
+ end
25
21
  end
26
22
 
27
- # Extract test method names and their line ranges from file content
28
- def extract_test_methods(lines)
29
- test_methods = []
30
- current_test = nil
31
-
32
- lines.each_with_index do |line, index|
33
- line_number = index + 1
34
-
35
- # Match test method definitions: def test_something or test "something"
36
- if line =~ /^\s*(def\s+(test_\w+)|test\s+["'](.+)["']\s+do)/
37
- test_name = $2 || $3 # Either def test_name or test "name"
38
-
39
- # Convert test "name" to test_name format for minitest --name option
40
- test_name = test_name.gsub(/\s+/, "_") if test_name && test_name.include?(" ")
41
-
42
- # Close previous test if any
43
- if current_test
44
- current_test[:end_line] = line_number - 1
45
- test_methods << current_test
46
- end
47
-
48
- current_test = {
49
- name: test_name,
50
- start_line: line_number,
51
- end_line: lines.size # Default to end of file
52
- }
53
- elsif line =~ /^\s*end\s*(#.*)?$/ && current_test
54
- # Found an end keyword - could be end of test method
55
- # Simple heuristic: if we're at the same or less indentation level, close the test
56
- current_indent = line[/^\s*/].length
23
+ def resolve_test_at_line(file_path, line_number)
24
+ resolve_identity(file_path, line_number).fetch(:name)
25
+ end
57
26
 
58
- if current_indent <= 2 # Assuming test methods are indented at most 2 spaces
59
- current_test[:end_line] = line_number
60
- test_methods << current_test
61
- current_test = nil
62
- end
63
- end
27
+ def resolve_identity(file_path, line_number, source: nil)
28
+ selector = "#{file_path}:#{line_number}"
29
+ source ||= File.read(file_path)
30
+ unless line_number.is_a?(Integer) && line_number.positive? && line_number <= source.lines.size
31
+ raise SelectionError, "Invalid test selector: #{selector}"
64
32
  end
33
+ parsed = Prism.parse(source)
34
+ raise SelectionError, "Malformed Ruby in test selector: #{selector}" unless parsed.success?
65
35
 
66
- # Close last test if still open
67
- if current_test
68
- current_test[:end_line] = lines.size
69
- test_methods << current_test
70
- end
36
+ declarations = []
37
+ scopes = Hash.new(0)
38
+ collect(parsed.value, [], false, true, declarations, scopes)
39
+ matches = declarations.select { |entry| (entry[:start_line]..entry[:end_line]).cover?(line_number) }
40
+ selected = matches.one? && matches.first
41
+ valid = selected && selected[:supported] && scopes[selected[:class_name]] == 1 &&
42
+ declarations.count { |entry| entry.values_at(:class_name, :name) == selected.values_at(:class_name, :name) } == 1
43
+ raise SelectionError, "Unmatched or ambiguous test selector: #{selector}" unless valid
71
44
 
72
- test_methods
45
+ selected.reject { |key, _| key == :supported }.merge(file: File.expand_path(file_path)).freeze
46
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => error
47
+ raise SelectionError, "Cannot read test selector #{selector}: #{error.class}"
73
48
  end
74
49
 
75
- # Given "file.rb:123", split into file and line number
76
- def parse_file_with_line(file_with_line)
77
- if file_with_line =~ /^(.+):(\d+)$/
78
- {file: $1, line: $2.to_i}
50
+ def collect(node, namespace, in_class, supported, declarations, scopes)
51
+ return unless node
52
+ case node
53
+ when Prism::ProgramNode
54
+ collect(node.statements, namespace, in_class, supported, declarations, scopes)
55
+ when Prism::StatementsNode
56
+ node.body.each { |child| collect(child, namespace, in_class, supported, declarations, scopes) }
57
+ when Prism::ClassNode, Prism::ModuleNode
58
+ text = node.constant_path.location.slice
59
+ static = text.match?(/\A(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\z/)
60
+ parts = static ? text.delete_prefix("::").split("::") : []
61
+ path = text.start_with?("::") ? parts : namespace + parts
62
+ scopes[path.join("::")] += 1
63
+ collect(node.body, path, node.is_a?(Prism::ClassNode), supported && static, declarations, scopes)
64
+ when Prism::DefNode
65
+ if node.name.to_s.start_with?("test_")
66
+ add_declaration(node, node.name.to_s, namespace, supported && in_class && node.receiver.nil?,
67
+ node.def_keyword_loc.start_line, declarations)
68
+ end
69
+ # Nested declarations cannot acquire the surrounding static class's identity.
70
+ collect(node.body, namespace, in_class, false, declarations, scopes)
71
+ when Prism::CallNode
72
+ if node.name == :test && node.block.is_a?(Prism::BlockNode)
73
+ arguments = node.arguments&.arguments || []
74
+ literal = arguments.one? && arguments.first.is_a?(Prism::StringNode)
75
+ name = literal ? "test_#{arguments.first.unescaped.gsub(/\s+/, '_')}" : nil
76
+ static = supported && in_class && node.receiver.nil? && literal && node.block.opening_loc.slice == "do"
77
+ add_declaration(node, name, namespace, static, node.block.opening_loc.start_line, declarations)
78
+ end
79
+ node.compact_child_nodes.each { |child| collect(child, namespace, in_class, false, declarations, scopes) }
79
80
  else
80
- {file: file_with_line, line: nil}
81
+ node.compact_child_nodes.each { |child| collect(child, namespace, in_class, false, declarations, scopes) }
81
82
  end
82
83
  end
84
+
85
+ def add_declaration(node, name, namespace, supported, method_line, declarations)
86
+ declarations << {name: name, class_name: namespace.join("::"), start_line: node.location.start_line,
87
+ end_line: node.location.end_line, method_line: method_line, supported: supported}
88
+ end
89
+ private_class_method :collect, :add_declaration
83
90
  end
84
91
  end
85
92
  end
@@ -15,7 +15,7 @@ module Ace
15
15
  # Pattern for inline errors - same structure as failures but with ERROR marker
16
16
  inline_error: /^\s+(test_[\w_]+).*?ERROR.*?\([\d.]+s\)\n(.*?)(?=^\s+test_[\w_]+.*?(?:PASS|FAIL|ERROR|SKIP)|^Finished in|\z)/m,
17
17
  location: /\[(.*?):(\d+)\]/,
18
- duration: /Finished in ([\d.]+)s/,
18
+ duration: /Finished(?: tests)? in ([\d.]+)s/,
19
19
  deprecation: /DEPRECATION WARNING: (.+)/,
20
20
  # Pattern to capture individual test times from verbose output
21
21
  # Matches Minitest::Reporters DefaultReporter format:
@@ -90,6 +90,7 @@ module Ace
90
90
  # Execution mode options
91
91
  option :parallel, type: :boolean, desc: "Run tests in parallel (experimental)"
92
92
  option :per_file, type: :boolean, desc: "Execute each test file separately (slower, for debugging)"
93
+ # File-line selectors resolve exact declarations; mixed whole-file/line input refuses.
93
94
  option :direct, type: :boolean, desc: "Force in-process execution (faster, less isolation)"
94
95
  option :subprocess, type: :boolean, desc: "Force subprocess execution (slower, full isolation)"
95
96
  option :run_in_sequence, type: :boolean, aliases: %w[--ris], desc: "Run test targets sequentially (default)"
@@ -221,10 +222,8 @@ module Ace
221
222
  config_data = config_loader.load(options[:config_path])
222
223
  config = config_loader.merge_with_options(config_data, options)
223
224
 
224
- timestamp_generator = Atoms::TimestampGenerator.new
225
225
  storage = Molecules::ReportStorage.new(
226
- base_dir: config[:defaults][:report_dir] || ".ace-local/test/reports",
227
- timestamp_generator: timestamp_generator
226
+ base_dir: config[:defaults][:report_dir] || ".ace-local/test/reports"
228
227
  )
229
228
 
230
229
  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),