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
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Test
|
|
9
|
+
module EndToEndRunner
|
|
10
|
+
module Molecules
|
|
11
|
+
# Builds a sandbox-local Ruby/Bundler runtime for E2E execution.
|
|
12
|
+
class SandboxRuntimeBuilder
|
|
13
|
+
DEFAULT_RUBY_VERSION = "3.4.9"
|
|
14
|
+
DEFAULT_SHARED_RUNTIME_CACHE_ROOT = ".ace-local/test-e2e/runtime-cache"
|
|
15
|
+
SHARED_RUNTIME_ENV_KEY = "ACE_E2E_SHARED_RUNTIME_ROOT"
|
|
16
|
+
RUNTIME_CACHE_LAYOUT_VERSION = 1
|
|
17
|
+
RESERVED_ENV_KEYS = %w[
|
|
18
|
+
PROJECT_ROOT_PATH
|
|
19
|
+
ACE_E2E_SOURCE_ROOT
|
|
20
|
+
ACE_CONFIG_PATH
|
|
21
|
+
BUNDLE_GEMFILE
|
|
22
|
+
BUNDLE_APP_CONFIG
|
|
23
|
+
BUNDLE_USER_HOME
|
|
24
|
+
BUNDLE_USER_CACHE
|
|
25
|
+
BUNDLE_USER_CONFIG
|
|
26
|
+
BUNDLE_PATH
|
|
27
|
+
BUNDLE_BIN
|
|
28
|
+
BUNDLE_DISABLE_SHARED_GEMS
|
|
29
|
+
BUNDLER_VERSION
|
|
30
|
+
GEM_HOME
|
|
31
|
+
GEM_PATH
|
|
32
|
+
RUBYOPT
|
|
33
|
+
RUBYLIB
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
def initialize(source_root:, ruby_version: nil, command_runner: nil)
|
|
37
|
+
@source_root = File.expand_path(source_root)
|
|
38
|
+
@ruby_version = (ruby_version || DEFAULT_RUBY_VERSION).to_s.strip
|
|
39
|
+
@command_runner = command_runner || method(:capture3)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def prepare(sandbox_root:, env: {}, tool_names: nil)
|
|
43
|
+
sandbox_root = File.expand_path(sandbox_root)
|
|
44
|
+
local_runtime_root = File.join(sandbox_root, ".ace-local", "e2e-runtime")
|
|
45
|
+
runtime_root = resolve_runtime_root(local_runtime_root, env)
|
|
46
|
+
FileUtils.mkdir_p(runtime_root) unless shared_runtime_root?(env)
|
|
47
|
+
|
|
48
|
+
runtime_env = build_runtime_env(
|
|
49
|
+
sandbox_root,
|
|
50
|
+
runtime_root,
|
|
51
|
+
env,
|
|
52
|
+
mutable_runtime_root: local_runtime_root
|
|
53
|
+
)
|
|
54
|
+
ensure_runtime_dirs(runtime_env)
|
|
55
|
+
if shared_runtime_root?(env)
|
|
56
|
+
ensure_shared_runtime!(runtime_root, tool_names)
|
|
57
|
+
else
|
|
58
|
+
prepare_runtime_root!(runtime_root, runtime_env, tool_names)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
runtime_root: runtime_root,
|
|
63
|
+
env: runtime_env
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def prepare_shared_runtime(cache_root: nil, tool_names: nil)
|
|
68
|
+
runtime_root = shared_runtime_root(cache_root: cache_root)
|
|
69
|
+
runtime_env = build_shared_runtime_env(runtime_root)
|
|
70
|
+
ensure_runtime_dirs(runtime_env)
|
|
71
|
+
prepare_runtime_root!(runtime_root, runtime_env, tool_names)
|
|
72
|
+
runtime_root
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def capture3(env, *cmd, chdir:)
|
|
78
|
+
Open3.capture3(env, *cmd, chdir: chdir, unsetenv_others: true)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_runtime_env(sandbox_root, runtime_root, env, mutable_runtime_root: runtime_root)
|
|
82
|
+
merged = stringify_keys(env).reject { |key, _value| RESERVED_ENV_KEYS.include?(key) }
|
|
83
|
+
bundler_root = File.join(mutable_runtime_root, "bundler")
|
|
84
|
+
gem_root = File.join(runtime_root, "gems")
|
|
85
|
+
bin_root = File.join(runtime_root, "bin")
|
|
86
|
+
path = merged["PATH"].to_s
|
|
87
|
+
path = ENV["PATH"].to_s if path.empty?
|
|
88
|
+
|
|
89
|
+
merged.merge(
|
|
90
|
+
"PROJECT_ROOT_PATH" => sandbox_root,
|
|
91
|
+
"ACE_E2E_SOURCE_ROOT" => @source_root,
|
|
92
|
+
"ACE_CONFIG_PATH" => File.join(sandbox_root, ".ace"),
|
|
93
|
+
"ACE_E2E_SANDBOX_RUNTIME_ROOT" => runtime_root,
|
|
94
|
+
"ACE_E2E_SANDBOX_RUBY_VERSION" => @ruby_version,
|
|
95
|
+
"ACE_E2E_SANDBOX_RUBY_ROOT" => (@ruby_version.empty? ? "" : resolve_ruby_install_path!),
|
|
96
|
+
"BUNDLE_GEMFILE" => File.join(runtime_root, "Gemfile"),
|
|
97
|
+
"BUNDLE_APP_CONFIG" => File.join(bundler_root, "app-config"),
|
|
98
|
+
"BUNDLE_USER_HOME" => File.join(bundler_root, "home"),
|
|
99
|
+
"BUNDLE_USER_CACHE" => File.join(bundler_root, "cache"),
|
|
100
|
+
"BUNDLE_USER_CONFIG" => File.join(bundler_root, "config"),
|
|
101
|
+
"BUNDLE_PATH" => gem_root,
|
|
102
|
+
"BUNDLE_DISABLE_SHARED_GEMS" => "true",
|
|
103
|
+
"BUNDLE_WITHOUT" => "",
|
|
104
|
+
"GEM_HOME" => gem_root,
|
|
105
|
+
"GEM_PATH" => gem_root,
|
|
106
|
+
"PATH" => [bin_root, path].reject(&:empty?).join(File::PATH_SEPARATOR)
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def build_shared_runtime_env(runtime_root)
|
|
111
|
+
build_runtime_env(@source_root, runtime_root, {}, mutable_runtime_root: runtime_root)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ensure_runtime_dirs(env)
|
|
115
|
+
[
|
|
116
|
+
env.fetch("ACE_CONFIG_PATH"),
|
|
117
|
+
env.fetch("BUNDLE_APP_CONFIG"),
|
|
118
|
+
env.fetch("BUNDLE_USER_HOME"),
|
|
119
|
+
env.fetch("BUNDLE_USER_CACHE"),
|
|
120
|
+
File.dirname(env.fetch("BUNDLE_USER_CONFIG")),
|
|
121
|
+
env.fetch("BUNDLE_PATH"),
|
|
122
|
+
env.fetch("GEM_HOME"),
|
|
123
|
+
File.join(env.fetch("ACE_E2E_SANDBOX_RUNTIME_ROOT"), "bin")
|
|
124
|
+
].each do |path|
|
|
125
|
+
FileUtils.mkdir_p(path)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def write_runtime_gemfile(runtime_root)
|
|
130
|
+
source_gemfile = File.join(@source_root, "Gemfile")
|
|
131
|
+
content = File.read(source_gemfile)
|
|
132
|
+
rewritten = content.gsub(/(path:\s*["'])([^"']+)(["'])/) do
|
|
133
|
+
prefix = Regexp.last_match(1)
|
|
134
|
+
relative_path = Regexp.last_match(2)
|
|
135
|
+
suffix = Regexp.last_match(3)
|
|
136
|
+
absolute_path = File.expand_path(relative_path, @source_root)
|
|
137
|
+
"#{prefix}#{absolute_path}#{suffix}"
|
|
138
|
+
end
|
|
139
|
+
File.write(File.join(runtime_root, "Gemfile"), rewritten)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def write_command_shims(runtime_root, tool_names)
|
|
143
|
+
bin_root = File.join(runtime_root, "bin")
|
|
144
|
+
requested = normalize_tool_names(tool_names)
|
|
145
|
+
executable_map = discover_executable_map
|
|
146
|
+
names = executable_map.keys | requested
|
|
147
|
+
|
|
148
|
+
names.each do |tool_name|
|
|
149
|
+
source_exec = executable_map[tool_name]
|
|
150
|
+
next unless source_exec
|
|
151
|
+
|
|
152
|
+
shim_path = File.join(bin_root, tool_name)
|
|
153
|
+
shim_body = if @ruby_version.empty?
|
|
154
|
+
<<~SH
|
|
155
|
+
#!/usr/bin/env bash
|
|
156
|
+
set -euo pipefail
|
|
157
|
+
exec ruby -rbundler/setup "#{source_exec}" "$@"
|
|
158
|
+
SH
|
|
159
|
+
else
|
|
160
|
+
ruby_exec = ruby_executable_path
|
|
161
|
+
<<~SH
|
|
162
|
+
#!/usr/bin/env bash
|
|
163
|
+
set -euo pipefail
|
|
164
|
+
exec "#{ruby_exec}" -rbundler/setup "#{source_exec}" "$@"
|
|
165
|
+
SH
|
|
166
|
+
end
|
|
167
|
+
File.write(shim_path, shim_body)
|
|
168
|
+
FileUtils.chmod(0o755, shim_path)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def discover_executable_map
|
|
173
|
+
@discover_executable_map ||= begin
|
|
174
|
+
paths = Dir.glob(File.join(@source_root, "bin", "ace-*")).sort
|
|
175
|
+
Dir.glob(File.join(@source_root, "ace-*", "exe", "ace-*")).sort.each do |path|
|
|
176
|
+
basename = File.basename(path)
|
|
177
|
+
next if paths.any? { |candidate| File.basename(candidate) == basename }
|
|
178
|
+
|
|
179
|
+
paths << path
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
paths.each_with_object({}) do |path, map|
|
|
183
|
+
map[File.basename(path)] = path
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def normalize_tool_names(tool_names)
|
|
189
|
+
Array(tool_names)
|
|
190
|
+
.flat_map { |entry| entry.to_s.split(",") }
|
|
191
|
+
.map(&:strip)
|
|
192
|
+
.reject(&:empty?)
|
|
193
|
+
.uniq
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def resolve_runtime_root(local_runtime_root, env)
|
|
197
|
+
shared_root = shared_runtime_root_from_env(env)
|
|
198
|
+
return shared_root if shared_root
|
|
199
|
+
|
|
200
|
+
local_runtime_root
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def shared_runtime_root?(env)
|
|
204
|
+
!shared_runtime_root_from_env(env).nil?
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def shared_runtime_root_from_env(env)
|
|
208
|
+
merged = stringify_keys(ENV.to_h).merge(stringify_keys(env))
|
|
209
|
+
raw = merged[SHARED_RUNTIME_ENV_KEY].to_s.strip
|
|
210
|
+
return nil if raw.empty?
|
|
211
|
+
|
|
212
|
+
File.expand_path(raw)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def shared_runtime_root(cache_root: nil)
|
|
216
|
+
base = if cache_root
|
|
217
|
+
File.expand_path(cache_root)
|
|
218
|
+
else
|
|
219
|
+
File.join(@source_root, DEFAULT_SHARED_RUNTIME_CACHE_ROOT)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
File.join(base, runtime_cache_key)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def runtime_cache_key
|
|
226
|
+
@runtime_cache_key ||= begin
|
|
227
|
+
digest = Digest::SHA256.new
|
|
228
|
+
digest.update("layout:#{RUNTIME_CACHE_LAYOUT_VERSION}\n")
|
|
229
|
+
digest.update("ruby:#{@ruby_version}\n")
|
|
230
|
+
digest.update(File.read(File.join(@source_root, "Gemfile")))
|
|
231
|
+
lockfile_path = File.join(@source_root, "Gemfile.lock")
|
|
232
|
+
digest.update(File.read(lockfile_path)) if File.file?(lockfile_path)
|
|
233
|
+
digest.hexdigest[0, 16]
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def ensure_shared_runtime!(runtime_root, tool_names)
|
|
238
|
+
runtime_env = build_shared_runtime_env(runtime_root)
|
|
239
|
+
ensure_runtime_dirs(runtime_env)
|
|
240
|
+
prepare_runtime_root!(runtime_root, runtime_env, tool_names)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def prepare_runtime_root!(runtime_root, env, tool_names)
|
|
244
|
+
with_runtime_lock(runtime_root) do
|
|
245
|
+
marker_path = File.join(runtime_root, ".bootstrapped")
|
|
246
|
+
return if File.exist?(marker_path)
|
|
247
|
+
|
|
248
|
+
FileUtils.mkdir_p(runtime_root)
|
|
249
|
+
write_runtime_gemfile(runtime_root)
|
|
250
|
+
write_command_shims(runtime_root, tool_names)
|
|
251
|
+
install_runtime!(runtime_root, env)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def with_runtime_lock(runtime_root)
|
|
256
|
+
lock_path = "#{runtime_root}.lock"
|
|
257
|
+
FileUtils.mkdir_p(File.dirname(lock_path))
|
|
258
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock_file|
|
|
259
|
+
lock_file.flock(File::LOCK_EX)
|
|
260
|
+
yield
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def install_runtime!(runtime_root, env)
|
|
265
|
+
marker_path = File.join(runtime_root, ".bootstrapped")
|
|
266
|
+
return if File.exist?(marker_path)
|
|
267
|
+
|
|
268
|
+
ensure_ruby_available!
|
|
269
|
+
ensure_no_global_ace_gems!
|
|
270
|
+
|
|
271
|
+
stdout, stderr, status = @command_runner.call(
|
|
272
|
+
install_env(env),
|
|
273
|
+
*runtime_command(%w[bundle install]),
|
|
274
|
+
chdir: runtime_root
|
|
275
|
+
)
|
|
276
|
+
return File.write(marker_path, "ok\n") if status.success?
|
|
277
|
+
|
|
278
|
+
raise [
|
|
279
|
+
"Sandbox bundle install failed for Ruby #{@ruby_version}",
|
|
280
|
+
stdout.to_s.strip,
|
|
281
|
+
stderr.to_s.strip
|
|
282
|
+
].reject(&:empty?).join("\n")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def ensure_ruby_available!
|
|
286
|
+
resolve_ruby_install_path!
|
|
287
|
+
rescue RuntimeError => e
|
|
288
|
+
raise e
|
|
289
|
+
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def resolve_ruby_install_path!
|
|
293
|
+
return @ruby_install_path if defined?(@ruby_install_path) && @ruby_install_path
|
|
294
|
+
|
|
295
|
+
stdout, stderr, status = @command_runner.call(
|
|
296
|
+
minimal_host_env,
|
|
297
|
+
"mise", "where", "ruby@#{@ruby_version}",
|
|
298
|
+
chdir: @source_root
|
|
299
|
+
)
|
|
300
|
+
if status.success?
|
|
301
|
+
@ruby_install_path = stdout.to_s.strip
|
|
302
|
+
return @ruby_install_path unless @ruby_install_path.empty?
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
raise [
|
|
306
|
+
"Dedicated sandbox Ruby #{ruby_label} is not available.",
|
|
307
|
+
"Install it with: mise install ruby@#{@ruby_version}",
|
|
308
|
+
stderr.to_s.strip
|
|
309
|
+
].reject(&:empty?).join("\n")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def ensure_no_global_ace_gems!
|
|
313
|
+
script = <<~RUBY
|
|
314
|
+
names = Gem::Specification.map(&:name).grep(/^ace-/)
|
|
315
|
+
puts names.join("\\n") unless names.empty?
|
|
316
|
+
exit(names.empty? ? 0 : 42)
|
|
317
|
+
RUBY
|
|
318
|
+
|
|
319
|
+
stdout, stderr, status = @command_runner.call(
|
|
320
|
+
minimal_host_env,
|
|
321
|
+
*runtime_command(["ruby", "-e", script]),
|
|
322
|
+
chdir: @source_root
|
|
323
|
+
)
|
|
324
|
+
return if status.success?
|
|
325
|
+
|
|
326
|
+
raise [
|
|
327
|
+
"Dedicated sandbox Ruby #{ruby_label} already exposes ace-* gems globally.",
|
|
328
|
+
stdout.to_s.strip,
|
|
329
|
+
stderr.to_s.strip
|
|
330
|
+
].reject(&:empty?).join("\n")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def runtime_command(cmd)
|
|
334
|
+
return cmd if @ruby_version.empty?
|
|
335
|
+
ruby = ruby_executable_path
|
|
336
|
+
command = Array(cmd)
|
|
337
|
+
executable = command.shift
|
|
338
|
+
|
|
339
|
+
case executable
|
|
340
|
+
when "ruby"
|
|
341
|
+
[ruby] + command
|
|
342
|
+
when "bundle", "gem"
|
|
343
|
+
[ruby, "-S", executable] + command
|
|
344
|
+
else
|
|
345
|
+
[ruby, "-S", executable] + command
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def ruby_executable_path
|
|
350
|
+
File.join(resolve_ruby_install_path!, "bin", "ruby")
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def minimal_host_env
|
|
354
|
+
{"HOME" => ENV["HOME"].to_s, "PATH" => ENV["PATH"].to_s}
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def install_env(env)
|
|
358
|
+
minimal_host_env.merge(env).merge("PATH" => env.fetch("PATH"))
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def ruby_label
|
|
362
|
+
@ruby_version.empty? ? "(default ruby)" : @ruby_version
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def stringify_keys(hash)
|
|
366
|
+
hash.each_with_object({}) do |(key, value), acc|
|
|
367
|
+
acc[key.to_s] = value
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
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,64 @@ 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
|
+
scenario_references = Atoms::ArtifactContractValidator.references_from_paths(
|
|
260
|
+
Array((scenario_frontmatter["sandbox-layout"] || {}).keys).select do |path|
|
|
261
|
+
declared_artifact_matches_tc?(path, tc_id)
|
|
262
|
+
end,
|
|
263
|
+
source: File.join(scenario_dir, "scenario.yml")
|
|
264
|
+
)
|
|
265
|
+
runner_references = Atoms::ArtifactContractValidator.extract(
|
|
266
|
+
runner_content,
|
|
267
|
+
source: File.join(scenario_dir, "#{tc_id}.runner.md")
|
|
268
|
+
)
|
|
269
|
+
verifier_references = Atoms::ArtifactContractValidator.extract(
|
|
270
|
+
verify_content,
|
|
271
|
+
source: File.join(scenario_dir, "#{tc_id}.verify.md")
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
Atoms::ArtifactContractValidator.validate!(
|
|
275
|
+
tc_id: tc_id,
|
|
276
|
+
scenario_dir: scenario_dir,
|
|
277
|
+
runner_references: runner_references,
|
|
278
|
+
verifier_references: verifier_references,
|
|
279
|
+
scenario_references: scenario_references
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
required = (scenario_references + runner_references)
|
|
283
|
+
.reject(&:optional)
|
|
284
|
+
.map(&:path)
|
|
285
|
+
.compact
|
|
286
|
+
.uniq
|
|
287
|
+
optional = runner_references
|
|
288
|
+
.select(&:optional)
|
|
289
|
+
.map(&:path)
|
|
290
|
+
.compact
|
|
291
|
+
.uniq
|
|
292
|
+
optional = optional.reject { |path| required.include?(path) }
|
|
293
|
+
|
|
294
|
+
[required.sort, optional.sort]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def declared_artifact_matches_tc?(path, tc_id)
|
|
298
|
+
artifact_index = extract_declared_artifact_index(path)
|
|
299
|
+
return true if artifact_index.nil?
|
|
300
|
+
|
|
301
|
+
tc_index = extract_tc_index(tc_id)
|
|
302
|
+
tc_index && artifact_index == tc_index
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def extract_declared_artifact_index(path)
|
|
306
|
+
match = path.to_s.match(%r{\Aresults/tc/(\d{1,3})(?:/|$)})
|
|
307
|
+
match ? match[1].to_i : nil
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def extract_tc_index(tc_id)
|
|
311
|
+
match = tc_id.to_s.match(/\ATC-(\d+)/i)
|
|
312
|
+
match ? match[1].to_i : nil
|
|
313
|
+
end
|
|
314
|
+
|
|
235
315
|
# Infer package name from scenario directory path
|
|
236
316
|
#
|
|
237
317
|
# @param scenario_dir [String] Path to scenario directory
|
|
@@ -240,9 +320,7 @@ module Ace
|
|
|
240
320
|
# Expected path: {package}/test/e2e/TS-{AREA}-{NNN}-{slug}/
|
|
241
321
|
parts = File.expand_path(scenario_dir).split("/")
|
|
242
322
|
parts.each_with_index do |part, idx|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
return parts[idx - 1]
|
|
323
|
+
return parts[idx - 1] if part == "test" && idx > 0 && parts[idx + 1] == "e2e"
|
|
246
324
|
end
|
|
247
325
|
|
|
248
326
|
"unknown"
|