ace-test-runner-e2e 0.29.8 → 0.40.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/e2e-runner/config.yml +14 -2
  3. data/CHANGELOG.md +233 -0
  4. data/README.md +2 -2
  5. data/exe/ace-test-e2e-sh +9 -4
  6. data/handbook/guides/e2e-testing.g.md +75 -9
  7. data/handbook/guides/scenario-yml-reference.g.md +21 -8
  8. data/handbook/guides/tc-authoring.g.md +23 -5
  9. data/handbook/skills/as-e2e-fix/SKILL.md +2 -2
  10. data/handbook/skills/as-e2e-review/SKILL.md +2 -2
  11. data/handbook/templates/ace-taskflow-fixture.template.md +17 -17
  12. data/handbook/templates/agent-experience-report.template.md +3 -2
  13. data/handbook/templates/scenario.yml.template.yml +7 -2
  14. data/handbook/templates/tc-file.template.md +16 -4
  15. data/handbook/workflow-instructions/e2e/analyze-failures.wf.md +53 -6
  16. data/handbook/workflow-instructions/e2e/create.wf.md +128 -25
  17. data/handbook/workflow-instructions/e2e/execute.wf.md +11 -7
  18. data/handbook/workflow-instructions/e2e/fix.wf.md +84 -15
  19. data/handbook/workflow-instructions/e2e/plan-changes.wf.md +33 -1
  20. data/handbook/workflow-instructions/e2e/review.wf.md +40 -25
  21. data/handbook/workflow-instructions/e2e/rewrite.wf.md +22 -8
  22. data/handbook/workflow-instructions/e2e/run.wf.md +50 -26
  23. data/handbook/workflow-instructions/e2e/setup-sandbox.wf.md +4 -4
  24. data/lib/ace/test/end_to_end_runner/atoms/artifact_contract_validator.rb +138 -0
  25. data/lib/ace/test/end_to_end_runner/atoms/skill_prompt_builder.rb +7 -5
  26. data/lib/ace/test/end_to_end_runner/atoms/skill_result_parser.rb +73 -7
  27. data/lib/ace/test/end_to_end_runner/cli/commands/run_suite.rb +195 -5
  28. data/lib/ace/test/end_to_end_runner/cli/commands/run_test.rb +58 -9
  29. data/lib/ace/test/end_to_end_runner/models/test_case.rb +8 -2
  30. data/lib/ace/test/end_to_end_runner/models/test_result.rb +9 -3
  31. data/lib/ace/test/end_to_end_runner/models/test_scenario.rb +4 -2
  32. data/lib/ace/test/end_to_end_runner/molecules/affected_detector.rb +7 -2
  33. data/lib/ace/test/end_to_end_runner/molecules/artifact_pruner.rb +61 -0
  34. data/lib/ace/test/end_to_end_runner/molecules/bwrap_sandbox_backend.rb +271 -0
  35. data/lib/ace/test/end_to_end_runner/molecules/config_loader.rb +28 -1
  36. data/lib/ace/test/end_to_end_runner/molecules/integration_runner.rb +122 -0
  37. data/lib/ace/test/end_to_end_runner/molecules/pipeline_executor.rb +235 -18
  38. data/lib/ace/test/end_to_end_runner/molecules/pipeline_prompt_bundler.rb +164 -13
  39. data/lib/ace/test/end_to_end_runner/molecules/pipeline_report_generator.rb +91 -19
  40. data/lib/ace/test/end_to_end_runner/molecules/pipeline_sandbox_builder.rb +121 -18
  41. data/lib/ace/test/end_to_end_runner/molecules/report_writer.rb +15 -12
  42. data/lib/ace/test/end_to_end_runner/molecules/sandbox_runtime_builder.rb +374 -0
  43. data/lib/ace/test/end_to_end_runner/molecules/scenario_loader.rb +83 -5
  44. data/lib/ace/test/end_to_end_runner/molecules/setup_executor.rb +121 -16
  45. data/lib/ace/test/end_to_end_runner/molecules/suite_report_writer.rb +422 -97
  46. data/lib/ace/test/end_to_end_runner/molecules/test_discoverer.rb +38 -13
  47. data/lib/ace/test/end_to_end_runner/molecules/test_executor.rb +27 -5
  48. data/lib/ace/test/end_to_end_runner/organisms/suite_orchestrator.rb +98 -18
  49. data/lib/ace/test/end_to_end_runner/organisms/test_orchestrator.rb +159 -19
  50. data/lib/ace/test/end_to_end_runner/version.rb +1 -1
  51. data/lib/ace/test/end_to_end_runner.rb +4 -0
  52. metadata +21 -2
