ace-test-runner-e2e 0.40.6 → 0.41.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 +4 -4
- data/CHANGELOG.md +16 -0
- data/lib/ace/test/end_to_end_runner/cli/commands/run_suite.rb +38 -6
- data/lib/ace/test/end_to_end_runner/molecules/failure_finder.rb +59 -10
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_executor.rb +49 -3
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_report_generator.rb +27 -3
- data/lib/ace/test/end_to_end_runner/molecules/release_manifest.rb +212 -0
- data/lib/ace/test/end_to_end_runner/molecules/report_writer.rb +5 -0
- data/lib/ace/test/end_to_end_runner/molecules/setup_executor.rb +252 -3
- data/lib/ace/test/end_to_end_runner/organisms/suite_orchestrator.rb +25 -1
- data/lib/ace/test/end_to_end_runner/organisms/test_orchestrator.rb +287 -14
- data/lib/ace/test/end_to_end_runner/version.rb +1 -1
- data/lib/ace/test/end_to_end_runner.rb +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '023288b5afdd9d9303ed0a4b439503062611cb9cd364e29f68aef91dab4a85c6'
|
|
4
|
+
data.tar.gz: 30026fd012aa74ebe994d0f41b0bcd809d6bf0599f4a842ad087756e88592d8b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '062688fc71b5c282c9789c0c60cd1242a4b454436f6f98cfb39475fccc33025da06962c5e18c85728766dd59c3fe49aaff35bc987c4585c75b5218d5027c0dd1'
|
|
7
|
+
data.tar.gz: c06d63cd12734b3ac962e0699ecab8934e7f8797ae92b8ee0cf4fef2c3992c980e1e2f664b93e7d844b2bf7564b1003513d8b1c0ec51808e7fee6742d9a49645
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.41.0] - 2026-09-29
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Pipeline verdict integrity: a passing report requires a completed verifier response covering every selected test case; an incomplete verdict set becomes an explicit ERROR (`incomplete_verdict_set`) instead of an inferred PASS.
|
|
14
|
+
- Pipeline failures record `failure_phase`, `failure_category`, and structured execution evidence in the ERROR report; sessions whose provider process began and then ended without a confirmed completion are marked `uncertain_execution: true` in metadata.yml.
|
|
15
|
+
- The full-suite automatic retry (retry-failures-once) excludes uncertain scenarios — their side effects are never replayed. They stay in the final tally as errors and are listed by `FailureFinder#find_uncertain_scenarios` for explicit reconciliation. Explicit single-run `--only-failures` keeps them runnable (deliberate human reconciliation).
|
|
16
|
+
- `read_agent_result` (single and package runs) and the suite metadata reconcile keep a fresh pipeline ERROR authoritative: retained metadata.yml can no longer upgrade an errored execution to PASS.
|
|
17
|
+
- Verifier failures report `infrastructure-error` (runner/repair failures keep `runner-error`).
|
|
18
|
+
- TS-MONO-001-style scenarios can declare a `release-manifest` setup action: the runner
|
|
19
|
+
carries only the explicitly allowed `ACE_RELEASE_MANIFEST` host input (absolute path,
|
|
20
|
+
defaulting to the source root) into deterministic setup, which validates the strict
|
|
21
|
+
schema-version-1 manifest and copies its exact bytes into the sandbox before any
|
|
22
|
+
install goal can run. Invalid or missing input fails setup, and a failed deterministic
|
|
23
|
+
setup now aborts the scenario with an ERROR result instead of degrading to an
|
|
24
|
+
LLM-driven sandbox.
|
|
25
|
+
|
|
10
26
|
## [0.40.6] - 2026-09-29
|
|
11
27
|
|
|
12
28
|
### Fixed
|
|
@@ -116,7 +116,11 @@ module Ace
|
|
|
116
116
|
timeout: options[:timeout],
|
|
117
117
|
tags: tags,
|
|
118
118
|
exclude_tags: exclude_tags,
|
|
119
|
-
verify: options[:verify]
|
|
119
|
+
verify: options[:verify],
|
|
120
|
+
# An explicit --only-failures run is deliberate human
|
|
121
|
+
# reconciliation: uncertain scenarios stay selectable there.
|
|
122
|
+
# The automatic retry pass below always excludes them.
|
|
123
|
+
include_uncertain_failures: only_failures
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
results = run_suite_with_retry(
|
|
@@ -190,20 +194,35 @@ module Ace
|
|
|
190
194
|
)
|
|
191
195
|
return annotated unless retry_failures_once && suite_failed?(initial_results)
|
|
192
196
|
|
|
197
|
+
# Scenarios whose provider session ended uncertainly are excluded
|
|
198
|
+
# from replay by the failure finder; account for them explicitly
|
|
199
|
+
# so they stay visible failures instead of vanishing.
|
|
200
|
+
uncertain = orchestrator.uncertain_scenarios(packages: run_options[:packages])
|
|
201
|
+
unless uncertain.empty?
|
|
202
|
+
output.puts "#{uncertain.length} scenario(s) ended in uncertain execution and will not be retried (side effects are not replayed): #{uncertain.join(", ")}"
|
|
203
|
+
output.puts "Reconcile those sessions manually before rerunning them."
|
|
204
|
+
end
|
|
205
|
+
|
|
193
206
|
output.puts "Retrying failed scenarios once..."
|
|
194
|
-
retry_results = orchestrator.run(
|
|
195
|
-
|
|
207
|
+
retry_results = orchestrator.run(
|
|
208
|
+
run_options.merge(only_failures: true, include_uncertain_failures: false)
|
|
209
|
+
)
|
|
210
|
+
if retry_results[:total].zero? && uncertain.empty?
|
|
196
211
|
raise Ace::Support::Cli::Error.new(
|
|
197
212
|
"Retry pass found no failed test scenarios from attempt 1; aborting instead of silently passing"
|
|
198
213
|
)
|
|
199
214
|
end
|
|
200
215
|
|
|
216
|
+
retry_results = with_uncertain_accounting(retry_results, uncertain)
|
|
217
|
+
|
|
201
218
|
flaky_scenarios = recovered_flaky_scenarios(initial_results, retry_results)
|
|
202
|
-
remaining_failures = failure_scenarios(retry_results)
|
|
219
|
+
remaining_failures = (failure_scenarios(retry_results) + uncertain).uniq.sort
|
|
203
220
|
final_report_path = write_retry_summary_report(initial_results, retry_results)
|
|
204
221
|
output.puts "Final Report: #{final_report_path}" if final_report_path
|
|
205
222
|
|
|
206
|
-
if remaining_failures.empty?
|
|
223
|
+
if (remaining_failures - uncertain).empty? && !uncertain.empty?
|
|
224
|
+
output.puts "All retried scenario(s) recovered; #{uncertain.length} uncertain scenario(s) still require reconciliation"
|
|
225
|
+
elsif remaining_failures.empty?
|
|
207
226
|
output.puts "#{flaky_scenarios.length} scenario(s) recovered on retry and were marked flaky"
|
|
208
227
|
else
|
|
209
228
|
output.puts "#{remaining_failures.length} scenario(s) still failing after retry"
|
|
@@ -217,10 +236,23 @@ module Ace
|
|
|
217
236
|
remaining_failures: remaining_failures,
|
|
218
237
|
initial_report_path: initial_results[:report_path],
|
|
219
238
|
retry_report_path: retry_results[:report_path],
|
|
220
|
-
report_path: final_report_path || retry_results[:report_path]
|
|
239
|
+
report_path: final_report_path || retry_results[:report_path],
|
|
240
|
+
uncertain_scenarios: uncertain
|
|
221
241
|
)
|
|
222
242
|
end
|
|
223
243
|
|
|
244
|
+
# Keep uncertain scenarios in the final tally. The retry pass does
|
|
245
|
+
# not run them (by design), so without this they would disappear
|
|
246
|
+
# from counts and a suite with uncertain sessions could exit 0.
|
|
247
|
+
def with_uncertain_accounting(retry_results, uncertain_ids)
|
|
248
|
+
return retry_results if uncertain_ids.empty?
|
|
249
|
+
|
|
250
|
+
tally = retry_results.dup
|
|
251
|
+
tally[:total] = tally[:total].to_i + uncertain_ids.size
|
|
252
|
+
tally[:errors] = tally[:errors].to_i + uncertain_ids.size
|
|
253
|
+
tally
|
|
254
|
+
end
|
|
255
|
+
|
|
224
256
|
def write_retry_summary_report(initial_results, retry_results)
|
|
225
257
|
build_retry_report_writer.write_retry_summary(
|
|
226
258
|
initial_results: initial_results,
|
|
@@ -6,15 +6,23 @@ module Ace
|
|
|
6
6
|
module Test
|
|
7
7
|
module EndToEndRunner
|
|
8
8
|
module Molecules
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
# Scans cache for failed test cases from previous E2E test runs
|
|
10
|
+
#
|
|
11
|
+
# Reads metadata.yml files from .ace-local/test-e2e/*-reports/ directories
|
|
12
|
+
# and extracts failed_test_cases arrays. Used by --only-failures CLI flag
|
|
13
|
+
# to re-run only tests that failed previously.
|
|
14
|
+
#
|
|
15
|
+
# Scenarios whose most recent run ended in uncertain execution (the provider
|
|
16
|
+
# session began and then ended without a confirmed completion) are excluded
|
|
17
|
+
# from find_failures_by_scenario — the automatic retry surface — because
|
|
18
|
+
# re-running them would replay side effects. They are listed by
|
|
19
|
+
# find_uncertain_scenarios instead so callers can surface an actionable
|
|
20
|
+
# diagnostic. Explicit single-run --only-failures paths keep them included:
|
|
21
|
+
# invoking it is a deliberate human reconciliation decision.
|
|
22
|
+
#
|
|
23
|
+
# Note: This is a Molecule (not an Atom) because it performs filesystem
|
|
24
|
+
# I/O via Dir.glob and YAML file reading.
|
|
25
|
+
class FailureFinder
|
|
18
26
|
CACHE_DIR = ".ace-local/test-e2e"
|
|
19
27
|
METADATA_FILE = "metadata.yml"
|
|
20
28
|
REPORTS_SUFFIX = "-reports"
|
|
@@ -81,11 +89,17 @@ module Ace
|
|
|
81
89
|
# Like find_failures_by_package but preserves per-scenario granularity.
|
|
82
90
|
# Callers can use this to re-run full failed scenarios.
|
|
83
91
|
#
|
|
92
|
+
# By default, scenarios whose most recent run is marked
|
|
93
|
+
# uncertain_execution are excluded — replaying them would repeat
|
|
94
|
+
# unverified side effects. The explicit reconciliation path passes
|
|
95
|
+
# include_uncertain: true to select them deliberately.
|
|
96
|
+
#
|
|
84
97
|
# @param packages [Array<String>] Package names to scan
|
|
85
98
|
# @param base_dir [String] Base directory to search from (default: current dir)
|
|
99
|
+
# @param include_uncertain [Boolean] keep uncertain scenarios selectable
|
|
86
100
|
# @return [Hash{String => Hash{String => Array<String>}}]
|
|
87
101
|
# Package name => { test-id => failed TC IDs }
|
|
88
|
-
def find_failures_by_scenario(packages:, base_dir: Dir.pwd)
|
|
102
|
+
def find_failures_by_scenario(packages:, base_dir: Dir.pwd, include_uncertain: false)
|
|
89
103
|
metadata_files = discover_metadata_files(base_dir)
|
|
90
104
|
return {} if metadata_files.empty?
|
|
91
105
|
|
|
@@ -96,6 +110,8 @@ module Ace
|
|
|
96
110
|
|
|
97
111
|
scenario_failures = {}
|
|
98
112
|
most_recent.each do |entry|
|
|
113
|
+
next if !include_uncertain && uncertain_execution?(entry[:data])
|
|
114
|
+
|
|
99
115
|
test_id = entry[:data]["test-id"]
|
|
100
116
|
failed_ids = extract_failed_test_cases(entry[:data])
|
|
101
117
|
scenario_failures[test_id] = failed_ids unless failed_ids.empty?
|
|
@@ -106,6 +122,32 @@ module Ace
|
|
|
106
122
|
result
|
|
107
123
|
end
|
|
108
124
|
|
|
125
|
+
# Find scenarios whose most recent run ended in uncertain execution,
|
|
126
|
+
# grouped by package. These need manual reconciliation before rerun;
|
|
127
|
+
# they are excluded from find_failures_by_scenario.
|
|
128
|
+
#
|
|
129
|
+
# @param packages [Array<String>] Package names to scan
|
|
130
|
+
# @param base_dir [String] Base directory to search from (default: current dir)
|
|
131
|
+
# @return [Hash{String => Array<String>}] Package name => uncertain test-ids
|
|
132
|
+
def find_uncertain_scenarios(packages:, base_dir: Dir.pwd)
|
|
133
|
+
metadata_files = discover_metadata_files(base_dir)
|
|
134
|
+
return {} if metadata_files.empty?
|
|
135
|
+
|
|
136
|
+
result = {}
|
|
137
|
+
packages.each do |package|
|
|
138
|
+
package_metadata = filter_by_package(metadata_files, package)
|
|
139
|
+
most_recent = most_recent_per_test(package_metadata)
|
|
140
|
+
|
|
141
|
+
uncertain = most_recent
|
|
142
|
+
.select { |entry| uncertain_execution?(entry[:data]) }
|
|
143
|
+
.map { |entry| entry[:data]["test-id"] }
|
|
144
|
+
.sort
|
|
145
|
+
|
|
146
|
+
result[package] = uncertain unless uncertain.empty?
|
|
147
|
+
end
|
|
148
|
+
result
|
|
149
|
+
end
|
|
150
|
+
|
|
109
151
|
private
|
|
110
152
|
|
|
111
153
|
# Discover all metadata.yml files in the cache directory
|
|
@@ -169,6 +211,13 @@ module Ace
|
|
|
169
211
|
entries.flat_map { |entry| extract_failed_test_cases(entry[:data]) }.uniq
|
|
170
212
|
end
|
|
171
213
|
|
|
214
|
+
# True when the latest run of this scenario ended in uncertain
|
|
215
|
+
# execution (the provider session began and ended without a
|
|
216
|
+
# confirmed completion; side effects may have happened).
|
|
217
|
+
def uncertain_execution?(data)
|
|
218
|
+
data["uncertain_execution"] == true
|
|
219
|
+
end
|
|
220
|
+
|
|
172
221
|
# Extract failed test case IDs from a single metadata hash
|
|
173
222
|
#
|
|
174
223
|
# Returns specific TC IDs when available, or ["*"] as a wildcard
|
|
@@ -42,7 +42,12 @@ module Ace
|
|
|
42
42
|
# @return [Models::TestResult]
|
|
43
43
|
def execute(scenario:, cli_args:, sandbox_path:, report_dir:, env_vars: nil, test_cases: nil)
|
|
44
44
|
started_at = Time.now
|
|
45
|
+
phase = :setup
|
|
45
46
|
FileUtils.mkdir_p(report_dir)
|
|
47
|
+
# A retry in the same report directory must start with neither
|
|
48
|
+
# terminal marker present: only this attempt's outcome may remain.
|
|
49
|
+
FileUtils.rm_f(File.join(report_dir, ".host-pipeline-complete.json"))
|
|
50
|
+
FileUtils.rm_f(File.join(report_dir, ".host-pipeline-failed.json"))
|
|
46
51
|
write_command_record(report_dir, "runner", provider: @provider, cli_args: cli_args)
|
|
47
52
|
write_tc_manifests(report_dir, scenario, test_cases: test_cases)
|
|
48
53
|
|
|
@@ -72,6 +77,7 @@ module Ace
|
|
|
72
77
|
test_cases: test_cases,
|
|
73
78
|
artifact_contract: declared_artifact_contract(scenario, test_cases: test_cases)
|
|
74
79
|
)
|
|
80
|
+
phase = :runner
|
|
75
81
|
runner_response = run_llm(
|
|
76
82
|
prompt_path: runner[:prompt_path],
|
|
77
83
|
system_path: runner[:system_path],
|
|
@@ -101,6 +107,7 @@ module Ace
|
|
|
101
107
|
artifact_contract: artifact_contract,
|
|
102
108
|
repair_mode: true
|
|
103
109
|
)
|
|
110
|
+
phase = :repair
|
|
104
111
|
repair_response = run_llm(
|
|
105
112
|
prompt_path: repair_runner[:prompt_path],
|
|
106
113
|
system_path: repair_runner[:system_path],
|
|
@@ -126,6 +133,7 @@ module Ace
|
|
|
126
133
|
artifact_contract: artifact_contract
|
|
127
134
|
)
|
|
128
135
|
write_command_record(report_dir, "verifier", provider: @verifier_provider, cli_args: cli_args)
|
|
136
|
+
phase = :verifier
|
|
129
137
|
verifier_response = run_llm(
|
|
130
138
|
prompt_path: verifier[:prompt_path],
|
|
131
139
|
system_path: verifier[:system_path],
|
|
@@ -137,13 +145,14 @@ module Ace
|
|
|
137
145
|
fallback: query_fallback_for(@verifier_provider)
|
|
138
146
|
)
|
|
139
147
|
|
|
140
|
-
@report_generator.generate(
|
|
148
|
+
report = @report_generator.generate(
|
|
141
149
|
scenario: scenario,
|
|
142
150
|
verifier_output: verifier_response[:text],
|
|
143
151
|
report_dir: report_dir,
|
|
144
152
|
provider: @verifier_provider,
|
|
145
153
|
started_at: started_at,
|
|
146
154
|
completed_at: Time.now,
|
|
155
|
+
expected_test_case_ids: selected_test_case_ids(scenario, test_cases),
|
|
147
156
|
metadata: base_metadata(
|
|
148
157
|
report_dir,
|
|
149
158
|
runner_observations: runner_observations,
|
|
@@ -151,8 +160,26 @@ module Ace
|
|
|
151
160
|
initial_artifact_contract: initial_artifact_contract
|
|
152
161
|
)
|
|
153
162
|
)
|
|
163
|
+
# Host-side completion record, written after the report: the
|
|
164
|
+
# runner-writable metadata.yml alone cannot prove the pipeline
|
|
165
|
+
# finished. A rescue below never writes this file.
|
|
166
|
+
File.write(File.join(report_dir, ".host-pipeline-complete.json"), JSON.generate({
|
|
167
|
+
"scenario" => scenario.test_id,
|
|
168
|
+
"runner_provider" => @provider,
|
|
169
|
+
"verifier_provider" => @verifier_provider,
|
|
170
|
+
"completed_at" => Time.now.utc.iso8601
|
|
171
|
+
}))
|
|
172
|
+
report
|
|
154
173
|
rescue => e
|
|
155
174
|
begin
|
|
175
|
+
# A failed pipeline leaves an explicit failure record instead of
|
|
176
|
+
# a completion record; finalization treats absence as failure.
|
|
177
|
+
File.write(File.join(report_dir, ".host-pipeline-failed.json"), JSON.generate({
|
|
178
|
+
"scenario" => scenario.test_id,
|
|
179
|
+
"failure_phase" => phase.to_s,
|
|
180
|
+
"error" => "#{e.class}: #{e.message}",
|
|
181
|
+
"completed_at" => Time.now.utc.iso8601
|
|
182
|
+
}))
|
|
156
183
|
@report_generator.write_failure_report(
|
|
157
184
|
scenario: scenario,
|
|
158
185
|
report_dir: report_dir,
|
|
@@ -160,8 +187,8 @@ module Ace
|
|
|
160
187
|
started_at: started_at || Time.now,
|
|
161
188
|
completed_at: Time.now,
|
|
162
189
|
error_message: "#{e.class}: #{e.message}",
|
|
163
|
-
failure_category: "runner-error",
|
|
164
|
-
metadata: base_metadata(report_dir)
|
|
190
|
+
failure_category: phase == :verifier ? "infrastructure-error" : "runner-error",
|
|
191
|
+
metadata: base_metadata(report_dir).merge(failure_evidence_metadata(e, phase))
|
|
165
192
|
)
|
|
166
193
|
rescue => write_error
|
|
167
194
|
Models::TestResult.new(
|
|
@@ -288,6 +315,25 @@ module Ace
|
|
|
288
315
|
Array(scenario.test_cases).select { |tc| wanted.include?(tc.tc_id.to_s.upcase) }
|
|
289
316
|
end
|
|
290
317
|
|
|
318
|
+
# TC ids the verifier must cover for the verdict set to be complete.
|
|
319
|
+
def selected_test_case_ids(scenario, test_cases)
|
|
320
|
+
select_test_cases(scenario, test_cases).map(&:tc_id)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Structured failure metadata for the error report. When the failure
|
|
324
|
+
# carries execution evidence (the provider session began and ended
|
|
325
|
+
# without a confirmed completion), the scenario is marked uncertain:
|
|
326
|
+
# automatic retries must not replay its side effects.
|
|
327
|
+
def failure_evidence_metadata(error, phase)
|
|
328
|
+
metadata = {"failure_phase" => phase.to_s}
|
|
329
|
+
evidence = Ace::LLM::Atoms::ErrorClassifier.execution_evidence_for(error)
|
|
330
|
+
return metadata unless evidence
|
|
331
|
+
|
|
332
|
+
metadata["uncertain_execution"] = true if evidence.uncertain_execution?
|
|
333
|
+
metadata["execution_evidence"] = evidence.to_h
|
|
334
|
+
metadata
|
|
335
|
+
end
|
|
336
|
+
|
|
291
337
|
def base_metadata(report_dir, runner_observations: nil, artifact_contract: nil, initial_artifact_contract: nil)
|
|
292
338
|
metadata = {
|
|
293
339
|
"runner_provider" => @provider,
|
|
@@ -31,9 +31,12 @@ module Ace
|
|
|
31
31
|
# @param provider [String]
|
|
32
32
|
# @param started_at [Time]
|
|
33
33
|
# @param completed_at [Time]
|
|
34
|
+
# @param expected_test_case_ids [Array<String>, nil] TC ids the verifier must cover
|
|
34
35
|
# @return [Models::TestResult]
|
|
35
|
-
def generate(scenario:, verifier_output:, report_dir:, provider:, started_at:, completed_at:,
|
|
36
|
+
def generate(scenario:, verifier_output:, report_dir:, provider:, started_at:, completed_at:,
|
|
37
|
+
expected_test_case_ids: nil, metadata: {})
|
|
36
38
|
parsed = parse_verifier_output(verifier_output, scenario)
|
|
39
|
+
parsed = enforce_verdict_completeness(parsed, expected_test_case_ids)
|
|
37
40
|
merged_metadata = metadata.merge(parsed[:metadata] || {})
|
|
38
41
|
|
|
39
42
|
result = Models::TestResult.new(
|
|
@@ -94,9 +97,30 @@ module Ace
|
|
|
94
97
|
result.with_report_dir(report_dir)
|
|
95
98
|
end
|
|
96
99
|
|
|
97
|
-
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# A passing verdict requires one valid verdict per selected test case.
|
|
103
|
+
# A verifier response covering only some of them stays an explicit
|
|
104
|
+
# ERROR — bundle output must never synthesize the missing verdicts.
|
|
105
|
+
def enforce_verdict_completeness(parsed, expected_test_case_ids)
|
|
106
|
+
expected = Array(expected_test_case_ids).map { |id| id.to_s.upcase }
|
|
107
|
+
return parsed if expected.empty? || parsed[:status] == "error"
|
|
108
|
+
|
|
109
|
+
verdict_ids = parsed[:test_cases].map { |tc| tc[:id].to_s.upcase }
|
|
110
|
+
missing = expected - verdict_ids
|
|
111
|
+
return parsed if missing.empty?
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
status: "error",
|
|
115
|
+
test_cases: parsed[:test_cases],
|
|
116
|
+
summary: "Incomplete verifier verdict set (#{verdict_ids.size}/#{expected.size} test cases)",
|
|
117
|
+
error: "Verifier response is missing verdicts for: #{missing.join(", ")}",
|
|
118
|
+
observations: parsed[:observations],
|
|
119
|
+
metadata: (parsed[:metadata] || {}).merge("incomplete_verdict_set" => true)
|
|
120
|
+
}
|
|
121
|
+
end
|
|
98
122
|
|
|
99
|
-
|
|
123
|
+
def parse_verifier_output(text, scenario)
|
|
100
124
|
goals = parse_goal_sections(text, scenario)
|
|
101
125
|
return build_result_from_goals(goals, text) unless goals.empty?
|
|
102
126
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "rubygems/version"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Test
|
|
10
|
+
module EndToEndRunner
|
|
11
|
+
module Molecules
|
|
12
|
+
# Validates the frozen release installation manifest used by release
|
|
13
|
+
# verification scenarios (TS-MONO-001).
|
|
14
|
+
#
|
|
15
|
+
# The manifest is an exact-version contract: schema_version 1, a full
|
|
16
|
+
# source commit, and a nonempty package array. Every package entry
|
|
17
|
+
# carries a unique ACE gem name, one exact artifact version (never a
|
|
18
|
+
# requirement), the full source commit the artifact was built from,
|
|
19
|
+
# and an optional explicit supersession list of earlier versions of
|
|
20
|
+
# the same package.
|
|
21
|
+
#
|
|
22
|
+
# Validation is strict on purpose: unknown fields, missing fields,
|
|
23
|
+
# duplicate names, non-exact versions, malformed SHAs, and
|
|
24
|
+
# contradictory supersession entries all fail before any install
|
|
25
|
+
# attempt is made.
|
|
26
|
+
class ReleaseManifest
|
|
27
|
+
SCHEMA_VERSION = 1
|
|
28
|
+
GEM_NAME_PATTERN = /\Aace-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/
|
|
29
|
+
SOURCE_SHA_PATTERN = /\A[0-9a-f]{40}\z/
|
|
30
|
+
|
|
31
|
+
# Raised for any manifest content or source-path failure. Setup
|
|
32
|
+
# converts this into a setup failure before installation runs.
|
|
33
|
+
class Invalid < StandardError; end
|
|
34
|
+
|
|
35
|
+
# Validate the manifest at +path+ and return its parsed content.
|
|
36
|
+
# @raise [Invalid] when the file is missing, unreadable,
|
|
37
|
+
# malformed, or violates the schema.
|
|
38
|
+
def self.load_validated(path)
|
|
39
|
+
data = parse_json(path)
|
|
40
|
+
validate_schema!(data)
|
|
41
|
+
data
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Validate the manifest at +source_path+ and copy the exact bytes
|
|
45
|
+
# to +target_path+, creating the target directory when needed.
|
|
46
|
+
# +required_packages+ names the gems the calling scenario's proof
|
|
47
|
+
# depends on; the manifest must cover every one of them. Entries
|
|
48
|
+
# may be plain names or {name, supersedes} maps requiring an
|
|
49
|
+
# explicit supersession declaration. The buffer is read once so
|
|
50
|
+
# the validated content, the written copy, and the returned digest
|
|
51
|
+
# are the same bytes even if the source file changes mid-flight.
|
|
52
|
+
# @raise [Invalid]
|
|
53
|
+
def self.validate_and_copy(source_path:, target_path:, required_packages: nil)
|
|
54
|
+
raw = read_source(source_path)
|
|
55
|
+
data = parse_buffer(raw, source_path)
|
|
56
|
+
validate_schema!(data)
|
|
57
|
+
entries = data["packages"].to_h { |package| [package["name"], package] }
|
|
58
|
+
Array(required_packages).each do |required|
|
|
59
|
+
name, supersedes = required_packages_entry(required)
|
|
60
|
+
entry = entries[name]
|
|
61
|
+
raise Invalid, "release manifest is missing required package: #{name}" unless entry
|
|
62
|
+
|
|
63
|
+
missing = Array(supersedes) - Array(entry["supersedes"])
|
|
64
|
+
next if missing.empty?
|
|
65
|
+
|
|
66
|
+
raise Invalid,
|
|
67
|
+
"release manifest does not declare that #{name} #{entry["artifact_version"]} supersedes #{missing.join(", ")}"
|
|
68
|
+
end
|
|
69
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
70
|
+
File.binwrite(target_path, raw)
|
|
71
|
+
Digest::SHA256.hexdigest(raw)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def read_source(path)
|
|
78
|
+
raise Invalid, "release manifest is missing: #{path}" unless File.file?(path)
|
|
79
|
+
raise Invalid, "release manifest is not readable: #{path}" unless File.readable?(path)
|
|
80
|
+
|
|
81
|
+
raw = File.binread(path)
|
|
82
|
+
raise Invalid, "release manifest is empty: #{path}" if raw.strip.empty?
|
|
83
|
+
|
|
84
|
+
raw
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_buffer(raw, path)
|
|
88
|
+
data = JSON.parse(raw)
|
|
89
|
+
raise Invalid, "release manifest must be a JSON object: #{path}" unless data.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
data
|
|
92
|
+
rescue JSON::ParserError => e
|
|
93
|
+
raise Invalid, "release manifest is not valid JSON: #{path} (#{e.message})"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_json(path)
|
|
97
|
+
parse_buffer(read_source(path), path)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_schema!(data)
|
|
101
|
+
reject_unknown_keys!(data, %w[schema_version source_sha packages], "manifest")
|
|
102
|
+
|
|
103
|
+
schema_version = data["schema_version"]
|
|
104
|
+
unless schema_version.is_a?(Integer) && schema_version == SCHEMA_VERSION
|
|
105
|
+
raise Invalid,
|
|
106
|
+
"unsupported schema_version: #{schema_version.inspect} (expected #{SCHEMA_VERSION})"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
validate_source_sha!(data["source_sha"], "manifest source_sha")
|
|
110
|
+
|
|
111
|
+
packages = data["packages"]
|
|
112
|
+
raise Invalid, "packages must be a nonempty array" unless packages.is_a?(Array) && !packages.empty?
|
|
113
|
+
|
|
114
|
+
seen = {}
|
|
115
|
+
packages.each_with_index do |entry, index|
|
|
116
|
+
name = validate_package!(entry, index)
|
|
117
|
+
raise Invalid, "duplicate package name: #{name}" if seen.key?(name)
|
|
118
|
+
|
|
119
|
+
seen[name] = true
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_package!(entry, index)
|
|
124
|
+
label = "packages[#{index}]"
|
|
125
|
+
raise Invalid, "#{label} must be an object" unless entry.is_a?(Hash)
|
|
126
|
+
|
|
127
|
+
reject_unknown_keys!(entry, %w[name artifact_version source_sha supersedes], label)
|
|
128
|
+
|
|
129
|
+
name = entry["name"]
|
|
130
|
+
unless name.is_a?(String) && name.match?(GEM_NAME_PATTERN)
|
|
131
|
+
raise Invalid, "#{label} has invalid ACE gem name: #{name.inspect}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
validate_exact_version!(entry["artifact_version"], "#{label}(#{name}) artifact_version")
|
|
135
|
+
validate_source_sha!(entry["source_sha"], "#{label}(#{name}) source_sha")
|
|
136
|
+
validate_supersedes!(entry["supersedes"], entry["artifact_version"], "#{label}(#{name})")
|
|
137
|
+
name
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_exact_version!(raw, label)
|
|
141
|
+
unless raw.is_a?(String) && !raw.strip.empty?
|
|
142
|
+
raise Invalid, "#{label} must be an exact version string, got #{raw.inspect}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
version = begin
|
|
146
|
+
Gem::Version.new(raw)
|
|
147
|
+
rescue ArgumentError
|
|
148
|
+
raise Invalid, "#{label} is not a valid version: #{raw.inspect}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
return if version.to_s == raw
|
|
152
|
+
|
|
153
|
+
raise Invalid,
|
|
154
|
+
"#{label} must be an exact version, not a requirement or shorthand: #{raw.inspect}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_source_sha!(raw, label)
|
|
158
|
+
return if raw.is_a?(String) && raw.match?(SOURCE_SHA_PATTERN)
|
|
159
|
+
|
|
160
|
+
raise Invalid, "#{label} must be a full 40-character commit SHA, got #{raw.inspect}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_supersedes!(raw, artifact_version, label)
|
|
164
|
+
return if raw.nil?
|
|
165
|
+
|
|
166
|
+
unless raw.is_a?(Array)
|
|
167
|
+
raise Invalid, "#{label} supersedes must be an array of version strings"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
artifact = Gem::Version.new(artifact_version)
|
|
171
|
+
seen = {}
|
|
172
|
+
raw.each do |entry|
|
|
173
|
+
validate_exact_version!(entry, "#{label} supersedes entry")
|
|
174
|
+
raise Invalid, "#{label} supersedes duplicates version #{entry}" if seen.key?(entry)
|
|
175
|
+
|
|
176
|
+
seen[entry] = true
|
|
177
|
+
next unless Gem::Version.new(entry) >= artifact
|
|
178
|
+
|
|
179
|
+
raise Invalid,
|
|
180
|
+
"#{label} supersedes must list earlier versions of the same package: #{entry} >= #{artifact_version}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def reject_unknown_keys!(hash, allowed, label)
|
|
185
|
+
unknown = hash.keys - allowed
|
|
186
|
+
return if unknown.empty?
|
|
187
|
+
|
|
188
|
+
raise Invalid, "#{label} has unknown fields: #{unknown.sort.join(", ")}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def required_packages_entry(required)
|
|
192
|
+
case required
|
|
193
|
+
when String
|
|
194
|
+
[required, nil]
|
|
195
|
+
when Hash
|
|
196
|
+
reject_unknown_keys!(required, %w[name supersedes], "required package entry")
|
|
197
|
+
name = required["name"]
|
|
198
|
+
unless name.is_a?(String) && name.match?(GEM_NAME_PATTERN)
|
|
199
|
+
raise Invalid, "required package entry has invalid ACE gem name: #{name.inspect}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
[name, required["supersedes"]]
|
|
203
|
+
else
|
|
204
|
+
raise Invalid, "required package entries must be names or {name, supersedes} maps"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -222,6 +222,11 @@ module Ace
|
|
|
222
222
|
metadata["works_for_end_user"] = result.metadata["works_for_end_user"] if result.metadata.key?("works_for_end_user")
|
|
223
223
|
metadata["user_friction"] = result.metadata["user_friction"] if result.metadata.key?("user_friction")
|
|
224
224
|
metadata["user_feedback"] = result.metadata["user_feedback"] if result.metadata.key?("user_feedback")
|
|
225
|
+
metadata["failure_category"] = result.metadata["failure_category"] if result.metadata.key?("failure_category")
|
|
226
|
+
metadata["failure_phase"] = result.metadata["failure_phase"] if result.metadata.key?("failure_phase")
|
|
227
|
+
metadata["uncertain_execution"] = result.metadata["uncertain_execution"] if result.metadata.key?("uncertain_execution")
|
|
228
|
+
metadata["execution_evidence"] = result.metadata["execution_evidence"] if result.metadata.key?("execution_evidence")
|
|
229
|
+
metadata["incomplete_verdict_set"] = result.metadata["incomplete_verdict_set"] if result.metadata.key?("incomplete_verdict_set")
|
|
225
230
|
|
|
226
231
|
if test_case
|
|
227
232
|
metadata["scenario-id"] = scenario.test_id
|