harnery 0.36.0 → 0.37.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.js +4 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +87 -46
- package/dist/commands/artifacts.d.ts.map +1 -1
- package/dist/commands/artifacts.js +26 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +11 -0
- package/dist/commands/diagnostics.d.ts +4 -0
- package/dist/commands/diagnostics.d.ts.map +1 -0
- package/dist/commands/diagnostics.js +229 -0
- package/dist/commands/files.d.ts +12 -0
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +54 -1
- package/dist/commands/governor.d.ts.map +1 -1
- package/dist/commands/governor.js +4 -0
- package/dist/commands/ledger-v3.d.ts.map +1 -1
- package/dist/commands/ledger-v3.js +71 -2
- package/dist/commands/qa-run.d.ts +4 -0
- package/dist/commands/qa-run.d.ts.map +1 -0
- package/dist/commands/qa-run.js +141 -0
- package/dist/commands/resources.d.ts +4 -0
- package/dist/commands/resources.d.ts.map +1 -0
- package/dist/commands/resources.js +29 -0
- package/dist/commands/supervisor.d.ts +4 -0
- package/dist/commands/supervisor.d.ts.map +1 -0
- package/dist/commands/supervisor.js +101 -0
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +17 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +4 -0
- package/dist/core/agents/cli.js +7 -3
- package/dist/core/agents/health.d.ts +17 -0
- package/dist/core/agents/health.d.ts.map +1 -0
- package/dist/core/agents/health.js +109 -0
- package/dist/core/agents/mailbox.d.ts +92 -0
- package/dist/core/agents/mailbox.d.ts.map +1 -0
- package/dist/core/agents/mailbox.js +325 -0
- package/dist/core/agents/reconcile-coordination-v3.d.ts +44 -0
- package/dist/core/agents/reconcile-coordination-v3.d.ts.map +1 -0
- package/dist/core/agents/reconcile-coordination-v3.js +58 -0
- package/dist/core/agents/render/prompt-context.d.ts +5 -0
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +34 -13
- package/dist/core/agents/render/session-context.d.ts +8 -0
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +34 -3
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +50 -1
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +6 -10
- package/dist/core/agents/session-finalizer-v3.js +4 -1
- package/dist/core/artifacts/index.d.ts +26 -1
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +146 -6
- package/dist/core/config.d.ts +12 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +43 -0
- package/dist/core/diagnostics/advice.d.ts +9 -0
- package/dist/core/diagnostics/advice.d.ts.map +1 -0
- package/dist/core/diagnostics/advice.js +112 -0
- package/dist/core/diagnostics/bundle.d.ts +35 -0
- package/dist/core/diagnostics/bundle.d.ts.map +1 -0
- package/dist/core/diagnostics/bundle.js +874 -0
- package/dist/core/diagnostics/comparison.d.ts +5 -0
- package/dist/core/diagnostics/comparison.d.ts.map +1 -0
- package/dist/core/diagnostics/comparison.js +444 -0
- package/dist/core/diagnostics/contract.d.ts +245 -0
- package/dist/core/diagnostics/contract.d.ts.map +1 -0
- package/dist/core/diagnostics/contract.js +18 -0
- package/dist/core/diagnostics/identity.d.ts +3 -0
- package/dist/core/diagnostics/identity.d.ts.map +1 -0
- package/dist/core/diagnostics/identity.js +10 -0
- package/dist/core/diagnostics/index.d.ts +8 -0
- package/dist/core/diagnostics/index.d.ts.map +1 -0
- package/dist/core/diagnostics/index.js +7 -0
- package/dist/core/diagnostics/replay.d.ts +8 -0
- package/dist/core/diagnostics/replay.d.ts.map +1 -0
- package/dist/core/diagnostics/replay.js +213 -0
- package/dist/core/diagnostics/sanitize.d.ts +9 -0
- package/dist/core/diagnostics/sanitize.d.ts.map +1 -0
- package/dist/core/diagnostics/sanitize.js +84 -0
- package/dist/core/events/legacy-storage/compression.d.ts +13 -0
- package/dist/core/events/legacy-storage/compression.d.ts.map +1 -0
- package/dist/core/events/legacy-storage/compression.js +107 -0
- package/dist/core/events/legacy-storage/index.d.ts +1 -0
- package/dist/core/events/legacy-storage/index.d.ts.map +1 -1
- package/dist/core/events/legacy-storage/index.js +1 -0
- package/dist/core/events/v3/archive-retention.d.ts +33 -0
- package/dist/core/events/v3/archive-retention.d.ts.map +1 -0
- package/dist/core/events/v3/archive-retention.js +195 -0
- package/dist/core/events/v3/capabilities.d.ts +1 -1
- package/dist/core/events/v3/capabilities.d.ts.map +1 -1
- package/dist/core/events/v3/capabilities.js +1 -0
- package/dist/core/events/v3/index.d.ts +1 -0
- package/dist/core/events/v3/index.d.ts.map +1 -1
- package/dist/core/events/v3/index.js +1 -0
- package/dist/core/events/v3/live-routing.d.ts.map +1 -1
- package/dist/core/events/v3/live-routing.js +1 -0
- package/dist/core/events/v3/producers/hook-base.d.ts +1 -1
- package/dist/core/events/v3/producers/hook-base.d.ts.map +1 -1
- package/dist/core/events/v3/producers/hook-base.js +13 -2
- package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
- package/dist/core/events/v3/producers/intake.js +1 -0
- package/dist/core/events/v3/producers/recorder.d.ts +16 -0
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +88 -1
- package/dist/core/hooks/adapter/output.d.ts +8 -0
- package/dist/core/hooks/adapter/output.d.ts.map +1 -1
- package/dist/core/hooks/adapter/output.js +11 -1
- package/dist/core/hooks/cli.js +183 -192
- package/dist/core/hooks/health.d.ts +59 -0
- package/dist/core/hooks/health.d.ts.map +1 -0
- package/dist/core/hooks/health.js +84 -0
- package/dist/core/resources/contract.d.ts +90 -0
- package/dist/core/resources/contract.d.ts.map +1 -0
- package/dist/core/resources/contract.js +2 -0
- package/dist/core/resources/index.d.ts +6 -0
- package/dist/core/resources/index.d.ts.map +1 -0
- package/dist/core/resources/index.js +5 -0
- package/dist/core/resources/sampler.d.ts +27 -0
- package/dist/core/resources/sampler.d.ts.map +1 -0
- package/dist/core/resources/sampler.js +357 -0
- package/dist/core/resources/service-status.d.ts +3 -0
- package/dist/core/resources/service-status.d.ts.map +1 -0
- package/dist/core/resources/service-status.js +48 -0
- package/dist/core/resources/service.d.ts +26 -0
- package/dist/core/resources/service.d.ts.map +1 -0
- package/dist/core/resources/service.js +292 -0
- package/dist/core/resources/storage.d.ts +13 -0
- package/dist/core/resources/storage.d.ts.map +1 -0
- package/dist/core/resources/storage.js +30 -0
- package/dist/core/storage/atomic-json.d.ts +3 -0
- package/dist/core/storage/atomic-json.d.ts.map +1 -0
- package/dist/core/storage/atomic-json.js +15 -0
- package/dist/core/storage/builtins.d.ts.map +1 -1
- package/dist/core/storage/builtins.js +45 -6
- package/dist/core/storage/logger.d.ts +2 -0
- package/dist/core/storage/logger.d.ts.map +1 -1
- package/dist/core/storage/logger.js +2 -0
- package/dist/core/storage/query.d.ts +14 -0
- package/dist/core/storage/query.d.ts.map +1 -1
- package/dist/core/storage/query.js +51 -0
- package/dist/core/supervisor/activity.d.ts +4 -0
- package/dist/core/supervisor/activity.d.ts.map +1 -0
- package/dist/core/supervisor/activity.js +141 -0
- package/dist/core/supervisor/contract.d.ts +268 -0
- package/dist/core/supervisor/contract.d.ts.map +1 -0
- package/dist/core/supervisor/contract.js +35 -0
- package/dist/core/supervisor/explanations.d.ts +4 -0
- package/dist/core/supervisor/explanations.d.ts.map +1 -0
- package/dist/core/supervisor/explanations.js +71 -0
- package/dist/core/supervisor/findings.d.ts +20 -0
- package/dist/core/supervisor/findings.d.ts.map +1 -0
- package/dist/core/supervisor/findings.js +399 -0
- package/dist/core/supervisor/history.d.ts +9 -0
- package/dist/core/supervisor/history.d.ts.map +1 -0
- package/dist/core/supervisor/history.js +46 -0
- package/dist/core/supervisor/hook-health-alerts.d.ts +14 -0
- package/dist/core/supervisor/hook-health-alerts.d.ts.map +1 -0
- package/dist/core/supervisor/hook-health-alerts.js +70 -0
- package/dist/core/supervisor/hook-health-storage.d.ts +5 -0
- package/dist/core/supervisor/hook-health-storage.d.ts.map +1 -0
- package/dist/core/supervisor/hook-health-storage.js +24 -0
- package/dist/core/supervisor/hook-health.d.ts +72 -0
- package/dist/core/supervisor/hook-health.d.ts.map +1 -0
- package/dist/core/supervisor/hook-health.js +203 -0
- package/dist/core/supervisor/hooks.d.ts +10 -0
- package/dist/core/supervisor/hooks.d.ts.map +1 -0
- package/dist/core/supervisor/hooks.js +28 -0
- package/dist/core/supervisor/index.d.ts +15 -0
- package/dist/core/supervisor/index.d.ts.map +1 -0
- package/dist/core/supervisor/index.js +14 -0
- package/dist/core/supervisor/log-feed.d.ts +9 -0
- package/dist/core/supervisor/log-feed.d.ts.map +1 -0
- package/dist/core/supervisor/log-feed.js +103 -0
- package/dist/core/supervisor/service.d.ts +29 -0
- package/dist/core/supervisor/service.d.ts.map +1 -0
- package/dist/core/supervisor/service.js +450 -0
- package/dist/core/supervisor/services.d.ts +11 -0
- package/dist/core/supervisor/services.d.ts.map +1 -0
- package/dist/core/supervisor/services.js +155 -0
- package/dist/core/supervisor/status.d.ts +3 -0
- package/dist/core/supervisor/status.d.ts.map +1 -0
- package/dist/core/supervisor/status.js +48 -0
- package/dist/core/supervisor/storage.d.ts +30 -0
- package/dist/core/supervisor/storage.d.ts.map +1 -0
- package/dist/core/supervisor/storage.js +73 -0
- package/dist/core/supervisor/timeline.d.ts +3 -0
- package/dist/core/supervisor/timeline.d.ts.map +1 -0
- package/dist/core/supervisor/timeline.js +87 -0
- package/dist/core/workflow/admission.d.ts +23 -0
- package/dist/core/workflow/admission.d.ts.map +1 -0
- package/dist/core/workflow/admission.js +136 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +77 -2
- package/dist/core/workflow/index.d.ts +3 -2
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +2 -1
- package/dist/core/workflow/proof.d.ts +2 -1
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +39 -1
- package/dist/core/workflow/run-state.d.ts +3 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +8 -1
- package/dist/core/workflow/types.d.ts +33 -1
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/types.js +2 -1
- package/dist/lib/browser/client.d.ts +5 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +1 -0
- package/dist/lib/browser/index.d.ts +2 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +2 -0
- package/dist/lib/browser/qa-run-contracts.d.ts +173 -0
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -0
- package/dist/lib/browser/qa-run-contracts.js +226 -0
- package/dist/lib/browser/qa-run.d.ts +51 -0
- package/dist/lib/browser/qa-run.d.ts.map +1 -0
- package/dist/lib/browser/qa-run.js +684 -0
- package/dist/lib/coord-root-id.d.ts +9 -0
- package/dist/lib/coord-root-id.d.ts.map +1 -0
- package/dist/lib/coord-root-id.js +17 -0
- package/dist/lib/instructions/apply.d.ts +3 -1
- package/dist/lib/instructions/apply.d.ts.map +1 -1
- package/dist/lib/instructions/apply.js +74 -3
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +6 -0
- package/package.json +12 -1
- package/src/commander.ts +26 -0
- package/src/commands/agents.ts +102 -49
- package/src/commands/artifacts.ts +40 -2
- package/src/commands/browse.ts +15 -0
- package/src/commands/diagnostics.ts +271 -0
- package/src/commands/files.ts +102 -2
- package/src/commands/governor.ts +8 -0
- package/src/commands/ledger-v3.ts +82 -0
- package/src/commands/qa-run.ts +184 -0
- package/src/commands/resources.ts +38 -0
- package/src/commands/supervisor.ts +141 -0
- package/src/commands/web.ts +19 -0
- package/src/commands/workflow.ts +8 -0
- package/src/core/agents/cli.ts +7 -3
- package/src/core/agents/health.ts +142 -0
- package/src/core/agents/mailbox.ts +373 -0
- package/src/core/agents/reconcile-coordination-v3.ts +77 -0
- package/src/core/agents/render/prompt-context.ts +42 -12
- package/src/core/agents/render/session-context.ts +37 -3
- package/src/core/agents/rules/commit-conflict.ts +58 -1
- package/src/core/agents/rules/stop-hook.ts +6 -12
- package/src/core/agents/session-finalizer-v3.ts +4 -1
- package/src/core/artifacts/index.ts +196 -10
- package/src/core/config.ts +111 -2
- package/src/core/diagnostics/advice.ts +147 -0
- package/src/core/diagnostics/bundle.ts +1142 -0
- package/src/core/diagnostics/comparison.ts +565 -0
- package/src/core/diagnostics/contract.ts +311 -0
- package/src/core/diagnostics/identity.ts +11 -0
- package/src/core/diagnostics/index.ts +7 -0
- package/src/core/diagnostics/replay.ts +314 -0
- package/src/core/diagnostics/sanitize.ts +97 -0
- package/src/core/events/legacy-storage/compression.ts +130 -0
- package/src/core/events/legacy-storage/index.ts +1 -0
- package/src/core/events/v3/archive-retention.ts +251 -0
- package/src/core/events/v3/capabilities.ts +2 -0
- package/src/core/events/v3/index.ts +9 -0
- package/src/core/events/v3/live-routing.ts +1 -0
- package/src/core/events/v3/producers/hook-base.ts +14 -2
- package/src/core/events/v3/producers/intake.ts +1 -0
- package/src/core/events/v3/producers/recorder.ts +110 -1
- package/src/core/hooks/adapter/output.ts +12 -1
- package/src/core/hooks/cli.ts +192 -215
- package/src/core/hooks/health.ts +146 -0
- package/src/core/resources/contract.ts +98 -0
- package/src/core/resources/index.ts +5 -0
- package/src/core/resources/sampler.ts +458 -0
- package/src/core/resources/service-status.ts +62 -0
- package/src/core/resources/service.ts +337 -0
- package/src/core/resources/storage.ts +44 -0
- package/src/core/storage/atomic-json.ts +15 -0
- package/src/core/storage/builtins.ts +62 -6
- package/src/core/storage/logger.ts +2 -0
- package/src/core/storage/query.ts +58 -0
- package/src/core/supervisor/activity.ts +193 -0
- package/src/core/supervisor/contract.ts +312 -0
- package/src/core/supervisor/explanations.ts +104 -0
- package/src/core/supervisor/findings.ts +788 -0
- package/src/core/supervisor/history.ts +61 -0
- package/src/core/supervisor/hook-health-alerts.ts +108 -0
- package/src/core/supervisor/hook-health-storage.ts +25 -0
- package/src/core/supervisor/hook-health.ts +301 -0
- package/src/core/supervisor/hooks.ts +35 -0
- package/src/core/supervisor/index.ts +14 -0
- package/src/core/supervisor/log-feed.ts +131 -0
- package/src/core/supervisor/service.ts +511 -0
- package/src/core/supervisor/services.ts +203 -0
- package/src/core/supervisor/status.ts +59 -0
- package/src/core/supervisor/storage.ts +126 -0
- package/src/core/supervisor/timeline.ts +133 -0
- package/src/core/workflow/admission.ts +215 -0
- package/src/core/workflow/engine.ts +93 -1
- package/src/core/workflow/index.ts +10 -0
- package/src/core/workflow/proof.ts +54 -1
- package/src/core/workflow/run-state.ts +13 -1
- package/src/core/workflow/types.ts +39 -1
- package/src/lib/browser/client.ts +6 -0
- package/src/lib/browser/index.ts +29 -0
- package/src/lib/browser/qa-run-contracts.ts +380 -0
- package/src/lib/browser/qa-run.ts +784 -0
- package/src/lib/coord-root-id.ts +23 -0
- package/src/lib/instructions/apply.ts +81 -2
- package/src/lib/instructions/templates.ts +6 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
// One-command page-QA matrix runner: planner, deterministic gates per
|
|
2
|
+
// context, interaction assertions, manifest-required critique, and the QA
|
|
3
|
+
// snapshot — executed as child `browse` processes through a bounded pool, and
|
|
4
|
+
// reported as one fail-closed machine-readable result.
|
|
5
|
+
//
|
|
6
|
+
// The runner never builds a shell string: every child is an argv array handed
|
|
7
|
+
// to an injectable exec function (default: node:child_process execFile with a
|
|
8
|
+
// closed stdin, bounded output, and an explicit timeout). Every child that
|
|
9
|
+
// fails, times out, or dies silently becomes a named blocker or a failed
|
|
10
|
+
// outcome in the result — nothing is filtered away.
|
|
11
|
+
//
|
|
12
|
+
// Toolkit tier: this module must not import src/core (layering check).
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { computeVerdict, mergeCoverage, QA_RUN_RESULT_SCHEMA_VERSION, } from "./qa-run-contracts.js";
|
|
17
|
+
/** Set on critique children unless the job permits metered critique: the
|
|
18
|
+
* host's critique provider must stay on subscription-backed headless
|
|
19
|
+
* harnesses and surface exhaustion instead of falling back to a metered API. */
|
|
20
|
+
export const QA_RUN_HEADLESS_ONLY_ENV = "HARNERY_CRITIQUE_HEADLESS_ONLY";
|
|
21
|
+
/** Result document written into the run's output directory. */
|
|
22
|
+
export const QA_RUN_RESULT_FILENAME = "page-qa-result.json";
|
|
23
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
24
|
+
const DEFAULT_COMMAND_CONCURRENCY = 2;
|
|
25
|
+
const EXEC_MAX_BUFFER = 16 * 1024 * 1024;
|
|
26
|
+
/** The planner's deterministic vocabulary is an executable contract, not a
|
|
27
|
+
* report-only hint. A job may add checks, but these arguments always run for
|
|
28
|
+
* every manifest context. `console` is enforced from the browse envelope
|
|
29
|
+
* because browse captures diagnostics without a separate fail flag. */
|
|
30
|
+
const MANIFEST_DETERMINISTIC_ARGS = {
|
|
31
|
+
console: [],
|
|
32
|
+
overflow: ["--check-overflow", "--check-overflow-fail"],
|
|
33
|
+
truncation: ["--check-truncation", "--check-truncation-fail"],
|
|
34
|
+
placeholder: ["--check-placeholder", "--check-placeholder-fail"],
|
|
35
|
+
};
|
|
36
|
+
/** Default executor: execFile with argv arrays only (never a shell string),
|
|
37
|
+
* closed stdin, bounded output buffers, and the policy timeout. */
|
|
38
|
+
export const defaultQaRunExec = (argv, options) => new Promise((resolvePromise) => {
|
|
39
|
+
const [command, ...args] = argv;
|
|
40
|
+
if (!command) {
|
|
41
|
+
resolvePromise({ exitCode: null, stdout: "", stderr: "", error: "empty argv" });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const child = execFile(command, args, {
|
|
45
|
+
timeout: options.timeoutMs,
|
|
46
|
+
maxBuffer: EXEC_MAX_BUFFER,
|
|
47
|
+
env: options.env,
|
|
48
|
+
killSignal: "SIGTERM",
|
|
49
|
+
}, (err, stdout, stderr) => {
|
|
50
|
+
const out = String(stdout ?? "");
|
|
51
|
+
const errOut = String(stderr ?? "");
|
|
52
|
+
if (!err) {
|
|
53
|
+
resolvePromise({ exitCode: 0, stdout: out, stderr: errOut });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const failure = err;
|
|
57
|
+
if (typeof failure.code === "number") {
|
|
58
|
+
// Completed with a nonzero exit code: a real outcome, not an error.
|
|
59
|
+
resolvePromise({ exitCode: failure.code, stdout: out, stderr: errOut });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const reason = failure.killed || failure.signal
|
|
63
|
+
? `killed by ${failure.signal ?? "signal"} (timeout ${options.timeoutMs}ms)`
|
|
64
|
+
: failure.message || "spawn failed";
|
|
65
|
+
resolvePromise({ exitCode: null, stdout: out, stderr: errOut, error: reason });
|
|
66
|
+
});
|
|
67
|
+
child.stdin?.end();
|
|
68
|
+
});
|
|
69
|
+
/** One-line tail excerpt of a failed child's output for blockers and
|
|
70
|
+
* failures, preferring stderr and falling back to stdout (browse prints its
|
|
71
|
+
* JSON error envelope there). Without this, a blocker like "planner exited 1"
|
|
72
|
+
* gives the reader nothing to act on. */
|
|
73
|
+
function execErrorExcerpt(res, maxLength = 240) {
|
|
74
|
+
const source = res.stderr.trim().length > 0 ? res.stderr : res.stdout;
|
|
75
|
+
const flat = source.trim().replace(/\s+/g, " ");
|
|
76
|
+
if (flat.length === 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
return flat.length > maxLength ? `…${flat.slice(-maxLength)}` : flat;
|
|
79
|
+
}
|
|
80
|
+
/** Fixed-size promise pool: at most `limit` workers in flight, items claimed
|
|
81
|
+
* in order, results written by index so completion order never matters. */
|
|
82
|
+
async function runPool(items, limit, worker) {
|
|
83
|
+
let next = 0;
|
|
84
|
+
const lanes = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
85
|
+
while (true) {
|
|
86
|
+
const index = next;
|
|
87
|
+
next += 1;
|
|
88
|
+
if (index >= items.length)
|
|
89
|
+
return;
|
|
90
|
+
const item = items[index];
|
|
91
|
+
await worker(item, index);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
await Promise.all(lanes);
|
|
95
|
+
}
|
|
96
|
+
function readEnvelope(jsonPath) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(readFileSync(jsonPath, "utf8"));
|
|
99
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
100
|
+
return parsed;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Best-effort: a missing or unparseable artifact is handled by the caller.
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
function gatherArtifacts(outPrefix) {
|
|
109
|
+
const artifacts = {};
|
|
110
|
+
const png = `${outPrefix}.png`;
|
|
111
|
+
const html = `${outPrefix}.html`;
|
|
112
|
+
const json = `${outPrefix}.json`;
|
|
113
|
+
if (existsSync(png))
|
|
114
|
+
artifacts.png = png;
|
|
115
|
+
if (existsSync(html))
|
|
116
|
+
artifacts.html = html;
|
|
117
|
+
if (existsSync(json))
|
|
118
|
+
artifacts.json = json;
|
|
119
|
+
return artifacts;
|
|
120
|
+
}
|
|
121
|
+
/** Best-effort extraction of failing-check details from a browse JSON
|
|
122
|
+
* envelope. The exit-code entry the caller records is the guaranteed
|
|
123
|
+
* minimum; these lines add human-readable specifics when parseable. */
|
|
124
|
+
function parseGateFailures(envelope) {
|
|
125
|
+
if (!envelope)
|
|
126
|
+
return [];
|
|
127
|
+
const failures = [];
|
|
128
|
+
const overflow = envelope.overflow;
|
|
129
|
+
if (overflow?.hasHorizontalOverflow) {
|
|
130
|
+
failures.push(`overflow: +${overflow.overflowPx ?? "?"}px horizontal overflow`);
|
|
131
|
+
}
|
|
132
|
+
const asserts = envelope.asserts;
|
|
133
|
+
for (const entry of asserts ?? []) {
|
|
134
|
+
if (entry.outcome === "fail") {
|
|
135
|
+
failures.push(`assert ${entry.op ?? "?"} ${entry.selector ?? "?"} → "${entry.actual ?? ""}"`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const visibility = envelope.visibility;
|
|
139
|
+
for (const entry of visibility ?? []) {
|
|
140
|
+
if (entry.found === false)
|
|
141
|
+
failures.push(`visibility ${entry.selector ?? "?"}: not found`);
|
|
142
|
+
else if (entry.cssVisible === false) {
|
|
143
|
+
failures.push(`visibility ${entry.selector ?? "?"}: CSS-hidden`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const critique = envelope.critique;
|
|
147
|
+
if (critique?.outcome === "fail") {
|
|
148
|
+
for (const finding of critique.findings ?? []) {
|
|
149
|
+
failures.push(`critique [${finding.severity ?? "?"}]: ${finding.description ?? ""}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return failures;
|
|
153
|
+
}
|
|
154
|
+
function diagnosticSummary(entry) {
|
|
155
|
+
if (typeof entry === "string")
|
|
156
|
+
return entry;
|
|
157
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
158
|
+
const record = entry;
|
|
159
|
+
for (const key of ["text", "message", "errorText", "failure", "url"]) {
|
|
160
|
+
const value = record[key];
|
|
161
|
+
if (typeof value === "string" && value.length > 0)
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return JSON.stringify(entry);
|
|
166
|
+
}
|
|
167
|
+
/** `console` in the planner contract covers every browser diagnostic that the
|
|
168
|
+
* browse envelope records. Keep the first three details actionable and name
|
|
169
|
+
* any remainder without allowing an unbounded page log to bloat the result. */
|
|
170
|
+
function parseConsoleFailures(envelope) {
|
|
171
|
+
if (!envelope)
|
|
172
|
+
return [];
|
|
173
|
+
const failures = [];
|
|
174
|
+
const groups = [
|
|
175
|
+
["console", envelope.consoleErrors],
|
|
176
|
+
["page", envelope.pageErrors],
|
|
177
|
+
["request", envelope.failedRequests],
|
|
178
|
+
];
|
|
179
|
+
for (const [label, value] of groups) {
|
|
180
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
181
|
+
continue;
|
|
182
|
+
for (const entry of value.slice(0, 3))
|
|
183
|
+
failures.push(`${label}: ${diagnosticSummary(entry)}`);
|
|
184
|
+
if (value.length > 3)
|
|
185
|
+
failures.push(`${label}: ${value.length - 3} additional failure(s)`);
|
|
186
|
+
}
|
|
187
|
+
return failures;
|
|
188
|
+
}
|
|
189
|
+
function providerLabel(critique) {
|
|
190
|
+
const meta = critique?.provider_meta;
|
|
191
|
+
if (meta) {
|
|
192
|
+
for (const key of ["provider", "route", "model"]) {
|
|
193
|
+
const value = meta[key];
|
|
194
|
+
if (typeof value === "string" && value.length > 0)
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return critique?.provider ? "host" : "none";
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Execute the whole QA matrix for one validated job and return the result
|
|
202
|
+
* (also written to `<outDir>/page-qa-result.json`). Stages: plan →
|
|
203
|
+
* deterministic gates (bounded pool) → interactions (serial) → critique (one
|
|
204
|
+
* context at a time; the critique provider owns tile concurrency) → snapshot.
|
|
205
|
+
* The verdict is computeVerdict over everything recorded — fail-closed.
|
|
206
|
+
*/
|
|
207
|
+
export async function runQaMatrix(options) {
|
|
208
|
+
const { job, outDir, browseArgv } = options;
|
|
209
|
+
const exec = options.exec ?? defaultQaRunExec;
|
|
210
|
+
const log = options.onLog ?? (() => { });
|
|
211
|
+
const timeoutMs = job.policy?.command_timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
212
|
+
const concurrency = job.policy?.command_concurrency ?? DEFAULT_COMMAND_CONCURRENCY;
|
|
213
|
+
mkdirSync(outDir, { recursive: true });
|
|
214
|
+
const startedAt = Date.now();
|
|
215
|
+
const wall = { plan: 0, gates: 0, interactions: 0, critique: 0, snapshot: 0, total: 0 };
|
|
216
|
+
const blockers = [];
|
|
217
|
+
const commands = [];
|
|
218
|
+
const critique = [];
|
|
219
|
+
let manifest = null;
|
|
220
|
+
let contexts = [];
|
|
221
|
+
let snapshot = { saved: false };
|
|
222
|
+
const baseEnv = { ...process.env, ...options.childEnv };
|
|
223
|
+
const timedExec = async (argv, env) => {
|
|
224
|
+
const started = Date.now();
|
|
225
|
+
let res;
|
|
226
|
+
try {
|
|
227
|
+
res = await exec(argv, { timeoutMs, env });
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
res = {
|
|
231
|
+
exitCode: null,
|
|
232
|
+
stdout: "",
|
|
233
|
+
stderr: "",
|
|
234
|
+
error: err instanceof Error ? err.message : String(err),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return { res, wallTimeMs: Date.now() - started };
|
|
238
|
+
};
|
|
239
|
+
const finalize = () => {
|
|
240
|
+
wall.total = Date.now() - startedAt;
|
|
241
|
+
const result = {
|
|
242
|
+
schema_version: QA_RUN_RESULT_SCHEMA_VERSION,
|
|
243
|
+
target: job.target,
|
|
244
|
+
...(job.tested_revision !== undefined ? { tested_revision: job.tested_revision } : {}),
|
|
245
|
+
mode: job.mode,
|
|
246
|
+
qa_plan: manifest,
|
|
247
|
+
contexts,
|
|
248
|
+
commands,
|
|
249
|
+
critique,
|
|
250
|
+
snapshot,
|
|
251
|
+
wall_time_ms: { ...wall },
|
|
252
|
+
blockers,
|
|
253
|
+
verdict: computeVerdict({
|
|
254
|
+
mode: job.mode,
|
|
255
|
+
blockers,
|
|
256
|
+
commands,
|
|
257
|
+
critique,
|
|
258
|
+
snapshotSaved: snapshot.saved,
|
|
259
|
+
}),
|
|
260
|
+
};
|
|
261
|
+
writeFileSync(join(outDir, QA_RUN_RESULT_FILENAME), `${JSON.stringify(result, null, 2)}\n`);
|
|
262
|
+
return result;
|
|
263
|
+
};
|
|
264
|
+
// Base render arguments shared by every per-context invocation.
|
|
265
|
+
const contextRenderArgs = (ctx) => [
|
|
266
|
+
"--viewport",
|
|
267
|
+
ctx.viewport,
|
|
268
|
+
...(ctx.theme === "dark" ? ["--color-scheme", "dark"] : []),
|
|
269
|
+
...(ctx.args ?? []),
|
|
270
|
+
];
|
|
271
|
+
// ------------------------------------------------------------------ plan
|
|
272
|
+
const planArgv = [
|
|
273
|
+
...browseArgv,
|
|
274
|
+
job.target,
|
|
275
|
+
"--qa-plan",
|
|
276
|
+
"--json",
|
|
277
|
+
"--no-screenshot",
|
|
278
|
+
...(job.qa_hints?.scopes ?? []).flatMap((selector) => ["--qa-scope", selector]),
|
|
279
|
+
...(job.qa_hints?.states?.length ? ["--qa-states", job.qa_hints.states.join(",")] : []),
|
|
280
|
+
];
|
|
281
|
+
log(`plan: ${job.target}`);
|
|
282
|
+
const planStart = Date.now();
|
|
283
|
+
const plan = await timedExec(planArgv, baseEnv);
|
|
284
|
+
wall.plan = Date.now() - planStart;
|
|
285
|
+
let planEnvelope;
|
|
286
|
+
if (!plan.res.error && plan.res.exitCode === 0) {
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(plan.res.stdout);
|
|
289
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
290
|
+
planEnvelope = parsed;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// handled below as a plan blocker
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const qaPlanReport = planEnvelope?.qaPlan;
|
|
298
|
+
manifest = qaPlanReport?.manifest ?? null;
|
|
299
|
+
const planUsable = manifest !== null && !manifest.incomplete && plan.res.exitCode === 0;
|
|
300
|
+
commands.push({
|
|
301
|
+
context_id: "plan",
|
|
302
|
+
check_id: "plan",
|
|
303
|
+
argv: planArgv,
|
|
304
|
+
exit_code: plan.res.exitCode,
|
|
305
|
+
outcome: planUsable ? "passed" : "unknown",
|
|
306
|
+
failures: planUsable
|
|
307
|
+
? []
|
|
308
|
+
: [plan.res.error, execErrorExcerpt(plan.res)].filter((entry) => Boolean(entry)),
|
|
309
|
+
artifacts: {},
|
|
310
|
+
wall_time_ms: plan.wallTimeMs,
|
|
311
|
+
});
|
|
312
|
+
if (plan.res.error) {
|
|
313
|
+
blockers.push({ stage: "plan", reason: `planner command did not complete: ${plan.res.error}` });
|
|
314
|
+
}
|
|
315
|
+
else if (plan.res.exitCode !== 0) {
|
|
316
|
+
const excerpt = execErrorExcerpt(plan.res);
|
|
317
|
+
blockers.push({
|
|
318
|
+
stage: "plan",
|
|
319
|
+
reason: `planner exited ${plan.res.exitCode}${excerpt ? `: ${excerpt}` : ""}`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
else if (!manifest) {
|
|
323
|
+
blockers.push({
|
|
324
|
+
stage: "plan",
|
|
325
|
+
reason: "planner envelope carried no parseable qaPlan.manifest",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
else if (manifest.incomplete) {
|
|
329
|
+
blockers.push({
|
|
330
|
+
stage: "plan",
|
|
331
|
+
reason: `planner manifest is incomplete: ${manifest.incomplete.reason}`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
if (!planUsable || !manifest) {
|
|
335
|
+
// Stop before any further browser work: an untrusted plan cannot gate.
|
|
336
|
+
contexts = manifest ? mergeCoverage(manifest, job) : [];
|
|
337
|
+
return finalize();
|
|
338
|
+
}
|
|
339
|
+
// ----------------------------------------------------------------- gates
|
|
340
|
+
contexts = mergeCoverage(manifest, job);
|
|
341
|
+
const unsupportedDeterministic = manifest.checks.deterministic.filter((check) => MANIFEST_DETERMINISTIC_ARGS[check] === undefined);
|
|
342
|
+
if (unsupportedDeterministic.length > 0) {
|
|
343
|
+
for (const check of unsupportedDeterministic) {
|
|
344
|
+
blockers.push({
|
|
345
|
+
stage: "gates",
|
|
346
|
+
reason: `planner requires deterministic check "${check}" but qa-run has no executable ` +
|
|
347
|
+
"mapping for it — coverage cannot be narrowed",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return finalize();
|
|
351
|
+
}
|
|
352
|
+
const manifestGateArgs = manifest.checks.deterministic.flatMap((check) => MANIFEST_DETERMINISTIC_ARGS[check] ?? []);
|
|
353
|
+
const enforceConsole = manifest.checks.deterministic.includes("console");
|
|
354
|
+
const checks = job.checks ?? [];
|
|
355
|
+
const gateOutcomes = new Array(contexts.length);
|
|
356
|
+
const gatesStart = Date.now();
|
|
357
|
+
await runPool(contexts, concurrency, async (ctx, index) => {
|
|
358
|
+
const applicable = checks.filter((check) => check.contexts === undefined || check.contexts.includes(ctx.id));
|
|
359
|
+
const checkId = [
|
|
360
|
+
...manifest.checks.deterministic.map((check) => `manifest:${check}`),
|
|
361
|
+
...applicable.map((check) => check.id),
|
|
362
|
+
].join("+") || "capture";
|
|
363
|
+
const outPrefix = join(outDir, ctx.id);
|
|
364
|
+
const argv = [
|
|
365
|
+
...browseArgv,
|
|
366
|
+
job.target,
|
|
367
|
+
...contextRenderArgs(ctx),
|
|
368
|
+
"--out",
|
|
369
|
+
outPrefix,
|
|
370
|
+
...manifestGateArgs,
|
|
371
|
+
...applicable.flatMap((check) => check.args),
|
|
372
|
+
];
|
|
373
|
+
log(`gate ${ctx.id}: ${checkId}`);
|
|
374
|
+
const { res, wallTimeMs } = await timedExec(argv, baseEnv);
|
|
375
|
+
const jsonPath = `${outPrefix}.json`;
|
|
376
|
+
const failures = [];
|
|
377
|
+
let outcome;
|
|
378
|
+
const envelope = existsSync(jsonPath) ? readEnvelope(jsonPath) : undefined;
|
|
379
|
+
const consoleFailures = enforceConsole ? parseConsoleFailures(envelope) : [];
|
|
380
|
+
if (!res.error && res.exitCode === 0 && existsSync(jsonPath)) {
|
|
381
|
+
if (consoleFailures.length > 0) {
|
|
382
|
+
outcome = "failed";
|
|
383
|
+
failures.push(...consoleFailures);
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
outcome = "passed";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
else if (!res.error && res.exitCode === 2 && existsSync(jsonPath)) {
|
|
390
|
+
outcome = "failed";
|
|
391
|
+
failures.push(`exit code 2 (${jsonPath})`);
|
|
392
|
+
failures.push(...parseGateFailures(envelope), ...consoleFailures);
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
outcome = "unknown";
|
|
396
|
+
const excerpt = execErrorExcerpt(res);
|
|
397
|
+
const base = res.error
|
|
398
|
+
? res.error
|
|
399
|
+
: !existsSync(jsonPath)
|
|
400
|
+
? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
|
|
401
|
+
: `exit code ${res.exitCode ?? "null"}`;
|
|
402
|
+
const reason = excerpt ? `${base}: ${excerpt}` : base;
|
|
403
|
+
failures.push(reason);
|
|
404
|
+
blockers.push({
|
|
405
|
+
stage: "gates",
|
|
406
|
+
context_id: ctx.id,
|
|
407
|
+
reason: `gate command did not complete: ${reason}`,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
gateOutcomes[index] = {
|
|
411
|
+
context_id: ctx.id,
|
|
412
|
+
check_id: checkId,
|
|
413
|
+
argv,
|
|
414
|
+
exit_code: res.exitCode,
|
|
415
|
+
outcome,
|
|
416
|
+
failures,
|
|
417
|
+
artifacts: gatherArtifacts(outPrefix),
|
|
418
|
+
wall_time_ms: wallTimeMs,
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
wall.gates = Date.now() - gatesStart;
|
|
422
|
+
// Manifest order regardless of completion order: outcomes were written by
|
|
423
|
+
// context index, so a straight push preserves it.
|
|
424
|
+
commands.push(...gateOutcomes);
|
|
425
|
+
// ---------------------------------------------------------- interactions
|
|
426
|
+
const interactionsStart = Date.now();
|
|
427
|
+
const declaredStates = new Set((job.interaction_states ?? []).map((state) => state.name));
|
|
428
|
+
const manifestStates = [
|
|
429
|
+
...new Set(manifest.contexts.map((context) => context.state).filter((state) => state !== "default")),
|
|
430
|
+
];
|
|
431
|
+
for (const state of manifestStates) {
|
|
432
|
+
if (!declaredStates.has(state)) {
|
|
433
|
+
blockers.push({
|
|
434
|
+
stage: "interactions",
|
|
435
|
+
reason: `manifest requires interaction state "${state}" but the job declares no matching ` +
|
|
436
|
+
"interaction_states entry — job coverage may never be narrower than the manifest",
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (const state of job.interaction_states ?? []) {
|
|
441
|
+
const outPrefix = join(outDir, `interaction-${state.name}`);
|
|
442
|
+
const argv = [
|
|
443
|
+
...browseArgv,
|
|
444
|
+
job.target,
|
|
445
|
+
"--out",
|
|
446
|
+
outPrefix,
|
|
447
|
+
...state.setup,
|
|
448
|
+
...state.assertions.flatMap((assertion) => ["--assert", assertion]),
|
|
449
|
+
"--assert-fail",
|
|
450
|
+
];
|
|
451
|
+
log(`interaction ${state.name}`);
|
|
452
|
+
const { res, wallTimeMs } = await timedExec(argv, baseEnv);
|
|
453
|
+
const jsonPath = `${outPrefix}.json`;
|
|
454
|
+
const failures = [];
|
|
455
|
+
let outcome;
|
|
456
|
+
if (!res.error && res.exitCode === 0 && existsSync(jsonPath)) {
|
|
457
|
+
outcome = "passed";
|
|
458
|
+
}
|
|
459
|
+
else if (!res.error && res.exitCode === 2 && existsSync(jsonPath)) {
|
|
460
|
+
outcome = "failed";
|
|
461
|
+
failures.push(`exit code 2 (${jsonPath})`);
|
|
462
|
+
failures.push(...parseGateFailures(readEnvelope(jsonPath)));
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
outcome = "unknown";
|
|
466
|
+
const excerpt = execErrorExcerpt(res);
|
|
467
|
+
const base = res.error
|
|
468
|
+
? res.error
|
|
469
|
+
: !existsSync(jsonPath)
|
|
470
|
+
? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
|
|
471
|
+
: `exit code ${res.exitCode ?? "null"}`;
|
|
472
|
+
const reason = excerpt ? `${base}: ${excerpt}` : base;
|
|
473
|
+
failures.push(reason);
|
|
474
|
+
blockers.push({
|
|
475
|
+
stage: "interactions",
|
|
476
|
+
context_id: state.name,
|
|
477
|
+
reason: `interaction command did not complete: ${reason}`,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
commands.push({
|
|
481
|
+
context_id: state.name,
|
|
482
|
+
check_id: `interaction:${state.name}`,
|
|
483
|
+
argv,
|
|
484
|
+
exit_code: res.exitCode,
|
|
485
|
+
outcome,
|
|
486
|
+
failures,
|
|
487
|
+
artifacts: gatherArtifacts(outPrefix),
|
|
488
|
+
wall_time_ms: wallTimeMs,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
wall.interactions = Date.now() - interactionsStart;
|
|
492
|
+
// -------------------------------------------------------------- critique
|
|
493
|
+
const visual = manifest.checks.visual;
|
|
494
|
+
const cleanSoFar = blockers.length === 0 && commands.every((command) => command.outcome === "passed");
|
|
495
|
+
const critiqueStart = Date.now();
|
|
496
|
+
const savedSnapshots = new Map();
|
|
497
|
+
if (cleanSoFar && visual !== "none") {
|
|
498
|
+
const critiqueEnv = job.policy?.allow_metered_critique
|
|
499
|
+
? { ...baseEnv }
|
|
500
|
+
: { ...baseEnv, [QA_RUN_HEADLESS_ONLY_ENV]: "1" };
|
|
501
|
+
const scopeSelectors = visual === "full-page" ? [undefined] : manifest.scopes.map((s) => s.selector);
|
|
502
|
+
for (const ctx of contexts) {
|
|
503
|
+
let tilesTotal = 0;
|
|
504
|
+
let tilesReviewed = 0;
|
|
505
|
+
let tilesReused = 0;
|
|
506
|
+
let provider = "none";
|
|
507
|
+
let contextOutcome = "passed";
|
|
508
|
+
const findings = [];
|
|
509
|
+
for (const [scopeIndex, selector] of scopeSelectors.entries()) {
|
|
510
|
+
const suffix = scopeSelectors.length > 1 ? `-scope${scopeIndex}` : "";
|
|
511
|
+
const outPrefix = join(outDir, `${ctx.id}-critique${suffix}`);
|
|
512
|
+
const argv = [
|
|
513
|
+
...browseArgv,
|
|
514
|
+
job.target,
|
|
515
|
+
...contextRenderArgs(ctx),
|
|
516
|
+
"--out",
|
|
517
|
+
outPrefix,
|
|
518
|
+
...(selector !== undefined ? ["--check-critique", selector] : ["--check-critique"]),
|
|
519
|
+
"--check-critique-fail",
|
|
520
|
+
...(manifest.baseline_source !== "none" ? ["--qa-reuse"] : []),
|
|
521
|
+
...(job.mode === "signoff"
|
|
522
|
+
? ["--qa-snapshot", "--qa-theme", ctx.theme, "--qa-state", ctx.state]
|
|
523
|
+
: []),
|
|
524
|
+
];
|
|
525
|
+
log(`critique ${ctx.id}${selector !== undefined ? ` [${selector}]` : ""}`);
|
|
526
|
+
const { res, wallTimeMs } = await timedExec(argv, critiqueEnv);
|
|
527
|
+
const jsonPath = `${outPrefix}.json`;
|
|
528
|
+
const envelope = readEnvelope(jsonPath);
|
|
529
|
+
const envelopeCritique = envelope?.critique;
|
|
530
|
+
const failures = [];
|
|
531
|
+
let commandOutcome;
|
|
532
|
+
if (res.error || (res.exitCode !== 0 && res.exitCode !== 2) || !envelope) {
|
|
533
|
+
commandOutcome = "unknown";
|
|
534
|
+
const excerpt = execErrorExcerpt(res);
|
|
535
|
+
const base = res.error
|
|
536
|
+
? res.error
|
|
537
|
+
: !envelope
|
|
538
|
+
? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
|
|
539
|
+
: `exit code ${res.exitCode ?? "null"}`;
|
|
540
|
+
const reason = excerpt ? `${base}: ${excerpt}` : base;
|
|
541
|
+
failures.push(reason);
|
|
542
|
+
blockers.push({
|
|
543
|
+
stage: "critique",
|
|
544
|
+
context_id: ctx.id,
|
|
545
|
+
reason: `critique command did not complete: ${reason}`,
|
|
546
|
+
});
|
|
547
|
+
contextOutcome = "unknown";
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
tilesTotal += envelopeCritique?.tiles ?? 0;
|
|
551
|
+
const reuse = envelope.qaReuse;
|
|
552
|
+
tilesReused += reuse?.tiles_reused ?? 0;
|
|
553
|
+
tilesReviewed += reuse?.tiles_reviewed ?? envelopeCritique?.tiles ?? 0;
|
|
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
|
+
}
|
|
596
|
+
}
|
|
597
|
+
commands.push({
|
|
598
|
+
context_id: ctx.id,
|
|
599
|
+
check_id: "critique",
|
|
600
|
+
argv,
|
|
601
|
+
exit_code: res.exitCode,
|
|
602
|
+
outcome: commandOutcome,
|
|
603
|
+
failures,
|
|
604
|
+
artifacts: gatherArtifacts(outPrefix),
|
|
605
|
+
wall_time_ms: wallTimeMs,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
critique.push({
|
|
609
|
+
context_id: ctx.id,
|
|
610
|
+
provider,
|
|
611
|
+
tiles_total: tilesTotal,
|
|
612
|
+
tiles_reviewed: tilesReviewed,
|
|
613
|
+
tiles_reused: tilesReused,
|
|
614
|
+
outcome: contextOutcome,
|
|
615
|
+
findings,
|
|
616
|
+
});
|
|
617
|
+
if (job.mode === "signoff" && contextOutcome === "passed" && !savedSnapshots.has(ctx.id)) {
|
|
618
|
+
blockers.push({
|
|
619
|
+
stage: "snapshot",
|
|
620
|
+
context_id: ctx.id,
|
|
621
|
+
reason: "signoff critique passed but no QA snapshot was persisted for the context",
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
wall.critique = Date.now() - critiqueStart;
|
|
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) {
|
|
633
|
+
const stillClean = commands.every((command) => command.outcome === "passed");
|
|
634
|
+
if (stillClean) {
|
|
635
|
+
for (const ctx of contexts) {
|
|
636
|
+
const outPrefix = join(outDir, `${ctx.id}-snapshot`);
|
|
637
|
+
const argv = [
|
|
638
|
+
...browseArgv,
|
|
639
|
+
job.target,
|
|
640
|
+
...contextRenderArgs(ctx),
|
|
641
|
+
"--out",
|
|
642
|
+
outPrefix,
|
|
643
|
+
"--qa-snapshot",
|
|
644
|
+
"--qa-theme",
|
|
645
|
+
ctx.theme,
|
|
646
|
+
"--qa-state",
|
|
647
|
+
ctx.state,
|
|
648
|
+
];
|
|
649
|
+
log(`snapshot ${ctx.id}`);
|
|
650
|
+
const { res, wallTimeMs } = await timedExec(argv, baseEnv);
|
|
651
|
+
const envelope = readEnvelope(`${outPrefix}.json`);
|
|
652
|
+
const saved = envelope?.qaPlan
|
|
653
|
+
?.snapshotSaved;
|
|
654
|
+
const ok = !res.error && res.exitCode === 0 && Boolean(saved?.path);
|
|
655
|
+
if (ok && saved?.path)
|
|
656
|
+
savedSnapshots.set(ctx.id, saved.path);
|
|
657
|
+
else {
|
|
658
|
+
blockers.push({
|
|
659
|
+
stage: "snapshot",
|
|
660
|
+
context_id: ctx.id,
|
|
661
|
+
reason: `snapshot command did not persist a baseline${res.error ? `: ${res.error}` : ` (exit code ${res.exitCode ?? "null"})`}`,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
commands.push({
|
|
665
|
+
context_id: ctx.id,
|
|
666
|
+
check_id: "snapshot",
|
|
667
|
+
argv,
|
|
668
|
+
exit_code: res.exitCode,
|
|
669
|
+
outcome: ok ? "passed" : "unknown",
|
|
670
|
+
failures: ok ? [] : [res.error ?? `exit code ${res.exitCode ?? "null"}`],
|
|
671
|
+
artifacts: gatherArtifacts(outPrefix),
|
|
672
|
+
wall_time_ms: wallTimeMs,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
wall.snapshot = Date.now() - snapshotStart;
|
|
678
|
+
if (job.mode === "signoff") {
|
|
679
|
+
const allSaved = contexts.length > 0 && contexts.every((ctx) => savedSnapshots.has(ctx.id));
|
|
680
|
+
const lastPath = [...savedSnapshots.values()].pop();
|
|
681
|
+
snapshot = { saved: allSaved, ...(lastPath ? { path: lastPath } : {}) };
|
|
682
|
+
}
|
|
683
|
+
return finalize();
|
|
684
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Local-only endpoint used to prove that a dashboard serves the caller's repo. */
|
|
2
|
+
export declare const COORD_ROOT_ID_PATH = "/api/coord-root";
|
|
3
|
+
/**
|
|
4
|
+
* Opaque identity for a canonical coordination root. The path stays local;
|
|
5
|
+
* only its digest crosses the dashboard boundary.
|
|
6
|
+
*/
|
|
7
|
+
export declare function coordRootId(root: string): string;
|
|
8
|
+
export declare function localCoordRootIdUrl(port?: number | string, protocol?: string): string;
|
|
9
|
+
//# sourceMappingURL=coord-root-id.d.ts.map
|