@@ -12,7 +12,7 @@ module Ace
12
12
  attr_reader :test_id, :title, :area, :package, :priority, :duration,
13
13
  :requires, :file_path, :content, :timeout,
14
14
  :setup_steps, :dir_path, :fixture_path, :test_cases,
15
- :tags, :tool_under_test, :sandbox_layout
15
+ :tags, :tool_under_test, :sandbox_layout, :sandbox_profile
16
16
 
17
17
  # @param test_id [String] Test identifier (e.g., "TS-LINT-001")
18
18
  # @param title [String] Test title
@@ -31,11 +31,12 @@ module Ace
31
31
  # @param tags [Array<String>] Scenario-level tags for discovery-time filtering
32
32
  # @param tool_under_test [String, nil] Primary tool under test
33
33
  # @param sandbox_layout [Hash] Declared sandbox artifact layout
34
+ # @param sandbox_profile [String] Sandbox bootstrap profile
34
35
  def initialize(test_id:, title:, area:, package:, file_path:, content:,
35
36
  priority: "medium", duration: "~5min", requires: {},
36
37
  setup_steps: [], dir_path: nil, fixture_path: nil, test_cases: [],
37
38
  timeout: nil, tags: [], tool_under_test: nil,
38
- sandbox_layout: {})
39
+ sandbox_layout: {}, sandbox_profile: "ace-default")
39
40
  @test_id = test_id
40
41
  @title = title
41
42
  @area = area
@@ -53,6 +54,7 @@ module Ace
53
54
  @tags = tags
54
55
  @tool_under_test = tool_under_test
55
56
  @sandbox_layout = sandbox_layout
57
+ @sandbox_profile = sandbox_profile
56
58
  end
57
59
 
58
60
  # Generate short package name (without ace- prefix)
@@ -39,10 +39,15 @@ module Ace
39
39
  # @return [Array<String>] Changed file paths
40
40
  def get_changed_files(base_dir, ref)
41
41
  # Run git diff to get changed files using array-based command for security
