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.
- checksums.yaml +4 -4
- data/.ace-defaults/e2e-runner/config.yml +14 -2
- data/CHANGELOG.md +233 -0
- data/README.md +2 -2
- data/exe/ace-test-e2e-sh +9 -4
- data/handbook/guides/e2e-testing.g.md +75 -9
- data/handbook/guides/scenario-yml-reference.g.md +21 -8
- data/handbook/guides/tc-authoring.g.md +23 -5
- data/handbook/skills/as-e2e-fix/SKILL.md +2 -2
- data/handbook/skills/as-e2e-review/SKILL.md +2 -2
- data/handbook/templates/ace-taskflow-fixture.template.md +17 -17
- data/handbook/templates/agent-experience-report.template.md +3 -2
- data/handbook/templates/scenario.yml.template.yml +7 -2
- data/handbook/templates/tc-file.template.md +16 -4
- data/handbook/workflow-instructions/e2e/analyze-failures.wf.md +53 -6
- data/handbook/workflow-instructions/e2e/create.wf.md +128 -25
- data/handbook/workflow-instructions/e2e/execute.wf.md +11 -7
- data/handbook/workflow-instructions/e2e/fix.wf.md +84 -15
- data/handbook/workflow-instructions/e2e/plan-changes.wf.md +33 -1
- data/handbook/workflow-instructions/e2e/review.wf.md +40 -25
- data/handbook/workflow-instructions/e2e/rewrite.wf.md +22 -8
- data/handbook/workflow-instructions/e2e/run.wf.md +50 -26
- data/handbook/workflow-instructions/e2e/setup-sandbox.wf.md +4 -4
- data/lib/ace/test/end_to_end_runner/atoms/artifact_contract_validator.rb +138 -0
- data/lib/ace/test/end_to_end_runner/atoms/skill_prompt_builder.rb +7 -5
- data/lib/ace/test/end_to_end_runner/atoms/skill_result_parser.rb +73 -7
- data/lib/ace/test/end_to_end_runner/cli/commands/run_suite.rb +195 -5
- data/lib/ace/test/end_to_end_runner/cli/commands/run_test.rb +58 -9
- data/lib/ace/test/end_to_end_runner/models/test_case.rb +8 -2
- data/lib/ace/test/end_to_end_runner/models/test_result.rb +9 -3
- data/lib/ace/test/end_to_end_runner/models/test_scenario.rb +4 -2
- data/lib/ace/test/end_to_end_runner/molecules/affected_detector.rb +7 -2
- data/lib/ace/test/end_to_end_runner/molecules/artifact_pruner.rb +61 -0
- data/lib/ace/test/end_to_end_runner/molecules/bwrap_sandbox_backend.rb +271 -0
- data/lib/ace/test/end_to_end_runner/molecules/config_loader.rb +28 -1
- data/lib/ace/test/end_to_end_runner/molecules/integration_runner.rb +122 -0
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_executor.rb +235 -18
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_prompt_bundler.rb +164 -13
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_report_generator.rb +91 -19
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_sandbox_builder.rb +121 -18
- data/lib/ace/test/end_to_end_runner/molecules/report_writer.rb +15 -12
- data/lib/ace/test/end_to_end_runner/molecules/sandbox_runtime_builder.rb +374 -0
- data/lib/ace/test/end_to_end_runner/molecules/scenario_loader.rb +83 -5
- data/lib/ace/test/end_to_end_runner/molecules/setup_executor.rb +121 -16
- data/lib/ace/test/end_to_end_runner/molecules/suite_report_writer.rb +422 -97
- data/lib/ace/test/end_to_end_runner/molecules/test_discoverer.rb +38 -13
- data/lib/ace/test/end_to_end_runner/molecules/test_executor.rb +27 -5
- data/lib/ace/test/end_to_end_runner/organisms/suite_orchestrator.rb +98 -18
- data/lib/ace/test/end_to_end_runner/organisms/test_orchestrator.rb +159 -19
- data/lib/ace/test/end_to_end_runner/version.rb +1 -1
- data/lib/ace/test/end_to_end_runner.rb +4 -0
- 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.
|
|
42
|
+
output, stderr, status = Open3.capture3("git", "diff", "--name-only", ref, "--",
|
|
43
43
|
chdir: base_dir)
|
|
44
44
|
|
|
45
|
-
|
|
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", "
|
|
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
|