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
@@ -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
- next unless part == "test" && idx > 0 && parts[idx + 1] == "e2e"
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"