ace-test-runner-e2e 0.40.5 → 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 +28 -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/bwrap_sandbox_backend.rb +11 -5
- 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_prompt_bundler.rb +2 -1
- 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
|
@@ -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
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "fileutils"
|
|
5
|
+
require "json"
|
|
4
6
|
require "open3"
|
|
5
7
|
require "shellwords"
|
|
8
|
+
require "time"
|
|
9
|
+
|
|
10
|
+
require_relative "release_manifest"
|
|
6
11
|
|
|
7
12
|
module Ace
|
|
8
13
|
module Test
|
|
@@ -12,7 +17,8 @@ module Ace
|
|
|
12
17
|
#
|
|
13
18
|
# Processes the setup array from scenario.yml, running each action
|
|
14
19
|
# via Ruby system calls (no LLM involved). Supports: git-init,
|
|
15
|
-
# copy-fixtures, run, write-file, agent-env,
|
|
20
|
+
# copy-fixtures, run, write-file, agent-env, tmux-session, and
|
|
21
|
+
# release-manifest actions.
|
|
16
22
|
#
|
|
17
23
|
# Note: This is a Molecule because it performs filesystem I/O and
|
|
18
24
|
# system calls via Open3 and FileUtils.
|
|
@@ -23,6 +29,20 @@ module Ace
|
|
|
23
29
|
RESERVED_ENV_KEYS = Molecules::SandboxRuntimeBuilder::RESERVED_ENV_KEYS + %w[
|
|
24
30
|
PATH HOME TMPDIR XDG_RUNTIME_DIR TMUX_TMPDIR ACE_TMUX_SESSION
|
|
25
31
|
]
|
|
32
|
+
RELEASE_MANIFEST_DEFAULT_PATH = File.join(".ace-local", "release", "installation-manifest.json")
|
|
33
|
+
# Retry trust state lives in the report directory — host-side,
|
|
34
|
+
# outside the runner-writable sandbox. A marker inside the sandbox
|
|
35
|
+
# would let a compromised runner forge reuse state, redirect
|
|
36
|
+
# manifest copies outside the sandbox, or skip revalidation.
|
|
37
|
+
SETUP_STATE_FILE = ".ace-e2e-setup-state.json"
|
|
38
|
+
# Only the sandbox runtime contract is persisted for reuse. The
|
|
39
|
+
# merged setup environment inherits the host process environment,
|
|
40
|
+
# which can carry secrets that must never land in a state file.
|
|
41
|
+
PERSISTED_ENV_KEYS = (
|
|
42
|
+
Molecules::SandboxRuntimeBuilder::RESERVED_ENV_KEYS + %w[
|
|
43
|
+
PATH ACE_E2E_SANDBOX_RUNTIME_ROOT ACE_E2E_SANDBOX_RUBY_VERSION ACE_E2E_SANDBOX_RUBY_ROOT
|
|
44
|
+
]
|
|
45
|
+
).freeze
|
|
26
46
|
|
|
27
47
|
def initialize(command_runner: nil, system_runner: nil, time_source: nil, sandbox_backend: nil)
|
|
28
48
|
@command_runner = command_runner || method(:capture3)
|
|
@@ -38,16 +58,41 @@ module Ace
|
|
|
38
58
|
# @param fixture_source [String, nil] Path to the fixtures/ directory
|
|
39
59
|
# @param scenario_name [String, nil] Test ID for tmux session naming (e.g., "TS-OVERSEER-001")
|
|
40
60
|
# @param run_id [String, nil] Unique run ID for deterministic tmux session naming
|
|
61
|
+
# @param release_manifest_path [String, nil] Explicit absolute manifest path carried
|
|
62
|
+
# from the invoking process (ACE_RELEASE_MANIFEST). When nil, the release-manifest
|
|
63
|
+
# step falls back to the default path under ACE_E2E_SOURCE_ROOT.
|
|
64
|
+
# @param state_file [String, nil] Host-side path (report directory) for retry
|
|
65
|
+
# state. When nil, no retry state is recorded and the sandbox is never reused.
|
|
41
66
|
# @return [Hash] Result with :success, :steps_completed, :error, :env, :tmux_session keys
|
|
42
67
|
def execute(setup_steps:, sandbox_dir:, fixture_source: nil, scenario_name: nil, run_id: nil, initial_env: {},
|
|
43
|
-
git_excludes: [])
|
|
68
|
+
git_excludes: [], release_manifest_path: nil, state_file: nil)
|
|
69
|
+
# Initialized before any filesystem operation: an early failure
|
|
70
|
+
# must reach the rescue as a clean setup failure, not as a
|
|
71
|
+
# secondary error from nil state.
|
|
72
|
+
env = {}
|
|
73
|
+
steps_completed = 0
|
|
74
|
+
@tmux_session = nil
|
|
75
|
+
@teardown_env = nil
|
|
44
76
|
FileUtils.mkdir_p(sandbox_dir)
|
|
77
|
+
# A rerun invalidates any earlier success up front: state is
|
|
78
|
+
# recreated only after every step and the persisted env succeed.
|
|
79
|
+
if state_file
|
|
80
|
+
FileUtils.rm_f(state_file)
|
|
81
|
+
# The prior attempt's completion record must never bless a
|
|
82
|
+
# retried run that fails before rewriting it.
|
|
83
|
+
FileUtils.rm_f(File.join(File.dirname(state_file), "metadata.yml"))
|
|
84
|
+
end
|
|
85
|
+
# Prior-attempt goal evidence must never survive into a retried
|
|
86
|
+
# run: the goals rebuild results/ from scratch.
|
|
87
|
+
FileUtils.rm_rf(File.join(sandbox_dir, "results"))
|
|
45
88
|
env = if @sandbox_backend
|
|
46
89
|
@sandbox_backend.prepared_env(initial_env.dup)
|
|
47
90
|
else
|
|
48
91
|
initial_env.dup
|
|
49
92
|
end
|
|
50
93
|
@git_excludes = normalize_git_excludes(git_excludes)
|
|
94
|
+
@release_manifest_input = release_manifest_path
|
|
95
|
+
@release_manifest_state = nil
|
|
51
96
|
steps_completed = 0
|
|
52
97
|
@tmux_session = nil
|
|
53
98
|
@scenario_name = scenario_name
|
|
@@ -58,12 +103,40 @@ module Ace
|
|
|
58
103
|
execute_step(step, sandbox_dir, env, fixture_source)
|
|
59
104
|
steps_completed += 1
|
|
60
105
|
end
|
|
106
|
+
merged = merged_environment(env)
|
|
107
|
+
if state_file
|
|
108
|
+
FileUtils.mkdir_p(File.dirname(state_file))
|
|
109
|
+
persisted = merged.slice(*PERSISTED_ENV_KEYS).compact
|
|
110
|
+
# The live value may be relative (agent-env PROJECT_ROOT_PATH: .);
|
|
111
|
+
# the persisted contract must be absolute so a reused sandbox is
|
|
112
|
+
# recognized as prepared by the pipeline.
|
|
113
|
+
if persisted["PROJECT_ROOT_PATH"] && !persisted["PROJECT_ROOT_PATH"].start_with?("/")
|
|
114
|
+
persisted["PROJECT_ROOT_PATH"] = File.expand_path(persisted["PROJECT_ROOT_PATH"], sandbox_dir)
|
|
115
|
+
end
|
|
116
|
+
generated_gemfile = File.join(sandbox_dir, "Gemfile")
|
|
117
|
+
source_root = env["ACE_E2E_SOURCE_ROOT"].to_s
|
|
118
|
+
source_gemfile = source_root.empty? ? nil : File.join(source_root, "Gemfile")
|
|
119
|
+
File.write(state_file, JSON.generate({
|
|
120
|
+
"completed_at" => Time.now.utc.iso8601,
|
|
121
|
+
"sandbox_dir" => sandbox_dir,
|
|
122
|
+
"setup_steps" => self.class.normalize_state_steps(setup_steps),
|
|
123
|
+
"release_manifest" => @release_manifest_state,
|
|
124
|
+
"env" => persisted,
|
|
125
|
+
"gemfile" => {
|
|
126
|
+
# The sandbox Gemfile is derived from the source root's
|
|
127
|
+
# Gemfile; a retry must regenerate it when either changes.
|
|
128
|
+
"source_root" => source_root,
|
|
129
|
+
"source_digest" => source_gemfile && File.file?(source_gemfile) ? Digest::SHA256.file(source_gemfile).hexdigest : nil,
|
|
130
|
+
"generated_digest" => File.file?(generated_gemfile) ? Digest::SHA256.file(generated_gemfile).hexdigest : nil
|
|
131
|
+
}
|
|
132
|
+
}))
|
|
133
|
+
end
|
|
61
134
|
|
|
62
135
|
{
|
|
63
136
|
success: true,
|
|
64
137
|
steps_completed: steps_completed,
|
|
65
138
|
error: nil,
|
|
66
|
-
env:
|
|
139
|
+
env: merged,
|
|
67
140
|
tmux_session: @tmux_session
|
|
68
141
|
}
|
|
69
142
|
rescue => e
|
|
@@ -129,6 +202,8 @@ module Ace
|
|
|
129
202
|
handle_env(value, env)
|
|
130
203
|
when "tmux-session"
|
|
131
204
|
handle_tmux_session(env, value)
|
|
205
|
+
when "release-manifest"
|
|
206
|
+
handle_release_manifest(value, sandbox_dir, env)
|
|
132
207
|
else
|
|
133
208
|
raise ArgumentError, "Unknown setup step type: #{key.inspect}"
|
|
134
209
|
end
|
|
@@ -219,6 +294,180 @@ module Ace
|
|
|
219
294
|
end
|
|
220
295
|
end
|
|
221
296
|
|
|
297
|
+
# Validate the frozen release manifest and copy its exact bytes into
|
|
298
|
+
# the sandbox. The source is the explicitly allowed input carried
|
|
299
|
+
# from the invoking process (ACE_RELEASE_MANIFEST, absolute path);
|
|
300
|
+
# without it, the default path under ACE_E2E_SOURCE_ROOT is used.
|
|
301
|
+
# Any missing, unreadable, malformed, or conflicting manifest raises
|
|
302
|
+
# here so setup fails before any install goal can run.
|
|
303
|
+
def handle_release_manifest(config, sandbox_dir, env)
|
|
304
|
+
target = config.is_a?(Hash) ? config["to"] : config
|
|
305
|
+
raise ArgumentError, "release-manifest step requires a target path" if target.to_s.strip.empty?
|
|
306
|
+
|
|
307
|
+
required = config.is_a?(Hash) ? Array(config["require"]) : []
|
|
308
|
+
source = release_manifest_source(env)
|
|
309
|
+
# The scenario declares the destination; the copy itself is still
|
|
310
|
+
# confined to the sandbox with no symlinked path components.
|
|
311
|
+
target_path = self.class.validated_manifest_target(sandbox_dir, target)
|
|
312
|
+
raise ArgumentError, "release-manifest target escapes the sandbox: #{target}" unless target_path
|
|
313
|
+
|
|
314
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
315
|
+
digest = Molecules::ReleaseManifest.validate_and_copy(
|
|
316
|
+
source_path: source,
|
|
317
|
+
target_path: target_path,
|
|
318
|
+
required_packages: required
|
|
319
|
+
)
|
|
320
|
+
@release_manifest_state = {
|
|
321
|
+
"source" => File.expand_path(source),
|
|
322
|
+
"target" => target,
|
|
323
|
+
"digest" => digest
|
|
324
|
+
}
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def release_manifest_source(env)
|
|
328
|
+
self.class.release_manifest_source_for(env, @release_manifest_input)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
class << self
|
|
332
|
+
# Retry state for a completed setup. Returns nil when there is no
|
|
333
|
+
# usable state (no file, unreadable, from another sandbox, or
|
|
334
|
+
# recorded for different setup steps).
|
|
335
|
+
def setup_state_for(state_file, sandbox_dir, setup_steps: nil)
|
|
336
|
+
return nil unless state_file && File.file?(state_file)
|
|
337
|
+
|
|
338
|
+
state = JSON.parse(File.read(state_file))
|
|
339
|
+
return nil unless state.is_a?(Hash)
|
|
340
|
+
return nil unless state["sandbox_dir"] &&
|
|
341
|
+
File.expand_path(state["sandbox_dir"]) == File.expand_path(sandbox_dir)
|
|
342
|
+
return nil if setup_steps && state["setup_steps"] != normalize_state_steps(setup_steps)
|
|
343
|
+
|
|
344
|
+
state
|
|
345
|
+
rescue JSON::ParserError, SystemCallError
|
|
346
|
+
nil
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def normalize_state_steps(setup_steps)
|
|
350
|
+
Array(setup_steps).map { |step| step.is_a?(Hash) ? [step.keys.first, step.values.first] : step }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# The manifest copy destination comes from trusted setup state,
|
|
354
|
+
# but the runner controls the sandbox filesystem in between: a
|
|
355
|
+
# symlinked path component could redirect the host-side write
|
|
356
|
+
# outside the sandbox. Parent components must be real
|
|
357
|
+
# directories; the final component may be an existing regular
|
|
358
|
+
# file (the previous validated copy).
|
|
359
|
+
def validated_manifest_target(sandbox_dir, target)
|
|
360
|
+
sandbox_root = File.expand_path(sandbox_dir)
|
|
361
|
+
# A symlinked sandbox root would redirect every confinement
|
|
362
|
+
# check below it.
|
|
363
|
+
return nil if File.lstat(sandbox_root).symlink?
|
|
364
|
+
relative = target.to_s.split("/")
|
|
365
|
+
current = sandbox_root
|
|
366
|
+
components = relative.reject { |part| part.empty? || part == "." }
|
|
367
|
+
components.each_with_index do |part, index|
|
|
368
|
+
current = File.join(current, part)
|
|
369
|
+
# lstat every component: File.exist? follows symlinks, so a
|
|
370
|
+
# dangling symlink would slip past an existence check and the
|
|
371
|
+
# write would create its target outside the sandbox. ENOENT
|
|
372
|
+
# means the component is genuinely absent (created fresh by
|
|
373
|
+
# mkdir_p); any other stat failure rejects reuse.
|
|
374
|
+
lstat = begin
|
|
375
|
+
File.lstat(current)
|
|
376
|
+
rescue Errno::ENOENT
|
|
377
|
+
next
|
|
378
|
+
rescue SystemCallError
|
|
379
|
+
return nil
|
|
380
|
+
end
|
|
381
|
+
return nil if lstat.symlink?
|
|
382
|
+
|
|
383
|
+
last = index == components.size - 1
|
|
384
|
+
if last
|
|
385
|
+
return nil if lstat.directory?
|
|
386
|
+
elsif !lstat.directory?
|
|
387
|
+
return nil
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
resolved = File.expand_path(current)
|
|
391
|
+
return nil unless resolved.start_with?(sandbox_root + File::SEPARATOR)
|
|
392
|
+
|
|
393
|
+
resolved
|
|
394
|
+
rescue SystemCallError
|
|
395
|
+
nil
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Revalidate the current manifest input against a completed
|
|
399
|
+
# sandbox before it is reused: the validated copy in the sandbox
|
|
400
|
+
# must still match the invoking process's manifest. Returns true
|
|
401
|
+
# when reuse may proceed (no manifest gate recorded, or the
|
|
402
|
+
# current input hashes to the recorded digest); false when the
|
|
403
|
+
# sandbox must be set up again.
|
|
404
|
+
def revalidate_release_manifest(state_file:, sandbox_dir:, explicit: nil, setup_steps: nil)
|
|
405
|
+
state = setup_state_for(state_file, sandbox_dir, setup_steps: setup_steps)
|
|
406
|
+
return false unless state
|
|
407
|
+
|
|
408
|
+
recorded = state["release_manifest"]
|
|
409
|
+
return true if recorded.nil?
|
|
410
|
+
|
|
411
|
+
target_path = validated_manifest_target(sandbox_dir, recorded["target"])
|
|
412
|
+
return false unless target_path
|
|
413
|
+
|
|
414
|
+
source = begin
|
|
415
|
+
release_manifest_source_for(state["env"] || {}, explicit)
|
|
416
|
+
rescue Molecules::ReleaseManifest::Invalid
|
|
417
|
+
return false
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# The source path itself must be the recorded one: an identical
|
|
421
|
+
# copy at a different path would be rejected by finalization's
|
|
422
|
+
# recorded-source binding, so reuse must not accept it either.
|
|
423
|
+
return false unless File.expand_path(source) == File.expand_path(recorded["source"].to_s)
|
|
424
|
+
|
|
425
|
+
# Read once: the digest check and the copy must see the same
|
|
426
|
+
# bytes even if the source changes mid-revalidation.
|
|
427
|
+
raw = begin
|
|
428
|
+
File.binread(source)
|
|
429
|
+
rescue SystemCallError
|
|
430
|
+
return false
|
|
431
|
+
end
|
|
432
|
+
return false if Digest::SHA256.hexdigest(raw) != recorded["digest"]
|
|
433
|
+
|
|
434
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
435
|
+
File.binwrite(target_path, raw)
|
|
436
|
+
true
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Retry reuse rebuilds the environment from the allow-listed
|
|
440
|
+
# runtime keys only (same boundary as initial setup), overlaid
|
|
441
|
+
# with the recorded sandbox runtime paths. Host secrets — even
|
|
442
|
+
# ones present in the current process — never reach the runner.
|
|
443
|
+
def build_reuse_env(state_env)
|
|
444
|
+
(PERSISTED_ENV_KEYS | %w[HOME PATH TMPDIR]).each_with_object({}) do |key, env|
|
|
445
|
+
value = state_env[key]
|
|
446
|
+
env[key] = value unless value.nil?
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def release_manifest_source_for(env, explicit)
|
|
451
|
+
explicit = explicit.to_s.strip
|
|
452
|
+
if explicit.empty?
|
|
453
|
+
source_root = env["ACE_E2E_SOURCE_ROOT"].to_s.strip
|
|
454
|
+
if source_root.empty?
|
|
455
|
+
raise Molecules::ReleaseManifest::Invalid,
|
|
456
|
+
"no release manifest input and no ACE_E2E_SOURCE_ROOT to derive the default path"
|
|
457
|
+
end
|
|
458
|
+
return File.join(source_root, RELEASE_MANIFEST_DEFAULT_PATH)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
unless explicit.start_with?("/")
|
|
462
|
+
raise Molecules::ReleaseManifest::Invalid,
|
|
463
|
+
"ACE_RELEASE_MANIFEST must be an absolute JSON file path, got: #{explicit}"
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
explicit
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
|
|
222
471
|
# Merge custom env vars with the process environment
|
|
223
472
|
#
|
|
224
473
|
# @param env [Hash] Custom environment variables
|
|
@@ -55,6 +55,25 @@ module Ace
|
|
|
55
55
|
@shared_runtime_root = nil
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# Scenarios whose most recent cached run ended in uncertain execution.
|
|
59
|
+
# They are excluded from automatic retry surfaces; callers must
|
|
60
|
+
# reconcile them manually.
|
|
61
|
+
#
|
|
62
|
+
# @param packages [Array<String>, nil] restrict to these packages
|
|
63
|
+
# @return [Array<String>] sorted test-ids with uncertain latest runs
|
|
64
|
+
def uncertain_scenarios(packages: nil)
|
|
65
|
+
names = packages ? packages.split(",").map(&:strip) : @discoverer.list_packages(base_dir: @base_dir)
|
|
66
|
+
return [] if names.empty?
|
|
67
|
+
|
|
68
|
+
@failure_finder.find_uncertain_scenarios(packages: names, base_dir: @base_dir)
|
|
69
|
+
.values
|
|
70
|
+
.flatten
|
|
71
|
+
.sort
|
|
72
|
+
rescue => e
|
|
73
|
+
warn "Warning: could not scan for uncertain scenarios: #{e.message}" if ENV["DEBUG"]
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
58
77
|
# Run E2E tests across all packages
|
|
59
78
|
#
|
|
60
79
|
# @param options [Hash] Execution options
|
|
@@ -103,7 +122,9 @@ module Ace
|
|
|
103
122
|
scenario_failures = nil
|
|
104
123
|
if options[:only_failures]
|
|
105
124
|
scenario_failures = @failure_finder.find_failures_by_scenario(
|
|
106
|
-
packages: packages,
|
|
125
|
+
packages: packages,
|
|
126
|
+
base_dir: @base_dir,
|
|
127
|
+
include_uncertain: options.fetch(:include_uncertain_failures, false)
|
|
107
128
|
)
|
|
108
129
|
|
|
109
130
|
if scenario_failures.empty?
|
|
@@ -574,6 +595,9 @@ module Ace
|
|
|
574
595
|
metadata = YAML.safe_load_file(metadata_path, permitted_classes: [Date])
|
|
575
596
|
status = metadata["status"]
|
|
576
597
|
return result unless status
|
|
598
|
+
# Retained metadata must never upgrade a fresh pipeline ERROR to
|
|
599
|
+
# PASS — an error stays authoritative until explicitly reconciled.
|
|
600
|
+
return result if result[:status] == "error" && status == "pass"
|
|
577
601
|
|
|
578
602
|
passed = metadata["tcs-passed"] || metadata.dig("results", "passed") || result[:passed_cases] || 0
|
|
579
603
|
total = metadata["tcs-total"] || metadata.dig("results", "total") || result[:total_cases] || 0
|