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.
- checksums.yaml +7 -0
- data/.ace-defaults/e2e-runner/config.yml +70 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-test-runner-e2e.yml +11 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-test-runner-e2e.yml +19 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-test-runner-e2e.yml +12 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-test-runner-e2e.yml +11 -0
- data/CHANGELOG.md +1166 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +15 -0
- data/exe/ace-test-e2e +15 -0
- data/exe/ace-test-e2e-sh +67 -0
- data/exe/ace-test-e2e-suite +13 -0
- data/handbook/guides/e2e-testing.g.md +124 -0
- data/handbook/guides/scenario-yml-reference.g.md +182 -0
- data/handbook/guides/tc-authoring.g.md +131 -0
- data/handbook/skills/as-e2e-create/SKILL.md +30 -0
- data/handbook/skills/as-e2e-fix/SKILL.md +35 -0
- data/handbook/skills/as-e2e-manage/SKILL.md +31 -0
- data/handbook/skills/as-e2e-plan-changes/SKILL.md +30 -0
- data/handbook/skills/as-e2e-review/SKILL.md +35 -0
- data/handbook/skills/as-e2e-rewrite/SKILL.md +31 -0
- data/handbook/skills/as-e2e-run/SKILL.md +48 -0
- data/handbook/skills/as-e2e-setup-sandbox/SKILL.md +34 -0
- data/handbook/templates/ace-taskflow-fixture.template.md +322 -0
- data/handbook/templates/agent-experience-report.template.md +89 -0
- data/handbook/templates/metadata.template.yml +49 -0
- data/handbook/templates/scenario.yml.template.yml +60 -0
- data/handbook/templates/tc-file.template.md +45 -0
- data/handbook/templates/test-report.template.md +94 -0
- data/handbook/workflow-instructions/e2e/analyze-failures.wf.md +126 -0
- data/handbook/workflow-instructions/e2e/create.wf.md +395 -0
- data/handbook/workflow-instructions/e2e/execute.wf.md +253 -0
- data/handbook/workflow-instructions/e2e/fix.wf.md +166 -0
- data/handbook/workflow-instructions/e2e/manage.wf.md +179 -0
- data/handbook/workflow-instructions/e2e/plan-changes.wf.md +255 -0
- data/handbook/workflow-instructions/e2e/review.wf.md +286 -0
- data/handbook/workflow-instructions/e2e/rewrite.wf.md +281 -0
- data/handbook/workflow-instructions/e2e/run.wf.md +355 -0
- data/handbook/workflow-instructions/e2e/setup-sandbox.wf.md +461 -0
- data/lib/ace/test/end_to_end_runner/atoms/display_helpers.rb +234 -0
- data/lib/ace/test/end_to_end_runner/atoms/prompt_builder.rb +199 -0
- data/lib/ace/test/end_to_end_runner/atoms/result_parser.rb +166 -0
- data/lib/ace/test/end_to_end_runner/atoms/skill_prompt_builder.rb +166 -0
- data/lib/ace/test/end_to_end_runner/atoms/skill_result_parser.rb +244 -0
- data/lib/ace/test/end_to_end_runner/atoms/suite_report_prompt_builder.rb +103 -0
- data/lib/ace/test/end_to_end_runner/atoms/tc_fidelity_validator.rb +39 -0
- data/lib/ace/test/end_to_end_runner/atoms/test_case_parser.rb +108 -0
- data/lib/ace/test/end_to_end_runner/cli/commands/run_suite.rb +130 -0
- data/lib/ace/test/end_to_end_runner/cli/commands/run_test.rb +156 -0
- data/lib/ace/test/end_to_end_runner/models/test_case.rb +47 -0
- data/lib/ace/test/end_to_end_runner/models/test_result.rb +115 -0
- data/lib/ace/test/end_to_end_runner/models/test_scenario.rb +90 -0
- data/lib/ace/test/end_to_end_runner/molecules/affected_detector.rb +92 -0
- data/lib/ace/test/end_to_end_runner/molecules/config_loader.rb +75 -0
- data/lib/ace/test/end_to_end_runner/molecules/failure_finder.rb +203 -0
- data/lib/ace/test/end_to_end_runner/molecules/fixture_copier.rb +35 -0
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_executor.rb +121 -0
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_prompt_bundler.rb +182 -0
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_report_generator.rb +321 -0
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_sandbox_builder.rb +131 -0
- data/lib/ace/test/end_to_end_runner/molecules/progress_display_manager.rb +172 -0
- data/lib/ace/test/end_to_end_runner/molecules/report_writer.rb +259 -0
- data/lib/ace/test/end_to_end_runner/molecules/scenario_loader.rb +254 -0
- data/lib/ace/test/end_to_end_runner/molecules/setup_executor.rb +181 -0
- data/lib/ace/test/end_to_end_runner/molecules/simple_display_manager.rb +72 -0
- data/lib/ace/test/end_to_end_runner/molecules/suite_progress_display_manager.rb +223 -0
- data/lib/ace/test/end_to_end_runner/molecules/suite_report_writer.rb +277 -0
- data/lib/ace/test/end_to_end_runner/molecules/suite_simple_display_manager.rb +116 -0
- data/lib/ace/test/end_to_end_runner/molecules/test_discoverer.rb +136 -0
- data/lib/ace/test/end_to_end_runner/molecules/test_executor.rb +332 -0
- data/lib/ace/test/end_to_end_runner/organisms/suite_orchestrator.rb +830 -0
- data/lib/ace/test/end_to_end_runner/organisms/test_orchestrator.rb +442 -0
- data/lib/ace/test/end_to_end_runner/version.rb +9 -0
- data/lib/ace/test/end_to_end_runner.rb +71 -0
- 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
|