42
- output, status = Open3.capture2("git", "diff", "--name-only", ref, "--",
42
+ output, stderr, status = Open3.capture3("git", "diff", "--name-only", ref, "--",
43
43
  chdir: base_dir)
44
44
 
45
- return [] unless status.success?
45
+ unless status.success?
46
+ message = stderr.to_s.strip
47
+ message = "git diff exited with status #{status.exitstatus}" if message.empty?
48
+ warn "Warning: git detection failed: #{message}"
49
+ return []
50
+ end
46
51
 
47
52
  output.lines.map(&:strip).reject(&:empty?)
48
53
  rescue => e
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Test
7
+ module EndToEndRunner
8
+ module Molecules
9
+ # Prunes stale E2E run artifacts while preserving suite reports and runtime cache.
10
+ class ArtifactPruner
11
+ ROOT_RELATIVE_PATH = File.join(".ace-local", "test-e2e")
12
+ PRESERVED_DIRECTORY_NAMES = %w[runtime-cache].freeze
13
+ PRESERVED_FILE_PATTERNS = [
14
+ /-suite-report\.md\z/,
15
+ /-suite-final-report\.md\z/
16
+ ].freeze
17
+
18
+ def prune(base_dir: Dir.pwd)
19
+ root = File.join(File.expand_path(base_dir), ROOT_RELATIVE_PATH)
20
+ return summary(root, [], []) unless Dir.exist?(root)
21
+
22
+ removed_paths = []
23
+ preserved_paths = []
24
+
25
+ Dir.children(root).sort.each do |entry|
26
+ path = File.join(root, entry)
27
+ if preserve_entry?(entry, path)
28
+ preserved_paths << path
29
+ else
30
+ FileUtils.rm_rf(path)
31
+ removed_paths << path
32
+ end
33
+ end
34
+
35
+ summary(root, removed_paths, preserved_paths)
36
+ end
37
+
38
+ private
39
+
40
+ def preserve_entry?(entry, path)
41
+ return true if File.directory?(path) && PRESERVED_DIRECTORY_NAMES.include?(entry)
42
+ return false unless File.file?(path)
43
+
44
+ PRESERVED_FILE_PATTERNS.any? { |pattern| pattern.match?(entry) }
45
+ end
46
+
47
+ def summary(root, removed_paths, preserved_paths)
48
+ {
49
+ root: root,
50
+ root_display: ROOT_RELATIVE_PATH,
51
+ removed_paths: removed_paths,
52
+ preserved_paths: preserved_paths,
53
+ deleted_count: removed_paths.length,
54
+ preserved_count: preserved_paths.length
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "fileutils"
5
+ require "open3"
6
+
7
+ module Ace
8
+ module Test
9
+ module EndToEndRunner
10
+ module Molecules
11
+ # Wraps subprocesses in a lightweight bubblewrap sandbox on Linux.
12
+ class BwrapSandboxBackend
13
+ BUNDLER_ENV_PREFIXES = %w[BUNDLE BUNDLER].freeze
14
+ STRIPPED_ENV_KEYS = %w[RUBYOPT RUBYLIB].freeze
15
+ PROVIDER_HOME_MOUNTS = [
16
+ [".claude", ".claude"],
17
+ [".codex", ".codex"],
18
+ [".gemini", ".gemini"],
19
+ [".pi", ".pi"],
20
+ [".local/share/opencode", ".local/share/opencode"]
21
+ ].freeze
22
+ DEFAULT_SYSTEM_MOUNTS = %w[/usr /bin /sbin /lib /lib64 /etc /opt /var/lib/flatpak/exports].freeze
23
+
24
+ attr_reader :sandbox_root
25
+
26
+ def self.supported?
27
+ Gem.win_platform? == false && RUBY_PLATFORM.include?("linux")
28
+ end
29
+
30
+ def self.available?(bwrap_path: "bwrap")
31
+ return false unless supported?
32
+
33
+ system("which", bwrap_path, out: File::NULL, err: File::NULL)
34
+ end
35
+
36
+ def initialize(sandbox_root:, source_root: nil, bwrap_path: "bwrap", outer_env: nil)
37
+ @sandbox_root = File.expand_path(sandbox_root)
38
+ @source_root = source_root ? File.expand_path(source_root) : infer_source_root
39
+ @bwrap_path = bwrap_path
40
+ @outer_env = sanitized_outer_env(outer_env)
41
+ end
42
+
43
+ def ensure_available!
44
+ return if self.class.available?(bwrap_path: @bwrap_path)
45
+
46
+ raise "bubblewrap is required for Linux E2E sandboxing but '#{@bwrap_path}' is not available"
47
+ end
48
+
49
+ def prepared_env(base_env = {})
50
+ env = stringify_keys(base_env)
51
+ STRIPPED_ENV_KEYS.each { |key| env.delete(key) }
52
+ env["PROJECT_ROOT_PATH"] = @sandbox_root
53
+ env["ACE_E2E_SOURCE_ROOT"] ||= @source_root if @source_root
54
+ env["HOME"] = sandbox_home
55
+ env["TMPDIR"] = sandbox_tmp
56
+ env["XDG_RUNTIME_DIR"] = sandbox_runtime_dir
57
+ env["TMUX_TMPDIR"] ||= sandbox_runtime_dir
58
+ env["BUNDLE_GEMFILE"] ||= File.join(@sandbox_root, ".ace-local", "e2e-runtime", "Gemfile")
59
+ env["ACE_CONFIG_PATH"] ||= File.join(@sandbox_root, ".ace")
60
+ env["BUNDLE_APP_CONFIG"] ||= bundler_app_config
61
+ env["BUNDLE_USER_HOME"] ||= bundler_home
62
+ env["BUNDLE_USER_CACHE"] ||= bundler_cache
63
+ env["BUNDLE_USER_CONFIG"] ||= bundler_user_config
64
+ env["PATH"] ||= ENV["PATH"].to_s
65
+ env
66
+ end
67
+
68
+ def wrap_command(cmd, chdir:, env: {})
69
+ command_prefix(chdir: chdir, env: env) + Array(cmd)
70
+ end
71
+
72
+ def command_prefix(chdir:, env: {})
73
+ ensure_available!
74
+ merged_env = prepared_env(env)
75
+ ensure_runtime_dirs(merged_env)
76
+
77
+ args = [
78
+ @bwrap_path,
79
+ "--clearenv",
80
+ "--die-with-parent",
81
+ "--new-session",
82
+ "--proc", "/proc",
83
+ "--dev-bind", "/dev", "/dev",
84
+ "--tmpfs", "/tmp",
85
+ "--tmpfs", home_root,
86
+ "--dir", merged_env.fetch("HOME"),
87
+ "--bind", merged_env.fetch("HOME"), merged_env.fetch("HOME")
88
+ ]
89
+
90
+ host_mounts(merged_env.fetch("PATH")).each do |mount|
91
+ append_bind(args, mount, mount, read_only: true)
92
+ end
93
+ append_bind(args, @source_root, @source_root, read_only: true) if @source_root
94
+ append_bind(args, @sandbox_root, @sandbox_root, read_only: false)
95
+ append_bind(args, support_root, support_root, read_only: false)
96
+ ruby_root = merged_env["ACE_E2E_SANDBOX_RUBY_ROOT"].to_s
97
+ append_bind(args, ruby_root, ruby_root, read_only: true) if !ruby_root.empty? && File.exist?(ruby_root)
98
+ bind_provider_homes(args, merged_env.fetch("HOME"))
99
+
100
+ setenvs = {
101
+ "HOME" => merged_env.fetch("HOME"),
102
+ "TMPDIR" => merged_env.fetch("TMPDIR"),
103
+ "XDG_RUNTIME_DIR" => merged_env.fetch("XDG_RUNTIME_DIR"),
104
+ "TMUX_TMPDIR" => merged_env.fetch("TMUX_TMPDIR"),
105
+ "PATH" => merged_env.fetch("PATH"),
106
+ "PROJECT_ROOT_PATH" => merged_env.fetch("PROJECT_ROOT_PATH")
107
+ }
108
+ setenvs["ACE_E2E_SOURCE_ROOT"] = merged_env["ACE_E2E_SOURCE_ROOT"] if merged_env["ACE_E2E_SOURCE_ROOT"]
109
+ setenvs["ACE_E2E_SANDBOX_RUBY_ROOT"] = ruby_root unless ruby_root.empty?
110
+ setenvs["ACE_TMUX_SESSION"] = merged_env["ACE_TMUX_SESSION"] if merged_env["ACE_TMUX_SESSION"]
111
+
112
+ merged_env.each do |key, value|
113
+ next if setenvs.key?(key)
114
+ next if value.nil?
115
+
116
+ setenvs[key] = value.to_s
117
+ end
118
+
119
+ setenvs.each do |key, value|
120
+ args.concat(["--setenv", key, value])
121
+ end
122
+
123
+ args.concat(["--chdir", File.expand_path(chdir), "--"])
124
+ args
125
+ end
126
+
127
+ def capture3(cmd, chdir:, env: {}, stdin_data: nil)
128
+ Open3.capture3(
129
+ @outer_env,
130
+ *wrap_command(cmd, chdir: chdir, env: env),
131
+ chdir: "/",
132
+ stdin_data: stdin_data
133
+ )
134
+ end
135
+
136
+ def exec(cmd, chdir:, env: {})
137
+ Kernel.exec(@outer_env, *wrap_command(cmd, chdir: chdir, env: env))
138
+ end
139
+
140
+ private
141
+
142
+ def sandbox_home
143
+ File.join(support_root, "home")
144
+ end
145
+
146
+ def sandbox_tmp
147
+ File.join(support_root, "tmp")
148
+ end
149
+
150
+ def sandbox_runtime_dir
151
+ File.join(support_root, "runtime")
152
+ end
153
+
154
+ def bundler_root
155
+ File.join(support_root, "bundler")
156
+ end
157
+
158
+ def bundler_home
159
+ File.join(bundler_root, "home")
160
+ end
161
+
162
+ def bundler_cache
163
+ File.join(bundler_root, "cache")
164
+ end
165
+
166
+ def bundler_user_config
167
+ File.join(bundler_root, "config")
168
+ end
169
+
170
+ def bundler_app_config
171
+ File.join(bundler_root, "app-config")
172
+ end
173
+
174
+ def home_root
175
+ File.dirname(actual_home)
176
+ end
177
+
178
+ def actual_home
179
+ File.expand_path(ENV.fetch("HOME"))
180
+ end
181
+
182
+ def infer_source_root
183
+ env_root = ENV["ACE_E2E_SOURCE_ROOT"].to_s.strip
184
+ return File.expand_path(env_root) unless env_root.empty?
185
+
186
+ File.expand_path(Dir.pwd)
187
+ end
188
+
189
+ def support_root
190
+ "#{@sandbox_root}.support"
191
+ end
192
+
193
+ def host_mounts(path_value)
194
+ mounts = DEFAULT_SYSTEM_MOUNTS.select { |path| File.exist?(path) }
195
+ path_value.to_s.split(File::PATH_SEPARATOR).each do |entry|
196
+ next if entry.to_s.strip.empty?
197
+
198
+ expanded = File.expand_path(entry)
199
+ next unless File.exist?(expanded)
200
+
201
+ mounts << expanded
202
+ mounts << mise_install_root(expanded) if expanded.include?("/.local/share/mise/installs/")
203
+ end
204
+
205
+ mounts.compact.uniq.sort
206
+ end
207
+
208
+ def mise_install_root(path)
209
+ match = path.match(%r{\A(.*/\.local/share/mise/installs/[^/]+/[^/]+)})
210
+ match && match[1]
211
+ end
212
+
213
+ def ensure_runtime_dirs(env)
214
+ [
215
+ @sandbox_root,
216
+ support_root,
217
+ env.fetch("HOME"),
218
+ env.fetch("TMPDIR"),
219
+ env.fetch("XDG_RUNTIME_DIR"),
220
+ env.fetch("BUNDLE_APP_CONFIG"),
221
+ env.fetch("BUNDLE_USER_HOME"),
222
+ env.fetch("BUNDLE_USER_CACHE")
223
+ ].each { |path| FileUtils.mkdir_p(path) }
224
+ FileUtils.chmod(0o700, env.fetch("XDG_RUNTIME_DIR")) if File.exist?(env.fetch("XDG_RUNTIME_DIR"))
225
+ end
226
+
227
+ def bind_provider_homes(args, sandbox_home_dir)
228
+ PROVIDER_HOME_MOUNTS.each do |source_suffix, target_suffix|
229
+ source = File.join(actual_home, source_suffix)
230
+ next unless File.exist?(source)
231
+
232
+ target = File.join(sandbox_home_dir, target_suffix)
233
+ FileUtils.mkdir_p(File.dirname(target))
234
+ FileUtils.mkdir_p(target) if File.directory?(source)
235
+ append_bind(args, source, target, read_only: false)
236
+ end
237
+ end
238
+
239
+ def append_bind(args, source, target, read_only:)
240
+ source = File.expand_path(source)
241
+ target = File.expand_path(target)
242
+ FileUtils.mkdir_p(File.dirname(target))
243
+ FileUtils.mkdir_p(target) if File.directory?(source)
244
+ args.concat([read_only ? "--ro-bind" : "--bind", source, target])
245
+ end
246
+
247
+ def stringify_keys(hash)
248
+ hash.each_with_object({}) { |(key, value), acc| acc[key.to_s] = value }
249
+ end
250
+
251
+ def sanitized_process_env(base_env)
252
+ stringify_keys(base_env).each_with_object({}) do |(key, value), env|
253
+ next if strip_env_key?(key)
254
+
255
+ env[key] = value
256
+ end
257
+ end
258
+
259
+ def sanitized_outer_env(outer_env)
260
+ path = stringify_keys(outer_env || {"PATH" => ENV["PATH"].to_s})["PATH"]
261
+ {"PATH" => path.to_s.empty? ? ENV["PATH"].to_s : path.to_s}
262
+ end
263
+
264
+ def strip_env_key?(key)
265
+ STRIPPED_ENV_KEYS.include?(key) || BUNDLER_ENV_PREFIXES.any? { |prefix| key.start_with?(prefix) }
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end
@@ -23,7 +23,22 @@ module Ace
23
23
  # @return [String] Default provider from config
24
24
  def self.default_provider
25
25
  config = load
26
- config.dig("execution", "provider") || "claude:sonnet"
26
+ config.dig("execution", "runner_provider") ||
27
+ config.dig("execution", "provider") || "claude:sonnet"
28
+ end
29
+
30
+ # @return [String] Default runner provider from config
31
+ def self.default_runner_provider
32
+ config = load
33
+ config.dig("execution", "runner_provider") ||
34
+ config.dig("execution", "provider") || "claude:sonnet"
35
+ end
36
+
37
+ # @return [String] Default verifier provider from config
38
+ def self.default_verifier_provider
39
+ config = load
40
+ config.dig("execution", "verifier_provider") ||
41
+ config.dig("execution", "provider") || "claude:sonnet"
27
42
  end
28
43
 
29
44
  # @return [Integer] Default timeout from config
@@ -38,6 +53,18 @@ module Ace
38
53
  config.dig("execution", "parallel") || 3
39
54
  end
40
55
 
56
+ # @return [String] Default sandbox bootstrap profile
57
+ def self.default_sandbox_profile
58
+ config = load
59
+ config.dig("sandbox", "profile") || "ace-default"
60
+ end
61
+
62
+ # @return [String] Dedicated Ruby version for sandbox runtime
63
+ def self.default_sandbox_ruby_version
64
+ config = load
65
+ config.dig("sandbox", "ruby_version") || "3.4.9"
66
+ end
67
+
41
68
  # @return [Array<String>] CLI provider names
42
69
  def self.cli_providers
43
70
  config = load
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "shellwords"
6
+ require "ace/test_support/sandbox_package_copy"
7
+
8
+ module Ace
9
+ module Test
10
+ module EndToEndRunner
11
+ module Molecules
12
+ # Runs deterministic preflight tests inside a sandboxed package copy.
13
+ class IntegrationRunner
14
+ def initialize(base_dir: Dir.pwd, package_copy: nil)
15
+ @base_dir = File.expand_path(base_dir)
16
+ @package_copy = package_copy || Ace::TestSupport::SandboxPackageCopy.new(source_root: @base_dir)
17
+ end
18
+
19
+ def run(package:, files:, timestamp:, output: $stdout)
20
+ return nil if files.nil? || files.empty?
21
+
22
+ started_at = Time.now
23
+ sandbox_root = File.join(@base_dir, ".ace-local", "test-e2e", "#{timestamp}-#{package}-preflight")
24
+ FileUtils.mkdir_p(sandbox_root)
25
+
26
+ package_copy_result = @package_copy.prepare(package_name: package, sandbox_root: sandbox_root)
27
+ package_root = resolve_package_root(sandbox_root, package)
28
+ env = package_copy_result[:env] || {}
29
+
30
+ test_cases = files.map do |file|
31
+ run_file(package_root, file, env, output)
32
+ end
33
+
34
+ status = if test_cases.any? { |tc| tc[:status] == "error" }
35
+ "error"
36
+ elsif test_cases.any? { |tc| tc[:status] == "fail" }
37
+ "fail"
38
+ else
39
+ "pass"
40
+ end
41
+
42
+ Models::TestResult.new(
43
+ test_id: "PREFLIGHT",
44
+ status: status,
45
+ test_cases: test_cases,
46
+ summary: preflight_summary(status, test_cases),
47
+ started_at: started_at,
48
+ completed_at: Time.now,
49
+ metadata: {
50
+ phase: "preflight",
51
+ package: package,
52
+ sandbox_root: sandbox_root
53
+ }
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ def run_file(package_root, file, env, output)
60
+ relative = file.sub(%r{\A#{Regexp.escape(@base_dir)}/?}, "")
61
+ package_relative = relative.sub(%r{\A[^/]+/}, "")
62
+
63
+ stdout, stderr, status = Open3.capture3(
64
+ env,
65
+ "ace-test",
66
+ package_relative,
67
+ chdir: package_root
68
+ )
69
+
70
+ output.puts "Preflight: #{package_relative} (#{status.success? ? "pass" : "fail"})"
71
+
72
+ {
73
+ id: package_relative,
74
+ description: package_relative,
75
+ status: status.success? ? "pass" : "fail",
76
+ actual: stdout,
77
+ notes: stderr,
78
+ metadata: {
79
+ phase: "preflight",
80
+ exit_status: status.exitstatus,
81
+ command: Shellwords.join(["ace-test", package_relative])
82
+ }
83
+ }
84
+ rescue StandardError => e
85
+ output.puts "Preflight: #{package_relative} (error)"
86
+
87
+ {
88
+ id: package_relative,
89
+ description: package_relative,
90
+ status: "error",
91
+ actual: "",
92
+ notes: e.message,
93
+ metadata: {
94
+ phase: "preflight",
95
+ command: Shellwords.join(["ace-test", package_relative])
96
+ }
97
+ }
98
+ end
99
+
100
+ def resolve_package_root(sandbox_root, package)
101
+ candidate = File.join(sandbox_root, package)
102
+ return candidate if Dir.exist?(candidate)
103
+
104
+ sandbox_root
105
+ end
106
+
107
+ def preflight_summary(status, test_cases)
108
+ passed = test_cases.count { |tc| tc[:status] == "pass" }
109
+ total = test_cases.size
110
+ prefix =
111
+ case status
112
+ when "pass" then "Preflight passed"
113
+ when "fail" then "Preflight failed"
114
+ else "Preflight errored"
115
+ end
116
+ "#{prefix}: #{passed}/#{total} files passed"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end