harnery 0.37.0 → 0.38.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.
- package/dist/commander.d.ts +9 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +7 -2
- package/dist/commands/admission.d.ts +21 -0
- package/dist/commands/admission.d.ts.map +1 -0
- package/dist/commands/admission.js +565 -0
- package/dist/commands/agents.d.ts +7 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +61 -1
- package/dist/commands/artifacts.d.ts.map +1 -1
- package/dist/commands/artifacts.js +48 -2
- package/dist/commands/browse-ai.d.ts +2 -2
- package/dist/commands/browse-ai.d.ts.map +1 -1
- package/dist/commands/browse-ai.js +6 -4
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +349 -21
- package/dist/commands/fetch.js +1 -0
- package/dist/commands/qa-record.d.ts +144 -0
- package/dist/commands/qa-record.d.ts.map +1 -0
- package/dist/commands/qa-record.js +0 -0
- package/dist/commands/qa-run.d.ts +7 -3
- package/dist/commands/qa-run.d.ts.map +1 -1
- package/dist/commands/qa-run.js +268 -13
- package/dist/commands/qa-status.d.ts +71 -0
- package/dist/commands/qa-status.d.ts.map +1 -0
- package/dist/commands/qa-status.js +490 -0
- package/dist/commands/qa-verify.d.ts +40 -0
- package/dist/commands/qa-verify.d.ts.map +1 -0
- package/dist/commands/qa-verify.js +180 -0
- package/dist/commands/review-pack.d.ts +4 -0
- package/dist/commands/review-pack.d.ts.map +1 -0
- package/dist/commands/review-pack.js +1001 -0
- package/dist/core/agents/qa-signal.d.ts +111 -0
- package/dist/core/agents/qa-signal.d.ts.map +1 -0
- package/dist/core/agents/qa-signal.js +231 -0
- package/dist/core/agents/session-name-display.d.ts +20 -5
- package/dist/core/agents/session-name-display.d.ts.map +1 -1
- package/dist/core/agents/session-name-display.js +67 -7
- package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
- package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +17 -0
- package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.js +1 -0
- package/dist/core/agents/state/live-coordination-writer.js +5 -0
- package/dist/core/artifacts/constants.d.ts +1 -1
- package/dist/core/artifacts/constants.js +1 -1
- package/dist/core/artifacts/index.d.ts +57 -6
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +265 -10
- package/dist/core/config.d.ts +8 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +16 -0
- package/dist/core/diagnostics/bundle.d.ts +16 -0
- package/dist/core/diagnostics/bundle.d.ts.map +1 -1
- package/dist/core/diagnostics/bundle.js +101 -7
- package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
- package/dist/core/events/v3/bootstrap.js +10 -0
- package/dist/core/events/v3/coordination-view.d.ts +3 -0
- package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
- package/dist/core/events/v3/coordination-view.js +69 -8
- package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
- package/dist/core/events/v3/producers/intake.js +9 -2
- package/dist/core/events/v3/producers/recorder.d.ts +23 -0
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +264 -18
- package/dist/core/hooks/cli.js +120 -27
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +10 -3
- package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
- package/dist/core/hooks/session-name-presence.js +4 -1
- package/dist/core/qa-artifacts.d.ts +20 -0
- package/dist/core/qa-artifacts.d.ts.map +1 -0
- package/dist/core/qa-artifacts.js +110 -0
- package/dist/core/resources/contract.d.ts +6 -0
- package/dist/core/resources/contract.d.ts.map +1 -1
- package/dist/core/resources/sampler.d.ts +7 -0
- package/dist/core/resources/sampler.d.ts.map +1 -1
- package/dist/core/resources/sampler.js +106 -5
- package/dist/lib/admission.d.ts +71 -0
- package/dist/lib/admission.d.ts.map +1 -0
- package/dist/lib/admission.js +264 -0
- package/dist/lib/agent-browser/client.d.ts +1 -1
- package/dist/lib/agent-browser/client.d.ts.map +1 -1
- package/dist/lib/agent-browser/client.js +1 -5
- package/dist/lib/browser/capture-fidelity.d.ts +39 -0
- package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
- package/dist/lib/browser/capture-fidelity.js +84 -0
- package/dist/lib/browser/client.d.ts +41 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +167 -9
- package/dist/lib/browser/critique.d.ts +38 -1
- package/dist/lib/browser/critique.d.ts.map +1 -1
- package/dist/lib/browser/critique.js +34 -6
- package/dist/lib/browser/index.d.ts +4 -2
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +2 -0
- package/dist/lib/browser/page-review-judge.d.ts +64 -0
- package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
- package/dist/lib/browser/page-review-judge.js +270 -0
- package/dist/lib/browser/page-review-pack.d.ts +613 -0
- package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
- package/dist/lib/browser/page-review-pack.js +1751 -0
- package/dist/lib/browser/qa-run-contracts.d.ts +214 -10
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
- package/dist/lib/browser/qa-run-contracts.js +136 -1
- package/dist/lib/browser/qa-run.d.ts +100 -10
- package/dist/lib/browser/qa-run.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.js +768 -169
- package/dist/lib/browser/request-diagnostics.d.ts +13 -0
- package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
- package/dist/lib/browser/request-diagnostics.js +18 -0
- package/dist/lib/browser/tiling.d.ts +19 -0
- package/dist/lib/browser/tiling.d.ts.map +1 -1
- package/dist/lib/browser/tiling.js +28 -0
- package/dist/lib/cookies/client.d.ts +9 -0
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +197 -44
- package/dist/lib/cookies/extra.d.ts +18 -0
- package/dist/lib/cookies/extra.d.ts.map +1 -0
- package/dist/lib/cookies/extra.js +14 -0
- package/dist/lib/cookies/index.d.ts +2 -1
- package/dist/lib/cookies/index.d.ts.map +1 -1
- package/dist/lib/cookies/index.js +2 -1
- package/dist/lib/durable-job.d.ts +124 -0
- package/dist/lib/durable-job.d.ts.map +1 -0
- package/dist/lib/durable-job.js +296 -0
- package/dist/lib/http/client.d.ts +7 -1
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +2 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +5 -2
- package/package.json +8 -2
- package/src/commander.ts +50 -2
- package/src/commands/admission.ts +699 -0
- package/src/commands/agents.ts +87 -1
- package/src/commands/artifacts.ts +97 -21
- package/src/commands/browse-ai.ts +10 -5
- package/src/commands/browse.ts +481 -20
- package/src/commands/fetch.ts +1 -0
- package/src/commands/qa-record.ts +682 -0
- package/src/commands/qa-run.ts +335 -16
- package/src/commands/qa-status.ts +608 -0
- package/src/commands/qa-verify.ts +238 -0
- package/src/commands/review-pack.ts +1281 -0
- package/src/core/agents/qa-signal.ts +261 -0
- package/src/core/agents/session-name-display.ts +78 -7
- package/src/core/agents/state/heartbeat-reader.ts +7 -0
- package/src/core/agents/state/heartbeat-writer.ts +23 -0
- package/src/core/agents/state/live-coordination-view.ts +1 -0
- package/src/core/agents/state/live-coordination-writer.ts +5 -0
- package/src/core/artifacts/constants.ts +1 -1
- package/src/core/artifacts/index.ts +370 -21
- package/src/core/config.ts +23 -0
- package/src/core/diagnostics/bundle.ts +119 -11
- package/src/core/events/v3/bootstrap.ts +10 -0
- package/src/core/events/v3/coordination-view.ts +100 -11
- package/src/core/events/v3/producers/intake.ts +9 -2
- package/src/core/events/v3/producers/recorder.ts +312 -18
- package/src/core/hooks/cli.ts +140 -32
- package/src/core/hooks/resolve/transcript.ts +10 -3
- package/src/core/hooks/session-name-presence.ts +6 -1
- package/src/core/qa-artifacts.ts +126 -0
- package/src/core/resources/contract.ts +7 -0
- package/src/core/resources/sampler.ts +138 -6
- package/src/lib/admission.ts +347 -0
- package/src/lib/agent-browser/client.ts +2 -10
- package/src/lib/browser/capture-fidelity.ts +98 -0
- package/src/lib/browser/client.ts +206 -10
- package/src/lib/browser/critique.ts +62 -7
- package/src/lib/browser/index.ts +36 -0
- package/src/lib/browser/page-review-judge.ts +360 -0
- package/src/lib/browser/page-review-pack.ts +2384 -0
- package/src/lib/browser/qa-run-contracts.ts +366 -3
- package/src/lib/browser/qa-run.ts +868 -190
- package/src/lib/browser/request-diagnostics.ts +27 -0
- package/src/lib/browser/tiling.ts +32 -0
- package/src/lib/cookies/client.ts +228 -42
- package/src/lib/cookies/extra.ts +28 -0
- package/src/lib/cookies/index.ts +2 -0
- package/src/lib/durable-job.ts +407 -0
- package/src/lib/http/client.ts +13 -1
- package/src/lib/instructions/templates.ts +5 -2
|
@@ -10,18 +10,73 @@
|
|
|
10
10
|
// outcome in the result — nothing is filtered away.
|
|
11
11
|
//
|
|
12
12
|
// Toolkit tier: this module must not import src/core (layering check).
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { cpus, freemem, loadavg, totalmem } from "node:os";
|
|
17
|
+
import { basename, join } from "node:path";
|
|
18
|
+
import { DEFAULT_CRITIQUE_RUBRIC } from "./critique.js";
|
|
19
|
+
import { judgePageReviewPack, toCritiqueRecords } from "./page-review-judge.js";
|
|
20
|
+
import { finalizePageReviewPack, gateHitsFromEnvelope, PAGE_REVIEW_DEFAULT_RETENTION_MINUTES, PAGE_REVIEW_FINDINGS_FILENAME, PAGE_REVIEW_PACK_DIRNAME, PAGE_REVIEW_PACK_SCHEMA, PAGE_REVIEW_REVIEW_FILENAME, readPackContext, readPackDom, readPackFullPage, readPackManifest, readPackSignature, } from "./page-review-pack.js";
|
|
21
|
+
import { QA_CRITIQUE_CONTRACT_VERSION, rubricDigest } from "./qa-reuse.js";
|
|
22
|
+
import { computeJobDigest, computeVerdict, mergeCoverage, QA_RUN_RESULT_SCHEMA_VERSION, QA_RUN_STATUS_SCHEMA_VERSION, } from "./qa-run-contracts.js";
|
|
23
|
+
import { saveQaSnapshot } from "./qa-snapshot.js";
|
|
24
|
+
/** Set on the runner's own environment before the host's critique provider is
|
|
25
|
+
* loaded for the judge stage, unless the job permits metered critique: the
|
|
26
|
+
* provider must stay on subscription-backed headless harnesses and surface
|
|
27
|
+
* exhaustion instead of falling back to a metered API. */
|
|
20
28
|
export const QA_RUN_HEADLESS_ONLY_ENV = "HARNERY_CRITIQUE_HEADLESS_ONLY";
|
|
21
29
|
/** Result document written into the run's output directory. */
|
|
22
30
|
export const QA_RUN_RESULT_FILENAME = "page-qa-result.json";
|
|
31
|
+
/** Pointer document written into the parent output directory after every
|
|
32
|
+
* run, naming the newest run's directory and verdict. Consumers resolve the
|
|
33
|
+
* current result through this pointer instead of guessing at loose files. */
|
|
34
|
+
export const QA_RUN_LATEST_FILENAME = "latest.json";
|
|
35
|
+
/**
|
|
36
|
+
* Publish the parent directory's latest-result pointer without allowing an
|
|
37
|
+
* older completion to replace a newer one. Both runner and manual evidence
|
|
38
|
+
* use this writer so every producer preserves the same ordering invariant.
|
|
39
|
+
*/
|
|
40
|
+
export function writeLatestPointer(outParent, input) {
|
|
41
|
+
const pointerPath = join(outParent, QA_RUN_LATEST_FILENAME);
|
|
42
|
+
let pointerIsNewer = true;
|
|
43
|
+
try {
|
|
44
|
+
const existing = JSON.parse(readFileSync(pointerPath, "utf8"));
|
|
45
|
+
if (typeof existing.completed_at === "string") {
|
|
46
|
+
const existingCompletedAt = Date.parse(existing.completed_at);
|
|
47
|
+
const candidateCompletedAt = Date.parse(input.completed_at);
|
|
48
|
+
pointerIsNewer =
|
|
49
|
+
Number.isNaN(existingCompletedAt) || candidateCompletedAt >= existingCompletedAt;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// No readable pointer yet: this result becomes the first one.
|
|
54
|
+
}
|
|
55
|
+
if (pointerIsNewer) {
|
|
56
|
+
const pointer = {
|
|
57
|
+
schema_version: 1,
|
|
58
|
+
...input,
|
|
59
|
+
result: join(input.dir, QA_RUN_RESULT_FILENAME),
|
|
60
|
+
};
|
|
61
|
+
const pointerTmp = join(outParent, `.${QA_RUN_LATEST_FILENAME}.${input.run_id}.tmp`);
|
|
62
|
+
writeFileSync(pointerTmp, `${JSON.stringify(pointer, null, 2)}\n`);
|
|
63
|
+
renameSync(pointerTmp, pointerPath);
|
|
64
|
+
}
|
|
65
|
+
return pointerPath;
|
|
66
|
+
}
|
|
67
|
+
/** Live status document beside the result (QaRunStatusDocument): written at
|
|
68
|
+
* start, every stage boundary, and on a heartbeat timer, so a disconnected
|
|
69
|
+
* client can tell a running job from a dead one without guessing. */
|
|
70
|
+
export const QA_RUN_STATUS_FILENAME = "run-status.json";
|
|
71
|
+
/** The effective validated job, written into the run directory so a
|
|
72
|
+
* reconnecting client can re-derive the job digest (`qa-verify --job`). */
|
|
73
|
+
export const QA_RUN_JOB_FILENAME = "job.json";
|
|
74
|
+
const STATUS_HEARTBEAT_MS = 15_000;
|
|
23
75
|
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
76
|
+
const DEFAULT_RUN_DEADLINE_MS = 900_000;
|
|
24
77
|
const DEFAULT_COMMAND_CONCURRENCY = 2;
|
|
78
|
+
/** Gate-hit rectangles forwarded to one context's capture child (`--review-pack-hit-rect`). */
|
|
79
|
+
const REVIEW_PACK_HIT_RECTS_PER_CONTEXT = 50;
|
|
25
80
|
const EXEC_MAX_BUFFER = 16 * 1024 * 1024;
|
|
26
81
|
/** The planner's deterministic vocabulary is an executable contract, not a
|
|
27
82
|
* report-only hint. A job may add checks, but these arguments always run for
|
|
@@ -33,38 +88,142 @@ const MANIFEST_DETERMINISTIC_ARGS = {
|
|
|
33
88
|
truncation: ["--check-truncation", "--check-truncation-fail"],
|
|
34
89
|
placeholder: ["--check-placeholder", "--check-placeholder-fail"],
|
|
35
90
|
};
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
91
|
+
/** Grace between the timeout's SIGTERM and the follow-up SIGKILL. A child
|
|
92
|
+
* that catches SIGTERM (Bun installs a handler by default) gets this long to
|
|
93
|
+
* exit before the kill is made non-negotiable. */
|
|
94
|
+
export const QA_RUN_KILL_GRACE_MS = 5_000;
|
|
95
|
+
/** After the group SIGKILL, how long to wait for stdio to drain before
|
|
96
|
+
* destroying the streams and settling anyway. An escaped grandchild (setsid)
|
|
97
|
+
* can hold the pipes open forever; the result must not wait on it. */
|
|
98
|
+
const KILL_DRAIN_MS = 2_000;
|
|
99
|
+
/** Default executor: spawn with argv arrays only (never a shell string),
|
|
100
|
+
* closed stdin, bounded output buffers, and the policy timeout.
|
|
101
|
+
*
|
|
102
|
+
* Timeout enforcement is escalated and group-wide, via `spawn` rather than
|
|
103
|
+
* `execFile` for two live-verified reasons. First, a child that catches
|
|
104
|
+
* SIGTERM while awaiting its own grandchildren turns a single polite kill
|
|
105
|
+
* into an unbounded wait (a critique command outlived its 120s cap by 10x).
|
|
106
|
+
* Second, `execFile` resolves only when the child's stdio closes, and an
|
|
107
|
+
* orphaned grandchild inheriting the pipe keeps it open after the child is
|
|
108
|
+
* dead — so even a delivered kill did not settle the call. The child is
|
|
109
|
+
* therefore spawned detached into its own process group; at the deadline the
|
|
110
|
+
* whole group gets SIGTERM, then SIGKILL after a grace, and the result
|
|
111
|
+
* settles on exit with whatever output drained, never waiting on a pipe an
|
|
112
|
+
* orphan still holds. A timed-out command reports an error even if the child
|
|
113
|
+
* then exits 0 — a result produced after the deadline cannot be trusted. */
|
|
38
114
|
export const defaultQaRunExec = (argv, options) => new Promise((resolvePromise) => {
|
|
39
115
|
const [command, ...args] = argv;
|
|
40
116
|
if (!command) {
|
|
41
117
|
resolvePromise({ exitCode: null, stdout: "", stderr: "", error: "empty argv" });
|
|
42
118
|
return;
|
|
43
119
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
120
|
+
// Windows has no process groups; the direct-child kill is the best
|
|
121
|
+
// available fallback there.
|
|
122
|
+
const groupKill = process.platform !== "win32";
|
|
123
|
+
const child = spawn(command, args, {
|
|
47
124
|
env: options.env,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
125
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
126
|
+
...(groupKill ? { detached: true } : {}),
|
|
127
|
+
});
|
|
128
|
+
let stdout = "";
|
|
129
|
+
let stderr = "";
|
|
130
|
+
let outBytes = 0;
|
|
131
|
+
let timedOut = false;
|
|
132
|
+
let overflowed = false;
|
|
133
|
+
let settled = false;
|
|
134
|
+
let termTimer;
|
|
135
|
+
let killTimer;
|
|
136
|
+
let drainTimer;
|
|
137
|
+
const settle = (result) => {
|
|
138
|
+
if (settled)
|
|
54
139
|
return;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
140
|
+
settled = true;
|
|
141
|
+
clearTimeout(termTimer);
|
|
142
|
+
clearTimeout(killTimer);
|
|
143
|
+
clearTimeout(drainTimer);
|
|
144
|
+
resolvePromise(result);
|
|
145
|
+
};
|
|
146
|
+
const signalGroup = (signal) => {
|
|
147
|
+
const pid = child.pid;
|
|
148
|
+
if (!pid)
|
|
60
149
|
return;
|
|
150
|
+
if (groupKill) {
|
|
151
|
+
try {
|
|
152
|
+
process.kill(-pid, signal);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Group already gone, or the leader died before setpgid: fall
|
|
157
|
+
// through to the direct child so the kill still lands somewhere.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
child.kill(signal);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Nothing left to kill.
|
|
61
165
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
166
|
+
};
|
|
167
|
+
const collect = (chunk, sink) => {
|
|
168
|
+
const text = String(chunk);
|
|
169
|
+
outBytes += text.length;
|
|
170
|
+
if (sink === "stdout")
|
|
171
|
+
stdout += text;
|
|
172
|
+
else
|
|
173
|
+
stderr += text;
|
|
174
|
+
if (outBytes > EXEC_MAX_BUFFER && !overflowed) {
|
|
175
|
+
overflowed = true;
|
|
176
|
+
signalGroup("SIGKILL");
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
child.stdout?.on("data", (chunk) => collect(chunk, "stdout"));
|
|
180
|
+
child.stderr?.on("data", (chunk) => collect(chunk, "stderr"));
|
|
181
|
+
child.on("error", (err) => {
|
|
182
|
+
settle({ exitCode: null, stdout, stderr, error: err.message || "spawn failed" });
|
|
66
183
|
});
|
|
67
|
-
|
|
184
|
+
const finish = (code, signal) => {
|
|
185
|
+
if (timedOut) {
|
|
186
|
+
settle({
|
|
187
|
+
exitCode: null,
|
|
188
|
+
stdout,
|
|
189
|
+
stderr,
|
|
190
|
+
error: `timed out after ${options.timeoutMs}ms (process group killed)`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
else if (overflowed) {
|
|
194
|
+
settle({
|
|
195
|
+
exitCode: null,
|
|
196
|
+
stdout,
|
|
197
|
+
stderr,
|
|
198
|
+
error: `output exceeded ${EXEC_MAX_BUFFER} bytes (process group killed)`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
else if (signal) {
|
|
202
|
+
settle({ exitCode: null, stdout, stderr, error: `killed by ${signal}` });
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
settle({ exitCode: code, stdout, stderr });
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
// "close" is the clean path: process exited AND stdio drained. "exit"
|
|
209
|
+
// arms the drain failsafe so an orphan holding the pipes cannot postpone
|
|
210
|
+
// the result forever.
|
|
211
|
+
child.on("close", (code, signal) => finish(code, signal));
|
|
212
|
+
child.on("exit", (code, signal) => {
|
|
213
|
+
drainTimer = setTimeout(() => {
|
|
214
|
+
child.stdout?.destroy();
|
|
215
|
+
child.stderr?.destroy();
|
|
216
|
+
finish(code, signal);
|
|
217
|
+
}, KILL_DRAIN_MS);
|
|
218
|
+
drainTimer.unref?.();
|
|
219
|
+
});
|
|
220
|
+
termTimer = setTimeout(() => {
|
|
221
|
+
timedOut = true;
|
|
222
|
+
signalGroup("SIGTERM");
|
|
223
|
+
}, options.timeoutMs);
|
|
224
|
+
killTimer = setTimeout(() => signalGroup("SIGKILL"), options.timeoutMs + QA_RUN_KILL_GRACE_MS);
|
|
225
|
+
termTimer.unref?.();
|
|
226
|
+
killTimer.unref?.();
|
|
68
227
|
});
|
|
69
228
|
/** One-line tail excerpt of a failed child's output for blockers and
|
|
70
229
|
* failures, preferring stderr and falling back to stdout (browse prints its
|
|
@@ -186,39 +345,181 @@ function parseConsoleFailures(envelope) {
|
|
|
186
345
|
}
|
|
187
346
|
return failures;
|
|
188
347
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
348
|
+
/**
|
|
349
|
+
* Lift per-backend vision latency out of the envelope's provider_meta. The
|
|
350
|
+
* shape is host-owned (`providers[name].latency_ms = {count, p50, p95}`), so
|
|
351
|
+
* read defensively: a malformed entry or one with zero calls is dropped.
|
|
352
|
+
* Without this the runner kept only the provider label, and a slow tile was
|
|
353
|
+
* visible in `ps` and nowhere in the result document.
|
|
354
|
+
*/
|
|
355
|
+
function critiqueLatency(providerMeta) {
|
|
356
|
+
const providers = providerMeta?.providers;
|
|
357
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers))
|
|
358
|
+
return undefined;
|
|
359
|
+
const out = {};
|
|
360
|
+
for (const [name, state] of Object.entries(providers)) {
|
|
361
|
+
const lat = state?.latency_ms;
|
|
362
|
+
if (!lat || typeof lat !== "object")
|
|
363
|
+
continue;
|
|
364
|
+
const { count, p50, p95 } = lat;
|
|
365
|
+
if (typeof count !== "number" || count <= 0)
|
|
366
|
+
continue;
|
|
367
|
+
if (typeof p50 !== "number" || typeof p95 !== "number")
|
|
368
|
+
continue;
|
|
369
|
+
out[name] = { count, p50, p95 };
|
|
197
370
|
}
|
|
198
|
-
return
|
|
371
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
199
372
|
}
|
|
200
373
|
/**
|
|
201
374
|
* Execute the whole QA matrix for one validated job and return the result
|
|
202
375
|
* (also written to `<outDir>/page-qa-result.json`). Stages: plan →
|
|
203
|
-
* deterministic gates (bounded pool) → interactions (serial) →
|
|
204
|
-
* context
|
|
205
|
-
*
|
|
376
|
+
* deterministic gates (bounded pool) → interactions (serial) → capture (each
|
|
377
|
+
* context rendered once into the run's page review pack through the same
|
|
378
|
+
* pool, browser closed) → critique (one in-process pool of vision calls over
|
|
379
|
+
* every tile of every context, no browser open) → snapshot (persisted from
|
|
380
|
+
* the pack's files in signoff). The verdict is computeVerdict over
|
|
381
|
+
* everything recorded — fail-closed.
|
|
206
382
|
*/
|
|
207
383
|
export async function runQaMatrix(options) {
|
|
208
|
-
const { job,
|
|
384
|
+
const { job, outParent, browseArgv } = options;
|
|
209
385
|
const exec = options.exec ?? defaultQaRunExec;
|
|
210
386
|
const log = options.onLog ?? (() => { });
|
|
211
387
|
const timeoutMs = job.policy?.command_timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
212
388
|
const concurrency = job.policy?.command_concurrency ?? DEFAULT_COMMAND_CONCURRENCY;
|
|
389
|
+
const hostSample = () => {
|
|
390
|
+
const holders = options.admission?.holders?.() ?? undefined;
|
|
391
|
+
return {
|
|
392
|
+
captured_at: new Date().toISOString(),
|
|
393
|
+
loadavg_1m: loadavg()[0] ?? 0,
|
|
394
|
+
free_mem_bytes: freemem(),
|
|
395
|
+
total_mem_bytes: totalmem(),
|
|
396
|
+
cpu_count: cpus().length,
|
|
397
|
+
// Exclude this run itself: a holder list that names the sampler tells
|
|
398
|
+
// the reader nothing about contention.
|
|
399
|
+
...(holders ? { competing: holders.filter((holder) => holder.pid !== process.pid) } : {}),
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
const stageHostSamples = {};
|
|
403
|
+
const runId = options.runId ?? randomUUID();
|
|
404
|
+
const outDir = join(outParent, `run-${runId}`);
|
|
213
405
|
mkdirSync(outDir, { recursive: true });
|
|
406
|
+
const startedAtIso = new Date().toISOString();
|
|
407
|
+
writeFileSync(join(outDir, QA_RUN_JOB_FILENAME), `${JSON.stringify(job, null, 2)}\n`);
|
|
408
|
+
// Live status: state + stage + heartbeat, written atomically so a client
|
|
409
|
+
// that lost its terminal can distinguish this run being alive from dead.
|
|
410
|
+
let statusState = "running";
|
|
411
|
+
let statusStage = null;
|
|
412
|
+
let statusQueue;
|
|
413
|
+
let statusVerdict;
|
|
414
|
+
const writeStatus = () => {
|
|
415
|
+
const status = {
|
|
416
|
+
schema_version: QA_RUN_STATUS_SCHEMA_VERSION,
|
|
417
|
+
run_id: runId,
|
|
418
|
+
pid: process.pid,
|
|
419
|
+
state: statusState,
|
|
420
|
+
stage: statusStage,
|
|
421
|
+
started_at: startedAtIso,
|
|
422
|
+
updated_at: new Date().toISOString(),
|
|
423
|
+
...(statusQueue ? { queue: statusQueue } : {}),
|
|
424
|
+
...(statusVerdict ? { verdict: statusVerdict } : {}),
|
|
425
|
+
};
|
|
426
|
+
try {
|
|
427
|
+
const tmp = join(outDir, `.${QA_RUN_STATUS_FILENAME}.tmp`);
|
|
428
|
+
writeFileSync(tmp, `${JSON.stringify(status, null, 2)}\n`);
|
|
429
|
+
renameSync(tmp, join(outDir, QA_RUN_STATUS_FILENAME));
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
// Status is advisory; the result document is the authoritative record.
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const heartbeat = setInterval(writeStatus, STATUS_HEARTBEAT_MS);
|
|
436
|
+
heartbeat.unref();
|
|
437
|
+
/** Enter a stage: record the host pressure it starts under and refresh the
|
|
438
|
+
* live status document in one place. */
|
|
439
|
+
const enterStage = (stage) => {
|
|
440
|
+
statusStage = stage;
|
|
441
|
+
stageHostSamples[stage] = hostSample();
|
|
442
|
+
writeStatus();
|
|
443
|
+
};
|
|
444
|
+
const revision = job.tested_revision !== undefined
|
|
445
|
+
? { tested_revision: job.tested_revision, revision_source: "job" }
|
|
446
|
+
: options.revisionProbe?.tested_revision !== undefined
|
|
447
|
+
? {
|
|
448
|
+
tested_revision: options.revisionProbe.tested_revision,
|
|
449
|
+
revision_source: "git",
|
|
450
|
+
...(options.revisionProbe.worktree_dirty !== undefined
|
|
451
|
+
? { worktree_dirty: options.revisionProbe.worktree_dirty }
|
|
452
|
+
: {}),
|
|
453
|
+
}
|
|
454
|
+
: { revision_source: "unknown" };
|
|
455
|
+
const jobDigest = computeJobDigest(job);
|
|
456
|
+
const wall = {
|
|
457
|
+
plan: 0,
|
|
458
|
+
gates: 0,
|
|
459
|
+
interactions: 0,
|
|
460
|
+
capture: 0,
|
|
461
|
+
critique: 0,
|
|
462
|
+
snapshot: 0,
|
|
463
|
+
total: 0,
|
|
464
|
+
};
|
|
465
|
+
// ------------------------------------------------------------- admission
|
|
466
|
+
// Queue wait happens before the runner clock starts: wall_time_ms.total
|
|
467
|
+
// stays pure runner time and the wait is reported as wall_time_ms.queue.
|
|
468
|
+
let releaseAdmission;
|
|
469
|
+
let admissionFailure;
|
|
470
|
+
if (options.admission) {
|
|
471
|
+
statusState = "queued";
|
|
472
|
+
statusQueue = {
|
|
473
|
+
resource: options.admission.resource,
|
|
474
|
+
waiting_since: new Date().toISOString(),
|
|
475
|
+
};
|
|
476
|
+
writeStatus();
|
|
477
|
+
const queueStart = Date.now();
|
|
478
|
+
try {
|
|
479
|
+
releaseAdmission = await options.admission.acquire((message) => {
|
|
480
|
+
log(message);
|
|
481
|
+
writeStatus();
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
admissionFailure = err instanceof Error ? err.message : String(err);
|
|
486
|
+
}
|
|
487
|
+
wall.queue = Date.now() - queueStart;
|
|
488
|
+
statusQueue = undefined;
|
|
489
|
+
}
|
|
490
|
+
statusState = "running";
|
|
491
|
+
writeStatus();
|
|
492
|
+
const hostStart = hostSample();
|
|
214
493
|
const startedAt = Date.now();
|
|
215
|
-
const wall = { plan: 0, gates: 0, interactions: 0, critique: 0, snapshot: 0, total: 0 };
|
|
216
494
|
const blockers = [];
|
|
495
|
+
// Overall deadline: the clock starts after admission (pure runner time) and
|
|
496
|
+
// is consulted before every command, so the worst overshoot is one command
|
|
497
|
+
// timeout plus the kill grace. Exceeding it fails closed as incomplete.
|
|
498
|
+
const runDeadlineMs = job.policy?.run_deadline_ms ?? DEFAULT_RUN_DEADLINE_MS;
|
|
499
|
+
let deadlineHit = false;
|
|
500
|
+
const pastDeadline = () => {
|
|
501
|
+
if (deadlineHit)
|
|
502
|
+
return true;
|
|
503
|
+
if (Date.now() - startedAt < runDeadlineMs)
|
|
504
|
+
return false;
|
|
505
|
+
deadlineHit = true;
|
|
506
|
+
blockers.push({
|
|
507
|
+
stage: "deadline",
|
|
508
|
+
reason: `run deadline of ${runDeadlineMs}ms exceeded — remaining commands were skipped and ` +
|
|
509
|
+
"the result finalized as incomplete (raise policy.run_deadline_ms for a legitimately " +
|
|
510
|
+
"larger matrix)",
|
|
511
|
+
});
|
|
512
|
+
log(`deadline: ${runDeadlineMs}ms exceeded, skipping remaining commands`);
|
|
513
|
+
return true;
|
|
514
|
+
};
|
|
217
515
|
const commands = [];
|
|
218
516
|
const critique = [];
|
|
517
|
+
const stagesRun = [];
|
|
219
518
|
let manifest = null;
|
|
220
519
|
let contexts = [];
|
|
221
520
|
let snapshot = { saved: false };
|
|
521
|
+
let critiquePool;
|
|
522
|
+
let reviewPack;
|
|
222
523
|
const baseEnv = { ...process.env, ...options.childEnv };
|
|
223
524
|
const timedExec = async (argv, env) => {
|
|
224
525
|
const started = Date.now();
|
|
@@ -238,15 +539,45 @@ export async function runQaMatrix(options) {
|
|
|
238
539
|
};
|
|
239
540
|
const finalize = () => {
|
|
240
541
|
wall.total = Date.now() - startedAt;
|
|
542
|
+
// Prefix semantics: the last stage the run progressed THROUGH cleanly.
|
|
543
|
+
// Stages are pushed in execution order; the first blocked stage ends the
|
|
544
|
+
// clean prefix, so a gate-blocked run reports "plan" even though the
|
|
545
|
+
// (empty) interactions stage technically executed afterwards.
|
|
546
|
+
const blockedStages = new Set(blockers.map((blocker) => blocker.stage));
|
|
547
|
+
let lastCompletedStage = null;
|
|
548
|
+
for (const stage of stagesRun) {
|
|
549
|
+
if (blockedStages.has(stage))
|
|
550
|
+
break;
|
|
551
|
+
lastCompletedStage = stage;
|
|
552
|
+
}
|
|
241
553
|
const result = {
|
|
242
554
|
schema_version: QA_RUN_RESULT_SCHEMA_VERSION,
|
|
555
|
+
evidence_source: "runner",
|
|
556
|
+
run: {
|
|
557
|
+
run_id: runId,
|
|
558
|
+
started_at: startedAtIso,
|
|
559
|
+
completed_at: new Date().toISOString(),
|
|
560
|
+
...revision,
|
|
561
|
+
job_digest: jobDigest,
|
|
562
|
+
out_dir: outDir,
|
|
563
|
+
},
|
|
564
|
+
host: {
|
|
565
|
+
start: hostStart,
|
|
566
|
+
finish: hostSample(),
|
|
567
|
+
...(Object.keys(stageHostSamples).length > 0 ? { stages: stageHostSamples } : {}),
|
|
568
|
+
},
|
|
569
|
+
last_completed_stage: lastCompletedStage,
|
|
243
570
|
target: job.target,
|
|
244
|
-
...(
|
|
571
|
+
...(revision.tested_revision !== undefined
|
|
572
|
+
? { tested_revision: revision.tested_revision }
|
|
573
|
+
: {}),
|
|
245
574
|
mode: job.mode,
|
|
246
575
|
qa_plan: manifest,
|
|
247
576
|
contexts,
|
|
248
577
|
commands,
|
|
249
578
|
critique,
|
|
579
|
+
...(critiquePool ? { critique_pool: critiquePool } : {}),
|
|
580
|
+
...(reviewPack ? { review_pack: reviewPack } : {}),
|
|
250
581
|
snapshot,
|
|
251
582
|
wall_time_ms: { ...wall },
|
|
252
583
|
blockers,
|
|
@@ -259,8 +590,27 @@ export async function runQaMatrix(options) {
|
|
|
259
590
|
}),
|
|
260
591
|
};
|
|
261
592
|
writeFileSync(join(outDir, QA_RUN_RESULT_FILENAME), `${JSON.stringify(result, null, 2)}\n`);
|
|
593
|
+
writeLatestPointer(outParent, {
|
|
594
|
+
run_id: runId,
|
|
595
|
+
dir: basename(outDir),
|
|
596
|
+
completed_at: result.run.completed_at,
|
|
597
|
+
verdict: result.verdict,
|
|
598
|
+
});
|
|
599
|
+
statusState = "completed";
|
|
600
|
+
statusStage = null;
|
|
601
|
+
statusVerdict = result.verdict;
|
|
602
|
+
writeStatus();
|
|
603
|
+
clearInterval(heartbeat);
|
|
604
|
+
releaseAdmission?.();
|
|
262
605
|
return result;
|
|
263
606
|
};
|
|
607
|
+
if (admissionFailure !== undefined) {
|
|
608
|
+
blockers.push({
|
|
609
|
+
stage: "admission",
|
|
610
|
+
reason: `no admission slot for browser work: ${admissionFailure}`,
|
|
611
|
+
});
|
|
612
|
+
return finalize();
|
|
613
|
+
}
|
|
264
614
|
// Base render arguments shared by every per-context invocation.
|
|
265
615
|
const contextRenderArgs = (ctx) => [
|
|
266
616
|
"--viewport",
|
|
@@ -268,6 +618,11 @@ export async function runQaMatrix(options) {
|
|
|
268
618
|
...(ctx.theme === "dark" ? ["--color-scheme", "dark"] : []),
|
|
269
619
|
...(ctx.args ?? []),
|
|
270
620
|
];
|
|
621
|
+
// The planner's tile ceiling and every critique child must agree on the
|
|
622
|
+
// per-context band cap, or the predicted cost and the real coverage drift.
|
|
623
|
+
const critiqueMaxTilesArgs = job.policy?.critique_max_tiles !== undefined
|
|
624
|
+
? ["--check-critique-max-tiles", String(job.policy.critique_max_tiles)]
|
|
625
|
+
: [];
|
|
271
626
|
// ------------------------------------------------------------------ plan
|
|
272
627
|
const planArgv = [
|
|
273
628
|
...browseArgv,
|
|
@@ -275,13 +630,16 @@ export async function runQaMatrix(options) {
|
|
|
275
630
|
"--qa-plan",
|
|
276
631
|
"--json",
|
|
277
632
|
"--no-screenshot",
|
|
633
|
+
...critiqueMaxTilesArgs,
|
|
278
634
|
...(job.qa_hints?.scopes ?? []).flatMap((selector) => ["--qa-scope", selector]),
|
|
279
635
|
...(job.qa_hints?.states?.length ? ["--qa-states", job.qa_hints.states.join(",")] : []),
|
|
280
636
|
];
|
|
281
637
|
log(`plan: ${job.target}`);
|
|
638
|
+
enterStage("plan");
|
|
282
639
|
const planStart = Date.now();
|
|
283
640
|
const plan = await timedExec(planArgv, baseEnv);
|
|
284
641
|
wall.plan = Date.now() - planStart;
|
|
642
|
+
stagesRun.push("plan");
|
|
285
643
|
let planEnvelope;
|
|
286
644
|
if (!plan.res.error && plan.res.exitCode === 0) {
|
|
287
645
|
try {
|
|
@@ -353,8 +711,11 @@ export async function runQaMatrix(options) {
|
|
|
353
711
|
const enforceConsole = manifest.checks.deterministic.includes("console");
|
|
354
712
|
const checks = job.checks ?? [];
|
|
355
713
|
const gateOutcomes = new Array(contexts.length);
|
|
714
|
+
enterStage("gates");
|
|
356
715
|
const gatesStart = Date.now();
|
|
357
716
|
await runPool(contexts, concurrency, async (ctx, index) => {
|
|
717
|
+
if (pastDeadline())
|
|
718
|
+
return;
|
|
358
719
|
const applicable = checks.filter((check) => check.contexts === undefined || check.contexts.includes(ctx.id));
|
|
359
720
|
const checkId = [
|
|
360
721
|
...manifest.checks.deterministic.map((check) => `manifest:${check}`),
|
|
@@ -367,6 +728,10 @@ export async function runQaMatrix(options) {
|
|
|
367
728
|
...contextRenderArgs(ctx),
|
|
368
729
|
"--out",
|
|
369
730
|
outPrefix,
|
|
731
|
+
// When the manifest requires a visual pass, the capture stage writes the
|
|
732
|
+
// full-page PNG into the pack, so the gate trio carries no screenshot;
|
|
733
|
+
// nothing reads the gate PNG (the envelope JSON is the evidence).
|
|
734
|
+
...(manifest.checks.visual !== "none" ? ["--no-screenshot"] : []),
|
|
370
735
|
...manifestGateArgs,
|
|
371
736
|
...applicable.flatMap((check) => check.args),
|
|
372
737
|
];
|
|
@@ -419,10 +784,13 @@ export async function runQaMatrix(options) {
|
|
|
419
784
|
};
|
|
420
785
|
});
|
|
421
786
|
wall.gates = Date.now() - gatesStart;
|
|
787
|
+
stagesRun.push("gates");
|
|
422
788
|
// Manifest order regardless of completion order: outcomes were written by
|
|
423
|
-
// context index, so a straight push preserves it.
|
|
424
|
-
|
|
789
|
+
// context index, so a straight push preserves it. Deadline-skipped slots
|
|
790
|
+
// are empty: the single deadline blocker is their record.
|
|
791
|
+
commands.push(...gateOutcomes.filter((outcome) => outcome !== undefined));
|
|
425
792
|
// ---------------------------------------------------------- interactions
|
|
793
|
+
enterStage("interactions");
|
|
426
794
|
const interactionsStart = Date.now();
|
|
427
795
|
const declaredStates = new Set((job.interaction_states ?? []).map((state) => state.name));
|
|
428
796
|
const manifestStates = [
|
|
@@ -438,6 +806,8 @@ export async function runQaMatrix(options) {
|
|
|
438
806
|
}
|
|
439
807
|
}
|
|
440
808
|
for (const state of job.interaction_states ?? []) {
|
|
809
|
+
if (pastDeadline())
|
|
810
|
+
break;
|
|
441
811
|
const outPrefix = join(outDir, `interaction-${state.name}`);
|
|
442
812
|
const argv = [
|
|
443
813
|
...browseArgv,
|
|
@@ -489,150 +859,377 @@ export async function runQaMatrix(options) {
|
|
|
489
859
|
});
|
|
490
860
|
}
|
|
491
861
|
wall.interactions = Date.now() - interactionsStart;
|
|
492
|
-
|
|
862
|
+
stagesRun.push("interactions");
|
|
863
|
+
// -------------------------------------------------------------- capture
|
|
864
|
+
// Each context is rendered ONCE into the run's page review pack (full-page
|
|
865
|
+
// screenshot, tiles as PNG files, DOM, signature) through the same bounded
|
|
866
|
+
// pool the gates used, and its browser closes before any vision call.
|
|
493
867
|
const visual = manifest.checks.visual;
|
|
494
868
|
const cleanSoFar = blockers.length === 0 && commands.every((command) => command.outcome === "passed");
|
|
495
|
-
const
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
869
|
+
const packDir = join(outDir, PAGE_REVIEW_PACK_DIRNAME);
|
|
870
|
+
const capturedRecords = [];
|
|
871
|
+
const judgeCommand = options.reviewPackJudgeCommand?.(packDir);
|
|
872
|
+
// Gate rectangles ride along from each gate's JSON artifact so the pack's
|
|
873
|
+
// inspection plan can point a runt, a contrast miss, or a clipped element
|
|
874
|
+
// at the tiles that show it. A missing or unparseable artifact simply
|
|
875
|
+
// contributes no hits; the gate's `failures` still carry the text.
|
|
876
|
+
const gateRecords = () => commands
|
|
877
|
+
.filter((command) => command.check_id !== "plan" && command.check_id !== "review-pack")
|
|
878
|
+
.map((command) => {
|
|
879
|
+
const hits = command.artifacts.json
|
|
880
|
+
? gateHitsFromEnvelope(readEnvelope(command.artifacts.json))
|
|
881
|
+
: [];
|
|
882
|
+
return {
|
|
883
|
+
context_id: command.context_id,
|
|
884
|
+
check_id: command.check_id,
|
|
885
|
+
outcome: command.outcome,
|
|
886
|
+
failures: command.failures,
|
|
887
|
+
...(hits.length > 0 ? { hits } : {}),
|
|
888
|
+
};
|
|
889
|
+
});
|
|
890
|
+
const finalizePack = (stage, critiqueRecords, pool) => {
|
|
891
|
+
try {
|
|
892
|
+
finalizePageReviewPack({
|
|
893
|
+
packDir,
|
|
894
|
+
target: job.target,
|
|
895
|
+
...(revision.tested_revision !== undefined
|
|
896
|
+
? { tested_revision: revision.tested_revision }
|
|
897
|
+
: {}),
|
|
898
|
+
contexts: capturedRecords,
|
|
899
|
+
gates: gateRecords(),
|
|
900
|
+
critique: critiqueRecords,
|
|
901
|
+
...(pool ? { pool } : {}),
|
|
902
|
+
...(judgeCommand ? { judgeCommand } : {}),
|
|
903
|
+
createdAt: startedAtIso,
|
|
904
|
+
// The retention clock restarts at each finalize, so it ends
|
|
905
|
+
// `retention_minutes` after the judge (or after capture when the
|
|
906
|
+
// judge never runs).
|
|
907
|
+
retention: {
|
|
908
|
+
expires_at: new Date(Date.now() +
|
|
909
|
+
(job.policy?.review_pack_retention_minutes ?? PAGE_REVIEW_DEFAULT_RETENTION_MINUTES) *
|
|
910
|
+
60_000).toISOString(),
|
|
911
|
+
managed: options.reviewPackManaged === true,
|
|
912
|
+
},
|
|
913
|
+
});
|
|
914
|
+
const written = readPackManifest(packDir);
|
|
915
|
+
reviewPack = {
|
|
916
|
+
schema: PAGE_REVIEW_PACK_SCHEMA,
|
|
917
|
+
dir: packDir,
|
|
918
|
+
review: join(packDir, PAGE_REVIEW_REVIEW_FILENAME),
|
|
919
|
+
findings: join(packDir, PAGE_REVIEW_FINDINGS_FILENAME),
|
|
920
|
+
...(written.retention ? { expires_at: written.retention.expires_at } : {}),
|
|
921
|
+
...(written.size_bytes !== undefined ? { size_bytes: written.size_bytes } : {}),
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
blockers.push({
|
|
926
|
+
stage,
|
|
927
|
+
reason: `page review pack could not be finalized: ${err instanceof Error ? err.message : String(err)}`,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
// The provider is loaded once, before capture, so the capture children can
|
|
932
|
+
// clamp band height to the routed model's vision budget (what browse does
|
|
933
|
+
// for --check-critique) and the judge reuses the same instance.
|
|
934
|
+
const priorHeadlessOnly = process.env[QA_RUN_HEADLESS_ONLY_ENV];
|
|
935
|
+
const restoreHeadlessOnly = () => {
|
|
936
|
+
if (priorHeadlessOnly === undefined)
|
|
937
|
+
delete process.env[QA_RUN_HEADLESS_ONLY_ENV];
|
|
938
|
+
else
|
|
939
|
+
process.env[QA_RUN_HEADLESS_ONLY_ENV] = priorHeadlessOnly;
|
|
940
|
+
};
|
|
941
|
+
let provider;
|
|
942
|
+
const runsVisual = cleanSoFar && visual !== "none";
|
|
943
|
+
if (runsVisual) {
|
|
944
|
+
if (!job.policy?.allow_metered_critique)
|
|
945
|
+
process.env[QA_RUN_HEADLESS_ONLY_ENV] = "1";
|
|
946
|
+
try {
|
|
947
|
+
provider =
|
|
948
|
+
options.critiqueProvider ??
|
|
949
|
+
(options.critiqueProviderLoader ? await options.critiqueProviderLoader() : undefined);
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
provider = undefined;
|
|
953
|
+
log(`critique provider failed to load: ${err instanceof Error ? err.message : String(err)}`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const bandArgs = provider?.tileBudgetPx !== undefined
|
|
957
|
+
? ["--check-critique-band", String(Math.max(200, Math.min(1400, provider.tileBudgetPx)))]
|
|
958
|
+
: [];
|
|
959
|
+
const scopeArgs = visual === "full-page"
|
|
960
|
+
? []
|
|
961
|
+
: manifest.scopes.flatMap((s) => ["--review-pack-scope", s.selector]);
|
|
962
|
+
// Gate-hit rectangles ride into each context's capture child so the bands
|
|
963
|
+
// they land in are cut even past the tile cap (`hit band N` tiles). The
|
|
964
|
+
// gates ran first, so the rects are known before the page is captured.
|
|
965
|
+
const hitRectsByContext = new Map();
|
|
966
|
+
for (const gate of gateRecords()) {
|
|
967
|
+
for (const hit of gate.hits ?? []) {
|
|
968
|
+
const list = hitRectsByContext.get(gate.context_id) ?? [];
|
|
969
|
+
if (list.length >= REVIEW_PACK_HIT_RECTS_PER_CONTEXT)
|
|
970
|
+
break;
|
|
971
|
+
const r = hit.rect;
|
|
972
|
+
list.push([r.x, r.y, Math.max(0, r.width), Math.max(0, r.height)].map((n) => Math.round(n)).join(","));
|
|
973
|
+
hitRectsByContext.set(gate.context_id, list);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const hitRectArgs = (contextId) => (hitRectsByContext.get(contextId) ?? []).flatMap((spec) => ["--review-pack-hit-rect", spec]);
|
|
977
|
+
enterStage("capture");
|
|
978
|
+
const captureStart = Date.now();
|
|
979
|
+
if (runsVisual) {
|
|
980
|
+
const captureOutcomes = new Array(contexts.length);
|
|
981
|
+
const captureRecords = new Array(contexts.length);
|
|
982
|
+
await runPool(contexts, concurrency, async (ctx, index) => {
|
|
983
|
+
if (pastDeadline())
|
|
984
|
+
return;
|
|
985
|
+
const outPrefix = join(outDir, `${ctx.id}-capture`);
|
|
986
|
+
const argv = [
|
|
987
|
+
...browseArgv,
|
|
988
|
+
job.target,
|
|
989
|
+
...contextRenderArgs(ctx),
|
|
990
|
+
"--out",
|
|
991
|
+
outPrefix,
|
|
992
|
+
"--no-screenshot",
|
|
993
|
+
"--review-pack",
|
|
994
|
+
packDir,
|
|
995
|
+
"--review-pack-context",
|
|
996
|
+
ctx.id,
|
|
997
|
+
"--qa-theme",
|
|
998
|
+
ctx.theme,
|
|
999
|
+
"--qa-state",
|
|
1000
|
+
ctx.state,
|
|
1001
|
+
...(visual === "scoped" ? ["--no-review-pack-bands"] : []),
|
|
1002
|
+
...scopeArgs,
|
|
1003
|
+
...critiqueMaxTilesArgs,
|
|
1004
|
+
...bandArgs,
|
|
1005
|
+
...hitRectArgs(ctx.id),
|
|
1006
|
+
];
|
|
1007
|
+
log(`capture ${ctx.id}${scopeArgs.length > 0 ? ` [${manifest.scopes.map((s) => s.selector).join(",")}]` : ""}`);
|
|
1008
|
+
const { res, wallTimeMs } = await timedExec(argv, baseEnv);
|
|
1009
|
+
const jsonPath = `${outPrefix}.json`;
|
|
1010
|
+
const envelope = readEnvelope(jsonPath);
|
|
1011
|
+
const report = envelope?.reviewPack;
|
|
1012
|
+
const failures = [];
|
|
1013
|
+
let outcome;
|
|
1014
|
+
let record;
|
|
1015
|
+
if (!res.error && res.exitCode === 0 && envelope && report?.context_id) {
|
|
1016
|
+
try {
|
|
1017
|
+
record = readPackContext(packDir, report.context_id);
|
|
1018
|
+
outcome = "passed";
|
|
548
1019
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
provider = providerLabel(envelopeCritique);
|
|
555
|
-
for (const finding of envelopeCritique?.findings ?? []) {
|
|
556
|
-
findings.push({
|
|
557
|
-
severity: finding.severity ?? "unknown",
|
|
558
|
-
summary: finding.category
|
|
559
|
-
? `${finding.category}: ${finding.description ?? ""}`
|
|
560
|
-
: (finding.description ?? ""),
|
|
561
|
-
...(selector !== undefined ? { selector } : {}),
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
if (envelopeCritique?.outcome === "pass" && res.exitCode === 0) {
|
|
565
|
-
commandOutcome = "passed";
|
|
566
|
-
}
|
|
567
|
-
else if (envelopeCritique?.outcome === "fail") {
|
|
568
|
-
commandOutcome = "failed";
|
|
569
|
-
failures.push(`critique found ${envelopeCritique.findings?.length ?? 0} defect(s)`);
|
|
570
|
-
contextOutcome = "failed";
|
|
571
|
-
}
|
|
572
|
-
else {
|
|
573
|
-
// "skipped" (no provider / exhausted headless list) or anything
|
|
574
|
-
// unrecognized: the review did not happen, so nothing is proven.
|
|
575
|
-
commandOutcome = "unknown";
|
|
576
|
-
const detail = envelopeCritique?.error ?? "critique reported no conclusive outcome";
|
|
577
|
-
failures.push(detail);
|
|
578
|
-
blockers.push({
|
|
579
|
-
stage: "critique",
|
|
580
|
-
context_id: ctx.id,
|
|
581
|
-
reason: job.policy?.allow_metered_critique
|
|
582
|
-
? `critique did not complete: ${detail}`
|
|
583
|
-
: `critique did not complete under the headless-only policy ` +
|
|
584
|
-
`(${QA_RUN_HEADLESS_ONLY_ENV}=1; permit metered fallback with ` +
|
|
585
|
-
`policy.allow_metered_critique / --allow-metered): ${detail}`,
|
|
586
|
-
});
|
|
587
|
-
if (contextOutcome !== "failed")
|
|
588
|
-
contextOutcome = "unknown";
|
|
589
|
-
}
|
|
590
|
-
if (job.mode === "signoff") {
|
|
591
|
-
const saved = envelope.qaPlan
|
|
592
|
-
?.snapshotSaved;
|
|
593
|
-
if (saved?.path)
|
|
594
|
-
savedSnapshots.set(ctx.id, saved.path);
|
|
595
|
-
}
|
|
1020
|
+
catch (err) {
|
|
1021
|
+
outcome = "unknown";
|
|
1022
|
+
const reason = `pack context unreadable: ${err instanceof Error ? err.message : String(err)}`;
|
|
1023
|
+
failures.push(reason);
|
|
1024
|
+
blockers.push({ stage: "capture", context_id: ctx.id, reason });
|
|
596
1025
|
}
|
|
597
|
-
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
outcome = "unknown";
|
|
1029
|
+
const excerpt = execErrorExcerpt(res);
|
|
1030
|
+
const base = res.error
|
|
1031
|
+
? res.error
|
|
1032
|
+
: !envelope
|
|
1033
|
+
? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
|
|
1034
|
+
: !report?.context_id
|
|
1035
|
+
? `exit code ${res.exitCode ?? "null"}, envelope carries no reviewPack record`
|
|
1036
|
+
: `exit code ${res.exitCode ?? "null"}`;
|
|
1037
|
+
const reason = excerpt ? `${base}: ${excerpt}` : base;
|
|
1038
|
+
failures.push(reason);
|
|
1039
|
+
blockers.push({
|
|
1040
|
+
stage: "capture",
|
|
598
1041
|
context_id: ctx.id,
|
|
599
|
-
|
|
600
|
-
argv,
|
|
601
|
-
exit_code: res.exitCode,
|
|
602
|
-
outcome: commandOutcome,
|
|
603
|
-
failures,
|
|
604
|
-
artifacts: gatherArtifacts(outPrefix),
|
|
605
|
-
wall_time_ms: wallTimeMs,
|
|
1042
|
+
reason: `capture command did not complete: ${reason}`,
|
|
606
1043
|
});
|
|
607
1044
|
}
|
|
608
|
-
|
|
1045
|
+
captureOutcomes[index] = {
|
|
609
1046
|
context_id: ctx.id,
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
1047
|
+
check_id: "review-pack",
|
|
1048
|
+
argv,
|
|
1049
|
+
exit_code: res.exitCode,
|
|
1050
|
+
outcome,
|
|
1051
|
+
failures,
|
|
1052
|
+
artifacts: gatherArtifacts(outPrefix),
|
|
1053
|
+
wall_time_ms: wallTimeMs,
|
|
1054
|
+
};
|
|
1055
|
+
captureRecords[index] = record;
|
|
1056
|
+
});
|
|
1057
|
+
commands.push(...captureOutcomes.filter((outcome) => outcome !== undefined));
|
|
1058
|
+
capturedRecords.push(...captureRecords.filter((record) => Boolean(record)));
|
|
1059
|
+
// The pack is readable from here on even if the judge never runs.
|
|
1060
|
+
if (capturedRecords.length > 0)
|
|
1061
|
+
finalizePack("capture", null);
|
|
1062
|
+
}
|
|
1063
|
+
wall.capture = Date.now() - captureStart;
|
|
1064
|
+
if (runsVisual)
|
|
1065
|
+
stagesRun.push("capture");
|
|
1066
|
+
// -------------------------------------------------------------- critique
|
|
1067
|
+
// One pool of vision calls across every captured context, from disk. No
|
|
1068
|
+
// browser is open during this stage.
|
|
1069
|
+
enterStage("critique");
|
|
1070
|
+
const critiqueStart = Date.now();
|
|
1071
|
+
const judgedById = new Map();
|
|
1072
|
+
if (runsVisual && capturedRecords.length > 0 && !pastDeadline()) {
|
|
1073
|
+
const judged = await judgePageReviewPack({
|
|
1074
|
+
packDir,
|
|
1075
|
+
provider,
|
|
1076
|
+
rubric: DEFAULT_CRITIQUE_RUBRIC,
|
|
1077
|
+
...(job.policy?.critique_pool !== undefined ? { concurrency: job.policy.critique_pool } : {}),
|
|
1078
|
+
contextIds: capturedRecords.map((record) => record.id),
|
|
1079
|
+
...(manifest.baseline_source !== "none"
|
|
1080
|
+
? {
|
|
1081
|
+
reuse: {
|
|
1082
|
+
target: job.target,
|
|
1083
|
+
...(options.snapshotStore ? { store: options.snapshotStore } : {}),
|
|
1084
|
+
},
|
|
1085
|
+
}
|
|
1086
|
+
: {}),
|
|
1087
|
+
deadlineAt: startedAt + runDeadlineMs,
|
|
1088
|
+
onLog: log,
|
|
1089
|
+
});
|
|
1090
|
+
const latency = critiqueLatency(judged.provider_meta);
|
|
1091
|
+
critiquePool = {
|
|
1092
|
+
concurrency: judged.pool.concurrency,
|
|
1093
|
+
tiles_total: judged.tiles_total,
|
|
1094
|
+
tiles_reviewed: judged.tiles_reviewed,
|
|
1095
|
+
tiles_reused: judged.tiles_reused,
|
|
1096
|
+
wall_time_ms: judged.pool.wall_time_ms,
|
|
1097
|
+
provider: judged.pool.provider,
|
|
1098
|
+
...(latency ? { latency_ms: latency } : {}),
|
|
1099
|
+
};
|
|
1100
|
+
for (const row of judged.contexts) {
|
|
1101
|
+
judgedById.set(row.context_id, row);
|
|
1102
|
+
let contextOutcome = row.outcome === "pass" ? "passed" : row.outcome === "fail" ? "failed" : "unknown";
|
|
1103
|
+
if (row.outcome === "skipped") {
|
|
1104
|
+
const detail = row.error ?? "critique reported no conclusive outcome";
|
|
1105
|
+
blockers.push({
|
|
1106
|
+
stage: "critique",
|
|
1107
|
+
context_id: row.context_id,
|
|
1108
|
+
reason: job.policy?.allow_metered_critique
|
|
1109
|
+
? `critique did not complete: ${detail}`
|
|
1110
|
+
: `critique did not complete under the headless-only policy ` +
|
|
1111
|
+
`(${QA_RUN_HEADLESS_ONLY_ENV}=1; permit metered fallback with ` +
|
|
1112
|
+
`policy.allow_metered_critique / --allow-metered): ${detail}`,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
else if (row.outcome === "incomplete") {
|
|
1116
|
+
blockers.push({
|
|
1117
|
+
stage: "critique",
|
|
1118
|
+
context_id: row.context_id,
|
|
1119
|
+
reason: `judge did not review ${row.tiles_unjudged} of ${row.tiles_total} tile(s) before the ` +
|
|
1120
|
+
"run deadline; nothing is proven for them",
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
// A capped capture holds the top of the page and nothing below it.
|
|
1124
|
+
// Signoff cannot rest on that; review mode keeps the row honest via
|
|
1125
|
+
// `coverage` and leaves the verdict to the tiles that were seen.
|
|
1126
|
+
if (job.mode === "signoff" && row.coverage.capped && contextOutcome !== "failed") {
|
|
1127
|
+
contextOutcome = "unknown";
|
|
1128
|
+
blockers.push({
|
|
1129
|
+
stage: "critique",
|
|
1130
|
+
context_id: row.context_id,
|
|
1131
|
+
reason: `critique coverage capped: ${row.coverage.bands_reviewed} of ${row.coverage.bands_total} bands ` +
|
|
1132
|
+
`reviewed (${row.coverage.reviewed_height_px} of ${row.coverage.page_height_px} px); raise ` +
|
|
1133
|
+
"policy.critique_max_tiles to review the whole page in signoff mode",
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
const scopeById = new Map(row.record.tiles.map((tile) => [tile.id, tile.scope]));
|
|
1137
|
+
critique.push({
|
|
1138
|
+
context_id: row.context_id,
|
|
1139
|
+
provider: row.provider,
|
|
1140
|
+
tiles_total: row.tiles_total,
|
|
1141
|
+
tiles_reviewed: row.tiles_reviewed,
|
|
1142
|
+
tiles_reused: row.tiles_reused,
|
|
614
1143
|
outcome: contextOutcome,
|
|
615
|
-
findings
|
|
1144
|
+
findings: row.findings.map((finding) => {
|
|
1145
|
+
const selector = scopeById.get(finding.tile_id);
|
|
1146
|
+
return {
|
|
1147
|
+
severity: finding.severity,
|
|
1148
|
+
summary: `${finding.category}: ${finding.description}`,
|
|
1149
|
+
tile: `${row.context_id}/${finding.tile_id}`,
|
|
1150
|
+
...(selector !== undefined ? { selector } : {}),
|
|
1151
|
+
};
|
|
1152
|
+
}),
|
|
1153
|
+
coverage: row.coverage,
|
|
616
1154
|
});
|
|
617
|
-
|
|
1155
|
+
}
|
|
1156
|
+
finalizePack("critique", toCritiqueRecords(judged), judged.pool);
|
|
1157
|
+
}
|
|
1158
|
+
if (runsVisual)
|
|
1159
|
+
restoreHeadlessOnly();
|
|
1160
|
+
wall.critique = Date.now() - critiqueStart;
|
|
1161
|
+
if (runsVisual)
|
|
1162
|
+
stagesRun.push("critique");
|
|
1163
|
+
// -------------------------------------------------------------- snapshot
|
|
1164
|
+
// Signoff persists each context's baseline from the pack's own files (no
|
|
1165
|
+
// browser). The critique rides along only when the whole tile set was
|
|
1166
|
+
// freshly judged, uncapped, and unscoped — a partial or scoped review must
|
|
1167
|
+
// never become the next baseline's finding record. When the manifest
|
|
1168
|
+
// required no visual pass, a dedicated browse --qa-snapshot pass still runs.
|
|
1169
|
+
enterStage("snapshot");
|
|
1170
|
+
const snapshotStart = Date.now();
|
|
1171
|
+
const savedSnapshots = new Map();
|
|
1172
|
+
if (job.mode === "signoff" && runsVisual) {
|
|
1173
|
+
for (const record of capturedRecords) {
|
|
1174
|
+
if (pastDeadline())
|
|
1175
|
+
break;
|
|
1176
|
+
const row = judgedById.get(record.id);
|
|
1177
|
+
const persistCritique = row !== undefined &&
|
|
1178
|
+
(row.outcome === "pass" || row.outcome === "fail") &&
|
|
1179
|
+
!record.coverage.capped &&
|
|
1180
|
+
row.tiles_reused === 0 &&
|
|
1181
|
+
row.tiles_unjudged === 0 &&
|
|
1182
|
+
record.scopes.length === 0;
|
|
1183
|
+
const persisted = persistCritique && row
|
|
1184
|
+
? {
|
|
1185
|
+
contract_version: QA_CRITIQUE_CONTRACT_VERSION,
|
|
1186
|
+
rubric_digest: rubricDigest(DEFAULT_CRITIQUE_RUBRIC),
|
|
1187
|
+
outcome: row.outcome,
|
|
1188
|
+
findings: row.findings.map(({ tile_id: _tileId, ...finding }) => finding),
|
|
1189
|
+
tiles: record.tiles.map((tile) => ({
|
|
1190
|
+
index: tile.index,
|
|
1191
|
+
label: tile.label,
|
|
1192
|
+
x: tile.x,
|
|
1193
|
+
scrollY: tile.scrollY,
|
|
1194
|
+
width: tile.width,
|
|
1195
|
+
height: tile.height,
|
|
1196
|
+
})),
|
|
1197
|
+
}
|
|
1198
|
+
: undefined;
|
|
1199
|
+
try {
|
|
1200
|
+
const saved = saveQaSnapshot(job.target, { viewport: record.viewport, theme: record.theme, state: record.state }, {
|
|
1201
|
+
signature: readPackSignature(packDir, record),
|
|
1202
|
+
domHtml: readPackDom(packDir, record),
|
|
1203
|
+
screenshotPng: readPackFullPage(packDir, record),
|
|
1204
|
+
...(persisted ? { critique: persisted } : {}),
|
|
1205
|
+
}, options.snapshotStore ?? {});
|
|
1206
|
+
savedSnapshots.set(record.id, saved.path);
|
|
1207
|
+
log(`snapshot ${record.id}: saved${persisted ? " with critique" : ""}`);
|
|
1208
|
+
}
|
|
1209
|
+
catch (err) {
|
|
618
1210
|
blockers.push({
|
|
619
1211
|
stage: "snapshot",
|
|
620
|
-
context_id:
|
|
1212
|
+
context_id: record.id,
|
|
1213
|
+
reason: `snapshot could not be persisted from the pack: ${err instanceof Error ? err.message : String(err)}`,
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
for (const row of critique) {
|
|
1218
|
+
if (row.outcome === "passed" && !savedSnapshots.has(row.context_id)) {
|
|
1219
|
+
blockers.push({
|
|
1220
|
+
stage: "snapshot",
|
|
1221
|
+
context_id: row.context_id,
|
|
621
1222
|
reason: "signoff critique passed but no QA snapshot was persisted for the context",
|
|
622
1223
|
});
|
|
623
1224
|
}
|
|
624
1225
|
}
|
|
625
1226
|
}
|
|
626
|
-
|
|
627
|
-
// -------------------------------------------------------------- snapshot
|
|
628
|
-
// Critique invocations carry --qa-snapshot in signoff mode. When the
|
|
629
|
-
// manifest requires no visual pass (visual === "none"), a passing signoff
|
|
630
|
-
// still needs its baseline persisted, so a dedicated snapshot pass runs.
|
|
631
|
-
const snapshotStart = Date.now();
|
|
632
|
-
if (job.mode === "signoff" && visual === "none" && blockers.length === 0) {
|
|
1227
|
+
else if (job.mode === "signoff" && visual === "none" && blockers.length === 0) {
|
|
633
1228
|
const stillClean = commands.every((command) => command.outcome === "passed");
|
|
634
1229
|
if (stillClean) {
|
|
635
1230
|
for (const ctx of contexts) {
|
|
1231
|
+
if (pastDeadline())
|
|
1232
|
+
break;
|
|
636
1233
|
const outPrefix = join(outDir, `${ctx.id}-snapshot`);
|
|
637
1234
|
const argv = [
|
|
638
1235
|
...browseArgv,
|
|
@@ -675,6 +1272,8 @@ export async function runQaMatrix(options) {
|
|
|
675
1272
|
}
|
|
676
1273
|
}
|
|
677
1274
|
wall.snapshot = Date.now() - snapshotStart;
|
|
1275
|
+
if (job.mode === "signoff")
|
|
1276
|
+
stagesRun.push("snapshot");
|
|
678
1277
|
if (job.mode === "signoff") {
|
|
679
1278
|
const allSaved = contexts.length > 0 && contexts.every((ctx) => savedSnapshots.has(ctx.id));
|
|
680
1279
|
const lastPath = [...savedSnapshots.values()].pop();
|