ace-test-runner-e2e 0.29.6 → 0.38.11
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 +187 -0
- data/README.md +2 -2
- data/exe/ace-test-e2e-sh +9 -4
- data/handbook/guides/e2e-testing.g.md +43 -9
- data/handbook/guides/scenario-yml-reference.g.md +16 -8
- data/handbook/guides/tc-authoring.g.md +12 -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 +13 -2
- data/handbook/templates/tc-file.template.md +14 -4
- data/handbook/workflow-instructions/e2e/analyze-failures.wf.md +53 -6
- data/handbook/workflow-instructions/e2e/create.wf.md +139 -23
- data/handbook/workflow-instructions/e2e/execute.wf.md +11 -7
- data/handbook/workflow-instructions/e2e/fix.wf.md +65 -15
- data/handbook/workflow-instructions/e2e/plan-changes.wf.md +17 -1
- data/handbook/workflow-instructions/e2e/review.wf.md +44 -28
- data/handbook/workflow-instructions/e2e/rewrite.wf.md +17 -3
- 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/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_test.rb +21 -8
- 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/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 +165 -25
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_prompt_bundler.rb +121 -8
- 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 +119 -18
- data/lib/ace/test/end_to_end_runner/molecules/report_writer.rb +13 -12
- data/lib/ace/test/end_to_end_runner/molecules/sandbox_runtime_builder.rb +282 -0
- data/lib/ace/test/end_to_end_runner/molecules/scenario_loader.rb +85 -5
- data/lib/ace/test/end_to_end_runner/molecules/setup_executor.rb +98 -16
- data/lib/ace/test/end_to_end_runner/molecules/suite_report_writer.rb +241 -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 +73 -15
- data/lib/ace/test/end_to_end_runner/organisms/test_orchestrator.rb +120 -19
- data/lib/ace/test/end_to_end_runner/version.rb +1 -1
- data/lib/ace/test/end_to_end_runner.rb +2 -0
- metadata +19 -2
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Test
|
|
8
|
+
module EndToEndRunner
|
|
9
|
+
module Molecules
|
|
10
|
+
# Builds a sandbox-local Ruby/Bundler runtime for E2E execution.
|
|
11
|
+
class SandboxRuntimeBuilder
|
|
12
|
+
DEFAULT_RUBY_VERSION = "3.4.9"
|
|
13
|
+
RESERVED_ENV_KEYS = %w[
|
|
14
|
+
PROJECT_ROOT_PATH
|
|
15
|
+
ACE_E2E_SOURCE_ROOT
|
|
16
|
+
ACE_CONFIG_PATH
|
|
17
|
+
BUNDLE_GEMFILE
|
|
18
|
+
BUNDLE_APP_CONFIG
|
|
19
|
+
BUNDLE_USER_HOME
|
|
20
|
+
BUNDLE_USER_CACHE
|
|
21
|
+
BUNDLE_USER_CONFIG
|
|
22
|
+
BUNDLE_PATH
|
|
23
|
+
BUNDLE_BIN
|
|
24
|
+
BUNDLE_DISABLE_SHARED_GEMS
|
|
25
|
+
BUNDLER_VERSION
|
|
26
|
+
GEM_HOME
|
|
27
|
+
GEM_PATH
|
|
28
|
+
RUBYOPT
|
|
29
|
+
RUBYLIB
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
def initialize(source_root:, ruby_version: nil, command_runner: nil)
|
|
33
|
+
@source_root = File.expand_path(source_root)
|
|
34
|
+
@ruby_version = (ruby_version || DEFAULT_RUBY_VERSION).to_s.strip
|
|
35
|
+
@command_runner = command_runner || method(:capture3)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def prepare(sandbox_root:, env: {}, tool_names: nil)
|
|
39
|
+
sandbox_root = File.expand_path(sandbox_root)
|
|
40
|
+
runtime_root = File.join(sandbox_root, ".ace-local", "e2e-runtime")
|
|
41
|
+
FileUtils.mkdir_p(runtime_root)
|
|
42
|
+
|
|
43
|
+
runtime_env = build_runtime_env(sandbox_root, runtime_root, env)
|
|
44
|
+
ensure_runtime_dirs(runtime_env)
|
|
45
|
+
write_runtime_gemfile(runtime_root)
|
|
46
|
+
write_command_shims(runtime_root, tool_names)
|
|
47
|
+
install_runtime!(runtime_root, runtime_env)
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
runtime_root: runtime_root,
|
|
51
|
+
env: runtime_env
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def capture3(env, *cmd, chdir:)
|
|
58
|
+
Open3.capture3(env, *cmd, chdir: chdir, unsetenv_others: true)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_runtime_env(sandbox_root, runtime_root, env)
|
|
62
|
+
merged = stringify_keys(env).reject { |key, _value| RESERVED_ENV_KEYS.include?(key) }
|
|
63
|
+
bundler_root = File.join(runtime_root, "bundler")
|
|
64
|
+
gem_root = File.join(runtime_root, "gems")
|
|
65
|
+
bin_root = File.join(runtime_root, "bin")
|
|
66
|
+
path = merged["PATH"].to_s
|
|
67
|
+
path = ENV["PATH"].to_s if path.empty?
|
|
68
|
+
|
|
69
|
+
merged.merge(
|
|
70
|
+
"PROJECT_ROOT_PATH" => sandbox_root,
|
|
71
|
+
"ACE_E2E_SOURCE_ROOT" => @source_root,
|
|
72
|
+
"ACE_CONFIG_PATH" => File.join(sandbox_root, ".ace"),
|
|
73
|
+
"ACE_E2E_SANDBOX_RUNTIME_ROOT" => runtime_root,
|
|
74
|
+
"ACE_E2E_SANDBOX_RUBY_VERSION" => @ruby_version,
|
|
75
|
+
"ACE_E2E_SANDBOX_RUBY_ROOT" => (@ruby_version.empty? ? "" : resolve_ruby_install_path!),
|
|
76
|
+
"BUNDLE_GEMFILE" => File.join(runtime_root, "Gemfile"),
|
|
77
|
+
"BUNDLE_APP_CONFIG" => File.join(bundler_root, "app-config"),
|
|
78
|
+
"BUNDLE_USER_HOME" => File.join(bundler_root, "home"),
|
|
79
|
+
"BUNDLE_USER_CACHE" => File.join(bundler_root, "cache"),
|
|
80
|
+
"BUNDLE_USER_CONFIG" => File.join(bundler_root, "config"),
|
|
81
|
+
"BUNDLE_PATH" => gem_root,
|
|
82
|
+
"BUNDLE_DISABLE_SHARED_GEMS" => "true",
|
|
83
|
+
"BUNDLE_WITHOUT" => "",
|
|
84
|
+
"GEM_HOME" => gem_root,
|
|
85
|
+
"GEM_PATH" => gem_root,
|
|
86
|
+
"PATH" => [bin_root, path].reject(&:empty?).join(File::PATH_SEPARATOR)
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ensure_runtime_dirs(env)
|
|
91
|
+
[
|
|
92
|
+
env.fetch("ACE_CONFIG_PATH"),
|
|
93
|
+
env.fetch("BUNDLE_APP_CONFIG"),
|
|
94
|
+
env.fetch("BUNDLE_USER_HOME"),
|
|
95
|
+
env.fetch("BUNDLE_USER_CACHE"),
|
|
96
|
+
File.dirname(env.fetch("BUNDLE_USER_CONFIG")),
|
|
97
|
+
env.fetch("BUNDLE_PATH"),
|
|
98
|
+
env.fetch("GEM_HOME"),
|
|
99
|
+
File.join(env.fetch("ACE_E2E_SANDBOX_RUNTIME_ROOT"), "bin")
|
|
100
|
+
].each do |path|
|
|
101
|
+
FileUtils.mkdir_p(path)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def write_runtime_gemfile(runtime_root)
|
|
106
|
+
source_gemfile = File.join(@source_root, "Gemfile")
|
|
107
|
+
content = File.read(source_gemfile)
|
|
108
|
+
rewritten = content.gsub(/(path:\s*["'])([^"']+)(["'])/) do
|
|
109
|
+
prefix = Regexp.last_match(1)
|
|
110
|
+
relative_path = Regexp.last_match(2)
|
|
111
|
+
suffix = Regexp.last_match(3)
|
|
112
|
+
absolute_path = File.expand_path(relative_path, @source_root)
|
|
113
|
+
"#{prefix}#{absolute_path}#{suffix}"
|
|
114
|
+
end
|
|
115
|
+
File.write(File.join(runtime_root, "Gemfile"), rewritten)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def write_command_shims(runtime_root, tool_names)
|
|
119
|
+
bin_root = File.join(runtime_root, "bin")
|
|
120
|
+
requested = normalize_tool_names(tool_names)
|
|
121
|
+
executable_map = discover_executable_map
|
|
122
|
+
names = executable_map.keys | requested
|
|
123
|
+
|
|
124
|
+
names.each do |tool_name|
|
|
125
|
+
source_exec = executable_map[tool_name]
|
|
126
|
+
next unless source_exec
|
|
127
|
+
|
|
128
|
+
shim_path = File.join(bin_root, tool_name)
|
|
129
|
+
shim_body = if @ruby_version.empty?
|
|
130
|
+
<<~SH
|
|
131
|
+
#!/usr/bin/env bash
|
|
132
|
+
set -euo pipefail
|
|
133
|
+
exec ruby -rbundler/setup "#{source_exec}" "$@"
|
|
134
|
+
SH
|
|
135
|
+
else
|
|
136
|
+
ruby_exec = ruby_executable_path
|
|
137
|
+
<<~SH
|
|
138
|
+
#!/usr/bin/env bash
|
|
139
|
+
set -euo pipefail
|
|
140
|
+
exec "#{ruby_exec}" -rbundler/setup "#{source_exec}" "$@"
|
|
141
|
+
SH
|
|
142
|
+
end
|
|
143
|
+
File.write(shim_path, shim_body)
|
|
144
|
+
FileUtils.chmod(0o755, shim_path)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def discover_executable_map
|
|
149
|
+
@discover_executable_map ||= begin
|
|
150
|
+
paths = Dir.glob(File.join(@source_root, "bin", "ace-*")).sort
|
|
151
|
+
Dir.glob(File.join(@source_root, "ace-*", "exe", "ace-*")).sort.each do |path|
|
|
152
|
+
basename = File.basename(path)
|
|
153
|
+
next if paths.any? { |candidate| File.basename(candidate) == basename }
|
|
154
|
+
|
|
155
|
+
paths << path
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
paths.each_with_object({}) do |path, map|
|
|
159
|
+
map[File.basename(path)] = path
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def normalize_tool_names(tool_names)
|
|
165
|
+
Array(tool_names)
|
|
166
|
+
.flat_map { |entry| entry.to_s.split(",") }
|
|
167
|
+
.map(&:strip)
|
|
168
|
+
.reject(&:empty?)
|
|
169
|
+
.uniq
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def install_runtime!(runtime_root, env)
|
|
173
|
+
marker_path = File.join(runtime_root, ".bootstrapped")
|
|
174
|
+
return if File.exist?(marker_path)
|
|
175
|
+
|
|
176
|
+
ensure_ruby_available!
|
|
177
|
+
ensure_no_global_ace_gems!
|
|
178
|
+
|
|
179
|
+
stdout, stderr, status = @command_runner.call(
|
|
180
|
+
install_env(env),
|
|
181
|
+
*runtime_command(%w[bundle install]),
|
|
182
|
+
chdir: runtime_root
|
|
183
|
+
)
|
|
184
|
+
return File.write(marker_path, "ok\n") if status.success?
|
|
185
|
+
|
|
186
|
+
raise [
|
|
187
|
+
"Sandbox bundle install failed for Ruby #{@ruby_version}",
|
|
188
|
+
stdout.to_s.strip,
|
|
189
|
+
stderr.to_s.strip
|
|
190
|
+
].reject(&:empty?).join("\n")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def ensure_ruby_available!
|
|
194
|
+
resolve_ruby_install_path!
|
|
195
|
+
rescue RuntimeError => e
|
|
196
|
+
raise e
|
|
197
|
+
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def resolve_ruby_install_path!
|
|
201
|
+
return @ruby_install_path if defined?(@ruby_install_path) && @ruby_install_path
|
|
202
|
+
|
|
203
|
+
stdout, stderr, status = @command_runner.call(
|
|
204
|
+
minimal_host_env,
|
|
205
|
+
"mise", "where", "ruby@#{@ruby_version}",
|
|
206
|
+
chdir: @source_root
|
|
207
|
+
)
|
|
208
|
+
if status.success?
|
|
209
|
+
@ruby_install_path = stdout.to_s.strip
|
|
210
|
+
return @ruby_install_path unless @ruby_install_path.empty?
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
raise [
|
|
214
|
+
"Dedicated sandbox Ruby #{ruby_label} is not available.",
|
|
215
|
+
"Install it with: mise install ruby@#{@ruby_version}",
|
|
216
|
+
stderr.to_s.strip
|
|
217
|
+
].reject(&:empty?).join("\n")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def ensure_no_global_ace_gems!
|
|
221
|
+
script = <<~RUBY
|
|
222
|
+
names = Gem::Specification.map(&:name).grep(/^ace-/)
|
|
223
|
+
puts names.join("\\n") unless names.empty?
|
|
224
|
+
exit(names.empty? ? 0 : 42)
|
|
225
|
+
RUBY
|
|
226
|
+
|
|
227
|
+
stdout, stderr, status = @command_runner.call(
|
|
228
|
+
minimal_host_env,
|
|
229
|
+
*runtime_command(["ruby", "-e", script]),
|
|
230
|
+
chdir: @source_root
|
|
231
|
+
)
|
|
232
|
+
return if status.success?
|
|
233
|
+
|
|
234
|
+
raise [
|
|
235
|
+
"Dedicated sandbox Ruby #{ruby_label} already exposes ace-* gems globally.",
|
|
236
|
+
stdout.to_s.strip,
|
|
237
|
+
stderr.to_s.strip
|
|
238
|
+
].reject(&:empty?).join("\n")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def runtime_command(cmd)
|
|
242
|
+
return cmd if @ruby_version.empty?
|
|
243
|
+
ruby = ruby_executable_path
|
|
244
|
+
command = Array(cmd)
|
|
245
|
+
executable = command.shift
|
|
246
|
+
|
|
247
|
+
case executable
|
|
248
|
+
when "ruby"
|
|
249
|
+
[ruby] + command
|
|
250
|
+
when "bundle", "gem"
|
|
251
|
+
[ruby, "-S", executable] + command
|
|
252
|
+
else
|
|
253
|
+
[ruby, "-S", executable] + command
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def ruby_executable_path
|
|
258
|
+
File.join(resolve_ruby_install_path!, "bin", "ruby")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def minimal_host_env
|
|
262
|
+
{"HOME" => ENV["HOME"].to_s, "PATH" => ENV["PATH"].to_s}
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def install_env(env)
|
|
266
|
+
minimal_host_env.merge(env).merge("PATH" => env.fetch("PATH"))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def ruby_label
|
|
270
|
+
@ruby_version.empty? ? "(default ruby)" : @ruby_version
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def stringify_keys(hash)
|
|
274
|
+
hash.each_with_object({}) do |(key, value), acc|
|
|
275
|
+
acc[key.to_s] = value
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -47,7 +47,8 @@ module Ace
|
|
|
47
47
|
test_cases: test_cases,
|
|
48
48
|
tags: parse_tags(frontmatter["tags"]),
|
|
49
49
|
tool_under_test: frontmatter["tool-under-test"],
|
|
50
|
-
sandbox_layout: frontmatter["sandbox-layout"] || {}
|
|
50
|
+
sandbox_layout: frontmatter["sandbox-layout"] || {},
|
|
51
|
+
sandbox_profile: parse_sandbox_profile(frontmatter["sandbox-profile"], yml_path)
|
|
51
52
|
)
|
|
52
53
|
end
|
|
53
54
|
|
|
@@ -154,6 +155,13 @@ module Ace
|
|
|
154
155
|
def parse_standalone_test_case(tc_id, runner_file, verify_file)
|
|
155
156
|
runner_content = File.read(runner_file)
|
|
156
157
|
verify_content = File.read(verify_file)
|
|
158
|
+
scenario_dir = File.dirname(runner_file)
|
|
159
|
+
declared_artifacts, optional_artifacts = declared_artifacts_for(
|
|
160
|
+
scenario_dir,
|
|
161
|
+
tc_id,
|
|
162
|
+
runner_content,
|
|
163
|
+
verify_content
|
|
164
|
+
)
|
|
157
165
|
|
|
158
166
|
Models::TestCase.new(
|
|
159
167
|
tc_id: tc_id,
|
|
@@ -161,7 +169,9 @@ module Ace
|
|
|
161
169
|
content: build_standalone_content(runner_content, verify_content),
|
|
162
170
|
file_path: File.expand_path(runner_file),
|
|
163
171
|
pending: nil,
|
|
164
|
-
goal_format: "standalone"
|
|
172
|
+
goal_format: "standalone",
|
|
173
|
+
declared_artifacts: declared_artifacts,
|
|
174
|
+
optional_artifacts: optional_artifacts
|
|
165
175
|
)
|
|
166
176
|
end
|
|
167
177
|
|
|
@@ -223,6 +233,18 @@ module Ace
|
|
|
223
233
|
tags.map(&:to_s).map(&:strip).reject(&:empty?).map(&:downcase)
|
|
224
234
|
end
|
|
225
235
|
|
|
236
|
+
def parse_sandbox_profile(raw_profile, source_path)
|
|
237
|
+
profile = raw_profile.to_s.strip
|
|
238
|
+
return Molecules::ConfigLoader.default_sandbox_profile if profile.empty?
|
|
239
|
+
|
|
240
|
+
allowed = %w[ace-default bundle-only custom]
|
|
241
|
+
return profile if allowed.include?(profile)
|
|
242
|
+
|
|
243
|
+
raise ArgumentError,
|
|
244
|
+
"Invalid sandbox-profile in #{source_path}: #{profile.inspect}. " \
|
|
245
|
+
"Allowed values: #{allowed.join(", ")}"
|
|
246
|
+
end
|
|
247
|
+
|
|
226
248
|
# Detect fixtures directory if it exists
|
|
227
249
|
#
|
|
228
250
|
# @param scenario_dir [String] Path to the scenario directory
|
|
@@ -232,6 +254,66 @@ module Ace
|
|
|
232
254
|
Dir.exist?(path) ? File.expand_path(path) : nil
|
|
233
255
|
end
|
|
234
256
|
|
|
257
|
+
def declared_artifacts_for(scenario_dir, tc_id, runner_content, verify_content)
|
|
258
|
+
scenario_frontmatter = parse_scenario_yml(File.join(scenario_dir, "scenario.yml"))
|
|
259
|
+
required_artifacts = []
|
|
260
|
+
optional_artifacts = []
|
|
261
|
+
|
|
262
|
+
required_artifacts.concat(
|
|
263
|
+
Array((scenario_frontmatter["sandbox-layout"] || {}).keys).select do |path|
|
|
264
|
+
declared_artifact_matches_tc?(path, tc_id)
|
|
265
|
+
end
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
[runner_content, verify_content].each do |content|
|
|
269
|
+
extract_declared_artifacts(content).each do |entry|
|
|
270
|
+
if entry[:optional]
|
|
271
|
+
optional_artifacts << entry[:path]
|
|
272
|
+
else
|
|
273
|
+
required_artifacts << entry[:path]
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
required = required_artifacts.map { |path| normalize_declared_artifact(path) }.compact.uniq
|
|
279
|
+
optional = optional_artifacts.map { |path| normalize_declared_artifact(path) }.compact.uniq
|
|
280
|
+
optional = optional.reject { |path| required.include?(path) }
|
|
281
|
+
|
|
282
|
+
[required.sort, optional.sort]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def declared_artifact_matches_tc?(path, tc_id)
|
|
286
|
+
artifact_index = extract_declared_artifact_index(path)
|
|
287
|
+
return true if artifact_index.nil?
|
|
288
|
+
|
|
289
|
+
tc_index = extract_tc_index(tc_id)
|
|
290
|
+
tc_index && artifact_index == tc_index
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def extract_declared_artifact_index(path)
|
|
294
|
+
match = path.to_s.match(%r{\Aresults/tc/(\d{1,3})(?:/|$)})
|
|
295
|
+
match ? match[1].to_i : nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def extract_tc_index(tc_id)
|
|
299
|
+
match = tc_id.to_s.match(/\ATC-(\d+)/i)
|
|
300
|
+
match ? match[1].to_i : nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def extract_declared_artifacts(markdown)
|
|
304
|
+
markdown.to_s.scan(%r{(?:`|"|')?(results/tc/\d{2}/[^\s`)"']+|results/tc/\d{2}/)(?:`|"|')?(\s*\(optional\))?}i).map do |match|
|
|
305
|
+
path, optional = match
|
|
306
|
+
{path: path, optional: !optional.to_s.empty?}
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def normalize_declared_artifact(path)
|
|
311
|
+
value = path.to_s.strip
|
|
312
|
+
return nil unless value.start_with?("results/tc/")
|
|
313
|
+
|
|
314
|
+
value.sub(%r{/+\z}, "")
|
|
315
|
+
end
|
|
316
|
+
|
|
235
317
|
# Infer package name from scenario directory path
|
|
236
318
|
#
|
|
237
319
|
# @param scenario_dir [String] Path to scenario directory
|
|
@@ -240,9 +322,7 @@ module Ace
|
|
|
240
322
|
# Expected path: {package}/test/e2e/TS-{AREA}-{NNN}-{slug}/
|
|
241
323
|
parts = File.expand_path(scenario_dir).split("/")
|
|
242
324
|
parts.each_with_index do |part, idx|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
return parts[idx - 1]
|
|
325
|
+
return parts[idx - 1] if part == "test" && idx > 0 && parts[idx + 1] == "e2e"
|
|
246
326
|
end
|
|
247
327
|
|
|
248
328
|
"unknown"
|
|
@@ -17,6 +17,20 @@ module Ace
|
|
|
17
17
|
# Note: This is a Molecule because it performs filesystem I/O and
|
|
18
18
|
# system calls via Open3 and FileUtils.
|
|
19
19
|
class SetupExecutor
|
|
20
|
+
AMBIENT_TMUX_ENV_VARS = %w[TMUX TMUX_PANE].freeze
|
|
21
|
+
BUNDLER_ENV_PREFIXES = %w[BUNDLE BUNDLER].freeze
|
|
22
|
+
STRIPPED_ENV_KEYS = %w[RUBYOPT RUBYLIB].freeze
|
|
23
|
+
RESERVED_ENV_KEYS = Molecules::SandboxRuntimeBuilder::RESERVED_ENV_KEYS + %w[
|
|
24
|
+
PATH HOME TMPDIR XDG_RUNTIME_DIR TMUX_TMPDIR ACE_TMUX_SESSION
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def initialize(command_runner: nil, system_runner: nil, time_source: nil, sandbox_backend: nil)
|
|
28
|
+
@command_runner = command_runner || method(:capture3)
|
|
29
|
+
@system_runner = system_runner || method(:system)
|
|
30
|
+
@time_source = time_source || -> { Time.now.to_i }
|
|
31
|
+
@sandbox_backend = sandbox_backend
|
|
32
|
+
end
|
|
33
|
+
|
|
20
34
|
# Execute all setup steps in a sandbox directory
|
|
21
35
|
#
|
|
22
36
|
# @param setup_steps [Array] Setup steps from scenario.yml
|
|
@@ -25,29 +39,54 @@ module Ace
|
|
|
25
39
|
# @param scenario_name [String, nil] Test ID for tmux session naming (e.g., "TS-OVERSEER-001")
|
|
26
40
|
# @param run_id [String, nil] Unique run ID for deterministic tmux session naming
|
|
27
41
|
# @return [Hash] Result with :success, :steps_completed, :error, :env, :tmux_session keys
|
|
28
|
-
def execute(setup_steps:, sandbox_dir:, fixture_source: nil, scenario_name: nil, run_id: nil)
|
|
42
|
+
def execute(setup_steps:, sandbox_dir:, fixture_source: nil, scenario_name: nil, run_id: nil, initial_env: {})
|
|
29
43
|
FileUtils.mkdir_p(sandbox_dir)
|
|
30
|
-
env =
|
|
44
|
+
env = if @sandbox_backend
|
|
45
|
+
@sandbox_backend.prepared_env(initial_env.dup)
|
|
46
|
+
else
|
|
47
|
+
initial_env.dup
|
|
48
|
+
end
|
|
31
49
|
steps_completed = 0
|
|
32
50
|
@tmux_session = nil
|
|
33
51
|
@scenario_name = scenario_name
|
|
34
52
|
@run_id = run_id
|
|
53
|
+
@teardown_env = nil
|
|
35
54
|
|
|
36
55
|
setup_steps.each do |step|
|
|
37
56
|
execute_step(step, sandbox_dir, env, fixture_source)
|
|
38
57
|
steps_completed += 1
|
|
39
58
|
end
|
|
40
59
|
|
|
41
|
-
{
|
|
60
|
+
{
|
|
61
|
+
success: true,
|
|
62
|
+
steps_completed: steps_completed,
|
|
63
|
+
error: nil,
|
|
64
|
+
env: merged_environment(env),
|
|
65
|
+
tmux_session: @tmux_session
|
|
66
|
+
}
|
|
42
67
|
rescue => e
|
|
43
|
-
{
|
|
68
|
+
{
|
|
69
|
+
success: false,
|
|
70
|
+
steps_completed: steps_completed,
|
|
71
|
+
error: e.message,
|
|
72
|
+
env: merged_environment(env),
|
|
73
|
+
tmux_session: @tmux_session
|
|
74
|
+
}
|
|
44
75
|
end
|
|
45
76
|
|
|
46
77
|
# Clean up resources created during setup (e.g. tmux session)
|
|
47
78
|
def teardown
|
|
48
79
|
return unless @tmux_session
|
|
49
80
|
|
|
50
|
-
|
|
81
|
+
if @sandbox_backend
|
|
82
|
+
@sandbox_backend.capture3(
|
|
83
|
+
["tmux", "kill-session", "-t", @tmux_session],
|
|
84
|
+
chdir: @teardown_env&.fetch("PROJECT_ROOT_PATH", Dir.pwd) || Dir.pwd,
|
|
85
|
+
env: @teardown_env || {}
|
|
86
|
+
)
|
|
87
|
+
else
|
|
88
|
+
@system_runner.call("tmux", "kill-session", "-t", @tmux_session, out: File::NULL, err: File::NULL)
|
|
89
|
+
end
|
|
51
90
|
@tmux_session = nil
|
|
52
91
|
end
|
|
53
92
|
|
|
@@ -99,13 +138,23 @@ module Ace
|
|
|
99
138
|
session_name = if name_source == "run-id" && @run_id && !@run_id.to_s.empty?
|
|
100
139
|
@run_id
|
|
101
140
|
else
|
|
102
|
-
@scenario_name ? "#{@scenario_name}-e2e" : "ace-e2e-#{
|
|
141
|
+
@scenario_name ? "#{@scenario_name}-e2e" : "ace-e2e-#{@time_source.call}"
|
|
142
|
+
end
|
|
143
|
+
tmux_env = merged_environment(env).merge("TMUX_TMPDIR" => env["TMUX_TMPDIR"].to_s.empty? ? nil : env["TMUX_TMPDIR"])
|
|
144
|
+
if @sandbox_backend
|
|
145
|
+
_stdout, stderr, status = @sandbox_backend.capture3(
|
|
146
|
+
["tmux", "new-session", "-d", "-s", session_name],
|
|
147
|
+
chdir: env["PROJECT_ROOT_PATH"] || Dir.pwd,
|
|
148
|
+
env: tmux_env
|
|
149
|
+
)
|
|
150
|
+
else
|
|
151
|
+
_stdout, stderr, status = @command_runner.call(tmux_env, "tmux", "new-session", "-d", "-s", session_name)
|
|
103
152
|
end
|
|
104
|
-
_stdout, stderr, status = Open3.capture3("tmux", "new-session", "-d", "-s", session_name)
|
|
105
153
|
raise "Failed to create tmux session '#{session_name}': #{stderr.strip}" unless status.success?
|
|
106
154
|
|
|
107
155
|
@tmux_session = session_name
|
|
108
156
|
env["ACE_TMUX_SESSION"] = session_name
|
|
157
|
+
@teardown_env = merged_environment(env)
|
|
109
158
|
end
|
|
110
159
|
|
|
111
160
|
# Initialize a git repo with test user config
|
|
@@ -123,20 +172,25 @@ module Ace
|
|
|
123
172
|
end
|
|
124
173
|
|
|
125
174
|
# Execute a shell command in the sandbox
|
|
126
|
-
# NOTE: Uses shell invocation
|
|
127
|
-
#
|
|
175
|
+
# NOTE: Uses shell invocation intentionally to support shell operators
|
|
176
|
+
# (&&, |, >) in scenario.yml setup steps. Commands originate from
|
|
128
177
|
# committed scenario.yml files, not user input, so shell injection risk is mitigated.
|
|
178
|
+
# We explicitly disable profile/rc loading to keep sandbox env authoritative.
|
|
129
179
|
def handle_run(command, sandbox_dir, env)
|
|
130
180
|
full_env = merged_environment(env)
|
|
131
|
-
# Re-export env vars
|
|
132
|
-
#
|
|
181
|
+
# Re-export env vars inside the command to keep explicit sandbox
|
|
182
|
+
# values authoritative across compound shell expressions.
|
|
133
183
|
export_vars = env.dup
|
|
134
184
|
%w[PROJECT_ROOT_PATH].each do |key|
|
|
135
185
|
export_vars[key] ||= ENV[key] if ENV[key]
|
|
136
186
|
end
|
|
137
187
|
exports = export_vars.map { |k, v| "export #{k}=#{Shellwords.shellescape(v.to_s)}" }.join("; ")
|
|
138
188
|
wrapped = exports.empty? ? command : "#{exports}; #{command}"
|
|
139
|
-
stdout, stderr, status =
|
|
189
|
+
stdout, stderr, status = if @sandbox_backend
|
|
190
|
+
@sandbox_backend.capture3(["bash", "--noprofile", "--norc", "-c", wrapped], chdir: sandbox_dir, env: full_env)
|
|
191
|
+
else
|
|
192
|
+
Open3.capture3(full_env, "bash", "--noprofile", "--norc", "-c", wrapped, chdir: sandbox_dir)
|
|
193
|
+
end
|
|
140
194
|
|
|
141
195
|
unless status.success?
|
|
142
196
|
raise "Setup step 'run' failed (exit #{status.exitstatus}): #{command}\n#{stderr}"
|
|
@@ -154,7 +208,12 @@ module Ace
|
|
|
154
208
|
|
|
155
209
|
# Merge environment variables for subsequent steps
|
|
156
210
|
def handle_env(vars, env)
|
|
157
|
-
vars.each
|
|
211
|
+
vars.each do |k, v|
|
|
212
|
+
key = k.to_s
|
|
213
|
+
next if RESERVED_ENV_KEYS.include?(key)
|
|
214
|
+
|
|
215
|
+
env[key] = v.to_s
|
|
216
|
+
end
|
|
158
217
|
end
|
|
159
218
|
|
|
160
219
|
# Merge custom env vars with the process environment
|
|
@@ -162,18 +221,41 @@ module Ace
|
|
|
162
221
|
# @param env [Hash] Custom environment variables
|
|
163
222
|
# @return [Hash] Merged environment
|
|
164
223
|
def merged_environment(env)
|
|
165
|
-
|
|
166
|
-
|
|
224
|
+
base_env = sanitized_process_environment
|
|
225
|
+
return base_env if env.empty?
|
|
226
|
+
|
|
227
|
+
base_env.merge(env.transform_keys(&:to_s))
|
|
167
228
|
end
|
|
168
229
|
|
|
169
230
|
# Run a command and raise on failure
|
|
170
231
|
def run_command(*args, chdir:, env: {})
|
|
171
|
-
|
|
232
|
+
merged_env = merged_environment(env)
|
|
233
|
+
_stdout, stderr, status = if @sandbox_backend
|
|
234
|
+
@sandbox_backend.capture3(args, chdir: chdir, env: merged_env)
|
|
235
|
+
else
|
|
236
|
+
@command_runner.call(merged_env, *args, chdir: chdir)
|
|
237
|
+
end
|
|
172
238
|
|
|
173
239
|
unless status.success?
|
|
174
240
|
raise "Command failed (exit #{status.exitstatus}): #{args.join(" ")}\n#{stderr}"
|
|
175
241
|
end
|
|
176
242
|
end
|
|
243
|
+
|
|
244
|
+
def capture3(*args, **kwargs)
|
|
245
|
+
Open3.capture3(*args, **kwargs)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def sanitized_process_environment
|
|
249
|
+
ENV.to_h.each_with_object({}) do |(key, value), env|
|
|
250
|
+
if AMBIENT_TMUX_ENV_VARS.include?(key) || STRIPPED_ENV_KEYS.include?(key) ||
|
|
251
|
+
BUNDLER_ENV_PREFIXES.any? { |prefix| key.start_with?(prefix) }
|
|
252
|
+
env[key] = nil
|
|
253
|
+
next
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
env[key] = value
|
|
257
|
+
end
|
|
258
|
+
end
|
|
177
259
|
end
|
|
178
260
|
end
|
|
179
261
|
end
|