ace-test-runner-e2e 0.29.0

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 (76) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/e2e-runner/config.yml +70 -0
  3. data/.ace-defaults/nav/protocols/guide-sources/ace-test-runner-e2e.yml +11 -0
  4. data/.ace-defaults/nav/protocols/skill-sources/ace-test-runner-e2e.yml +19 -0
  5. data/.ace-defaults/nav/protocols/tmpl-sources/ace-test-runner-e2e.yml +12 -0
  6. data/.ace-defaults/nav/protocols/wfi-sources/ace-test-runner-e2e.yml +11 -0
  7. data/CHANGELOG.md +1166 -0
  8. data/LICENSE +21 -0
  9. data/README.md +42 -0
  10. data/Rakefile +15 -0
  11. data/exe/ace-test-e2e +15 -0
  12. data/exe/ace-test-e2e-sh +67 -0
  13. data/exe/ace-test-e2e-suite +13 -0
  14. data/handbook/guides/e2e-testing.g.md +124 -0
  15. data/handbook/guides/scenario-yml-reference.g.md +182 -0
  16. data/handbook/guides/tc-authoring.g.md +131 -0
  17. data/handbook/skills/as-e2e-create/SKILL.md +30 -0
  18. data/handbook/skills/as-e2e-fix/SKILL.md +35 -0
  19. data/handbook/skills/as-e2e-manage/SKILL.md +31 -0
  20. data/handbook/skills/as-e2e-plan-changes/SKILL.md +30 -0
  21. data/handbook/skills/as-e2e-review/SKILL.md +35 -0
  22. data/handbook/skills/as-e2e-rewrite/SKILL.md +31 -0
  23. data/handbook/skills/as-e2e-run/SKILL.md +48 -0
  24. data/handbook/skills/as-e2e-setup-sandbox/SKILL.md +34 -0
  25. data/handbook/templates/ace-taskflow-fixture.template.md +322 -0
  26. data/handbook/templates/agent-experience-report.template.md +89 -0
  27. data/handbook/templates/metadata.template.yml +49 -0
  28. data/handbook/templates/scenario.yml.template.yml +60 -0
  29. data/handbook/templates/tc-file.template.md +45 -0
  30. data/handbook/templates/test-report.template.md +94 -0
  31. data/handbook/workflow-instructions/e2e/analyze-failures.wf.md +126 -0
  32. data/handbook/workflow-instructions/e2e/create.wf.md +395 -0
  33. data/handbook/workflow-instructions/e2e/execute.wf.md +253 -0
  34. data/handbook/workflow-instructions/e2e/fix.wf.md +166 -0
  35. data/handbook/workflow-instructions/e2e/manage.wf.md +179 -0
  36. data/handbook/workflow-instructions/e2e/plan-changes.wf.md +255 -0
  37. data/handbook/workflow-instructions/e2e/review.wf.md +286 -0
  38. data/handbook/workflow-instructions/e2e/rewrite.wf.md +281 -0
  39. data/handbook/workflow-instructions/e2e/run.wf.md +355 -0
  40. data/handbook/workflow-instructions/e2e/setup-sandbox.wf.md +461 -0
  41. data/lib/ace/test/end_to_end_runner/atoms/display_helpers.rb +234 -0
  42. data/lib/ace/test/end_to_end_runner/atoms/prompt_builder.rb +199 -0
  43. data/lib/ace/test/end_to_end_runner/atoms/result_parser.rb +166 -0
  44. data/lib/ace/test/end_to_end_runner/atoms/skill_prompt_builder.rb +166 -0
  45. data/lib/ace/test/end_to_end_runner/atoms/skill_result_parser.rb +244 -0
  46. data/lib/ace/test/end_to_end_runner/atoms/suite_report_prompt_builder.rb +103 -0
  47. data/lib/ace/test/end_to_end_runner/atoms/tc_fidelity_validator.rb +39 -0
  48. data/lib/ace/test/end_to_end_runner/atoms/test_case_parser.rb +108 -0
  49. data/lib/ace/test/end_to_end_runner/cli/commands/run_suite.rb +130 -0
  50. data/lib/ace/test/end_to_end_runner/cli/commands/run_test.rb +156 -0
  51. data/lib/ace/test/end_to_end_runner/models/test_case.rb +47 -0
  52. data/lib/ace/test/end_to_end_runner/models/test_result.rb +115 -0
  53. data/lib/ace/test/end_to_end_runner/models/test_scenario.rb +90 -0
  54. data/lib/ace/test/end_to_end_runner/molecules/affected_detector.rb +92 -0
  55. data/lib/ace/test/end_to_end_runner/molecules/config_loader.rb +75 -0
  56. data/lib/ace/test/end_to_end_runner/molecules/failure_finder.rb +203 -0
  57. data/lib/ace/test/end_to_end_runner/molecules/fixture_copier.rb +35 -0
  58. data/lib/ace/test/end_to_end_runner/molecules/pipeline_executor.rb +121 -0
  59. data/lib/ace/test/end_to_end_runner/molecules/pipeline_prompt_bundler.rb +182 -0
  60. data/lib/ace/test/end_to_end_runner/molecules/pipeline_report_generator.rb +321 -0
  61. data/lib/ace/test/end_to_end_runner/molecules/pipeline_sandbox_builder.rb +131 -0
  62. data/lib/ace/test/end_to_end_runner/molecules/progress_display_manager.rb +172 -0
  63. data/lib/ace/test/end_to_end_runner/molecules/report_writer.rb +259 -0
  64. data/lib/ace/test/end_to_end_runner/molecules/scenario_loader.rb +254 -0
  65. data/lib/ace/test/end_to_end_runner/molecules/setup_executor.rb +181 -0
  66. data/lib/ace/test/end_to_end_runner/molecules/simple_display_manager.rb +72 -0
  67. data/lib/ace/test/end_to_end_runner/molecules/suite_progress_display_manager.rb +223 -0
  68. data/lib/ace/test/end_to_end_runner/molecules/suite_report_writer.rb +277 -0
  69. data/lib/ace/test/end_to_end_runner/molecules/suite_simple_display_manager.rb +116 -0
  70. data/lib/ace/test/end_to_end_runner/molecules/test_discoverer.rb +136 -0
  71. data/lib/ace/test/end_to_end_runner/molecules/test_executor.rb +332 -0
  72. data/lib/ace/test/end_to_end_runner/organisms/suite_orchestrator.rb +830 -0
  73. data/lib/ace/test/end_to_end_runner/organisms/test_orchestrator.rb +442 -0
  74. data/lib/ace/test/end_to_end_runner/version.rb +9 -0
  75. data/lib/ace/test/end_to_end_runner.rb +71 -0
  76. metadata +220 -0
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "stringio"
5
+ require "ace/support/cli"
6
+
7
+ module Ace
8
+ module Test
9
+ module EndToEndRunner
10
+ module CLI
11
+ module Commands
12
+ # CLI command for running E2E tests
13
+ #
14
+ # Supports running a single test by ID or all tests in a package.
15
+ # Tests are executed via LLM and results are written to standard
16
+ # report locations.
17
+ class RunTest < Ace::Support::Cli::Command
18
+ include Ace::Support::Cli::Base
19
+
20
+ desc <<~DESC.strip
21
+ Run E2E tests via LLM execution
22
+
23
+ Discovers and executes TS-* test scenarios in a package's test/e2e/ directory.
24
+ Tests are sent to an LLM provider which executes the test steps and returns
25
+ structured results.
26
+
27
+ Output:
28
+ Exit codes: 0 (all pass), 1 (any fail/error)
29
+ Reports written to: .ace-local/test-e2e/{timestamp}-{pkg}-{id}-reports/
30
+ DESC
31
+
32
+ example [
33
+ "ace-lint TS-LINT-001 # Run specific test",
34
+ "ace-lint # Run all tests in package",
35
+ "ace-lint --provider gemini:flash # Use specific provider",
36
+ "ace-lint --provider glite # Use API provider (predict mode)",
37
+ "ace-lint --tags smoke # Run only smoke-tagged scenarios",
38
+ "ace-lint TS-LINT-003 --dry-run # Preview scenarios that would run"
39
+ ]
40
+
41
+ argument :package, required: true, desc: "Package name (e.g., ace-lint)"
42
+ argument :test_id, required: false, desc: "Test ID (e.g., TS-LINT-001)"
43
+
44
+ option :provider, type: :string, default: Molecules::ConfigLoader.default_provider,
45
+ desc: "LLM provider:model (e.g., claude:sonnet, gemini:flash)"
46
+ option :cli_args, type: :string,
47
+ desc: "Extra args for CLI-based LLM providers"
48
+ option :timeout, type: :string, default: Molecules::ConfigLoader.default_timeout.to_s,
49
+ desc: "Timeout per test in seconds"
50
+ option :parallel, type: :string, default: Molecules::ConfigLoader.default_parallel.to_s,
51
+ desc: "Number of tests to run in parallel (1 = sequential)"
52
+ option :progress, type: :boolean, desc: "Enable live animated display"
53
+ option :run_id, type: :string,
54
+ desc: "Pre-generated run ID for deterministic report paths"
55
+ option :report_dir, type: :string,
56
+ desc: "Explicit report directory path (overrides computed path)"
57
+ option :dry_run, type: :boolean,
58
+ desc: "Preview which scenarios would run without executing"
59
+ option :tags, type: :string,
60
+ desc: "Comma-separated scenario tags to include"
61
+ option :verify, type: :boolean,
62
+ desc: "Run independent verifier pass after runner execution"
63
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
64
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
65
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
66
+
67
+ def call(package:, test_id: nil, **options)
68
+ options = coerce_types(options, timeout: :integer, parallel: :integer)
69
+ output = quiet?(options) ? StringIO.new : $stdout
70
+
71
+ # Handle dry-run mode
72
+ if options[:dry_run]
73
+ return handle_dry_run(package, test_id, output, tags: parse_tags(options[:tags]))
74
+ end
75
+
76
+ orchestrator = Organisms::TestOrchestrator.new(
77
+ provider: options[:provider],
78
+ timeout: options[:timeout],
79
+ parallel: options[:parallel],
80
+ progress: options[:progress]
81
+ )
82
+
83
+ results = orchestrator.run(
84
+ package: package,
85
+ test_id: test_id,
86
+ verify: options[:verify],
87
+ tags: parse_tags(options[:tags]),
88
+ cli_args: options[:cli_args],
89
+ run_id: options[:run_id],
90
+ report_dir: options[:report_dir],
91
+ output: output
92
+ )
93
+
94
+ if results.empty?
95
+ raise Ace::Support::Cli::Error.new(
96
+ "No tests found for package '#{package}'" +
97
+ (test_id ? " with ID '#{test_id}'" : "")
98
+ )
99
+ end
100
+
101
+ # Exit with error if any test failed (excluding skips)
102
+ if results.any?(&:failed?)
103
+ failed = results.select(&:failed?)
104
+ failed_ids = failed.map(&:test_id).join(", ")
105
+ raise Ace::Support::Cli::Error.new(
106
+ "#{failed.size} test(s) failed: #{failed_ids}"
107
+ )
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ # Handle dry-run mode: preview which scenarios would run
114
+ #
115
+ # @param package [String] Package name
116
+ # @param test_id [String, nil] Test ID
117
+ # @param output [IO] Output stream
118
+ def handle_dry_run(package, test_id, output, tags: [])
119
+ discoverer = Molecules::TestDiscoverer.new
120
+ loader = Molecules::ScenarioLoader.new
121
+
122
+ files = discoverer.find_tests(
123
+ package: package,
124
+ test_id: test_id,
125
+ tags: tags,
126
+ base_dir: Dir.pwd
127
+ )
128
+ if files.empty?
129
+ raise Ace::Support::Cli::Error.new(
130
+ "No tests found for package '#{package}'" +
131
+ (test_id ? " with ID '#{test_id}'" : "")
132
+ )
133
+ end
134
+
135
+ output.puts "Dry run: preview of scenarios to execute"
136
+ output.puts ""
137
+
138
+ files.each do |file|
139
+ scenario = loader.load(File.dirname(file))
140
+ output.puts "#{scenario.test_id}: #{scenario.title}"
141
+ output.puts " [run] full scenario (#{scenario.test_case_ids.size} test cases)"
142
+ output.puts ""
143
+ end
144
+ end
145
+
146
+ def parse_tags(raw)
147
+ return [] if raw.nil? || raw.strip.empty?
148
+
149
+ raw.split(",").map(&:strip).reject(&:empty?).map(&:downcase)
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Test
5
+ module EndToEndRunner
6
+ module Models
7
+ # Data model representing a single test case definition.
8
+ #
9
+ # Contains parsed frontmatter metadata and the full markdown body
10
+ # from an independent test case file within a scenario directory.
11
+ class TestCase
12
+ attr_reader :tc_id, :title, :content, :file_path, :pending, :goal_format
13
+
14
+ # @param tc_id [String] Test case identifier (e.g., "TC-001")
15
+ # @param title [String] Test case title from frontmatter
16
+ # @param content [String] Full markdown body (below frontmatter)
17
+ # @param file_path [String] Absolute path to the source test file
18
+ # @param pending [String, nil] Pending reason (presence = pending, value = reason)
19
+ # @param goal_format [String, nil] Test case source format ("standalone")
20
+ def initialize(tc_id:, title:, content:, file_path:, pending: nil, goal_format: nil)
21
+ @tc_id = tc_id
22
+ @title = title
23
+ @content = content
24
+ @file_path = file_path
25
+ @pending = pending
26
+ @goal_format = goal_format
27
+ end
28
+
29
+ # Whether this test case is pending (should be skipped)
30
+ # @return [Boolean]
31
+ def pending?
32
+ !pending.nil?
33
+ end
34
+
35
+ # Generate short test case ID for directory naming
36
+ # @return [String] Short ID (e.g., "tc001" from "TC-001", "tc001a" from "TC-001a")
37
+ def short_id
38
+ match = tc_id.match(/TC-(\d+[a-z]*)/i)
39
+ return "tc#{match[1]}" if match
40
+
41
+ tc_id.downcase.gsub(/[^a-z0-9]/, "")
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Test
5
+ module EndToEndRunner
6
+ module Models
7
+ # Data model representing the result of an E2E test execution
8
+ #
9
+ # Contains the status, individual test case results, and metadata
10
+ # from executing a test scenario via LLM.
11
+ class TestResult
12
+ attr_reader :test_id, :status, :test_cases, :summary,
13
+ :started_at, :completed_at, :report_dir, :error
14
+
15
+ # @param test_id [String] Test identifier
16
+ # @param status [String] Overall status: "pass", "fail", "partial", "error"
17
+ # @param test_cases [Array<Hash>] Individual test case results
18
+ # @param summary [String] Brief execution summary
19
+ # @param started_at [Time] When execution started
20
+ # @param completed_at [Time] When execution completed
21
+ # @param report_dir [String, nil] Path to the reports directory
22
+ # @param error [String, nil] Error message if execution failed
23
+ def initialize(test_id:, status:, test_cases: [], summary: "",
24
+ started_at: nil, completed_at: nil, report_dir: nil, error: nil)
25
+ @test_id = test_id
26
+ @status = status
27
+ @test_cases = test_cases
28
+ @summary = summary
29
+ @started_at = started_at || Time.now
30
+ @completed_at = completed_at || Time.now
31
+ @report_dir = report_dir
32
+ @error = error
33
+ end
34
+
35
+ # Check if the test passed
36
+ # @return [Boolean]
37
+ def success?
38
+ status == "pass"
39
+ end
40
+
41
+ # Check if the test failed (non-pass, non-skip status)
42
+ # @return [Boolean]
43
+ def failed?
44
+ !success? && !skipped?
45
+ end
46
+
47
+ # Check if the test was skipped
48
+ # @return [Boolean]
49
+ def skipped?
50
+ status == "skip"
51
+ end
52
+
53
+ # Count of passed test cases
54
+ # @return [Integer]
55
+ def passed_count
56
+ test_cases.count { |tc| tc[:status] == "pass" }
57
+ end
58
+
59
+ # Count of failed test cases
60
+ # @return [Integer]
61
+ def failed_count
62
+ test_cases.count { |tc| tc[:status] == "fail" }
63
+ end
64
+
65
+ # Total number of test cases
66
+ # @return [Integer]
67
+ def total_count
68
+ test_cases.size
69
+ end
70
+
71
+ # IDs of failed test cases
72
+ # @return [Array<String>] List of test case IDs with "fail" status
73
+ def failed_test_case_ids
74
+ test_cases
75
+ .select { |tc| tc[:status] == "fail" }
76
+ .map { |tc| tc[:id] }
77
+ end
78
+
79
+ # Duration in seconds
80
+ # @return [Float]
81
+ def duration
82
+ (completed_at - started_at).to_f
83
+ end
84
+
85
+ # Return a copy with the report_dir set
86
+ # @param dir [String] Path to the reports directory
87
+ # @return [TestResult] New result with report_dir
88
+ def with_report_dir(dir)
89
+ TestResult.new(
90
+ test_id: test_id,
91
+ status: status,
92
+ test_cases: test_cases,
93
+ summary: summary,
94
+ started_at: started_at,
95
+ completed_at: completed_at,
96
+ report_dir: dir,
97
+ error: error
98
+ )
99
+ end
100
+
101
+ # Human-readable duration string
102
+ # @return [String]
103
+ def duration_display
104
+ d = duration
105
+ if d < 60
106
+ "#{d.round(1)}s"
107
+ else
108
+ "#{(d / 60).floor}m #{(d % 60).round(0)}s"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Test
5
+ module EndToEndRunner
6
+ module Models
7
+ # Data model representing a parsed E2E test scenario (TS-*/scenario.yml directory)
8
+ #
9
+ # Contains all information extracted from a test scenario including
10
+ # scenario.yml metadata, test cases, and setup steps.
11
+ class TestScenario
12
+ attr_reader :test_id, :title, :area, :package, :priority, :duration,
13
+ :requires, :file_path, :content, :timeout,
14
+ :setup_steps, :dir_path, :fixture_path, :test_cases,
15
+ :tags, :tool_under_test, :sandbox_layout
16
+
17
+ # @param test_id [String] Test identifier (e.g., "TS-LINT-001")
18
+ # @param title [String] Test title
19
+ # @param area [String] Test area (e.g., "lint")
20
+ # @param package [String] Package name (e.g., "ace-lint")
21
+ # @param priority [String] Priority level (default: "medium")
22
+ # @param duration [String] Expected duration (default: "~5min")
23
+ # @param requires [Hash] Required tools and versions
24
+ # @param file_path [String] Absolute path to the scenario directory
25
+ # @param content [String] Full markdown content of the scenario
26
+ # @param timeout [Integer, nil] Optional per-scenario timeout in seconds
27
+ # @param setup_steps [Array] Declarative setup steps from scenario.yml
28
+ # @param dir_path [String, nil] Path to the scenario directory
29
+ # @param fixture_path [String, nil] Path to the fixtures/ directory
30
+ # @param test_cases [Array<Models::TestCase>] Independent test case files
31
+ # @param tags [Array<String>] Scenario-level tags for discovery-time filtering
32
+ # @param tool_under_test [String, nil] Primary tool under test
33
+ # @param sandbox_layout [Hash] Declared sandbox artifact layout
34
+ def initialize(test_id:, title:, area:, package:, file_path:, content:,
35
+ priority: "medium", duration: "~5min", requires: {},
36
+ setup_steps: [], dir_path: nil, fixture_path: nil, test_cases: [],
37
+ timeout: nil, tags: [], tool_under_test: nil,
38
+ sandbox_layout: {})
39
+ @test_id = test_id
40
+ @title = title
41
+ @area = area
42
+ @package = package
43
+ @priority = priority
44
+ @duration = duration
45
+ @requires = requires
46
+ @file_path = file_path
47
+ @content = content
48
+ @timeout = timeout
49
+ @setup_steps = setup_steps
50
+ @dir_path = dir_path
51
+ @fixture_path = fixture_path
52
+ @test_cases = test_cases
53
+ @tags = tags
54
+ @tool_under_test = tool_under_test
55
+ @sandbox_layout = sandbox_layout
56
+ end
57
+
58
+ # Generate short package name (without ace- prefix)
59
+ # @return [String] Short package name (e.g., "lint" from "ace-lint")
60
+ def short_package
61
+ package.sub(/\Aace-/, "")
62
+ end
63
+
64
+ # Generate short test ID for directory naming
65
+ # @return [String] Short ID (e.g., "ts001" from "TS-LINT-001")
66
+ def short_id
67
+ match = test_id.match(/TS-[A-Z0-9]+-(\d+[a-z]*)/)
68
+ return "ts#{match[1]}" if match
69
+
70
+ test_id.downcase.gsub(/[^a-z0-9]/, "")
71
+ end
72
+
73
+ # Extract test case IDs from the test_cases array
74
+ #
75
+ # @return [Array<String>] List of test case IDs (e.g., ["TC-001", "TC-002"])
76
+ def test_case_ids
77
+ @test_case_ids ||= test_cases.map(&:tc_id)
78
+ end
79
+
80
+ # Build a directory name for sandbox/reports
81
+ # @param timestamp [String] Timestamp ID (7-char Base36)
82
+ # @return [String] Directory name (e.g., "8xyz12-lint-ts001")
83
+ def dir_name(timestamp)
84
+ "#{timestamp}-#{short_package}-#{short_id}"
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+
6
+ module Ace
7
+ module Test
8
+ module EndToEndRunner
9
+ module Molecules
10
+ # Detects packages affected by recent changes
11
+ #
12
+ # Analyzes git diff to determine which packages have changed
13
+ # since the last commit, allowing selective test execution.
14
+ class AffectedDetector
15
+ # Default git reference to compare against
16
+ DEFAULT_REF = "HEAD~1"
17
+
18
+ # Detect packages that have changed
19
+ #
20
+ # @param base_dir [String] Base directory for git operations
21
+ # @param ref [String] Git reference to compare against (default: HEAD~1)
22
+ # @return [Array<String>] List of affected package names
23
+ def detect(base_dir: Dir.pwd, ref: DEFAULT_REF)
24
+ diff_files = get_changed_files(base_dir, ref)
25
+ return [] if diff_files.empty?
26
+
27
+ diff_files.map { |file| extract_package(file, base_dir) }
28
+ .compact
29
+ .uniq
30
+ .sort
31
+ end
32
+
33
+ private
34
+
35
+ # Get list of changed files from git
36
+ #
37
+ # @param base_dir [String] Base directory
38
+ # @param ref [String] Git reference
39
+ # @return [Array<String>] Changed file paths
40
+ def get_changed_files(base_dir, ref)
41
+ # Run git diff to get changed files using array-based command for security
42
+ output, status = Open3.capture2("git", "diff", "--name-only", ref, "--",
43
+ chdir: base_dir)
44
+
45
+ return [] unless status.success?
46
+
47
+ output.lines.map(&:strip).reject(&:empty?)
48
+ rescue => e
49
+ # If git command fails, warn and return empty array
50
+ # This can happen in shallow clones or if ref doesn't exist
51
+ warn "Warning: git detection failed: #{e.message}"
52
+ []
53
+ end
54
+
55
+ # Extract package name from file path
56
+ #
57
+ # @param file_path [String] Path to changed file
58
+ # @param base_dir [String] Base directory
59
+ # @return [String, nil] Package name or nil if not in a package
60
+ def extract_package(file_path, base_dir)
61
+ # Make path relative to base directory
62
+ relative_path = if file_path.start_with?("/")
63
+ begin
64
+ Pathname.new(file_path).relative_path_from(Pathname.new(base_dir)).to_s
65
+ rescue ArgumentError
66
+ # file_path is not under base_dir, use original path
67
+ file_path
68
+ end
69
+ else
70
+ file_path
71
+ end
72
+
73
+ # Split path and check first component
74
+ parts = relative_path.split("/")
75
+
76
+ # Check if first part looks like a package name (starts with "ace-")
77
+ package = parts.first
78
+ return nil unless package
79
+ return nil unless package.start_with?("ace-")
80
+ return nil if package == "." # Skip current dir
81
+
82
+ # Verify it's actually a directory (a package)
83
+ package_dir = File.join(base_dir, package)
84
+ return nil unless File.directory?(package_dir)
85
+
86
+ package
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/config"
4
+
5
+ module Ace
6
+ module Test
7
+ module EndToEndRunner
8
+ module Molecules
9
+ # Load configuration using Ace::Support::Config.create() API
10
+ # Follows ADR-022: Configuration Default and Override Pattern
11
+ #
12
+ # Configuration priority (highest to lowest):
13
+ # 1. CLI options (handled by callers)
14
+ # 2. Project config: .ace/e2e-runner/config.yml
15
+ # 3. Gem defaults: ace-test-runner-e2e/.ace-defaults/e2e-runner/config.yml
16
+ class ConfigLoader
17
+ # Load and return merged config hash
18
+ # @return [Hash] Configuration with string keys
19
+ def self.load
20
+ new.load
21
+ end
22
+
23
+ # @return [String] Default provider from config
24
+ def self.default_provider
25
+ config = load
26
+ config.dig("execution", "provider") || "claude:sonnet"
27
+ end
28
+
29
+ # @return [Integer] Default timeout from config
30
+ def self.default_timeout
31
+ config = load
32
+ config.dig("execution", "timeout") || 300
33
+ end
34
+
35
+ # @return [Integer] Default parallel count from config
36
+ def self.default_parallel
37
+ config = load
38
+ config.dig("execution", "parallel") || 3
39
+ end
40
+
41
+ # @return [Array<String>] CLI provider names
42
+ def self.cli_providers
43
+ config = load
44
+ config.dig("providers", "cli") || %w[claude gemini codex codexoss opencode pi]
45
+ end
46
+
47
+ # @param provider_name [String] Provider name (e.g., "claude")
48
+ # @return [String, nil] Required CLI args for provider
49
+ def self.cli_args_for(provider_name)
50
+ config = load
51
+ config.dig("providers", "cli_args", provider_name)
52
+ end
53
+
54
+ # Load merged config from cascade
55
+ # @return [Hash] Configuration with string keys
56
+ def load
57
+ gem_root = Gem.loaded_specs["ace-test-runner-e2e"]&.gem_dir ||
58
+ File.expand_path("../../../../..", __dir__)
59
+
60
+ resolver = Ace::Support::Config.create(
61
+ config_dir: ".ace",
62
+ defaults_dir: ".ace-defaults",
63
+ gem_path: gem_root
64
+ )
65
+
66
+ resolver.resolve_namespace("e2e-runner").data
67
+ rescue => e
68
+ warn "Warning: Could not load e2e-runner config: #{e.message}" if ENV["DEBUG"]
69
+ {}
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end