@broberg/lens-daemon 0.0.0-stage → 0.1.5

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/src/flow.ts ADDED
@@ -0,0 +1,2953 @@
1
+ // F082 — Lens E2E flow executor.
2
+ //
3
+ // A "flow" is a manuscript: an ordered list of steps a repo commits, executed
4
+ // by the daemon in ONE fresh authed Playwright page (state persists across
5
+ // steps — that's what makes it End-to-End: log in, navigate, fill, submit,
6
+ // assert the result). Every element is anchored on `data-testid` — never a CSS
7
+ // guess — the same strict contract as the capture catalogue.
8
+ //
9
+ // Reuses the F074 foundation: withPage() for the authed context, authPrepare()
10
+ // for the credential pre-step, takeShot() for per-step screenshots. A step that
11
+ // throws stops the flow and pins a screenshot of the failure, so "create user"
12
+ // failing at the submit button is obvious with the exact before-shot.
13
+
14
+ import nodePath from 'node:path';
15
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
16
+ import { tmpdir } from 'node:os';
17
+ import type { Locator, Page } from 'playwright';
18
+ import {
19
+ resolveTarget,
20
+ isEditableElement,
21
+ evalAssertBody,
22
+ noVerdictMessage,
23
+ type AssertOutcome,
24
+ type Target,
25
+ } from '@broberg/lens-engine';
26
+ import { withPage, type LensEngine } from './browser.ts';
27
+ import { authPrepare, type AuthConfig, type AuthEnv } from './auth/index.ts';
28
+ import { takeShot, settleForShot } from './capture.ts';
29
+ import { runCritic, topFindingMessage, type CriticMode, type CriticReport } from './critic.ts';
30
+ import { waitForBuild, observeBuild } from './build-marker.ts';
31
+ import { fillAndVerify } from './fill.ts';
32
+ import { fillSecret } from './secret-fill.ts';
33
+ import { storagePayload, type StorageSeed } from './seed-storage.ts';
34
+ import { passwordInUrl, type FilledPassword, type PasswordInUrlFinding } from './password-in-url.ts';
35
+ import {
36
+ EDIT_EVENTS,
37
+ waitForListening,
38
+ waitForListeningOn,
39
+ readinessOfElement,
40
+ notListeningMessage,
41
+ type ReadinessResult,
42
+ type NotListeningFinding,
43
+ } from './readiness.ts';
44
+
45
+ /** F255.1 — how a click activates a button. See `clickAndActivate`. */
46
+ export type ActivateMode = 'auto' | 'click' | 'enter';
47
+
48
+ /** F009.121 — where a `fill` reads a SECRET from instead of carrying it inline:
49
+ * a gitignored file (absolute, or relative to the run's local_path) or a daemon
50
+ * env var. The value is read at run time and never echoed. */
51
+ export type ValueFrom = { path: string } | { env: string };
52
+
53
+ export type FlowStep =
54
+ // F074.51 — `timeout` was absent here while every other verb carried it, so a
55
+ // goto's 30s was unoverridable. Found by the typecheck of this card's own
56
+ // tests: normalising `timeout_ms` onto a step nobody reads would have shipped a
57
+ // NEW instance of the exact lie this card closes.
58
+ | { type: 'goto'; path: string; label?: string; timeout?: number }
59
+ // F074.23 — single-anchor locating steps accept EITHER a `testid` (string,
60
+ // resolved as [data-testid="…"], unchanged) OR a self-healing `target`
61
+ // (LocateSpec: testid|css|role|label|placeholder|text|vision), resolved
62
+ // through the ONE resolver in @broberg/lens-engine (shared with cloud runFlow
63
+ // so the self-heal priority can't drift). `target` is what lets the daemon
64
+ // drive testid-less sites (App Store Connect) same-IP. Exactly one is required.
65
+ // F255.1 — `activate` picks how a BUTTON is fired. 'auto' (default) clicks, and only
66
+ // if the click produced nothing observable falls back to focus+Enter; 'click' forces
67
+ // the old mouse-only behaviour; 'enter' skips straight to focus+Enter.
68
+ // F255.4 — `at` clicks a POINT inside the element ("50%,28%" or "40,120" px)
69
+ // instead of its centre, for canvas/3D surfaces where the centre hits nothing.
70
+ | { type: 'click'; testid?: string; target?: Target; label?: string; timeout?: number; activate?: ActivateMode; at?: string }
71
+ | { type: 'fill'; testid?: string; target?: Target; value?: string; valueFrom?: ValueFrom; label?: string; timeout?: number }
72
+ | { type: 'select'; testid?: string; target?: Target; value: string; label?: string; timeout?: number }
73
+ | { type: 'check'; testid?: string; target?: Target; label?: string; timeout?: number }
74
+ | { type: 'uncheck'; testid?: string; target?: Target; label?: string; timeout?: number }
75
+ | { type: 'waitFor'; testid?: string; target?: Target; label?: string; timeout?: number }
76
+ | { type: 'expectText'; testid?: string; target?: Target; text: string; match?: 'contains' | 'equals' | 'not-equals'; label?: string; timeout?: number }
77
+ | { type: 'expectVisible'; testid?: string; target?: Target; label?: string; timeout?: number }
78
+ // F236 — prove an element is editable RIGHT NOW (contenteditable nearest-ancestor
79
+ // wins + inherited counts, OR an enabled non-readonly input/textarea/select). The
80
+ // live-DOM complement to coverage(); @broberg/lens-engine 0.4.0's isEditableElement
81
+ // is the ONE shared "editable" definition (serialized into the page via evaluate).
82
+ | { type: 'expectEditable'; testid?: string; target?: Target; label?: string; timeout?: number }
83
+ | { type: 'expectAbsent'; testid: string; label?: string; timeout?: number }
84
+ // F074.59 — assert MOTION by observing it. Reading `animationName` off computed
85
+ // style proves a RULE EXISTS; it says nothing about whether the animation plays,
86
+ // and it reads identically in a browser that has stopped every animation. These
87
+ // two verbs measure the thing itself, and they are a matched pair on purpose:
88
+ // expectAnimating alone would pass on any element in a browser that animates
89
+ // everything, and expectStill alone would pass in one that animates nothing.
90
+ | { type: 'expectAnimating'; testid?: string; target?: Target; property?: string; label?: string; timeout?: number }
91
+ | { type: 'expectStill'; testid?: string; target?: Target; property?: string; label?: string; timeout?: number }
92
+ // F213.8 — gate on the page actually LISTENING, not merely painted. The
93
+ // browser owns form controls itself, so a write before the app's scripts
94
+ // attach lands in the DOM, reads back green, and is silently lost.
95
+ | { type: 'waitForReady'; testid?: string; target?: Target; events?: string[]; label?: string; timeout?: number }
96
+ // F213.9 — the POSITIVE form of "the overlay is gone". A negative assertion can
97
+ // always be green too early; this one cannot, because the target must EXIST and be
98
+ // what the browser would actually hit at its own centre.
99
+ | { type: 'expectTopmost'; testid?: string; target?: Target; label?: string; timeout?: number }
100
+ // F082.x (lens-gap 019eeec7) — frame-aware, CSS-selector steps for cross-origin
101
+ // surfaces a top-frame testid can't reach (e.g. Stripe's PCI card iframes).
102
+ // `frame` is one iframe selector or an outer→inner chain; absent = top frame.
103
+ | { type: 'fillSelector'; selector: string; value: string; frame?: string | string[]; label?: string; timeout?: number }
104
+ | { type: 'clickSelector'; selector: string; frame?: string | string[]; label?: string; timeout?: number; at?: string }
105
+ // F255.7 — block until the app serves the build under test. `sha` waits for THAT build;
106
+ // without it, for any build other than the one live when the step started.
107
+ | { type: 'waitForBuild'; sha?: string; label?: string; timeout?: number }
108
+ // Wait until the page URL contains this substring — the redirect-back assertion
109
+ // so a manuscript knows it left the 3rd party and is back on our origin.
110
+ | { type: 'waitForUrl'; url: string; label?: string; timeout?: number }
111
+ // F082.x (lens-gap) — DOM inspector: dump a frame's iframes/inputs/clickables
112
+ // (names, ids, roles, iframe titles, testids, text) so an author can discover
113
+ // the exact anchors on a 3rd-party page (Stripe's accordion + PCI iframes)
114
+ // WITHOUT raw Playwright. CSP-safe — a real fn injected by Playwright, never
115
+ // page-level eval (Stripe's strict CSP blocks new Function/eval).
116
+ // F389.2 — `html: true` returns the element's (or, without a testid, the page
117
+ // body's) full outerHTML instead of the anchor dump, so a built screen can be
118
+ // compared with its approved mockup (cardmem_check_mockup_fidelity).
119
+ | { type: 'inspect'; frame?: string | string[]; html?: boolean; testid?: string; label?: string; timeout?: number }
120
+ | { type: 'assert'; js: string; label?: string; timeout?: number }
121
+ | { type: 'screenshot'; label?: string }
122
+ // F213.2 — interaction verbs that make write-flows drivable end-to-end
123
+ // (paired with the F213.1 write-capable mint so the submit actually persists).
124
+ // clear: empty a field. press: a keyboard key on a testid (focused) or global
125
+ // (no testid) — Enter-to-submit, arrow-nav, autocomplete triggering. drag: a
126
+ // real HTML5 drag-and-drop from one testid to another (Playwright dragTo),
127
+ // for DnD features a synthetic-event assert can't cleanly drive (lens-gap 019f2438).
128
+ | { type: 'clear'; testid?: string; target?: Target; label?: string; timeout?: number }
129
+ | { type: 'press'; key: string; testid?: string; label?: string; timeout?: number }
130
+ | { type: 'drag'; testid: string; toTestid: string; label?: string; timeout?: number }
131
+ // F213.6 — uploadFile: set a fixture file on an input[type=file] (Playwright
132
+ // setInputFiles), INCLUDING a hidden input behind a custom "add photo" button.
133
+ // `path` is a repo-relative fixture path (sandboxed to local_path) or a data:
134
+ // URI; an array = a multi-file upload. Drives real add-photo/attach steps a DSL
135
+ // could not before (fd-sundhed skadefoto-flow, lens-gap 019f581a).
136
+ | { type: 'uploadFile'; testid: string; path: string | string[]; label?: string; timeout?: number }
137
+ // F213.3 — composite + control-flow verbs for FMC-level manuscripts.
138
+ // autocomplete: type into a field, wait for the suggestion list to render, then
139
+ // pick a suggestion by visible text (pickText) or index (pickIndex) — a real
140
+ // lookup→select, not a bare fill. `option` is the suggestion-item testid.
141
+ // capture: read an element's text (or an input's value, from:'value') into a
142
+ // named var so a LATER step reuses it via {{name}} — passing data forward.
143
+ // conditional: run a child block only when `testid` is present (present:true →
144
+ // 'if') or absent (present:false → 'unless'), so a manuscript branches on page
145
+ // state (a cookie banner, an optional step) without failing the whole run.
146
+ | { type: 'autocomplete'; testid: string; value: string; option: string; pickText?: string; pickIndex?: number; label?: string; timeout?: number }
147
+ | { type: 'capture'; testid: string; as: string; from?: 'text' | 'value'; label?: string; timeout?: number }
148
+ | { type: 'conditional'; testid: string; present: boolean; steps: FlowStep[]; label?: string; timeout?: number }
149
+ | { type: 'loop'; data: Array<Record<string, unknown>>; steps: FlowStep[]; label?: string; parallel?: number };
150
+
151
+ export interface Flow {
152
+ name: string;
153
+ base_url: string;
154
+ /** Daemon-side auth pre-step (storageState / statePath / cookieJwt / mintEndpoint). */
155
+ auth?: AuthConfig;
156
+ /** Flags a flow that creates/changes data (create user, submit form). Surfaced
157
+ * so a mutating flow against prod is an explicit, visible choice. */
158
+ mutates?: boolean;
159
+ /** F255.7 — gate the WHOLE flow on the deployed build being live: a sha to wait for, or
160
+ * true to wait for any change from the build serving when the run starts. The common
161
+ * case is "deploy, then verify everything", so it belongs on the flow, not per step. */
162
+ wait_for_build?: string | boolean;
163
+ /** F213.10 — how long, after the last verdict, to keep watching for the page
164
+ * ejecting us. Default {@link DEFAULT_EJECT_GRACE_MS}; 0 opts out. Raise it
165
+ * for an app whose auth check is slow (fd-sundhed's bounced at ~3.6s). */
166
+ eject_grace_ms?: number;
167
+ steps: FlowStep[];
168
+ }
169
+
170
+ /**
171
+ * MEASURED, not chosen. Against a fixture that bounces 800ms after load:
172
+ *
173
+ * grace 1000 → F.FF.F caught 4 of 6
174
+ * grace 1500 → FFFFFF caught 6 of 6
175
+ * grace 3000 → FFFFFF caught 6 of 6
176
+ *
177
+ * The window is measured from when the goto RESOLVES, which is already later
178
+ * than the moment the page's own check started, so a window merely larger than
179
+ * the bounce is not enough — the margin gets eaten by that offset plus run
180
+ * variance. 1000ms left ~200ms of margin and lost a third of the time.
181
+ *
182
+ * A guard that catches two thirds is worse than no guard, because it teaches a
183
+ * reader that green means safe. 2000ms buys margin over the 1500 that was merely
184
+ * sufficient here, and it is still bounded.
185
+ *
186
+ * THIS IS NOT A UNIVERSAL NUMBER. fd-sundhed's app bounced at ~3.6s behind a
187
+ * splash screen; that one needs `eject_grace_ms: 5000` and no default would have
188
+ * caught it. What this default covers is the ORDINARY case — a token check that
189
+ * resolves as soon as the page has booted.
190
+ */
191
+ export const DEFAULT_EJECT_GRACE_MS = 2000;
192
+
193
+ /** F074.48 — one failed HTTP response, as the app sent it. */
194
+ export interface FailedResponse {
195
+ method: string;
196
+ url: string;
197
+ status: number;
198
+ /** The body, capped and redacted. A stated marker when it could not be read —
199
+ * "we did not look" and "there was nothing" must never look the same. */
200
+ body: string;
201
+ }
202
+
203
+ /** Cap per failing step. A flow against a chatty app must not turn its report
204
+ * into a network log; the first few failures are what explain the step. */
205
+ const MAX_FAILED_RESPONSES_PER_STEP = 5;
206
+ /** Cap per body. Long enough for an error message, short enough that nobody
207
+ * stores a page of HTML per step. */
208
+ const MAX_BODY_CHARS = 600;
209
+
210
+ /**
211
+ * F074.48 — strip obvious credential shapes from a captured error body.
212
+ *
213
+ * DEFENCE IN DEPTH, NOT A GUARANTEE, and the distinction is this week's lesson:
214
+ * a pattern list is a DENYLIST, so it will miss the shape nobody thought of. The
215
+ * real protection here is narrowness — only FAILED responses are read, capped at
216
+ * 600 chars, and flow results stay on the machine that ran the flow.
217
+ *
218
+ * There is no shared @broberg/* redactor to reuse: @broberg/apikey 0.1.0 exports
219
+ * generateKey/hashKey/verifyKey/makeKeyPreview and no redactor (checked, not
220
+ * assumed). Filed as a reuse gap; if one lands, this is deleted for it.
221
+ */
222
+ export function redactBody(text: string): string {
223
+ return text
224
+ .replace(/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}/g, '$1-[REDACTED]')
225
+ .replace(/\b(pa|pi|piw|uk|trail|lens)_[A-Za-z0-9_-]{12,}/g, '$1_[REDACTED]')
226
+ .replace(/\bgh[pousr]_[A-Za-z0-9]{16,}/g, 'gh?_[REDACTED]')
227
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[REDACTED-JWT]')
228
+ .replace(/\b([Bb]earer\s+)[A-Za-z0-9._-]{16,}/g, '$1[REDACTED]')
229
+ .replace(/("(?:password|secret|token|api[_-]?key)"\s*:\s*")[^"]{4,}/gi, '$1[REDACTED]');
230
+ }
231
+
232
+ export interface FlowStepResult {
233
+ index: number;
234
+ type: string;
235
+ label: string;
236
+ status: 'pass' | 'fail' | 'skip';
237
+ testid: string | null;
238
+ detail: string | null;
239
+ /** PNG bytes for screenshot steps + the failing step (pinned for debugging). */
240
+ screenshot: Buffer | null;
241
+ /** F213.11 — milliseconds between the most recent navigation and the moment THIS
242
+ * step started. NOT the step's own duration; the name says `after_navigation`
243
+ * precisely because "elapsed" would be read as duration by the next person.
244
+ *
245
+ * It exists because a write into a page that has not begun listening reads back
246
+ * green and is silently lost (F213.8). sanne lost three runs' data that way and
247
+ * spent them suspecting her own instrument. A step showing `fill @ 180ms` next to
248
+ * a lost write answers that immediately.
249
+ *
250
+ * Present on PASSING steps too, deliberately: a number that only appears on
251
+ * failures cannot be used to investigate a run that passed for the wrong reason,
252
+ * which is the entire premise of F213.8.
253
+ *
254
+ * OPTIONAL on purpose. A SKIPPED step never started, so it has no such moment —
255
+ * and stamping it with a number would say it ran. Absent means "did not run".
256
+ * The engine-mapped cloud results (mapEngineFlowResult) also omit it: they report
257
+ * per-step duration, not a navigation offset, and inventing one from a duration
258
+ * would be a fabricated measurement — the exact class this field exists to expose.
259
+ *
260
+ * F213.13 — AND IT IS ABSENT ON A `goto`. The clock only restarts once the goto
261
+ * has finished, so a goto's own reading is measured from the PREVIOUS navigation
262
+ * (for the first goto, from the start of the flow — i.e. browser launch). It is
263
+ * a start, like every other reading, but against a different origin, so putting
264
+ * it in the same column invites a subtraction that silently spans two clocks.
265
+ * sanne hit this within an hour of the field going live (#21315): she read the
266
+ * goto's number as the step's END and could not tell the two kinds apart. The
267
+ * cure is to remove the trap, not to document it — the next person subtracts the
268
+ * two numbers whatever a comment says. Absent here means "this step IS the
269
+ * navigation", and every remaining reading shares one origin. */
270
+ ms_after_navigation?: number;
271
+ /** F213.13 — how long THIS step took, once it started. The companion to the
272
+ * field above, and the pair is what makes a report answerable.
273
+ *
274
+ * A step with waiting built into it (a click that retries actionability, a
275
+ * waitFor) reports a start and then hides everything that follows: sanne could
276
+ * say her `fill` was stamped 1119ms later when a click preceded it, but not when
277
+ * the click itself actually landed inside its own step — so "a click waits long
278
+ * enough" and "a click happens to wait long enough HERE" stayed undecidable.
279
+ * start + duration closes that.
280
+ *
281
+ * OPTIONAL for the same reason as above: a SKIPPED step has no duration. */
282
+ ms_step_duration?: number;
283
+ /** F126.7 — the FULL composition report for a screenshot step, on pass and on
284
+ * fail. Previously the findings ended as a string carrying the count and the
285
+ * first message; the rest were discarded, so a caller fixed the one issue it
286
+ * was shown, re-ran, went green, and the unreported findings were silently
287
+ * recorded as resolved. Same reason `network` exists two fields up: a report
288
+ * that says less than the run measured, without saying so. */
289
+ critic?: CriticReport | null;
290
+ /** F074.48 — the FAILED HTTP responses that arrived while this step ran, with
291
+ * the body the app actually sent. Present only on a FAILING step: on a passing
292
+ * one it would be noise, and noise in a report is how people stop reading it.
293
+ *
294
+ * pitch-vault (#20462/#20464) drove a write-flow with a read-only capture
295
+ * principal. Our guard refused it correctly with
296
+ * `403 {"error":"Lens capture sessions are read-only"}` — a fully
297
+ * self-explaining answer — and the report showed only
298
+ * "expected [data-testid=pitch-save] to be absent, but it is visible".
299
+ * Fifteen minutes fault-finding an app that was working as designed.
300
+ *
301
+ * The wire had the answer both times. The instrument dropped it. */
302
+ network?: FailedResponse[];
303
+ /** F074.29 — WHY this step failed, when the answer is "the probe was broken"
304
+ * rather than "the app was wrong". Absent on an ordinary assertion failure.
305
+ *
306
+ * Deliberately a SEPARATE field instead of a new `status` value: every
307
+ * consumer that asks `status === 'fail'` (including this file's own
308
+ * `anyFail`) must keep counting a broken probe as a failure. A new status
309
+ * would have silently downgraded exactly the runs that deserve attention —
310
+ * the same class of bug this whole story is about.
311
+ *
312
+ * fd-sundhed's argument for surfacing it at all (#18586): "a throw is not a
313
+ * slow truth, it is a broken instrument. Otherwise people learn to read
314
+ * 'threw' as 'not hydrated yet' and wait for something that never comes." */
315
+ fault?: 'instrument';
316
+ }
317
+
318
+ /** A failure caused by the PROBE, not by the app under test: an assert body that
319
+ * cannot compile, or one that throws every time it runs. Carried as a distinct
320
+ * error type so the recorder can label it without string-matching messages. */
321
+ export class InstrumentError extends Error {
322
+ readonly fault = 'instrument' as const;
323
+ constructor(message: string) {
324
+ super(message);
325
+ this.name = 'InstrumentError';
326
+ }
327
+ }
328
+
329
+ export interface FlowResult {
330
+ name: string;
331
+ status: 'pass' | 'fail' | 'error';
332
+ steps: FlowStepResult[];
333
+ failed_at: number | null;
334
+ reason: string | null;
335
+ duration_ms: number;
336
+ /** F255.5 — the whole run as a .webm, present only when record:true was asked for.
337
+ * Screenshots show states; this shows the MOTION between them. */
338
+ video?: Buffer | null;
339
+ /** F074.30 — the build sha this run was looking at, stamped at capture time.
340
+ * NULL when the target exposes no marker. Never re-read later: asking after
341
+ * the fact returns TODAY's build for an OLD recording. */
342
+ build?: string | null;
343
+ /** F074.36 — the URL the run ended on. NULL means not measured. Lets a reader
344
+ * answer "which page was that verdict actually about?" after the fact. */
345
+ final_url?: string | null;
346
+ /** F074.99 — a password this run typed came back in the final URL. Null when
347
+ * it did not, which is every ordinary run. Names the field and the parameter,
348
+ * never the value. */
349
+ password_in_url?: PasswordInUrlFinding | null;
350
+ /** F074.99 part 1 — an edit verb wrote to a field on a page where nothing was
351
+ * listening, or (state 'uninstrumented') the check could not run at all. Null
352
+ * means no finding on a run that DID look; absent means a result from before
353
+ * the check existed. Never fails the run — see the field's own type. */
354
+ not_listening?: NotListeningFinding | null;
355
+ /** F074.36 — set when a goto landed somewhere other than the path asked for,
356
+ * e.g. an auth wall. Present on passing runs too (a silently rerouted green
357
+ * run is precisely the case worth re-checking); only a FAILING run has it
358
+ * prepended to `reason`. */
359
+ redirected?: string | null;
360
+ /** F074.36 — set when the applied credential cannot reach the target host at
361
+ * all (cookies scoped elsewhere, or none). The SPA-proof half of the check:
362
+ * an unauthenticated single-page app renders its login screen at the URL you
363
+ * asked for, so the routing looks perfect and only the credential tells the
364
+ * truth. Does NOT cover a well-scoped credential the server has expired. */
365
+ auth_reach?: string | null;
366
+ /** F213.10 — set when the page LOADED where we asked and then moved away on its
367
+ * own, i.e. the run formed verdicts about a surface it was thrown off. Fails
368
+ * the run: unlike a redirect, there is no reading of this that is benign. */
369
+ ejected?: string | null;
370
+ /** F074.59 — which motion state this run executed under.
371
+ *
372
+ * THREE VALUES, NOT TWO. 'reduce' | 'no-preference' say what the browser was
373
+ * told; ABSENT means an older run that predates the field, i.e. NOT RECORDED —
374
+ * which is a different fact from "it ran under reduce" and must not be readable
375
+ * as it. A green run that cannot say whether motion was on is uninterpretable,
376
+ * and that ambiguity is exactly what left F144.23's animation criterion neither
377
+ * provable nor disprovable. */
378
+ reduced_motion?: 'reduce' | 'no-preference';
379
+ }
380
+
381
+ export const STEP_TYPES = new Set([
382
+ 'goto', 'click', 'fill', 'select', 'check', 'uncheck', 'waitFor',
383
+ 'expectText', 'expectVisible', 'expectEditable', 'expectAbsent', 'expectTopmost', 'assert', 'screenshot', 'loop',
384
+ // F074.59 — motion assertions
385
+ 'expectAnimating', 'expectStill',
386
+ 'fillSelector', 'clickSelector', 'waitForUrl', 'inspect',
387
+ // F213.2 — interaction verbs
388
+ 'clear', 'press', 'drag',
389
+ // F213.6 — file upload
390
+ 'uploadFile',
391
+ // F213.3 — composite + control-flow verbs
392
+ 'autocomplete', 'capture', 'conditional',
393
+ // F255.7 — gate on the deployed build actually serving
394
+ 'waitForBuild',
395
+ // F213.8 — readiness gate (the page is listening, not just painted)
396
+ 'waitForReady',
397
+ ]);
398
+
399
+ /** Validate + normalise an untrusted flow (HTTP/MCP boundary). Throws on the
400
+ * first structural problem — the daemon avoids a zod dep, so this is hand-rolled
401
+ * in the same style as the other daemon request validators. */
402
+ export function parseFlow(input: unknown): Flow {
403
+ const f = input as Partial<Flow>;
404
+ if (!f || typeof f !== 'object') throw new Error('flow must be an object');
405
+ if (typeof f.name !== 'string' || !f.name.trim()) throw new Error('flow.name is required');
406
+ if (typeof f.base_url !== 'string' || !/^https?:\/\//.test(f.base_url)) {
407
+ throw new Error('flow.base_url must be an http(s) URL');
408
+ }
409
+ if (!Array.isArray(f.steps) || f.steps.length === 0) throw new Error('flow.steps[] is required');
410
+ // F255.7 — a sha string or the boolean shorthand. Anything else is a typo that would
411
+ // otherwise silently disable the gate, which is the failure mode the gate exists for.
412
+ if (f.wait_for_build !== undefined && typeof f.wait_for_build !== 'string' && typeof f.wait_for_build !== 'boolean') {
413
+ throw new Error('flow.wait_for_build must be a commit sha (string) or true');
414
+ }
415
+ // F213.10 — same reasoning as wait_for_build, and it matters more here: a typo
416
+ // ("eject_grace" for "eject_grace_ms", a string "1000") would fall back to the
417
+ // default silently. That is survivable. But a NEGATIVE or NaN value would
418
+ // disable the guard while the author believes they raised it, and a guard
419
+ // believed-on-but-off is worse than one honestly off.
420
+ if (f.eject_grace_ms !== undefined && (typeof f.eject_grace_ms !== 'number' || !Number.isFinite(f.eject_grace_ms) || f.eject_grace_ms < 0)) {
421
+ throw new Error('flow.eject_grace_ms must be a non-negative number of milliseconds');
422
+ }
423
+ validateAndNormalizeSteps(f.steps);
424
+ return {
425
+ name: f.name.trim(),
426
+ base_url: f.base_url,
427
+ auth: f.auth,
428
+ mutates: f.mutates === true,
429
+ ...(f.wait_for_build !== undefined ? { wait_for_build: f.wait_for_build } : {}),
430
+ ...(f.eject_grace_ms !== undefined ? { eject_grace_ms: f.eject_grace_ms } : {}),
431
+ steps: f.steps as FlowStep[],
432
+ };
433
+ }
434
+
435
+ // F074.23 — light structural check for a self-healing `target` at the HTTP/MCP
436
+ // boundary (the daemon stays zod-free in this validator; the engine's
437
+ // resolveTarget does the real resolution at run time). A bare string is the
438
+ // selector layer; an object must carry ≥1 locator layer and only known keys.
439
+ const TARGET_KEYS = ['testid', 'css', 'role', 'name', 'label', 'placeholder', 'text', 'vision', 'nth', 'exact'];
440
+ const TARGET_LAYERS = ['testid', 'css', 'role', 'label', 'placeholder', 'text', 'vision'];
441
+ function isValidTarget(t: unknown): boolean {
442
+ if (typeof t === 'string') return t.length > 0;
443
+ if (!t || typeof t !== 'object' || Array.isArray(t)) return false;
444
+ const keys = Object.keys(t);
445
+ return keys.length > 0 && keys.every((k) => TARGET_KEYS.includes(k)) && TARGET_LAYERS.some((k) => k in (t as object));
446
+ }
447
+
448
+ /** F009.119 — the fields each step type uses, beyond the shared ones. Mirrors the
449
+ * FlowStep union above; flow-step-fields.test.ts reads the union from source and
450
+ * fails if the two drift. An unknown field is REFUSED: `inspect` with a guessed
451
+ * `selector` used to run a whole-page dump in silence (cardmem, flow b62ffca3). */
452
+ const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms'];
453
+ const TARGETED = ['testid', 'target'];
454
+ export const STEP_FIELDS: Record<string, string[]> = {
455
+ goto: ['path'],
456
+ click: [...TARGETED, 'activate', 'at'],
457
+ fill: [...TARGETED, 'value', 'valueFrom'],
458
+ select: [...TARGETED, 'value'],
459
+ check: TARGETED,
460
+ uncheck: TARGETED,
461
+ waitFor: TARGETED,
462
+ expectText: [...TARGETED, 'text', 'match'],
463
+ expectVisible: TARGETED,
464
+ expectEditable: TARGETED,
465
+ expectAbsent: ['testid'],
466
+ expectAnimating: [...TARGETED, 'property'],
467
+ expectStill: [...TARGETED, 'property'],
468
+ waitForReady: [...TARGETED, 'events'],
469
+ expectTopmost: TARGETED,
470
+ fillSelector: ['selector', 'value', 'frame'],
471
+ clickSelector: ['selector', 'frame', 'at'],
472
+ waitForBuild: ['sha'],
473
+ waitForUrl: ['url'],
474
+ inspect: ['frame', 'html', 'testid'],
475
+ assert: ['js'],
476
+ screenshot: [],
477
+ clear: TARGETED,
478
+ press: ['key', 'testid'],
479
+ drag: ['testid', 'toTestid'],
480
+ uploadFile: ['testid', 'path'],
481
+ autocomplete: ['testid', 'value', 'option', 'pickText', 'pickIndex'],
482
+ capture: ['testid', 'as', 'from'],
483
+ conditional: ['testid', 'present', 'steps'],
484
+ loop: ['data', 'steps', 'parallel'],
485
+ };
486
+
487
+ function validateAndNormalizeSteps(steps: unknown[], depth = 0): void {
488
+ if (depth > 3) throw new Error('flow loops nested too deep (max 3)');
489
+ for (const [index, raw] of steps.entries()) {
490
+ const s = raw as FlowStep & Record<string, unknown>;
491
+ if (!s || typeof s !== 'object' || !STEP_TYPES.has(s.type)) {
492
+ // F074.50 — name the ones that DO exist. The list is rendered from
493
+ // STEP_TYPES itself, never a copy: a verb added tomorrow appears here
494
+ // without anyone editing this string. Two sessions lost time on one day to
495
+ // this message naming only the guess — pitch-vault hand-rolled synthetic
496
+ // KeyboardEvents while `press` already existed, and storeform concluded
497
+ // their OWN product could not branch while `conditional` already existed.
498
+ throw new Error(
499
+ `invalid step type: ${JSON.stringify((s as { type?: unknown })?.type)} — known: ${[...STEP_TYPES].join(', ')}`,
500
+ );
501
+ }
502
+ // F074.51 — accept `timeout_ms`, the name the rest of this wire teaches
503
+ // (session_id, state_path, base_url, and service.ts's own timeout_ms on the
504
+ // capture body). Until this line it was silently dropped and the caller got
505
+ // the default back inside a message confidently naming a number they never
506
+ // chose — storeform read that as "Play Console is slow" for two days.
507
+ //
508
+ // Normalising HERE rather than inside stepTimeout() is the load-bearing
509
+ // choice: THREE places read step.timeout directly (stepTimeout, the
510
+ // waitForBuild step, and the inspect probe), so a fix in the shared reader
511
+ // alone would leave two verbs still lying. One boundary, every verb, and
512
+ // nested loop/conditional steps too via the recursion below.
513
+ const alias = (s as { timeout_ms?: unknown }).timeout_ms;
514
+ if (typeof alias === 'number' && Number.isFinite(alias) && alias >= 0) s.timeout = alias;
515
+ if (s.type === 'goto' && typeof (s as { path?: unknown }).path !== 'string') {
516
+ throw new Error('goto step requires a string path');
517
+ }
518
+ if (s.type === 'loop') {
519
+ if (!Array.isArray((s as { data?: unknown }).data)) throw new Error('loop step requires data[]');
520
+ if (!Array.isArray((s as { steps?: unknown }).steps)) throw new Error('loop step requires steps[]');
521
+ validateAndNormalizeSteps((s as { steps: unknown[] }).steps, depth + 1);
522
+ }
523
+ // F213.3 — control-flow + composite verbs.
524
+ if (s.type === 'conditional') {
525
+ if (typeof (s as { present?: unknown }).present !== 'boolean') throw new Error('conditional step requires a boolean present');
526
+ if (!Array.isArray((s as { steps?: unknown }).steps)) throw new Error('conditional step requires steps[]');
527
+ validateAndNormalizeSteps((s as { steps: unknown[] }).steps, depth + 1);
528
+ }
529
+ if (s.type === 'autocomplete') {
530
+ if (typeof (s as { value?: unknown }).value !== 'string') throw new Error('autocomplete step requires a string value');
531
+ if (typeof (s as { option?: unknown }).option !== 'string') throw new Error('autocomplete step requires a string option testid');
532
+ const pt = (s as { pickText?: unknown }).pickText;
533
+ const pi = (s as { pickIndex?: unknown }).pickIndex;
534
+ if (typeof pt !== 'string' && typeof pi !== 'number') {
535
+ throw new Error('autocomplete step requires pickText (string) or pickIndex (number)');
536
+ }
537
+ }
538
+ if (s.type === 'capture' && typeof (s as { as?: unknown }).as !== 'string') {
539
+ throw new Error('capture step requires a string "as" name');
540
+ }
541
+ // F009.121 — a fill carries its value inline OR names where to read a secret
542
+ // from; never both (which one would win is a guess), never neither.
543
+ if (s.type === 'fill') {
544
+ const hasValue = typeof (s as { value?: unknown }).value === 'string';
545
+ const vf = (s as { valueFrom?: unknown }).valueFrom;
546
+ if (hasValue && vf !== undefined) throw new Error('fill step takes value OR valueFrom, not both');
547
+ if (!hasValue && vf === undefined) throw new Error('fill step requires a string value or valueFrom {path} | {env}');
548
+ if (vf !== undefined) {
549
+ const keys = vf && typeof vf === 'object' && !Array.isArray(vf) ? Object.keys(vf) : [];
550
+ const k = keys[0];
551
+ if (keys.length !== 1 || (k !== 'path' && k !== 'env') || typeof (vf as Record<string, unknown>)[k] !== 'string' || !(vf as Record<string, string>)[k]) {
552
+ throw new Error('fill valueFrom must be exactly one of {path: "<file>"} or {env: "<NAME>"}');
553
+ }
554
+ }
555
+ }
556
+ // F074.23 — single-anchor steps accept a string testid OR a self-healing
557
+ // `target` (LocateSpec); the rest still require a string testid.
558
+ const targetable = ['click', 'fill', 'select', 'check', 'uncheck', 'waitFor', 'expectText', 'expectVisible', 'expectEditable', 'expectTopmost', 'clear', 'waitForReady', 'expectAnimating', 'expectStill'];
559
+ // `expectAbsent` IS NOT MISSING FROM `targetable` BY OVERSIGHT — do not "fix"
560
+ // the asymmetry by moving it up. Measured 2026-08-18 with components: a
561
+ // LocateSpec resolves through the engine's resolveTarget, and their F071.4
562
+ // makes that resolver WAIT (a race over all layers against the caller's
563
+ // timeout) instead of taking an instant `count()` snapshot. Every positive
564
+ // verb wants that. `expectAbsent` is the one verb that must NOT have it:
565
+ // "prove this is gone" would then always cost the full timeout, and worse,
566
+ // the verb's meaning changes — it would be asserting on whatever the patient
567
+ // race eventually resolved, not on the element it was asked about.
568
+ // Staying testid-only keeps it on `page.locator(sel(testid))`, which is a
569
+ // plain locator this verb times itself (see the settle window below).
570
+ const testidOnly = ['expectAbsent', 'drag', 'autocomplete', 'capture', 'conditional', 'uploadFile'];
571
+ if (testidOnly.includes(s.type) && typeof (s as { testid?: unknown }).testid !== 'string') {
572
+ throw new Error(`${s.type} step requires a string testid`);
573
+ }
574
+ if (targetable.includes(s.type)) {
575
+ const hasTestid = typeof (s as { testid?: unknown }).testid === 'string';
576
+ const hasTarget = isValidTarget((s as { target?: unknown }).target);
577
+ if (!hasTestid && !hasTarget) {
578
+ throw new Error(`${s.type} step requires a string testid or a target (testid/css/role/label/placeholder/text)`);
579
+ }
580
+ }
581
+ // F255.1 — a typo'd activation mode must fail loudly, not silently fall back to 'auto'.
582
+ if (s.type === 'click') {
583
+ const a = (s as { activate?: unknown }).activate;
584
+ if (a !== undefined && a !== 'auto' && a !== 'click' && a !== 'enter') {
585
+ throw new Error("click step activate must be 'auto', 'click' or 'enter'");
586
+ }
587
+ }
588
+ // F255.4 — validate the `at` offset AT PARSE TIME, so a typo is a flow-authoring
589
+ // error rather than a mid-run surprise on step 12.
590
+ if (s.type === 'click' || s.type === 'clickSelector') {
591
+ const at = (s as { at?: unknown }).at;
592
+ if (at !== undefined) {
593
+ if (typeof at !== 'string') throw new Error(`${s.type} step "at" must be a string`);
594
+ parseAtOffset(at);
595
+ }
596
+ }
597
+ // F213.2 — drag needs a target testid; press needs a key (its testid is optional).
598
+ if (s.type === 'drag' && typeof (s as { toTestid?: unknown }).toTestid !== 'string') {
599
+ throw new Error('drag step requires a string toTestid');
600
+ }
601
+ if (s.type === 'press' && typeof (s as { key?: unknown }).key !== 'string') {
602
+ throw new Error('press step requires a string key');
603
+ }
604
+ // F213.6 — uploadFile needs a string path or a non-empty string[] of paths.
605
+ if (s.type === 'uploadFile') {
606
+ const p = (s as { path?: unknown }).path;
607
+ const ok = typeof p === 'string' ? p.length > 0 : Array.isArray(p) && p.length > 0 && p.every((x) => typeof x === 'string' && x.length > 0);
608
+ if (!ok) throw new Error('uploadFile step requires a non-empty string path or string[] path');
609
+ }
610
+ if ((s.type === 'fillSelector' || s.type === 'clickSelector') && typeof (s as { selector?: unknown }).selector !== 'string') {
611
+ throw new Error(`${s.type} step requires a string selector`);
612
+ }
613
+ if (s.type === 'fillSelector' && typeof (s as { value?: unknown }).value !== 'string') {
614
+ throw new Error('fillSelector step requires a string value');
615
+ }
616
+ if (s.type === 'waitForUrl' && typeof (s as { url?: unknown }).url !== 'string') {
617
+ throw new Error('waitForUrl step requires a string url');
618
+ }
619
+ const frameRef = (s as { frame?: unknown }).frame;
620
+ if (
621
+ frameRef !== undefined &&
622
+ typeof frameRef !== 'string' &&
623
+ !(Array.isArray(frameRef) && frameRef.every((x) => typeof x === 'string'))
624
+ ) {
625
+ throw new Error(`${s.type} step frame must be a string or string[]`);
626
+ }
627
+ // Checked LAST, so a step missing its required field keeps the message that
628
+ // names that field — the more useful one when both are wrong.
629
+ const allowed = [...SHARED_STEP_FIELDS, ...(STEP_FIELDS[s.type] ?? [])];
630
+ const unknown = Object.keys(s).filter((k) => !allowed.includes(k));
631
+ if (unknown.length > 0) {
632
+ throw new Error(
633
+ `step ${index} (${s.type}${depth > 0 ? `, nested depth ${depth}` : ''}): unknown field ${unknown.map((k) => JSON.stringify(k)).join(', ')} — ${s.type} accepts: ${allowed.filter((k) => k !== 'type').join(', ')}`,
634
+ );
635
+ }
636
+ }
637
+ }
638
+
639
+ /** Collect every data-testid a flow references — the flow-gate cross-checks
640
+ * these against the repo's inventory (a missing anchor is a hard block). */
641
+ export function flowTestIds(flow: Flow): string[] {
642
+ const ids = new Set<string>();
643
+ const walk = (steps: FlowStep[]) => {
644
+ for (const s of steps) {
645
+ if ('testid' in s && s.testid) ids.add(s.testid);
646
+ if (s.type === 'drag' && s.toTestid) ids.add(s.toTestid); // F213.2 — drag target anchor
647
+ if (s.type === 'autocomplete' && s.option) ids.add(s.option); // F213.3 — suggestion-list anchor
648
+ if (s.type === 'loop' || s.type === 'conditional') walk(s.steps); // F213.3 — conditional block
649
+ }
650
+ };
651
+ walk(flow.steps);
652
+ return [...ids];
653
+ }
654
+
655
+ // ── F082.5 — markdown manuscript → compiled flow ────────────────────────────
656
+ //
657
+ // A human-readable manuscript (fmc-automation style) compiles to the Flow JSON
658
+ // the executor runs. Header lines set name/base/auth/mutates; each `- <verb>`
659
+ // line is a step. testids are written in [brackets]. Grammar:
660
+ //
661
+ // # flow: create-user
662
+ // base: https://staging.example.com
663
+ // mutates: true
664
+ //
665
+ // - goto /signup
666
+ // - fill [email] = alice@example.com
667
+ // - select [role] = admin
668
+ // - check [agree]
669
+ // - click [submit]
670
+ // - waitFor [toast]
671
+ // - expectVisible [success]
672
+ // - expectText [success] ~ Account created
673
+ // - expectAbsent [error]
674
+ // - assert: return document.title.length > 0
675
+ // - screenshot after-submit
676
+ //
677
+ // Frame-aware CSS-selector steps (lens-gap 019eeec7) reach a cross-origin iframe
678
+ // a [testid] can't (Stripe PCI fields). `@frame <a> >> <b>` nests outer→inner;
679
+ // fillSelector's value delimiter is " = " (spaced) so it survives `[name="x"]`:
680
+ //
681
+ // - fillSelector input[name="cardnumber"] = 4242 4242 4242 4242 @frame iframe[name^="__privateStripeFrame"]
682
+ // - clickSelector .SubmitButton @frame iframe[name^="__privateStripeFrame"]
683
+ // - waitForUrl /shop/receipt/
684
+ //
685
+ // Loops are indentation-based:
686
+ //
687
+ // - loop parallel 4:
688
+ // data: [{"n":"Alice","r":"admin"},{"n":"Bob","r":"user"}]
689
+ // - goto /signup
690
+ // - fill [name] = {{n}}
691
+ // - select [role] = {{r}}
692
+ // - click [submit]
693
+ //
694
+ // `data:` is a JSON array; nested `- ` lines are the loop body; `{{field}}`
695
+ // substitutes per row. `parallel N` runs iterations concurrently.
696
+ //
697
+ // F213.3 — composite + control-flow verbs (FMC-level manuscripts):
698
+ //
699
+ // - autocomplete [city] = Cop -> [city-option] "Copenhagen" (pick by text)
700
+ // - autocomplete [city] = Cop -> [city-option] #0 (pick by index)
701
+ // - capture [order-id] as orderId (element text → var)
702
+ // - capture [ref-field] as ref value (input .value → var)
703
+ // - fill [confirm-ref] = {{orderId}} (reuse a captured var)
704
+ //
705
+ // - if [cookie-banner]: # run the block only when the anchor is present
706
+ // - click [cookie-accept]
707
+ // - unless [logged-in]: # run the block only when the anchor is absent
708
+ // - goto /login
709
+ //
710
+ // An `if`/`unless` block whose condition isn't met is SKIPPED (its child steps
711
+ // report `skip`), so a manuscript branches on page state without failing the run.
712
+ interface IndentLine {
713
+ indent: number;
714
+ text: string;
715
+ }
716
+
717
+ export function compileManuscript(md: string): Flow {
718
+ const headers: Record<string, string> = {};
719
+ const lines: IndentLine[] = md
720
+ .split('\n')
721
+ .map((l) => ({ indent: (l.match(/^\s*/)?.[0].length ?? 0), text: l.trim() }))
722
+ .filter((l) => l.text && !l.text.startsWith('//'));
723
+
724
+ let i = 0;
725
+ for (; i < lines.length; i++) {
726
+ const { text } = lines[i]!;
727
+ if (text.startsWith('-')) break;
728
+ if (text.startsWith('#')) {
729
+ const m = text.match(/^#\s*flow:\s*(.+)$/i);
730
+ if (m) headers.name = m[1]!.trim();
731
+ continue;
732
+ }
733
+ const h = text.match(/^(\w+)\s*:\s*(.+)$/);
734
+ if (h) headers[h[1]!.toLowerCase()] = h[2]!.trim();
735
+ }
736
+
737
+ // F074.34 — a `#` line AFTER the preamble is a comment, not a header and not a
738
+ // terminator. It used to end the step block: parseStepBlock breaks on any line
739
+ // that does not start with `-`, so a comment between two steps silently
740
+ // TRUNCATED the manuscript — the surviving prefix ran and the run reported
741
+ // PASS. fd-sundhed lost two asserts and a screenshot that way and got a green
742
+ // run for work that never executed (#18606). Comments appear in the most
743
+ // carefully written manuscripts, because that is where people explain WHY.
744
+ const stepLines = lines.filter((l, idx) => !(idx >= i && l.text.startsWith('#')));
745
+ const stepStart = stepLines.findIndex((l, idx) => idx >= 0 && l.text.startsWith('-'));
746
+ const [steps, consumedTo] = parseStepBlock(
747
+ stepLines,
748
+ stepStart < 0 ? stepLines.length : stepStart,
749
+ stepStart < 0 ? 0 : stepLines[stepStart]!.indent,
750
+ );
751
+
752
+ // F074.34 — and the general guard, which matters more than the comment fix:
753
+ // ANY line the parser did not consume now fails LOUDLY. Silent truncation is
754
+ // the one behaviour a verification tool must never have, because the result
755
+ // is a green run for steps that were never executed. This catches every
756
+ // future unrecognised syntax, not just `#`.
757
+ if (consumedTo < stepLines.length) {
758
+ const bad = stepLines[consumedTo]!;
759
+ throw new Error(
760
+ `manuscript line ${consumedTo + 1} was not understood and would have been silently dropped ` +
761
+ `(every step after it too): "${bad.text.slice(0, 80)}". Steps must start with "- ".`,
762
+ );
763
+ }
764
+
765
+ if (!headers.name) throw new Error('manuscript needs a "# flow: <name>" header');
766
+ if (!headers.base) throw new Error('manuscript needs a "base: <url>" header');
767
+ const flow: Flow = {
768
+ name: headers.name,
769
+ base_url: headers.base,
770
+ steps,
771
+ ...(headers.mutates === 'true' ? { mutates: true } : {}),
772
+ };
773
+ return parseFlow(flow); // reuse the structural validator
774
+ }
775
+
776
+ // Parse `- ` step lines at >= minIndent into FlowSteps, recursing into loop
777
+ // blocks. Returns the steps + the index of the first line NOT consumed.
778
+ function parseStepBlock(lines: IndentLine[], start: number, minIndent: number): [FlowStep[], number] {
779
+ const steps: FlowStep[] = [];
780
+ let i = start;
781
+ while (i < lines.length) {
782
+ const line = lines[i]!;
783
+ if (line.indent < minIndent || !line.text.startsWith('-')) break;
784
+ const body = line.text.replace(/^-\s*/, '');
785
+ // F213.3 — `if [anchor]:` / `unless [anchor]:` block; nested `- ` lines are
786
+ // the conditional body (same indentation grammar as loop).
787
+ const condM = body.match(/^(if|unless)\s+\[([^\]]+)\]\s*:?\s*$/i);
788
+ if (condM) {
789
+ const present = condM[1]!.toLowerCase() === 'if';
790
+ const testid = condM[2]!.trim();
791
+ const [childSteps, nextJ] = parseStepBlock(lines, i + 1, line.indent + 1);
792
+ steps.push({ type: 'conditional', testid, present, steps: childSteps });
793
+ i = nextJ;
794
+ continue;
795
+ }
796
+ const loopM = body.match(/^loop(?:\s+parallel\s+(\d+))?\s*:?\s*$/i);
797
+ if (loopM) {
798
+ const parallel = loopM[1] ? Number(loopM[1]) : undefined;
799
+ let j = i + 1;
800
+ let data: Array<Record<string, unknown>> = [];
801
+ // child non-`-` lines (data:) before the nested steps
802
+ while (j < lines.length && lines[j]!.indent > line.indent && !lines[j]!.text.startsWith('-')) {
803
+ const d = lines[j]!.text.match(/^data\s*:\s*(.+)$/i);
804
+ if (d) {
805
+ try {
806
+ data = JSON.parse(d[1]!);
807
+ } catch (err) {
808
+ throw new Error(`loop data is not valid JSON: ${err instanceof Error ? err.message : err}`);
809
+ }
810
+ }
811
+ j++;
812
+ }
813
+ const [childSteps, nextJ] = parseStepBlock(lines, j, line.indent + 1);
814
+ steps.push({ type: 'loop', data, steps: childSteps, ...(parallel ? { parallel } : {}) });
815
+ i = nextJ;
816
+ continue;
817
+ }
818
+ steps.push(parseManuscriptStep(body));
819
+ i++;
820
+ }
821
+ return [steps, i];
822
+ }
823
+
824
+ // F082.5 STRETCH — autonomous discovery: propose a candidate smoke-flow from a
825
+ // project's route map (manifest surface paths) + their root [data-testid]
826
+ // anchors, with no hand-written manuscript. A starting draft the author refines
827
+ // (visit each route, assert its anchor is visible, screenshot). `surfaces` are
828
+ // {path, testid} pairs the caller extracts from the manifest/inventory.
829
+ export function proposeFlow(
830
+ name: string,
831
+ baseUrl: string,
832
+ surfaces: Array<{ path: string; testid: string }>,
833
+ ): Flow {
834
+ const steps: FlowStep[] = [];
835
+ for (const s of surfaces) {
836
+ steps.push({ type: 'goto', path: s.path });
837
+ steps.push({ type: 'expectVisible', testid: s.testid });
838
+ steps.push({ type: 'screenshot', label: s.testid });
839
+ }
840
+ if (steps.length === 0) throw new Error('no surfaces with a [data-testid] anchor to propose a flow from');
841
+ return { name, base_url: baseUrl, steps };
842
+ }
843
+
844
+ // Split a manuscript step's optional " @frame <a> >> <b>" suffix off. Returns
845
+ // [mainPart, frameChain|null]; frames nest outer→inner, '>>'-separated.
846
+ function splitFrame(s: string): [string, string[] | null] {
847
+ const at = s.search(/\s@frame\s/i);
848
+ if (at < 0) return [s, null];
849
+ const chain = s
850
+ .slice(at)
851
+ .replace(/^\s@frame\s/i, '')
852
+ .split('>>')
853
+ .map((x) => x.trim())
854
+ .filter(Boolean);
855
+ return [s.slice(0, at), chain.length ? chain : null];
856
+ }
857
+
858
+ /**
859
+ * F074.55 — the verbs the PROSE parser accepts, in the casing an author writes.
860
+ *
861
+ * Three repos have now reported a Lens verb as "missing" when it was present:
862
+ * pitch-vault hand-rolled synthetic KeyboardEvents rather than use `press`,
863
+ * storeform found the vocabulary by failing through four wrong guesses, and
864
+ * fd-sundhed reported `press` as absent. In every case the ability existed and
865
+ * there was no route to it — the tool description listed 9 of these by hand and
866
+ * the rejection message named none.
867
+ *
868
+ * An ability that exists and cannot be found costs the same as one that is
869
+ * missing, and it is worse on a board: nobody files a card about something they
870
+ * believe does not exist.
871
+ *
872
+ * This list is the ONE source. The rejection message and the MCP tool
873
+ * descriptions both read it, and a test fails if it disagrees with what the
874
+ * switch below actually accepts — in either direction.
875
+ */
876
+ export const MANUSCRIPT_VERBS = [
877
+ 'goto', 'click', 'fill', 'select', 'check', 'uncheck', 'clear',
878
+ 'press', 'drag', 'upload', 'autocomplete',
879
+ 'waitFor', 'waitForUrl', 'waitForBuild',
880
+ 'expectText', 'expectVisible', 'expectEditable', 'expectAbsent', 'expectTopmost',
881
+ 'fillSelector', 'clickSelector',
882
+ 'inspect', 'assert', 'screenshot', 'capture',
883
+ ] as const;
884
+
885
+ function parseManuscriptStep(s: string): FlowStep {
886
+ const testid = () => {
887
+ const m = s.match(/\[([^\]]+)\]/);
888
+ if (!m) throw new Error(`step "${s}" needs a [testid]`);
889
+ return m[1]!.trim();
890
+ };
891
+ const verb = s.split(/\s|\[|:/)[0]!.toLowerCase();
892
+ switch (verb) {
893
+ case 'goto':
894
+ return { type: 'goto', path: s.replace(/^goto\s+/i, '').trim() };
895
+ // F255.1 — `click [testid] via enter` / `via click` overrides the default 'auto'
896
+ // activation (click, then focus+Enter only if nothing observable happened).
897
+ // F255.4 — `click [testid] @ 50%,28%` clicks that point inside the element.
898
+ case 'click': {
899
+ const via = s.match(/\bvia\s+(auto|click|enter)\b/i);
900
+ const at = s.match(/@\s*(-?[\d.]+%?\s*[, ]\s*-?[\d.]+%?)\s*$/);
901
+ const step: FlowStep = { type: 'click', testid: testid() };
902
+ if (via) step.activate = via[1]!.toLowerCase() as ActivateMode;
903
+ if (at) step.at = at[1]!.trim();
904
+ return step;
905
+ }
906
+ case 'check': return { type: 'check', testid: testid() };
907
+ case 'uncheck': return { type: 'uncheck', testid: testid() };
908
+ case 'waitfor': return { type: 'waitFor', testid: testid() };
909
+ case 'expectvisible': return { type: 'expectVisible', testid: testid() };
910
+ case 'expecteditable': return { type: 'expectEditable', testid: testid() };
911
+ case 'expectabsent': return { type: 'expectAbsent', testid: testid() };
912
+ case 'expecttopmost': return { type: 'expectTopmost', testid: testid() };
913
+ case 'fill': {
914
+ // F009.121 — "fill [pw] from file <path>" / "from env <NAME>": the secret
915
+ // is read at run time, so the manuscript (and its label) never holds it.
916
+ const from = s.match(/\]\s+from\s+(file|env)\s+(\S.*)$/i);
917
+ if (from) {
918
+ const src = from[2]!.trim();
919
+ return { type: 'fill', testid: testid(), valueFrom: from[1]!.toLowerCase() === 'file' ? { path: src } : { env: src } };
920
+ }
921
+ const v = s.split('=').slice(1).join('=').trim();
922
+ return { type: 'fill', testid: testid(), value: v };
923
+ }
924
+ case 'select': {
925
+ const v = s.split('=').slice(1).join('=').trim();
926
+ return { type: 'select', testid: testid(), value: v };
927
+ }
928
+ case 'clear': return { type: 'clear', testid: testid() };
929
+ case 'press': {
930
+ // "press Enter" (global) or "press Enter [testid]" (on a focused element).
931
+ const hasTestid = /\[[^\]]+\]/.test(s);
932
+ const key = s.replace(/^press\s+/i, '').replace(/\s*\[[^\]]+\]\s*$/, '').trim();
933
+ if (!key) throw new Error(`press step "${s}" needs a key (e.g. "press Enter")`);
934
+ return { type: 'press', key, ...(hasTestid ? { testid: testid() } : {}) };
935
+ }
936
+ case 'drag': {
937
+ // "drag [source] -> [target]" (any two [testid]s in order).
938
+ const ids = [...s.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]!.trim());
939
+ if (ids.length < 2) throw new Error(`drag step "${s}" needs [source] and [target] testids`);
940
+ return { type: 'drag', testid: ids[0]!, toTestid: ids[1]! };
941
+ }
942
+ case 'upload': {
943
+ // "upload [input-testid] = <fixture>" — a repo-relative fixture path
944
+ // (test/fixtures/lens/…), a comma-separated list for a multi-file upload,
945
+ // or a single data: URI. F213.6.
946
+ const id = testid();
947
+ const rhs = s.split('=').slice(1).join('=').trim();
948
+ if (!rhs) throw new Error(`upload step "${s}" needs "= <fixture path>"`);
949
+ const parts = rhs.startsWith('data:') ? [rhs] : rhs.split(',').map((x) => x.trim()).filter(Boolean);
950
+ return { type: 'uploadFile', testid: id, path: parts.length === 1 ? parts[0]! : parts };
951
+ }
952
+ case 'autocomplete': {
953
+ // "autocomplete [input] = <typed value> -> [option] "<text>"" (pick by text)
954
+ // or "... -> [option] #<index>" (pick by nth suggestion).
955
+ const ids = [...s.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]!.trim());
956
+ if (ids.length < 2) throw new Error(`autocomplete step "${s}" needs [input] and [option] testids`);
957
+ const valM = s.match(/=\s*(.+?)\s*->/);
958
+ if (!valM) throw new Error(`autocomplete step "${s}" needs "= <value> -> [option]"`);
959
+ const tail = s.slice(s.lastIndexOf(']') + 1).trim();
960
+ const idxM = tail.match(/^#(\d+)/);
961
+ const textM = tail.match(/^"([^"]*)"/) ?? tail.match(/^'([^']*)'/);
962
+ const baseStep = { type: 'autocomplete' as const, testid: ids[0]!, option: ids[1]!, value: valM[1]!.trim() };
963
+ if (idxM) return { ...baseStep, pickIndex: Number(idxM[1]) };
964
+ if (textM) return { ...baseStep, pickText: textM[1]! };
965
+ throw new Error(`autocomplete step "${s}" needs a pick after [option]: "text" or #index`);
966
+ }
967
+ case 'capture': {
968
+ // "capture [testid] as <name>" (element text) or "... as <name> value" (input value).
969
+ const m = s.match(/^capture\s+\[([^\]]+)\]\s+as\s+(\w+)(\s+value)?\s*$/i);
970
+ if (!m) throw new Error(`capture step "${s}" needs "capture [testid] as <name> [value]"`);
971
+ return { type: 'capture', testid: m[1]!.trim(), as: m[2]!, ...(m[3] ? { from: 'value' as const } : {}) };
972
+ }
973
+ case 'expecttext': {
974
+ // F074.56 — the operator is REQUIRED, and the expected text may not be empty.
975
+ //
976
+ // This was `s.split('~').slice(1).join('~').trim()`, so a line WITHOUT a
977
+ // tilde produced text:"" and asserted "contains the empty string" — true of
978
+ // every element that exists. Reported by moovyy (#21738) and reproduced:
979
+ // `expectText [board-root] = <text nowhere on the page>` ran GREEN
980
+ // (e93303e6-9ab8-4fc5-8ebb-82e1431c6c87, label `⊇ ""`) while the same
981
+ // assertion with `~` failed correctly (25ad7063). The runner was sound; the
982
+ // parser dropped the expected text.
983
+ //
984
+ // Three spellings reached that no-op — an unrecognised operator, no operator
985
+ // at all, and a bare `~` — so all three are refused HERE, at compile time.
986
+ // A runtime warning on a run that still reports PASS is the defect in a hat.
987
+ const afterId = s.replace(/^expectText\s*/i, '').replace(/\[[^\]]*\]/, '').trim();
988
+ const m = /^(==|!=|~)([\s\S]*)$/.exec(afterId);
989
+ if (!m) {
990
+ throw new Error(
991
+ `expectText step "${s}" needs an operator: "~" (contains), "==" (equals exactly) or "!=" (differs). ` +
992
+ `Without one the expected text is dropped and the step passes on any element.`,
993
+ );
994
+ }
995
+ const text = m[2]!.trim();
996
+ if (!text) {
997
+ throw new Error(
998
+ `expectText step "${s}" has no expected text — that asserts the empty string, which passes on every element.`,
999
+ );
1000
+ }
1001
+ const match = m[1] === '~' ? 'contains' : m[1] === '==' ? 'equals' : 'not-equals';
1002
+ return { type: 'expectText', testid: testid(), text, match };
1003
+ }
1004
+ case 'waitforurl':
1005
+ return { type: 'waitForUrl', url: s.replace(/^waitForUrl\s+/i, '').trim() };
1006
+ case 'fillselector': {
1007
+ // Value delimiter is " = " (spaced) so it survives a CSS attribute
1008
+ // selector like input[name="cardnumber"] that itself contains '='.
1009
+ const [main, frame] = splitFrame(s);
1010
+ const afterVerb = main.replace(/^fillSelector\s+/i, '');
1011
+ const eq = afterVerb.indexOf(' = ');
1012
+ if (eq < 0) throw new Error(`fillSelector step "${s}" needs " = <value>"`);
1013
+ return {
1014
+ type: 'fillSelector',
1015
+ selector: afterVerb.slice(0, eq).trim(),
1016
+ value: afterVerb.slice(eq + 3).trim(),
1017
+ ...(frame ? { frame } : {}),
1018
+ };
1019
+ }
1020
+ case 'clickselector': {
1021
+ const [main, frame] = splitFrame(s);
1022
+ return {
1023
+ type: 'clickSelector',
1024
+ selector: main.replace(/^clickSelector\s+/i, '').trim(),
1025
+ ...(frame ? { frame } : {}),
1026
+ };
1027
+ }
1028
+ case 'inspect': {
1029
+ const [, frame] = splitFrame(s);
1030
+ return { type: 'inspect', ...(frame ? { frame } : {}) };
1031
+ }
1032
+ case 'assert':
1033
+ return { type: 'assert', js: s.replace(/^assert\s*:?\s*/i, '').trim() };
1034
+ case 'screenshot':
1035
+ return { type: 'screenshot', label: s.replace(/^screenshot\s*/i, '').trim() || undefined };
1036
+ // F255.7 — `waitForBuild <sha>` or bare `waitForBuild` (any change from the live build).
1037
+ case 'waitforbuild': {
1038
+ const sha = s.replace(/^waitforbuild\s*/i, '').trim();
1039
+ return { type: 'waitForBuild', ...(sha ? { sha } : {}) };
1040
+ }
1041
+ default:
1042
+ // Naming the alternatives is the whole fix: the FLOW path has answered
1043
+ // "known: …" since the earlier round of this class, and the prose path —
1044
+ // the one we tell repos to start with — never got the same sentence.
1045
+ throw new Error(
1046
+ `unknown manuscript verb: "${verb}" in "${s}" — known: ${MANUSCRIPT_VERBS.join(', ')}`,
1047
+ );
1048
+ }
1049
+ }
1050
+
1051
+ // The number of leaf steps a step-list expands to (a loop contributes
1052
+ // data.length × its body length). Used to pre-allocate a contiguous index block
1053
+ // per loop iteration, so the per-step report stays deterministically ordered
1054
+ // even when iterations run concurrently (F082.4 parallel workers).
1055
+ function flattenCount(steps: FlowStep[]): number {
1056
+ let n = 0;
1057
+ for (const s of steps) {
1058
+ if (s.type === 'loop') n += s.data.length * flattenCount(s.steps);
1059
+ else if (s.type === 'conditional') n += flattenCount(s.steps); // F213.3 — body counts once (run OR skipped, indices stay aligned)
1060
+ else n += 1;
1061
+ }
1062
+ return n;
1063
+ }
1064
+
1065
+ // Bounded-concurrency pool: run `tasks` with at most `limit` in flight.
1066
+ async function runPool(tasks: Array<() => Promise<void>>, limit: number): Promise<void> {
1067
+ let cursor = 0;
1068
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
1069
+ for (;;) {
1070
+ const i = cursor++;
1071
+ if (i >= tasks.length) return;
1072
+ await tasks[i]!();
1073
+ }
1074
+ });
1075
+ await Promise.all(workers);
1076
+ }
1077
+
1078
+ function subst(str: string, vars: Record<string, unknown>): string {
1079
+ return str.replace(/\{\{(\w+)\}\}/g, (_, k) => (k in vars ? String(vars[k]) : `{{${k}}}`));
1080
+ }
1081
+
1082
+ const sel = (testid: string) => `[data-testid="${testid}"]`;
1083
+ const stepTimeout = (s: { timeout?: number }) => s.timeout ?? 8000;
1084
+
1085
+ // F082.x (lens-gap 019eeec7) — resolve a CSS-selector locator that may live
1086
+ // inside a cross-origin frame chain. `frame` is one iframe selector or an
1087
+ // outer→inner chain; absent = the top frame. Playwright drives a real browser,
1088
+ // so it CAN type into a 3rd-party PCI iframe (Stripe card fields) that page-
1089
+ // context JS can't touch — that's the whole reason this primitive exists.
1090
+ function resolveLocator(page: Page, selector: string, frame?: string | string[]) {
1091
+ const chain = frame == null ? [] : Array.isArray(frame) ? frame : [frame];
1092
+ if (chain.length === 0) return page.locator(selector);
1093
+ let fl = page.frameLocator(chain[0]!);
1094
+ for (let i = 1; i < chain.length; i++) fl = fl.frameLocator(chain[i]!);
1095
+ return fl.locator(selector);
1096
+ }
1097
+
1098
+ // F074.23 — resolve a single-anchor step's Locator. A self-healing LocateSpec
1099
+ // `target` goes through the engine's ONE resolver (shared with cloud runFlow so
1100
+ // the self-heal priority can't drift); otherwise the classic [data-testid] path,
1101
+ // byte-identical to before. Returns the matched layer (resolved_via) for the
1102
+ // step report's audit trail (null on the testid path).
1103
+ // F071.4 (@broberg/lens-engine 0.8.0) — the resolver now WAITS, so it SPENDS the
1104
+ // caller's budget, and the verb after it must wait on what is LEFT.
1105
+ //
1106
+ // This is the whole point of the breaking signature, and getting it wrong here
1107
+ // would reproduce F074.51 inside its own fix: pass 2 burns `timeout_ms` racing
1108
+ // the layers, then a verb that also waits `timeout_ms` makes a caller who asked
1109
+ // for 5s wait 10 and be told 5. `remaining_ms` never reaches 0 — Playwright reads
1110
+ // `timeout: 0` as "wait forever", so the engine floors it at 1.
1111
+ //
1112
+ // The classic testid path costs nothing to resolve, so it hands the FULL budget
1113
+ // on: that path never waited, and must not start paying for a race it does not run.
1114
+ async function locate(
1115
+ page: Page,
1116
+ step: { type: string; testid?: string; target?: Target; timeout?: number },
1117
+ ): Promise<{ loc: Locator; via: string | null; remaining: number }> {
1118
+ const budget = stepTimeout(step);
1119
+ if (step.target !== undefined) {
1120
+ const r = await resolveTarget(page, step.target, { action: step.type, timeoutMs: budget });
1121
+ return { loc: r.locator, via: r.resolved_via, remaining: r.remaining_ms };
1122
+ }
1123
+ return { loc: page.locator(sel(step.testid!)).first(), via: null, remaining: budget };
1124
+ }
1125
+
1126
+ // A compact human label for a target (for step descriptions + failure messages).
1127
+ function targetSummary(t: Target): string {
1128
+ if (typeof t === 'string') return t;
1129
+ const layer = TARGET_LAYERS.find((k) => k in (t as Record<string, unknown>));
1130
+ const val = layer ? (t as Record<string, unknown>)[layer] : '';
1131
+ const name = (t as { name?: string }).name;
1132
+ return `${layer ?? '?'}=${val}${name ? ` "${name}"` : ''}`;
1133
+ }
1134
+
1135
+ /** How a step addressed its target, for a report. */
1136
+ function anchorOf(step: { testid?: string; target?: Target }): string {
1137
+ if (step.testid) return `[data-testid="${step.testid}"]`;
1138
+ return step.target ? targetSummary(step.target) : 'the field';
1139
+ }
1140
+
1141
+ /** F074.99 — how long an edit verb waits for the page to start listening.
1142
+ *
1143
+ * FAR shorter than the step timeout, and that is the whole posture. MEASURED
1144
+ * against fd-sundhed's A/B rig (same server, same port, two hostnames): a
1145
+ * hydrated page answers after 204/177/162ms, a dead one takes 8.3s to give its
1146
+ * verdict. A static form with five fills must not pay 40s to learn something
1147
+ * true about all five, so the wait gets its own budget and the write proceeds
1148
+ * either way. */
1149
+ const READINESS_BUDGET_MS = 1_000;
1150
+
1151
+ /**
1152
+ * F074.99 part 1 — ask the F213.8 probe whether anything is listening BEFORE an
1153
+ * edit verb writes, and record the answer without acting on it.
1154
+ *
1155
+ * WHY THIS IS NOT A STEP THE AUTHOR TYPES. `waitForReady` has existed for a month
1156
+ * and is correct; fd-sundhed had it written into their own memory and still got 12
1157
+ * of 12 green steps over two empty fields. A check that depends on someone
1158
+ * remembering it at the right moment is the person, not the check.
1159
+ *
1160
+ * WHY IT DOES NOT FAIL THE RUN. A plain HTML form, a server-rendered page and a
1161
+ * static site all legitimately never listen. Reddening them would be a correction
1162
+ * for some and a regression for others on the same day, and a gate that reddens
1163
+ * working flows gets switched off rather than fixed. Escalating stays available as
1164
+ * its own announced decision.
1165
+ *
1166
+ * THE WAIT IS NOT A COST IT ADDS. `fill` already waits for VISIBILITY, and the
1167
+ * ~180ms measured above is how long hydration runs AFTER the element is visible —
1168
+ * so today the write lands inside that window. This removes a race; it does not
1169
+ * buy one.
1170
+ */
1171
+ async function consultReadiness(
1172
+ page: Page,
1173
+ loc: Locator,
1174
+ step: { testid?: string; target?: Target },
1175
+ readySink?: NotListeningFinding[],
1176
+ ): Promise<void> {
1177
+ if (!readySink) return;
1178
+ const url = safeUrl(page) ?? '';
1179
+ // Once a page has answered, the other edit verbs on it skip the probe. One
1180
+ // finding is what a reader acts on, and five identical ones would each cost the
1181
+ // budget to say the same thing.
1182
+ if (readySink.some((f) => f.url === url)) return;
1183
+ const events = [...EDIT_EVENTS];
1184
+ const anchor = anchorOf(step);
1185
+ const r = await waitForListeningOn(
1186
+ // Asked through the LOCATOR the verb is about to write to, not by re-querying
1187
+ // a selector: a selector that matches several elements would probe one element
1188
+ // and write to another, and the two answers would be about different things.
1189
+ () =>
1190
+ loc
1191
+ .evaluate(readinessOfElement, events, { timeout: READINESS_BUDGET_MS })
1192
+ .then((v) => v as ReadinessResult)
1193
+ .catch(() => null),
1194
+ anchor,
1195
+ events,
1196
+ READINESS_BUDGET_MS,
1197
+ );
1198
+ if (r.ok) return;
1199
+ // 'no-element' is a selector problem, not a hydration one, and the verb's own
1200
+ // wait reports it properly a moment later. Never answered at all → we have no
1201
+ // verdict to report, and whatever stopped the probe will stop the write too, so
1202
+ // the step fails loudly on its own rather than silently here.
1203
+ if (!r.answered || (r.state !== 'not-listening' && r.state !== 'uninstrumented')) return;
1204
+ readySink.push({
1205
+ anchor,
1206
+ url,
1207
+ state: r.state,
1208
+ waited_ms: r.waitedMs,
1209
+ message: notListeningMessage(r.state, anchor, r.waitedMs, events),
1210
+ });
1211
+ }
1212
+
1213
+ function describe(step: FlowStep): string {
1214
+ switch (step.type) {
1215
+ case 'goto': return `goto ${step.path}`;
1216
+ case 'assert': return `assert ${step.js.slice(0, 40)}`;
1217
+ case 'screenshot': return step.label ?? 'screenshot';
1218
+ case 'expectText': {
1219
+ const sym = step.match === 'equals' ? '≡' : step.match === 'not-equals' ? '≢' : '⊇';
1220
+ return `expectText ${step.testid ?? targetSummary(step.target!)} ${sym} "${step.text}"`;
1221
+ }
1222
+ case 'fillSelector': return `fillSelector ${step.selector}`;
1223
+ case 'clickSelector': return `clickSelector ${step.selector}${step.at ? ` @ ${step.at}` : ''}`;
1224
+ case 'waitForUrl': return `waitForUrl ${step.url}`;
1225
+ case 'inspect': return step.html ? `inspect html${step.testid ? ` [${step.testid}]` : ''}` : step.frame ? 'inspect @frame' : 'inspect';
1226
+ case 'drag': return `drag ${step.testid} → ${step.toTestid}`;
1227
+ case 'uploadFile': return `upload ${Array.isArray(step.path) ? `${step.path.length} files` : (step.path.startsWith('data:') ? 'data:…' : step.path)} → ${step.testid}`;
1228
+ case 'press': return `press ${step.key}${step.testid ? ` @${step.testid}` : ''}`;
1229
+ case 'autocomplete': return `autocomplete ${step.testid} → ${step.pickText !== undefined ? `"${step.pickText}"` : `#${step.pickIndex}`}`;
1230
+ case 'capture': return `capture ${step.testid} → {{${step.as}}}`;
1231
+ case 'conditional': return `${step.present ? 'if' : 'unless'} [${step.testid}]`;
1232
+ case 'loop': return 'loop';
1233
+ case 'waitForBuild': return `waitForBuild${step.sha ? ` ${step.sha.slice(0, 8)}` : ' (changed)'}`;
1234
+ // F255.4 — show the offset in the step label so a positioned click is
1235
+ // recognisable in the report (a plain "click canvas" hides where it aimed).
1236
+ case 'click':
1237
+ return `click ${step.testid ?? targetSummary(step.target!)}${step.at ? ` @ ${step.at}` : ''}`;
1238
+ default: {
1239
+ const s2 = step as { testid?: string; target?: Target };
1240
+ const anchor = s2.testid ?? (s2.target !== undefined ? targetSummary(s2.target) : '');
1241
+ return `${step.type} ${anchor}`.trim();
1242
+ }
1243
+ }
1244
+ }
1245
+
1246
+ function testidOf(step: FlowStep): string | null {
1247
+ return 'testid' in step && step.testid ? step.testid : null;
1248
+ }
1249
+
1250
+ /** F255.4 — parse an `at` offset: "50%,28%" (fraction of the element box) or "40,120"
1251
+ * (css pixels from its top-left). Mixed units are allowed ("50%,20"). Throws on garbage
1252
+ * so a typo fails the step instead of silently clicking the centre — a canvas click that
1253
+ * lands in the wrong place looks like "the app ignored me", the hardest bug to read. */
1254
+ /**
1255
+ * F074.56 — the expectText comparison, as a pure decision.
1256
+ *
1257
+ * Lives here rather than inline in runFlow because the runtime path needs a
1258
+ * browser and CI has none: a comparison nothing executes is the same defect this
1259
+ * card is about, one file along.
1260
+ *
1261
+ * `equals` trims — the DOM adds surrounding whitespace, the author did not — and
1262
+ * normalises nothing else. `not-equals` additionally REFUSES an empty actual:
1263
+ * different-because-there-is-nothing is not proof of anything, and letting it
1264
+ * pass would rebuild this card's own defect one level down.
1265
+ */
1266
+ export function textMatches(
1267
+ mode: 'contains' | 'equals' | 'not-equals',
1268
+ actual: string,
1269
+ want: string,
1270
+ ): boolean {
1271
+ const got = actual.trim();
1272
+ if (mode === 'contains') return actual.includes(want);
1273
+ if (mode === 'equals') return got === want;
1274
+ return got !== '' && got !== want;
1275
+ }
1276
+
1277
+ export function parseAtOffset(at: string): { x: number; y: number; xPct: boolean; yPct: boolean } {
1278
+ const m = at.trim().match(/^(-?[\d.]+)(%?)\s*[, ]\s*(-?[\d.]+)(%?)$/);
1279
+ if (!m) throw new Error(`click "at" must look like "50%,28%" or "40,120" — got "${at}"`);
1280
+ const x = Number(m[1]);
1281
+ const y = Number(m[3]);
1282
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error(`click "at" has a non-numeric coordinate: "${at}"`);
1283
+ return { x, y, xPct: m[2] === '%', yPct: m[4] === '%' };
1284
+ }
1285
+
1286
+ /** Turn an `at` offset into Playwright's element-relative click position (css px). */
1287
+ export async function positionFor(loc: Locator, at: string): Promise<{ x: number; y: number }> {
1288
+ const o = parseAtOffset(at);
1289
+ if (!o.xPct && !o.yPct) return { x: o.x, y: o.y };
1290
+ const box = await loc.boundingBox();
1291
+ if (!box) throw new Error('cannot resolve a percentage click position: the element has no box');
1292
+ return {
1293
+ x: o.xPct ? (box.width * o.x) / 100 : o.x,
1294
+ y: o.yPct ? (box.height * o.y) / 100 : o.y,
1295
+ };
1296
+ }
1297
+
1298
+ /** F255.1 — is this target a button-like control? Only those get the Enter fallback:
1299
+ * radios, labels and links register fine with a native click. Must be a real function,
1300
+ * not a source string: Playwright evaluates a string as an EXPRESSION, so the arrow
1301
+ * source came back as an unserialisable function and the guard silently read false —
1302
+ * which is why the fallback never fired on the fixture that reproduces the bug. */
1303
+ function isButtonLike(el: Element): boolean {
1304
+ const t = el.tagName.toLowerCase();
1305
+ if (t === 'button') return true;
1306
+ if (t === 'input') return ['submit', 'button', 'reset'].includes((el as HTMLInputElement).type);
1307
+ return el.getAttribute('role') === 'button';
1308
+ }
1309
+
1310
+ /** How long to watch for an effect before deciding the click was a silent no-op. Polled,
1311
+ * so a click that works costs only the first tick — not the whole window. */
1312
+ /** F213.9 — how long a page must have been up before "the element is not there" is
1313
+ * allowed to mean "it is gone" rather than "it has not mounted yet". Charged only
1314
+ * for the time the page has NOT already been up, so a late expectAbsent is free. */
1315
+ const ABSENCE_SETTLE_MS = 1200;
1316
+ const ACTIVATION_PROBE_MS = 700;
1317
+ const ACTIVATION_TICK_MS = 50;
1318
+
1319
+ /** F255.1 — fire a button and make sure the activation actually TOOK.
1320
+ *
1321
+ * Angular/MDC forms (Google Play Console and friends) do not run their save handler on a
1322
+ * synthetic mouse click: the click "succeeds", nothing persists, and a later Next stays
1323
+ * disabled because the form still holds unsaved changes. That is a silent false pass —
1324
+ * the worst failure mode a verification tool has, because the run goes green on a lie.
1325
+ * StoreForm proved the cure by hand (focus the button, press Enter); this makes it the
1326
+ * engine's job so no other repo has to rediscover it.
1327
+ *
1328
+ * The fallback only fires when NOTHING observable happened: no DOM mutation, no network
1329
+ * request, no navigation, within ACTIVATION_PROBE_MS. That guard is what makes it safe to
1330
+ * have on by default — a Send/Pay button that DID work always trips at least one of those
1331
+ * three signals, so it can never be double-fired. `activate:'click'` opts out entirely.
1332
+ * Returns true when the Enter fallback was used (so a caller could surface it). */
1333
+ export async function clickAndActivate(
1334
+ page: Page,
1335
+ loc: Locator,
1336
+ mode: ActivateMode,
1337
+ timeout: number,
1338
+ ): Promise<boolean> {
1339
+ if (mode === 'enter') {
1340
+ await loc.waitFor({ state: 'visible', timeout });
1341
+ await loc.evaluate((el) => (el as HTMLElement).focus());
1342
+ await page.keyboard.press('Enter');
1343
+ return true;
1344
+ }
1345
+ if (mode === 'click') {
1346
+ await loc.click({ timeout });
1347
+ return false;
1348
+ }
1349
+
1350
+ const buttonLike = await loc.evaluate(isButtonLike).catch(() => false);
1351
+ if (!buttonLike) {
1352
+ await loc.click({ timeout });
1353
+ return false;
1354
+ }
1355
+
1356
+ // Count only requests a BUTTON would plausibly cause. Counting every request made
1357
+ // the guard useless: a page's favicon fetch alone was enough to look like "the click
1358
+ // worked", so the fallback never fired on the very fixture that reproduces the bug.
1359
+ let requests = 0;
1360
+ const onRequest = (req: { resourceType: () => string }) => {
1361
+ const t = req.resourceType();
1362
+ if (t === 'xhr' || t === 'fetch' || t === 'document') requests += 1;
1363
+ };
1364
+ page.on('request', onRequest);
1365
+ const urlBefore = page.url();
1366
+ await page
1367
+ .evaluate(() => {
1368
+ const w = window as unknown as { __lensMut?: number; __lensMo?: MutationObserver };
1369
+ w.__lensMut = 0;
1370
+ w.__lensMo?.disconnect();
1371
+ const mo = new MutationObserver((records) => {
1372
+ w.__lensMut = (w.__lensMut ?? 0) + records.length;
1373
+ });
1374
+ // F255.9 — a MutationObserver does not cross shadow boundaries, so a click
1375
+ // whose only effect is inside a web component read as a silent no-op and got
1376
+ // a second activation (a switch toggled back). Observe every OPEN shadow root.
1377
+ const opts = { subtree: true, childList: true, attributes: true, characterData: true };
1378
+ const observe = (root: Document | ShadowRoot) => {
1379
+ mo.observe(root instanceof Document ? root.documentElement : root, opts);
1380
+ root.querySelectorAll('*').forEach((el) => {
1381
+ if (el.shadowRoot) observe(el.shadowRoot);
1382
+ });
1383
+ };
1384
+ observe(document);
1385
+ w.__lensMo = mo;
1386
+ })
1387
+ .catch(() => {});
1388
+
1389
+ // F255.9 — focus moving is an effect too: a handler that hands focus elsewhere
1390
+ // did something, and an Enter sent after it lands on the NEW element.
1391
+ const focusBefore = await page.evaluateHandle(() => {
1392
+ let a: Element | null = document.activeElement;
1393
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
1394
+ return a;
1395
+ });
1396
+
1397
+ let target: Awaited<ReturnType<Locator['elementHandle']>> | null = null;
1398
+ try {
1399
+ // F009.118 — take the handle BEFORE the click. Taken after, a button the click
1400
+ // removed never resolves, and elementHandle() waited out Playwright's 30 s default
1401
+ // before the catch turned it into null — every dialog-close click took 30 s.
1402
+ target = await loc.elementHandle({ timeout }).catch(() => null);
1403
+ await loc.click({ timeout });
1404
+ const deadline = Date.now() + ACTIVATION_PROBE_MS;
1405
+ for (;;) {
1406
+ const mutations = await page
1407
+ .evaluate(() => (window as unknown as { __lensMut?: number }).__lensMut ?? 0)
1408
+ .catch(() => 1); // page navigated away → that IS an effect
1409
+ if (mutations > 0 || requests > 0 || page.url() !== urlBefore) return false;
1410
+ const focusMoved = await page
1411
+ .evaluate(
1412
+ ([before, clicked]) => {
1413
+ let a: Element | null = document.activeElement;
1414
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
1415
+ // The click itself focusing its own button is not an effect.
1416
+ return a !== before && a !== clicked;
1417
+ },
1418
+ [focusBefore, target] as const,
1419
+ )
1420
+ .catch(() => false);
1421
+ if (focusMoved) return false;
1422
+ if (Date.now() >= deadline) break;
1423
+ await page.waitForTimeout(ACTIVATION_TICK_MS);
1424
+ }
1425
+ // Nothing moved: DOM untouched, no request, same URL. This is the Angular/MDC
1426
+ // silent no-op — activate the button for real.
1427
+ await loc.evaluate((el) => (el as HTMLElement).focus());
1428
+ await page.keyboard.press('Enter');
1429
+ return true;
1430
+ } finally {
1431
+ page.off('request', onRequest);
1432
+ await focusBefore.dispose().catch(() => {});
1433
+ await target?.dispose().catch(() => {});
1434
+ await page
1435
+ .evaluate(() => {
1436
+ const w = window as unknown as { __lensMo?: MutationObserver };
1437
+ w.__lensMo?.disconnect();
1438
+ w.__lensMo = undefined;
1439
+ })
1440
+ .catch(() => {});
1441
+ }
1442
+ }
1443
+
1444
+ /** Execute one step against the live page. Returns a screenshot Buffer only for
1445
+ * `screenshot` steps; throws on any failure (the caller pins a shot + stops). */
1446
+ /**
1447
+ * F074.36 — did we end up somewhere we did not ask for?
1448
+ *
1449
+ * Returns a human sentence when the landed page differs from the requested one,
1450
+ * else null. Deliberately compares ORIGIN + PATHNAME only: a query string or
1451
+ * hash changing is routine (tracking params, SPA state) and says nothing, while
1452
+ * a path change is the thing that makes a verdict be about the wrong page.
1453
+ * Trailing slashes are normalised — `/board` and `/board/` are the same page.
1454
+ *
1455
+ * There is no vocabulary of login-page words here on purpose. "Does this look
1456
+ * like a sign-in screen" is a guess about the world and would only ever find the
1457
+ * shapes we already thought of; "you asked for X and got Y" is a measurement and
1458
+ * works on an app we have never seen.
1459
+ */
1460
+ function comparePaths(a: string, b: string): { from: string; to: string } | null {
1461
+ let x: URL;
1462
+ let y: URL;
1463
+ try {
1464
+ x = new URL(a);
1465
+ y = new URL(b);
1466
+ } catch {
1467
+ return null; // not comparable — say nothing rather than guess
1468
+ }
1469
+ // `new URL` ACCEPTS about:blank, data:, blob: and chrome-error://. Their origin
1470
+ // is the string "null" and the path is not a path, so comparing them yields
1471
+ // gibberish — the first version of this reported "loaded nullblank, then moved
1472
+ // to /login", which is a fabricated finding dressed as a measurement. Caught by
1473
+ // the test below, and it is not hypothetical: about:blank is exactly what a page
1474
+ // reads before its first navigation.
1475
+ if (!/^https?:$/.test(x.protocol) || !/^https?:$/.test(y.protocol)) return null;
1476
+ const norm = (u: URL) => `${u.origin}${u.pathname.replace(/\/+$/, '') || '/'}`;
1477
+ const from = norm(x);
1478
+ const to = norm(y);
1479
+ return from === to ? null : { from, to };
1480
+ }
1481
+
1482
+ export function redirectOf(requested: string, landed: string): string | null {
1483
+ const d = comparePaths(requested, landed);
1484
+ return d && `redirected: asked for ${d.from}, landed on ${d.to}`;
1485
+ }
1486
+
1487
+ /**
1488
+ * F213.10 — the page LOADED where we asked, then moved somewhere else on its own.
1489
+ *
1490
+ * A DIFFERENT FACT FROM `redirectOf`, and the two need different fixes, which is
1491
+ * why they get different words. "Asked for X, landed on Y" is the server or the
1492
+ * router refusing at the door — you look at the route. "Loaded X, then moved to
1493
+ * Y" is the shape of every client-gated SPA auth failure: the shell is served to
1494
+ * anyone, the client checks a token a beat later, and routes to /login. There the
1495
+ * page is innocent and the IDENTITY was wrong, so a reader who is sent to debug
1496
+ * the route wastes the afternoon.
1497
+ *
1498
+ * fd-sundhed (#21299) sharpened the precondition by measuring their own app and
1499
+ * finding they were IMMUNE: their access check withholds the content, so an
1500
+ * unauthorised run has nothing to photograph and simply times out. The vulnerable
1501
+ * shape is specifically "the surface RENDERS, we assert, and we are THEN ejected"
1502
+ * — a late redirect alone is not enough.
1503
+ */
1504
+ export function ejectionOf(loaded: string, current: string): string | null {
1505
+ const d = comparePaths(loaded, current);
1506
+ return d && `ejected: loaded ${d.from}, then moved to ${d.to} without being asked — the verdicts above were measured on a page the run was thrown off (usually the wrong identity, not a broken page)`;
1507
+ }
1508
+
1509
+ /**
1510
+ * Steps that move the page ON PURPOSE. A URL change while one of these runs is
1511
+ * the flow doing its job, never an ejection.
1512
+ *
1513
+ * THIS SET IS THE WHOLE CHECK. Without it the guard fires on every flow that
1514
+ * clicks a link — which is every real flow — and a guard that cries on the
1515
+ * common case is one people switch off, taking the rare true positive with it.
1516
+ * When in doubt a verb belongs HERE: a missed ejection is a silent green, but a
1517
+ * false ejection is a red on working software, and the second is what destroys
1518
+ * trust in the instrument.
1519
+ */
1520
+ const NAVIGATES_BY_DESIGN = new Set([
1521
+ 'goto', 'click', 'clickSelector', 'press', 'drag', 'autocomplete', 'uploadFile',
1522
+ 'waitForUrl', 'waitForBuild',
1523
+ // Every one of these can route on a change handler: a search box that submits,
1524
+ // a <select> that filters by URL, a checkbox that writes a query param.
1525
+ 'fill', 'fillSelector', 'clear', 'select', 'check', 'uncheck',
1526
+ ]);
1527
+
1528
+ /** `page.url()` throws once the page is closed. A URL we cannot read is "not
1529
+ * measured", never a difference — inventing one would manufacture an ejection
1530
+ * out of teardown. */
1531
+ function safeUrl(page: Page): string | null {
1532
+ try {
1533
+ return page.url();
1534
+ } catch {
1535
+ return null;
1536
+ }
1537
+ }
1538
+
1539
+ /** The line the set above draws, stated once: an ACTION may move the page, a
1540
+ * VERDICT may not. What is left over — the expect family, assert, screenshot,
1541
+ * waitFor — is exactly what forms an opinion about what is on screen, and an
1542
+ * opinion formed on a page we were then thrown off is the thing being removed. */
1543
+ const formsAVerdict = (type: string) => !NAVIGATES_BY_DESIGN.has(type);
1544
+
1545
+ /**
1546
+ * What does a URL change after a verdict step MEAN? Extracted as a pure function
1547
+ * because the answer is a RULE, not a timing coincidence — and the first version
1548
+ * of this got it wrong in the dangerous direction.
1549
+ *
1550
+ * MEASURED DEFECT (found reviewing my own diff, then reproduced): a click whose
1551
+ * navigation commits LATER than the click step returns. `page.url()` read right
1552
+ * after the click still shows the OLD page, so it became the baseline, and the
1553
+ * next verdict step — now on the new page — was reported as an ejection.
1554
+ *
1555
+ * navigation 400ms after the click 0/5 false ejections
1556
+ * navigation 1800ms after the click 5/5 FALSE EJECTIONS
1557
+ *
1558
+ * The 400ms case passed only because the click's own actionability wait happened
1559
+ * to cover it. That is luck, not a guard, and it is exactly the accidental
1560
+ * protection F213.8 warned against relying on.
1561
+ *
1562
+ * So a deferred navigation gets ONE free pass: if the flow performed a navigating
1563
+ * action and the page has since moved, that is the action landing, and we
1564
+ * re-baseline instead of accusing. `goto` is excluded from `navActionPending` by
1565
+ * the caller — Playwright already waited for it, so its URL is settled and a
1566
+ * later change really is someone else's doing.
1567
+ *
1568
+ * THE COST OF THIS, STATED: an auth bounce triggered BY a click is indistinguish-
1569
+ * able from that click's own navigation, so the first change after an action is
1570
+ * never reported. We keep the shape that matters — goto → assert, which is what a
1571
+ * wrong identity looks like — and we never redden working software. A false
1572
+ * ejection would get the whole guard switched off, taking the true positives with
1573
+ * it; that trade is deliberate, not an oversight.
1574
+ */
1575
+ export function classifyUrlChange(o: { loaded: string; current: string; navActionPending: boolean }):
1576
+ { kind: 'none' } | { kind: 'flow-navigation'; rebaseTo: string } | { kind: 'ejection'; message: string } {
1577
+ const message = ejectionOf(o.loaded, o.current);
1578
+ if (!message) return { kind: 'none' };
1579
+ if (o.navActionPending) return { kind: 'flow-navigation', rebaseTo: o.current };
1580
+ return { kind: 'ejection', message };
1581
+ }
1582
+
1583
+ /** Per-step carrier for the composition report. Explicit rather than a module
1584
+ * variable: two flows running at once would otherwise report each other's
1585
+ * findings, which is a worse failure than reporting none. */
1586
+ type CriticSink = { critic: CriticReport | null };
1587
+
1588
+ /** F074.59 — the properties sampled to detect motion when a step does not name one.
1589
+ * Deliberately a SHORT list of things that actually move on screen: a wide sweep of
1590
+ * computed style picks up values that differ for reasons unrelated to animation
1591
+ * (a resize, a font settling) and turns a motion assertion into a flake. */
1592
+ const MOTION_PROPS = [
1593
+ 'opacity', 'transform', 'box-shadow', 'background-color', 'color',
1594
+ 'filter', 'width', 'height', 'left', 'top',
1595
+ ] as const;
1596
+
1597
+ export interface MotionSample {
1598
+ /** Names of the animations/transitions the Web Animations API reports as
1599
+ * RUNNING on the element or its subtree, at the first instant. */
1600
+ running: string[];
1601
+ /** Properties whose computed value DIFFERED between the two instants. */
1602
+ changed: string[];
1603
+ /** What the BROWSER reports for prefers-reduced-motion — read from the page, not
1604
+ * from what we meant to ask for. That distinction is the whole point: if the
1605
+ * option plumbing breaks, this still tells the truth about the run, so the
1606
+ * failure message can name the real cause instead of restating the intent. */
1607
+ reducedMotion: 'reduce' | 'no-preference';
1608
+ /** How far apart the two samples were, in ms. Reported so a null result can be
1609
+ * read as "nothing moved in 200ms" rather than an unqualified "nothing moved". */
1610
+ gapMs: number;
1611
+ }
1612
+
1613
+ /** F074.59 — sample an element twice and report what moved.
1614
+ *
1615
+ * TWO INDEPENDENT SIGNALS, because each one alone lies in a different direction:
1616
+ * getAnimations() sees a declared animation that is running but may be animating a
1617
+ * property with no visible effect; a computed-value diff sees real change but cannot
1618
+ * tell an animation from a one-off layout shift. Requiring BOTH for "animating" and
1619
+ * NEITHER for "still" is what makes the pair discriminating. */
1620
+ async function sampleMotion(
1621
+ loc: Locator,
1622
+ property: string | undefined,
1623
+ gapMs: number,
1624
+ ): Promise<MotionSample> {
1625
+ const props = property ? [property] : [...MOTION_PROPS];
1626
+ return (await loc.evaluate(
1627
+ (el: Element, args: { props: string[]; gapMs: number }) =>
1628
+ new Promise((resolve) => {
1629
+ const read = (): Record<string, string> => {
1630
+ const cs = getComputedStyle(el);
1631
+ const out: Record<string, string> = {};
1632
+ for (const k of args.props) out[k] = cs.getPropertyValue(k);
1633
+ return out;
1634
+ };
1635
+ const running = (): string[] => {
1636
+ const anyEl = el as Element & { getAnimations?: (o?: { subtree?: boolean }) => unknown[] };
1637
+ if (typeof anyEl.getAnimations !== 'function') return [];
1638
+ return anyEl
1639
+ .getAnimations({ subtree: true })
1640
+ .filter((a) => (a as { playState?: string }).playState === 'running')
1641
+ .map((a) => {
1642
+ const x = a as { animationName?: string; transitionProperty?: string };
1643
+ return x.animationName ?? x.transitionProperty ?? 'animation';
1644
+ });
1645
+ };
1646
+ const before = read();
1647
+ const runningBefore = running();
1648
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
1649
+ ? 'reduce'
1650
+ : 'no-preference';
1651
+ setTimeout(() => {
1652
+ const after = read();
1653
+ resolve({
1654
+ running: runningBefore,
1655
+ changed: args.props.filter((k) => before[k] !== after[k]),
1656
+ reducedMotion: reduced,
1657
+ gapMs: args.gapMs,
1658
+ });
1659
+ }, args.gapMs);
1660
+ }),
1661
+ { props, gapMs },
1662
+ )) as MotionSample;
1663
+ }
1664
+
1665
+ /** F074.59 — the sampling window. Long enough that a slow keyframe (the 2.4s
1666
+ * breathing dot) changes a value inside it, short enough not to tax a flow. */
1667
+ const MOTION_SAMPLE_MS = 250;
1668
+
1669
+
1670
+ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<string, unknown>, critic: CriticMode, localPath?: string, sink?: CriticSink, pwSink?: FilledPassword[], readySink?: NotListeningFinding[]): Promise<Buffer | null | { __inspect: string } | { __via: string; __build?: string; __redirect?: string }> {
1671
+ switch (step.type) {
1672
+ case 'goto': {
1673
+ const p = subst(step.path, vars);
1674
+ const url = /^https?:\/\//.test(p) ? p : `${base}${p.startsWith('/') ? p : `/${p}`}`;
1675
+ // F098.6 — 'domcontentloaded' (not 'load'): a flow's explicit waitFor/
1676
+ // expectVisible steps gate content; 'load' hangs on live-data/canvas SPAs.
1677
+ // F074.51 — honour an explicit per-step timeout, but keep 30s as goto's own
1678
+ // default rather than falling back to stepTimeout()'s 8s: a navigation is
1679
+ // not an element wait, and silently cutting every flow's first hop from 30s
1680
+ // to 8s would be a regression dressed as a fix.
1681
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: step.timeout ?? 30_000 });
1682
+ // F074.36 — a goto that lands somewhere else used to report a bare PASS.
1683
+ // It is the single most useful fact about a failing run (you asked for
1684
+ // /board, an auth wall sent you to /), and it was thrown away. Reported as
1685
+ // a step detail always; the run only LEADS with it when it also failed, so
1686
+ // a legitimate redirect can never turn a green run red.
1687
+ const landed = redirectOf(url, page.url());
1688
+ return landed ? { __via: '', __redirect: landed } : null;
1689
+ }
1690
+ case 'click': {
1691
+ const { loc, via, remaining } = await locate(page, step);
1692
+ if (step.at) {
1693
+ // F255.4 — a positioned click is aimed at a spot inside a canvas/3D surface,
1694
+ // which is never a button, so it goes straight to a trusted Playwright click
1695
+ // at that offset (no activation probe).
1696
+ await loc.click({ timeout: remaining, position: await positionFor(loc, step.at) });
1697
+ } else {
1698
+ await clickAndActivate(page, loc, step.activate ?? 'auto', remaining);
1699
+ }
1700
+ return via ? { __via: via } : null;
1701
+ }
1702
+ case 'fill': {
1703
+ const { loc, via, remaining } = await locate(page, step);
1704
+ await consultReadiness(page, loc, step, readySink);
1705
+ // F213.7 — fill proves its own write; a silent no-op or a prepended value
1706
+ // used to pass as success, which is the half of the fleet save-rule this
1707
+ // instrument could not perform.
1708
+ if (step.valueFrom) {
1709
+ await fillSecret(loc, step.valueFrom, localPath, remaining, pwSink, anchorOf(step));
1710
+ return via ? { __via: via } : null;
1711
+ }
1712
+ const filledValue = subst(step.value ?? '', vars);
1713
+ await fillAndVerify(loc, filledValue, remaining);
1714
+ // F074.99 — remember a PASSWORD so the end of the run can notice it coming
1715
+ // back in the URL. Read from the element rather than guessed from the name:
1716
+ // a field called "password" may be a text input and a password field may be
1717
+ // called anything, and only the element knows which it is. Failure to read
1718
+ // it is not a failure of the fill — a report is not worth a red run.
1719
+ if (filledValue.length > 0) {
1720
+ const isPassword = await loc
1721
+ .evaluate((el) => (el as HTMLInputElement).type === 'password')
1722
+ .catch(() => false);
1723
+ if (isPassword) pwSink?.push({ anchor: anchorOf(step), value: filledValue });
1724
+ }
1725
+ return via ? { __via: via } : null;
1726
+ }
1727
+ case 'select': {
1728
+ const { loc, via, remaining } = await locate(page, step);
1729
+ await consultReadiness(page, loc, step, readySink);
1730
+ await loc.selectOption(subst(step.value, vars), { timeout: remaining });
1731
+ return via ? { __via: via } : null;
1732
+ }
1733
+ case 'check': {
1734
+ const { loc, via, remaining } = await locate(page, step);
1735
+ await consultReadiness(page, loc, step, readySink);
1736
+ await loc.check({ timeout: remaining });
1737
+ return via ? { __via: via } : null;
1738
+ }
1739
+ case 'uncheck': {
1740
+ const { loc, via, remaining } = await locate(page, step);
1741
+ await consultReadiness(page, loc, step, readySink);
1742
+ await loc.uncheck({ timeout: remaining });
1743
+ return via ? { __via: via } : null;
1744
+ }
1745
+ case 'waitFor':
1746
+ case 'expectVisible': {
1747
+ const { loc, via, remaining } = await locate(page, step);
1748
+ await loc.waitFor({ state: 'visible', timeout: remaining });
1749
+ return via ? { __via: via } : null;
1750
+ }
1751
+ case 'expectEditable': {
1752
+ // F236 — visible first (like expectText), then POLL the shared editability
1753
+ // predicate until the step timeout. isEditableElement is pure + self-contained
1754
+ // so it serializes into the page via loc.evaluate — ONE "editable" definition,
1755
+ // identical to the engine's expectEditable flow-step. A present-but-not-editable
1756
+ // target (e.g. click-to-edit that never armed the field) throws a named error.
1757
+ const { loc, via, remaining } = await locate(page, step);
1758
+ const anchor = step.testid ? `[data-testid="${step.testid}"]` : targetSummary(step.target!);
1759
+ const timeout = remaining;
1760
+ try {
1761
+ await loc.waitFor({ state: 'visible', timeout });
1762
+ } catch {
1763
+ throw new Error(`${anchor} never became visible within ${timeout}ms`);
1764
+ }
1765
+ const deadline = Date.now() + timeout;
1766
+ for (;;) {
1767
+ const editable = await loc.evaluate(isEditableElement).catch(() => false);
1768
+ if (editable) return via ? { __via: via } : null;
1769
+ if (Date.now() >= deadline) {
1770
+ throw new Error(`expected ${anchor} to be editable within ${timeout}ms, but it is present-but-not-editable`);
1771
+ }
1772
+ await page.waitForTimeout(150);
1773
+ }
1774
+ }
1775
+ // F074.59 — the motion pair.
1776
+ //
1777
+ // WHY THESE EXIST: the fleet's house rules make a loading indicator mandatory on
1778
+ // anything clickable that takes >100ms, and until now not one of them could be
1779
+ // proven to move. Worse, the Lens browser has been reporting
1780
+ // prefers-reduced-motion:reduce since F098.8 — set BY US — so any check that read
1781
+ // an animation off computed style was reading a rule in a browser that had
1782
+ // stopped playing it. A spinner that silently froze looked exactly like a pass.
1783
+ //
1784
+ // Both verbs report the browser's OWN prefers-reduced-motion in their failure
1785
+ // message, read from the page rather than from the option we passed. If the
1786
+ // plumbing that carries `reduced_motion` ever breaks, the message says
1787
+ // "the run executed under reduce" instead of a mystery — an instrument that
1788
+ // names why it could not see, rather than reporting that there was nothing there.
1789
+ case 'expectAnimating':
1790
+ case 'expectStill': {
1791
+ const wantMotion = step.type === 'expectAnimating';
1792
+ const { loc, via, remaining } = await locate(page, step);
1793
+ const anchorName = step.testid ? `[data-testid="${step.testid}"]` : targetSummary(step.target!);
1794
+ try {
1795
+ await loc.waitFor({ state: 'visible', timeout: remaining });
1796
+ } catch {
1797
+ throw new Error(`${anchorName} never became visible within ${remaining}ms`);
1798
+ }
1799
+ const deadline = Date.now() + remaining;
1800
+ let last: MotionSample | null = null;
1801
+ for (;;) {
1802
+ const m = await sampleMotion(loc, step.property, MOTION_SAMPLE_MS);
1803
+ last = m;
1804
+ const moving = m.running.length > 0 && m.changed.length > 0;
1805
+ if (wantMotion && moving) {
1806
+ return { __via: `${via ? `${via}; ` : ''}animating under ${m.reducedMotion}: ${m.running.join(', ')} changed ${m.changed.join(', ')}` };
1807
+ }
1808
+ // STILLNESS IS NOT PROVEN BY ONE LOOK, and it is not proven by the first
1809
+ // look either — an animation with a delay starts later. So expectStill
1810
+ // keeps sampling for the WHOLE window and fails the moment it sees motion;
1811
+ // only an element that stayed still the entire time passes.
1812
+ if (!wantMotion && moving) {
1813
+ throw new Error(
1814
+ `expected ${anchorName} to be STILL, but it is animating under prefers-reduced-motion: ${m.reducedMotion}` +
1815
+ ` — running: ${m.running.join(', ') || 'none'}; changed: ${m.changed.join(', ') || 'none'}`,
1816
+ );
1817
+ }
1818
+ if (Date.now() >= deadline) break;
1819
+ }
1820
+ if (!wantMotion) return via ? { __via: `${via}; still under ${last?.reducedMotion ?? 'unknown'}` } : { __via: `still under ${last?.reducedMotion ?? 'unknown'}` };
1821
+ // The failure that matters: say WHICH signal was missing and, above all,
1822
+ // whether this browser was even allowed to animate.
1823
+ const m = last!;
1824
+ const why =
1825
+ m.reducedMotion === 'reduce'
1826
+ ? ` THE RUN EXECUTED UNDER prefers-reduced-motion: reduce, which is the Lens default (F098.8) — pass reduced_motion:'no-preference' to see motion at all.`
1827
+ : ` The run executed under prefers-reduced-motion: no-preference, so the browser WAS allowed to animate — this is a real stillness.`;
1828
+ throw new Error(
1829
+ `expected ${anchorName} to be ANIMATING within ${remaining}ms, but in ${m.gapMs}ms samples` +
1830
+ ` the Web Animations API reported running: [${m.running.join(', ')}] and these computed properties changed: [${m.changed.join(', ')}].` +
1831
+ why,
1832
+ );
1833
+ }
1834
+ // F213.8 — the readiness gate. Not a sleep: it asks whether anything is
1835
+ // registered for the events an edit dispatches, anywhere on this element's
1836
+ // propagation path. sanne's workaround was `assert performance.now() > 3000`,
1837
+ // which is green on a fast machine and red in CI — the failure mode replaced.
1838
+ case 'waitForReady': {
1839
+ const cssFor = (): string => {
1840
+ if (step.testid) return sel(step.testid);
1841
+ const t = step.target;
1842
+ if (typeof t === 'string') return t;
1843
+ if (t && typeof t === 'object') {
1844
+ if (typeof (t as { css?: string }).css === 'string') return (t as { css: string }).css;
1845
+ if (typeof (t as { testid?: string }).testid === 'string') return sel((t as { testid: string }).testid);
1846
+ }
1847
+ // A role/label/text LocateSpec has no CSS form, and silently probing
1848
+ // something else would answer a different question than the one asked.
1849
+ throw new Error('waitForReady needs a testid or a css/testid target — a role/label/text target has no selector form to probe');
1850
+ };
1851
+ const events = step.events?.length ? step.events : [...EDIT_EVENTS];
1852
+ const r = await waitForListening(page, cssFor(), events, stepTimeout(step));
1853
+ if (!r.ok) throw new Error(r.error);
1854
+ return { __via: `listening via ${r.via} after ${r.waitedMs}ms` };
1855
+ }
1856
+ case 'expectAbsent': {
1857
+ // F213.9 — "not mounted YET" and "correctly gone" used to be the same green.
1858
+ // This was a one-shot count, so it passed the instant a component had not
1859
+ // hydrated. fd-sundhed measured their splash guard green at 654ms and 1731ms
1860
+ // on a splash that arrived at 2967ms and covered the screen until 5287ms.
1861
+ //
1862
+ // The irony was in this same file: expectText already POLLS precisely because
1863
+ // a one-shot read races hydration. That insight had only ever been applied to
1864
+ // the POSITIVE verbs, where the race fails RED. Here the identical race fails
1865
+ // GREEN, which is the direction nobody investigates.
1866
+ //
1867
+ // THE SETTLE WINDOW COSTS NOTHING ON A PAGE THAT HAS ALREADY BEEN UP. The
1868
+ // element has had `performance.now()` milliseconds to appear already, so we
1869
+ // only wait for the REMAINDER — an expectAbsent late in a flow is free, and
1870
+ // only one placed immediately after a goto pays. Without that, every flow gets
1871
+ // slower, and a verb that taxes you is one authors stop using.
1872
+ // THE REACH OF THIS VERB (F213.8's rule — state the limit beside the check):
1873
+ // a green expectAbsent proves the element was not visible during the settle
1874
+ // window. It does NOT prove it will never appear — a component that mounts
1875
+ // AFTER the window still passes. If you need "it is gone and stays gone", use
1876
+ // expectTopmost on the thing you actually want, which cannot be green early.
1877
+ const settle = step.timeout ?? ABSENCE_SETTLE_MS;
1878
+ const upFor = await page.evaluate(() => performance.now()).catch(() => settle);
1879
+ const remaining = Math.max(0, settle - upFor);
1880
+ const loc = page.locator(sel(step.testid));
1881
+ if (remaining > 0) {
1882
+ try {
1883
+ await loc.first().waitFor({ state: 'visible', timeout: remaining });
1884
+ } catch {
1885
+ // never appeared within the window → genuinely absent
1886
+ }
1887
+ }
1888
+ const count = await loc.count();
1889
+ if (count > 0 && (await loc.first().isVisible())) {
1890
+ // Name WHICH of the two it is: a caller needs to know whether it never went
1891
+ // away or arrived late, because those have different fixes.
1892
+ const late = upFor < settle ? ' (it appeared while we waited for the page to settle)' : '';
1893
+ throw new Error(`expected [data-testid="${step.testid}"] to be absent, but it is visible${late}`);
1894
+ }
1895
+ return null;
1896
+ }
1897
+ case 'expectTopmost': {
1898
+ // F213.9 — fd-sundhed's cure, and it generalises: ask whether the WANTED thing
1899
+ // is there and on top, not whether the unwanted thing is gone.
1900
+ //
1901
+ // target not in the DOM yet → RED
1902
+ // an overlay covers it → RED
1903
+ // it exists and is the top hit → GREEN
1904
+ //
1905
+ // A negative assertion can always be green too early. This one cannot, because
1906
+ // the target must EXIST and be what the browser would actually hit. Their first
1907
+ // attempt was "the centre of the screen is not the splash" — 18 of 18 green
1908
+ // while the screenshot still showed the splash. A negative assertion phrased
1909
+ // more cleverly is still a negative assertion.
1910
+ const { loc, via, remaining } = await locate(page, step);
1911
+ await loc.waitFor({ state: 'visible', timeout: remaining });
1912
+ const hit = await loc.evaluate((el) => {
1913
+ const r = el.getBoundingClientRect();
1914
+ const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
1915
+ if (!top) return { ok: false, what: '(nothing — the centre is off-screen)' };
1916
+ if (top === el || el.contains(top)) return { ok: true, what: '' };
1917
+ const d = top as HTMLElement;
1918
+ const id = d.getAttribute('data-testid');
1919
+ return { ok: false, what: id ? `[data-testid="${id}"]` : `<${d.tagName.toLowerCase()}>` };
1920
+ });
1921
+ if (!hit.ok) {
1922
+ const anchor = step.testid ? `[data-testid="${step.testid}"]` : targetSummary(step.target!);
1923
+ throw new Error(`expected ${anchor} to be the topmost element at its own centre, but the browser would hit ${hit.what}`);
1924
+ }
1925
+ return via ? { __via: via } : null;
1926
+ }
1927
+ case 'expectText': {
1928
+ // upmetrics #2 — POLL the substring until the step timeout, not "wait for
1929
+ // element then read once". An SPA route mounts its shell synchronously
1930
+ // (e.g. "Overview") then hydrates data ("sdk 0.1.4") via async fetch a
1931
+ // beat later; a one-shot read races the hydration and false-fails.
1932
+ const { loc, via, remaining } = await locate(page, step);
1933
+ const anchor = step.testid ? `[data-testid="${step.testid}"]` : targetSummary(step.target!);
1934
+ const want = subst(step.text, vars);
1935
+ const timeout = remaining;
1936
+ const deadline = Date.now() + timeout;
1937
+ let last = '';
1938
+ try {
1939
+ await loc.waitFor({ state: 'visible', timeout });
1940
+ } catch {
1941
+ throw new Error(`${anchor} never became visible within ${timeout}ms`);
1942
+ }
1943
+ // F074.56 — `equals` is the operator Christian's 2026-08-16 rule needs: a
1944
+ // saved value proven by STRICT equality, because `contains` fails in the
1945
+ // GREEN direction. Surrounding whitespace is trimmed (the DOM adds it, the
1946
+ // author did not); nothing else is normalised.
1947
+ const mode = step.match ?? 'contains';
1948
+ const verb = mode === 'equals' ? 'to equal' : mode === 'not-equals' ? 'to differ from' : 'to contain';
1949
+ for (;;) {
1950
+ last = (await loc.textContent().catch(() => null)) ?? '';
1951
+ // A `!=` that passes because the element is EMPTY would be this card's own
1952
+ // defect one level down — different-because-there-is-nothing is not proof.
1953
+ const hit = textMatches(mode, last, want);
1954
+ if (hit) return via ? { __via: via } : null;
1955
+ if (Date.now() >= deadline) {
1956
+ const why = mode === 'not-equals' && last.trim() === ''
1957
+ ? ` — the element is EMPTY, which is not proof that it differs`
1958
+ : '';
1959
+ throw new Error(`expected ${anchor} ${verb} "${want}" within ${timeout}ms, last "${last.trim().slice(0, 160)}"${why}`);
1960
+ }
1961
+ await page.waitForTimeout(150);
1962
+ }
1963
+ }
1964
+ case 'assert': {
1965
+ // upmetrics #2 — POLL the predicate until the step timeout so an assert
1966
+ // over hydrating data (or a redirecting page) settles instead of racing.
1967
+ const body = subst(step.js, vars);
1968
+ const timeout = stepTimeout(step);
1969
+ const deadline = Date.now() + timeout;
1970
+ // F074.29: remember the last thrown error so the timeout message can say
1971
+ // the code exploded rather than claiming the predicate was false.
1972
+ let lastThrow = '';
1973
+ let lastDetail: string | undefined;
1974
+ for (;;) {
1975
+ const next = nextAssertState(await page.evaluate(evalAssertBody, body));
1976
+ if (next.action === 'pass') return null;
1977
+ // Both of these are the PROBE being broken, not the app being wrong:
1978
+ // a body that cannot compile, and a body that threw on every attempt
1979
+ // until the deadline. They stay failures; they just say whose fault.
1980
+ if (next.action === 'fail_now') {
1981
+ throw new InstrumentError(`${next.reason}: ${body.slice(0, 80)}`);
1982
+ }
1983
+ lastThrow = next.lastThrow;
1984
+ lastDetail = next.lastDetail;
1985
+
1986
+ if (Date.now() >= deadline) {
1987
+ const msg = assertTimeoutMessage(timeout, body, lastThrow, lastDetail);
1988
+ throw lastThrow ? new InstrumentError(msg) : new Error(msg);
1989
+ }
1990
+ await page.waitForTimeout(150);
1991
+ }
1992
+ }
1993
+ case 'screenshot': {
1994
+ // F255.3 — settle FIRST. Flow screenshots used to grab the frame the instant the
1995
+ // step ran, so a shot right after a click caught the page mid-transition: on the
1996
+ // F249 mockups list a just-ticked checkbox (transition: background 0.12s) came out
1997
+ // still white while every DOM assert said checked. That reads as "the feature is
1998
+ // broken" when it is the camera that was early. Same settle as lens_capture.
1999
+ await settleForShot(page);
2000
+ const shot = (await takeShot(page, 'viewport', null, 8000)).screenshot;
2001
+ // F126.4 — run the composition critic on this captured frame. A
2002
+ // high-severity finding fails the step (the caller pins the shot + stops),
2003
+ // so a visibly-broken screen breaks the E2E flow too.
2004
+ if (critic !== 'off') {
2005
+ const report = await runCritic(page, shot, critic);
2006
+ // F126.7 — carry the WHOLE report out, on pass and on fail alike.
2007
+ //
2008
+ // It used to end here as a string: the count plus the FIRST message, with
2009
+ // the other findings discarded. xrt81 (#21050) measured what that costs.
2010
+ // A session is told about one issue, fixes it, re-runs, goes green — and
2011
+ // the findings it was never shown are now silently recorded as resolved.
2012
+ // The gate then reports success on a surface with unfixed high-severity
2013
+ // problems, and reports it MORE confidently than before, because there is
2014
+ // a passing run behind it. Every other member of this family produced a
2015
+ // confusing or permissive answer; this one laundered a loss into a pass.
2016
+ //
2017
+ // And on a PASSING step the report vanished entirely, so a screen with
2018
+ // four medium findings and zero high ones reported nothing at all.
2019
+ // `high > 0` decides the STATUS; it must not decide what gets reported.
2020
+ if (sink) sink.critic = report;
2021
+ if (report.high > 0) {
2022
+ const top = topFindingMessage(report);
2023
+ throw new Error(`composition: ${report.high} high-severity issue(s)${top ? ` — ${top}` : ''}`);
2024
+ }
2025
+ }
2026
+ return shot;
2027
+ }
2028
+ case 'fillSelector':
2029
+ await resolveLocator(page, step.selector, step.frame)
2030
+ .first()
2031
+ .fill(subst(step.value, vars), { timeout: stepTimeout(step) });
2032
+ return null;
2033
+ case 'waitForBuild': {
2034
+ // F255.7 — polled from HERE, not through the page: a service worker will happily
2035
+ // serve a cached build and a page-side fetch would report the new marker on a page
2036
+ // still running the old code (fd-sundhed's PWA, 2026-07-30).
2037
+ const r = await waitForBuild(base, {
2038
+ expected: step.sha ?? null,
2039
+ ...(step.timeout ? { timeoutMs: step.timeout } : {}),
2040
+ });
2041
+ // F074.30 — hand the sha BACK, don't just render it into a label. This is
2042
+ // the authoritative capture-time build: what the app was serving AFTER the
2043
+ // wait, which is exactly what the run then looked at. Uses the same
2044
+ // out-of-band channel as __via/__inspect (execStep has no run scope).
2045
+ return {
2046
+ __via: `build ${r.build.slice(0, 8)} live after ${r.polls} polls (${r.mode})`,
2047
+ __build: r.build,
2048
+ };
2049
+ }
2050
+ case 'clickSelector': {
2051
+ const loc = resolveLocator(page, step.selector, step.frame).first();
2052
+ await loc.click({
2053
+ timeout: stepTimeout(step),
2054
+ ...(step.at ? { position: await positionFor(loc, step.at) } : {}),
2055
+ });
2056
+ return null;
2057
+ }
2058
+ // F213.2 — interaction verbs.
2059
+ case 'clear': {
2060
+ const { loc, via, remaining } = await locate(page, step);
2061
+ await consultReadiness(page, loc, step, readySink);
2062
+ await loc.clear({ timeout: remaining });
2063
+ return via ? { __via: via } : null;
2064
+ }
2065
+ case 'press':
2066
+ // A key on a focused testid, or globally (no testid) — Enter-to-submit,
2067
+ // arrow-nav, autocomplete triggering.
2068
+ if (step.testid) {
2069
+ await page.locator(sel(step.testid)).first().press(step.key, { timeout: stepTimeout(step) });
2070
+ } else {
2071
+ await page.keyboard.press(step.key);
2072
+ }
2073
+ return null;
2074
+ case 'drag':
2075
+ // A real HTML5 drag-and-drop (dragstart→dragover→drop) via Playwright's
2076
+ // dragTo — drives the app's actual DnD handlers, unlike synthetic events.
2077
+ await page
2078
+ .locator(sel(step.testid))
2079
+ .first()
2080
+ .dragTo(page.locator(sel(step.toTestid)).first(), { timeout: stepTimeout(step) });
2081
+ return null;
2082
+ case 'uploadFile': {
2083
+ // F213.6 — set the fixture(s) on the target input[type=file] (works on a
2084
+ // HIDDEN input behind a custom button). setInputFiles takes either a list of
2085
+ // absolute paths OR a list of FilePayloads; a step is one kind, not mixed.
2086
+ const paths = Array.isArray(step.path) ? step.path : [step.path];
2087
+ const isData = (p: string) => p.startsWith('data:');
2088
+ if (paths.some(isData) && !paths.every(isData)) {
2089
+ throw new Error('uploadFile: a step must be all data: URIs OR all fixture paths, not a mix');
2090
+ }
2091
+ const files = paths.every(isData) ? paths.map(dataUriToPayload) : paths.map((p) => resolveFixturePath(localPath, p));
2092
+ await page.locator(sel(step.testid)).first().setInputFiles(files, { timeout: stepTimeout(step) });
2093
+ return null;
2094
+ }
2095
+ case 'waitForUrl': {
2096
+ const want = subst(step.url, vars);
2097
+ await page.waitForURL((u) => u.toString().includes(want), { timeout: stepTimeout(step) });
2098
+ return null;
2099
+ }
2100
+ case 'inspect': {
2101
+ if (step.html) {
2102
+ const target = step.testid ? page.locator(sel(step.testid)).first() : resolveLocator(page, 'body', step.frame).first();
2103
+ await target.waitFor({ state: 'attached', timeout: stepTimeout(step) });
2104
+ return { __inspect: await target.evaluate((el) => (el as Element).outerHTML, undefined, { timeout: stepTimeout(step) }) };
2105
+ }
2106
+ // CSP-safe DOM dump: a real fn (Playwright-injected, NOT page eval) run in
2107
+ // the top frame or — with `frame` — inside a cross-origin iframe, so the
2108
+ // author discovers the exact anchors without raw Playwright.
2109
+ const dump = await resolveLocator(page, 'html', step.frame)
2110
+ .first()
2111
+ .evaluate((el) => {
2112
+ const doc = (el as Element).ownerDocument ?? document;
2113
+ const txt = (e: Element) => (e.textContent ?? '').trim().slice(0, 40);
2114
+ const pick = (e: Element) => ({
2115
+ tag: e.tagName.toLowerCase(),
2116
+ type: e.getAttribute('type') ?? undefined,
2117
+ name: e.getAttribute('name') ?? undefined,
2118
+ id: e.id || undefined,
2119
+ role: e.getAttribute('role') ?? undefined,
2120
+ testid: e.getAttribute('data-testid') ?? undefined,
2121
+ title: e.getAttribute('title') ?? undefined,
2122
+ placeholder: e.getAttribute('placeholder') ?? undefined,
2123
+ ariaLabel: e.getAttribute('aria-label') ?? undefined,
2124
+ text: txt(e) || undefined,
2125
+ // Surface a stable structural handle for NAME-LESS icon buttons (the
2126
+ // whole class Set-of-Marks/role/text can't anchor): the class list +
2127
+ // a trimmed outerHTML carrying any class/data-*/aria-* an author can
2128
+ // turn into a deterministic CSS locator.
2129
+ cls: e.getAttribute('class') || undefined,
2130
+ html: (e as HTMLElement).outerHTML?.slice(0, 400) || undefined,
2131
+ });
2132
+ const iframes = Array.from(doc.querySelectorAll('iframe')).map((f) => ({
2133
+ title: (f as HTMLIFrameElement).title || undefined,
2134
+ name: (f as HTMLIFrameElement).name || undefined,
2135
+ id: f.id || undefined,
2136
+ src: ((f as HTMLIFrameElement).getAttribute('src') ?? '').slice(0, 70) || undefined,
2137
+ }));
2138
+ const inputs = Array.from(doc.querySelectorAll('input,select,textarea')).map(pick);
2139
+ const clickables = Array.from(
2140
+ doc.querySelectorAll('button,[role=button],[role=radio],[role=tab],label,[data-testid]'),
2141
+ )
2142
+ .slice(0, 60)
2143
+ .map(pick);
2144
+ // F009.119 — name the full-HTML option where the truncation is seen: two
2145
+ // sessions in two days worked around the 400-char cut without knowing
2146
+ // `html:true` existed.
2147
+ const hint = "each element's html is cut at 400 chars; for one element's FULL outerHTML use {type:'inspect', html:true, testid}";
2148
+ return JSON.stringify({ hint, iframes, inputs, clickables });
2149
+ }, undefined, { timeout: stepTimeout(step) });
2150
+ return { __inspect: String(dump) };
2151
+ }
2152
+ // F213.3 — composite autocomplete: type, wait for the suggestion list, pick.
2153
+ case 'autocomplete': {
2154
+ // fill() dispatches an `input` event (what most autocompletes listen to) so
2155
+ // the suggestion list opens; then wait for ≥1 option and click the pick.
2156
+ await page.locator(sel(step.testid)).first().fill(subst(step.value, vars), { timeout: stepTimeout(step) });
2157
+ const options = page.locator(sel(step.option));
2158
+ await options.first().waitFor({ state: 'visible', timeout: stepTimeout(step) });
2159
+ if (step.pickText !== undefined) {
2160
+ await options.filter({ hasText: subst(step.pickText, vars) }).first().click({ timeout: stepTimeout(step) });
2161
+ } else {
2162
+ await options.nth(step.pickIndex ?? 0).click({ timeout: stepTimeout(step) });
2163
+ }
2164
+ return null;
2165
+ }
2166
+ // F213.3 — capture an element's text (or an input's value) into a named var,
2167
+ // reused by a later step via {{name}}. Mutates the shared `vars` in place.
2168
+ case 'capture': {
2169
+ const loc = page.locator(sel(step.testid)).first();
2170
+ await loc.waitFor({ state: 'attached', timeout: stepTimeout(step) });
2171
+ vars[step.as] =
2172
+ step.from === 'value'
2173
+ ? await loc.inputValue({ timeout: stepTimeout(step) })
2174
+ : ((await loc.textContent({ timeout: stepTimeout(step) })) ?? '').trim();
2175
+ return null;
2176
+ }
2177
+ case 'conditional':
2178
+ throw new Error('conditional steps are handled by runSeq, never execStep');
2179
+ case 'loop':
2180
+ throw new Error('loop steps are flattened before execution');
2181
+ }
2182
+ }
2183
+
2184
+ export interface RunFlowOptions {
2185
+ /** F082.4 — resume: skip leaf steps before this index (re-run a bulk data
2186
+ * flow from where it left off). Default 0 (run from the start). */
2187
+ from?: number;
2188
+ /** F092.9 — viewport for the flow's page(s). Default 1280x800 (desktop). Set
2189
+ * e.g. 390x844 to drive + screenshot mobile-only UI. */
2190
+ viewport?: { width: number; height: number };
2191
+ /** F098.9 — engine to drive the flow (chromium default; webkit/firefox for
2192
+ * cross-engine bugs, e.g. a WebKit-only mobile-drawer collapse). */
2193
+ engine?: LensEngine;
2194
+ /** F074.57 — BCP-47 locale + IANA timezone for the browser context. A flow on
2195
+ * a bilingual site is the reason this is per-CALL and not only per-project:
2196
+ * the same surface needs one run per language. Unset passes nothing. */
2197
+ locale?: string;
2198
+ timezone?: string;
2199
+ /** F074.59 — what this run reports for `prefers-reduced-motion`. Default
2200
+ * 'reduce' (what every run did before the option existed); 'no-preference'
2201
+ * is what lets `expectAnimating` see an animation at all. */
2202
+ reducedMotion?: 'reduce' | 'no-preference';
2203
+ /** F126.4 — composition critic to run on each `screenshot` step. 'off'
2204
+ * (default) | 'dom' | 'vision' | 'both'. A high-severity finding fails that
2205
+ * step (and thus the flow). */
2206
+ critic?: CriticMode;
2207
+ /** F213.6 — repo root for resolving an uploadFile step's fixture path. The
2208
+ * path is sandboxed to this dir (no `../` escape). Absent → only data: URIs. */
2209
+ localPath?: string;
2210
+ /** F255.5 — record the whole driven run to a .webm, returned as FlowResult.video.
2211
+ * Off by default: recording costs disk + a little time on every run, and most flows
2212
+ * only need the per-step stills. */
2213
+ record?: boolean;
2214
+ /** F074.97 — localStorage/sessionStorage written BEFORE the flow's first
2215
+ * navigation, scoped to the flow's OWN base_url origin. The bug that prompted
2216
+ * this needs a flow as often as a single shot, and a capability present on one
2217
+ * entry point and absent on another is the shape F074.23/.24 exist to remove.
2218
+ * The origin guard matters more here: a flow may redirect to a payment
2219
+ * provider, and a seed must never follow it there. */
2220
+ storage?: StorageSeed;
2221
+ }
2222
+
2223
+ // F213.6 — resolve an uploadFile fixture to what Playwright's setInputFiles wants.
2224
+ // A `data:` URI → a FilePayload (mime + bytes carried inline). A plain path → the
2225
+ // absolute path, SANDBOXED to the repo root so a manuscript can't read /etc/passwd;
2226
+ // Playwright reads the file + infers its mime itself. Mixing the two kinds in one
2227
+ // step is rejected (keeps the setInputFiles arg homogeneous).
2228
+ type FilePayload = { name: string; mimeType: string; buffer: Buffer };
2229
+ export function dataUriToPayload(p: string): FilePayload {
2230
+ const m = /^data:([^;,]*)(;base64)?,([\s\S]*)$/.exec(p);
2231
+ if (!m) throw new Error('uploadFile: invalid data: URI fixture');
2232
+ const mimeType = m[1] || 'application/octet-stream';
2233
+ const buffer = m[2] ? Buffer.from(m[3]!, 'base64') : Buffer.from(decodeURIComponent(m[3]!));
2234
+ const ext = (mimeType.split('/')[1] || 'bin').split('+')[0];
2235
+ return { name: `fixture.${ext}`, mimeType, buffer };
2236
+ }
2237
+ export function resolveFixturePath(localPath: string | undefined, p: string): string {
2238
+ if (!localPath) throw new Error('uploadFile: a fixture path needs local_path — pass it to lens_run_flow / lens_run_manuscript (data: URIs work without it)');
2239
+ const root = nodePath.resolve(localPath);
2240
+ const abs = nodePath.resolve(root, p);
2241
+ if (abs !== root && !abs.startsWith(root + nodePath.sep)) throw new Error(`uploadFile: fixture path escapes the repo root: ${p}`);
2242
+ return abs;
2243
+ }
2244
+
2245
+ /** Run a flow end-to-end. Top-level steps run sequentially in one authed page;
2246
+ * a failure there aborts the flow (prerequisite). A `loop` runs its iterations
2247
+ * either sequentially on the same page or — with `parallel: N` — concurrently,
2248
+ * each iteration in its own fresh authed page (F082.4 parallel workers). A
2249
+ * failing step inside an iteration fails + skips the rest of THAT iteration but
2250
+ * never aborts the others (per-item report). Pins a screenshot on every
2251
+ * failure. Each leaf gets a deterministic pre-allocated index. */
2252
+ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {}): Promise<FlowResult> {
2253
+ const startedAt = Date.now();
2254
+ const results: FlowStepResult[] = [];
2255
+ const base = flow.base_url.replace(/\/$/, '');
2256
+ const from = Math.max(0, opts.from ?? 0);
2257
+ const prepare = authPrepare(flow.auth, env);
2258
+ // F074.97 — computed ONCE from the flow's own base_url, so every page this run
2259
+ // opens (including each parallel loop iteration) seeds the same origin and
2260
+ // nothing else. A bad seed throws here, before a browser is launched.
2261
+ const seedStorage = storagePayload(opts.storage, base);
2262
+ // F074.99 — passwords this run typed, so the report can say if one comes back
2263
+ // in the URL. Run-level (a parallel loop iteration fills on its own page and
2264
+ // the finding is about the RUN), and it never leaves the process: the finding
2265
+ // names the field and the parameter, never the value.
2266
+ const filledPasswords: FilledPassword[] = [];
2267
+ // F074.99 part 1 — pages where an edit verb wrote while nothing was listening.
2268
+ // Run-level for the same reason: the finding is about the RUN, and the first
2269
+ // one is what a reader acts on.
2270
+ const notListening: NotListeningFinding[] = [];
2271
+ let firstFailedAt: number | null = null;
2272
+ let reason: string | null = null;
2273
+ let aborted = false;
2274
+ /** F074.36 — the first "asked for X, landed on Y", if any. */
2275
+ let firstRedirect: string | null = null;
2276
+ /** F074.36 — where the run actually ended. null = not measured. */
2277
+ let finalUrl: string | null = null;
2278
+ /**
2279
+ * F074.36 — set while the browser is on a page we did not ask for, CLEARED by
2280
+ * the next goto that lands where it asked.
2281
+ *
2282
+ * This is what makes `fault:'instrument'` safe to apply. Tagging every failure
2283
+ * in a run that was redirected at any point would mislabel a genuine app bug
2284
+ * as a broken probe — which is worse than the silence it replaces, because it
2285
+ * gives a reader permission to dismiss a real finding. A failure only stops
2286
+ * being evidence about the app while we are still standing on the wrong page.
2287
+ */
2288
+ let onWrongPage: string | null = null;
2289
+ /** F074.36 — set when the applied credential cannot reach the target host.
2290
+ * `certain` separates the unambiguous mismatch (we hold cookies, none for
2291
+ * this host) from the ambiguous one (no cookies at all — legitimate for a
2292
+ * localStorage-authenticated app). Only the CERTAIN case may tag a failure
2293
+ * `fault:'instrument'`; tagging the ambiguous one would label every genuine
2294
+ * bug in such an app as a broken probe, which is worse than the silence this
2295
+ * card removes because it gives a reader permission to dismiss a real
2296
+ * finding. Both are still reported. */
2297
+ let authReach: string | null = null;
2298
+ let authReachCertain = false;
2299
+ /** F213.10 — where the most recent navigation LANDED. Null until the first one. */
2300
+ let urlAtLoad: string | null = null;
2301
+ /** F213.10 — the first "we were thrown off the page we were judging", if any. */
2302
+ let firstEjection: string | null = null;
2303
+ /** F213.10 — the flow performed a navigating action whose navigation may not
2304
+ * have committed yet, so the next URL change belongs to us, not to the app. */
2305
+ let navActionPending = false;
2306
+ /** F213.10 — did this run ever form a verdict about a page? A flow that only
2307
+ * drives and never asserts has no verdict to void, so it never pays the grace. */
2308
+ let formedAVerdict = false;
2309
+
2310
+ /**
2311
+ * F074.48 — every failed HTTP response the run has seen, in arrival order.
2312
+ *
2313
+ * Collected for the WHOLE run and sliced per step, rather than armed and
2314
+ * disarmed around each step: a refusal often lands a tick after the click that
2315
+ * caused it, and a listener that stops at the step boundary would miss exactly
2316
+ * the response that explains the failure.
2317
+ */
2318
+ const failedResponses: FailedResponse[] = [];
2319
+
2320
+ /** Attach the collector to a page. Called for every page a flow drives — the
2321
+ * main one AND each parallel loop iteration, which get their own context. */
2322
+ const collectFailures = (page: Page) => {
2323
+ page.on('response', (res) => {
2324
+ if (res.ok()) return;
2325
+ const entry: FailedResponse = {
2326
+ method: res.request().method(),
2327
+ url: res.url(),
2328
+ status: res.status(),
2329
+ body: '',
2330
+ };
2331
+ failedResponses.push(entry);
2332
+ // Read the body OUT OF BAND. A body can be unreadable (already consumed, a
2333
+ // redirect, binary) and reading must never be able to fail the run — the
2334
+ // whole point of this card is a report that says more, not a runner that
2335
+ // breaks more.
2336
+ res
2337
+ .text()
2338
+ .then((t) => {
2339
+ entry.body = redactBody(t.trim()).slice(0, MAX_BODY_CHARS);
2340
+ })
2341
+ .catch(() => {
2342
+ // Stated, not silent: "we could not read it" and "it was empty" are
2343
+ // different facts, and collapsing them is the defect this card fixes.
2344
+ entry.body = '[body not readable]';
2345
+ });
2346
+ });
2347
+ };
2348
+
2349
+ const noteFail = (i: number, msg: string) => {
2350
+ if (firstFailedAt === null) {
2351
+ firstFailedAt = i;
2352
+ reason = msg;
2353
+ }
2354
+ };
2355
+ // F213.11 — reset on EVERY navigation, not just the first. A flow that gotos twice
2356
+ // would otherwise report page two's steps as tens of seconds late, and a field that
2357
+ // reads as noise is a field nobody checks.
2358
+ let lastNavAt = Date.now();
2359
+ const record = (r: FlowStepResult) => {
2360
+ results.push(r);
2361
+ };
2362
+ const skipFill = (steps: FlowStep[], startIndex: number, vars: Record<string, unknown>, detail: string) => {
2363
+ let idx = startIndex;
2364
+ for (const s of steps) {
2365
+ if (s.type === 'loop') {
2366
+ s.data.forEach((row, k) => {
2367
+ skipFill(s.steps, idx, { ...vars, ...row, __index: k }, detail);
2368
+ idx += flattenCount(s.steps);
2369
+ });
2370
+ } else if (s.type === 'conditional') {
2371
+ // F213.3 — a conditional's children occupy a contiguous index block; skip
2372
+ // them all so the report stays aligned whether the branch ran or not.
2373
+ skipFill(s.steps, idx, vars, detail);
2374
+ idx += flattenCount(s.steps);
2375
+ } else {
2376
+ record({ index: idx, type: s.type, label: s.label ?? describe(s), status: 'skip', testid: testidOf(s), detail, screenshot: null });
2377
+ idx += 1;
2378
+ }
2379
+ }
2380
+ };
2381
+
2382
+ // Run a step-list on `page` starting at `startIndex`. Returns whether a
2383
+ // non-loop step failed (so the caller can stop THIS sequence).
2384
+ async function runSeq(page: Page, steps: FlowStep[], vars: Record<string, unknown>, startIndex: number): Promise<{ failedHere: boolean }> {
2385
+ let idx = startIndex;
2386
+ for (let s = 0; s < steps.length; s++) {
2387
+ const step = steps[s]!;
2388
+ if (step.type === 'loop') {
2389
+ const bodyLen = flattenCount(step.steps);
2390
+ const par = Math.max(1, step.parallel ?? 1);
2391
+ const loopBase = idx;
2392
+ if (par > 1) {
2393
+ // Parallel: each iteration in its OWN authed page; failures stay local.
2394
+ const tasks = step.data.map((row, k) => async () => {
2395
+ const iterBase = loopBase + k * bodyLen;
2396
+ const rowVars = { ...vars, ...row, __index: k };
2397
+ try {
2398
+ await withPage({ prepareContext: prepare, seedStorage, viewport: opts.viewport, engine: opts.engine, locale: opts.locale, timezone: opts.timezone, reducedMotion: opts.reducedMotion }, (p2) => {
2399
+ // Each parallel iteration drives its OWN page — attach there too,
2400
+ // or a failure inside a loop reports no network at all.
2401
+ collectFailures(p2);
2402
+ return runSeq(p2, step.steps, rowVars, iterBase);
2403
+ });
2404
+ } catch (err) {
2405
+ // a whole-iteration crash (e.g. auth/navigation) — record + move on
2406
+ record({ index: iterBase, type: 'loop', label: `iteration ${k} crashed`, status: 'fail', testid: null, detail: err instanceof Error ? err.message : String(err), screenshot: null });
2407
+ noteFail(iterBase, 'iteration crashed');
2408
+ }
2409
+ });
2410
+ await runPool(tasks, par);
2411
+ } else {
2412
+ for (let k = 0; k < step.data.length; k++) {
2413
+ const rowVars = { ...vars, ...step.data[k], __index: k };
2414
+ await runSeq(page, step.steps, rowVars, loopBase + k * bodyLen);
2415
+ // a failing iteration does NOT stop the loop — continue.
2416
+ }
2417
+ }
2418
+ idx += step.data.length * bodyLen;
2419
+ continue;
2420
+ }
2421
+
2422
+ if (step.type === 'conditional') {
2423
+ // F213.3 — probe the anchor's presence (short, non-fatal), then run the
2424
+ // child block only when presence matches `present` ('if' vs 'unless');
2425
+ // otherwise skip it (children report 'skip' — no failure). Children
2426
+ // occupy a fixed [condBase, condBase+bodyLen) block so indices stay
2427
+ // deterministic regardless of branch.
2428
+ const bodyLen = flattenCount(step.steps);
2429
+ const condBase = idx;
2430
+ idx += bodyLen;
2431
+ const probe = step.timeout ?? 2000;
2432
+ let visible = false;
2433
+ try {
2434
+ await page.locator(sel(step.testid)).first().waitFor({ state: 'visible', timeout: probe });
2435
+ visible = true;
2436
+ } catch {
2437
+ // anchor not present within the probe window → treated as absent
2438
+ }
2439
+ if (visible === step.present) {
2440
+ const r = await runSeq(page, step.steps, vars, condBase);
2441
+ if (r.failedHere) {
2442
+ // a child step really failed → abort THIS sequence like any leaf fail.
2443
+ skipFill(steps.slice(s + 1), idx, vars, 'skipped after failure');
2444
+ return { failedHere: true };
2445
+ }
2446
+ } else {
2447
+ skipFill(step.steps, condBase, vars, step.present ? 'if: anchor absent — skipped' : 'unless: anchor present — skipped');
2448
+ }
2449
+ continue;
2450
+ }
2451
+
2452
+ // ── leaf step ──
2453
+ const i = idx;
2454
+ idx += 1;
2455
+ const label = step.label ?? describe(step);
2456
+ if (i < from) {
2457
+ record({ index: i, type: step.type, label, status: 'skip', testid: testidOf(step), detail: 'resumed past', screenshot: null });
2458
+ continue;
2459
+ }
2460
+ // F213.11 — read BEFORE the step runs, so the number is "how long after the
2461
+ // page loaded did this start", never "how long did it take".
2462
+ const stepStartedAt = Date.now();
2463
+ const msAfterNav = stepStartedAt - lastNavAt;
2464
+ // F213.13 — a goto's reading is measured from the PREVIOUS navigation, so it
2465
+ // is not the same quantity as its siblings and must not sit in their column.
2466
+ const navOffset = step.type === 'goto' ? {} : { ms_after_navigation: msAfterNav };
2467
+ // Declared OUTSIDE the try: the failing step is exactly where the findings
2468
+ // matter most, and a sink scoped to the try is unreachable from the catch.
2469
+ const sink: CriticSink = { critic: null };
2470
+ try {
2471
+ const out = await execStep(page, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening);
2472
+ const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
2473
+ const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
2474
+ const via = obj && '__via' in obj ? (obj as { __via: string }).__via : null;
2475
+ // F074.30 — a waitForBuild step KNOWS the live sha; it wins over the
2476
+ // best-effort read taken before driving.
2477
+ if (obj && '__build' in obj) observedBuild = (obj as { __build: string }).__build;
2478
+ // F074.36 — remember the FIRST redirect so a later failure can lead with
2479
+ // it. Kept even when every step passes: a green run that was silently
2480
+ // rerouted is exactly the case a reader needs to be able to re-check.
2481
+ const redirect = obj && '__redirect' in obj ? (obj as { __redirect?: string }).__redirect ?? null : null;
2482
+ if (redirect && !firstRedirect) firstRedirect = redirect;
2483
+ // A goto is the only step that can put us on — or take us off — a page we
2484
+ // did not ask for.
2485
+ if (step.type === 'goto') onWrongPage = redirect;
2486
+ // F213.10 — remember where each navigation LANDED, so a later move away
2487
+ // from it is measurable. Read after the goto settles, not from the
2488
+ // requested URL: an app that legitimately rewrites /admin → /admin/users
2489
+ // at load has not ejected us, and comparing against what we asked for
2490
+ // would call that an ejection on every single run.
2491
+ if (formsAVerdict(step.type)) formedAVerdict = true;
2492
+ if (NAVIGATES_BY_DESIGN.has(step.type)) {
2493
+ urlAtLoad = safeUrl(page) ?? urlAtLoad;
2494
+ // A goto is already settled — Playwright waited for it. Any OTHER
2495
+ // navigating action may commit after it returns, so it leaves a note
2496
+ // saying "one URL change after this is mine". See classifyUrlChange.
2497
+ if (step.type !== 'goto') navActionPending = true;
2498
+ } else if (urlAtLoad) {
2499
+ // A verdict step just formed an opinion. If the page moved out from
2500
+ // under it, that opinion is void — and `page.url()` is a synchronous
2501
+ // read of state Playwright already holds, so this costs nothing and
2502
+ // adds no polling (the tax AC4 forbids).
2503
+ const verdict = classifyUrlChange({ loaded: urlAtLoad, current: safeUrl(page) ?? urlAtLoad, navActionPending });
2504
+ if (verdict.kind === 'flow-navigation') {
2505
+ urlAtLoad = verdict.rebaseTo;
2506
+ navActionPending = false;
2507
+ } else if (verdict.kind === 'ejection') {
2508
+ // InstrumentError, not a plain throw: this is a broken measurement,
2509
+ // not an app bug, and F074.29's fault:'instrument' already carries
2510
+ // that distinction through the report.
2511
+ firstEjection = firstEjection ?? verdict.message;
2512
+ throw new InstrumentError(verdict.message);
2513
+ }
2514
+ }
2515
+ // The clock restarts here, so the goto itself reports its own pre-nav value
2516
+ // and every following step is measured from THIS page, not the previous one.
2517
+ if (step.type === 'goto') lastNavAt = Date.now();
2518
+ record({
2519
+ index: i,
2520
+ type: step.type,
2521
+ label,
2522
+ status: 'pass',
2523
+ ...navOffset,
2524
+ ms_step_duration: Date.now() - stepStartedAt,
2525
+ testid: testidOf(step),
2526
+ detail: redirect ?? inspect ?? (via ? `resolved via ${via}` : null),
2527
+ screenshot: Buffer.isBuffer(out) ? out : null,
2528
+ // Attached on a PASSING step too. `high > 0` decides the status; it must
2529
+ // not decide what gets reported. A screen with four medium findings and
2530
+ // no high ones used to report nothing at all.
2531
+ ...(sink.critic ? { critic: sink.critic } : {}),
2532
+ });
2533
+ } catch (err) {
2534
+ // F213.13 — STOP THE CLOCK HERE, before the diagnostics. Everything below
2535
+ // (a 5s failure screenshot, the 150ms settle for in-flight body reads) is
2536
+ // OUR work, not the step's. Measured on the wire: a 500ms timeout reported
2537
+ // ms_step_duration 5018, which reads as "this waited five seconds" — a
2538
+ // fabricated measurement of exactly the kind this pair exists to expose,
2539
+ // and on the failing step, where the number is read most carefully.
2540
+ const failedAfter = Date.now() - stepStartedAt;
2541
+ const msg = err instanceof Error ? err.message : String(err);
2542
+ let shot: Buffer | null = null;
2543
+ try {
2544
+ shot = (await takeShot(page, 'viewport', null, 5000)).screenshot;
2545
+ } catch {
2546
+ // page may be in a bad state — a missing failure shot is acceptable
2547
+ }
2548
+ // F074.36 — a failure measured while standing on a page we never asked
2549
+ // for is not evidence about the app, so it carries the same
2550
+ // `fault:'instrument'` flag F074.29 introduced. `status` stays 'fail', so
2551
+ // nothing that counts failures can stop seeing it.
2552
+ // authReach outranks the URL check and covers the SPA case the URL check
2553
+ // cannot see. It applies to the WHOLE run: the operator asked for an
2554
+ // authenticated measurement and did not get one, so no step in it is the
2555
+ // measurement they asked for.
2556
+ const wrongPage =
2557
+ (authReachCertain ? authReach : null) ??
2558
+ (step.type !== 'goto' && onWrongPage ? onWrongPage : null);
2559
+ // Give in-flight body reads a tick to settle: the refusal that explains
2560
+ // a failure often lands with, or just after, the throw that surfaced it.
2561
+ await new Promise((r) => setTimeout(r, 150));
2562
+ // The MOST RECENT failures of the run, not this step's slice.
2563
+ //
2564
+ // MEASURED, and it is why the first version of this reported nothing: the
2565
+ // refusal is caused by the CLICK and surfaces as a failure on the NEXT
2566
+ // step (expectAbsent). A per-step slice attributes it to the passing step
2567
+ // — where nothing is attached — and the failing step reports an empty
2568
+ // array. Exactly the silence this card exists to remove, rebuilt by
2569
+ // being tidy about step boundaries.
2570
+ //
2571
+ // A flow aborts at its first failure, so "recent" cannot drag in noise
2572
+ // from later work; it can only include the responses that led here.
2573
+ const netSlice = failedResponses.slice(-MAX_FAILED_RESPONSES_PER_STEP);
2574
+ record({
2575
+ index: i,
2576
+ type: step.type,
2577
+ label,
2578
+ // Still 'fail' — a broken probe is never less than a failure.
2579
+ status: 'fail',
2580
+ // F213.11 — on the FAILING step above all: a lost write is exactly where
2581
+ // "this ran 180ms after the page loaded" is the answer.
2582
+ ...navOffset,
2583
+ // F213.13 — and how long it spent failing: a step that burned its whole
2584
+ // timeout and one that threw instantly are different diagnoses.
2585
+ ms_step_duration: failedAfter,
2586
+ testid: testidOf(step),
2587
+ detail: wrongPage ? `${wrongPage} — this step was measured on that page:\n${msg}` : msg,
2588
+ screenshot: shot,
2589
+ ...(err instanceof InstrumentError || wrongPage ? { fault: 'instrument' as const } : {}),
2590
+ // F126.7 — ALL the composition findings, not the count plus the first
2591
+ // message. Without this a caller fixes the one issue it was shown, goes
2592
+ // green, and the rest are silently recorded as resolved.
2593
+ ...(sink.critic ? { critic: sink.critic } : {}),
2594
+ // F074.48 — what the APP said while this step ran. Without it the
2595
+ // report states only what the assertion expected, and a guard doing
2596
+ // its job is indistinguishable from an app that is broken.
2597
+ ...(netSlice.length ? { network: netSlice } : {}),
2598
+ });
2599
+ noteFail(i, msg);
2600
+ // Skip the rest of THIS sequence (rest of the iteration / the top level).
2601
+ skipFill(steps.slice(s + 1), idx, vars, 'skipped after failure');
2602
+ return { failedHere: true };
2603
+ }
2604
+ }
2605
+ return { failedHere: false };
2606
+ }
2607
+
2608
+ // F255.5 — recording writes into a temp dir; Playwright only finalises the .webm when
2609
+ // the context closes, so the file is read AFTER withPage resolves. `videoPage` holds
2610
+ // the page reference across that boundary.
2611
+ // F074.30 — which build this run measured. A waitForBuild step overwrites it
2612
+ // with the authoritative post-wait sha; otherwise we take one best-effort read
2613
+ // before driving, so the row still says what it was looking at. Stays null when
2614
+ // the target exposes no marker — "not measured", never "same as the other run".
2615
+ let observedBuild: string | null = await observeBuild(base);
2616
+
2617
+ const videoDir = opts.record ? await mkdtemp(nodePath.join(tmpdir(), 'lens-flow-video-')) : null;
2618
+ let videoPage: Page | null = null;
2619
+ const readVideo = async (): Promise<Buffer | null> => {
2620
+ if (!videoDir) return null;
2621
+ try {
2622
+ const p = await videoPage?.video()?.path();
2623
+ const bytes = p ? await readFile(p) : null;
2624
+ return bytes;
2625
+ } catch {
2626
+ return null; // a missing recording must never fail an otherwise-good run
2627
+ } finally {
2628
+ await rm(videoDir, { recursive: true, force: true }).catch(() => {});
2629
+ }
2630
+ };
2631
+
2632
+ // F255.7 — gate the WHOLE run on the build under test actually serving, BEFORE a browser
2633
+ // is even launched. A flow that verifies the previous build is the most expensive kind of
2634
+ // green: it exercises real behaviour, so nothing in the report looks wrong.
2635
+ if (flow.wait_for_build !== undefined && flow.wait_for_build !== false) {
2636
+ try {
2637
+ const expected = typeof flow.wait_for_build === 'string' ? flow.wait_for_build : null;
2638
+ const r = await waitForBuild(base, { expected });
2639
+ console.log(`[lens/flow] ${flow.name}: build ${r.build.slice(0, 8)} live after ${r.polls} polls (${r.mode})`);
2640
+ } catch (err) {
2641
+ const msg = err instanceof Error ? err.message : String(err);
2642
+ skipFill(flow.steps, 0, {}, 'skipped — the build under test never went live');
2643
+ return {
2644
+ name: flow.name,
2645
+ status: 'error',
2646
+ steps: results.sort((a, b) => a.index - b.index),
2647
+ failed_at: 0,
2648
+ reason: `wait_for_build: ${msg}`,
2649
+ duration_ms: Date.now() - startedAt,
2650
+ reduced_motion: opts.reducedMotion ?? 'reduce',
2651
+ };
2652
+ }
2653
+ }
2654
+
2655
+ try {
2656
+ await withPage(
2657
+ {
2658
+ prepareContext: prepare,
2659
+ seedStorage,
2660
+ viewport: opts.viewport,
2661
+ engine: opts.engine,
2662
+ locale: opts.locale,
2663
+ timezone: opts.timezone,
2664
+ reducedMotion: opts.reducedMotion,
2665
+ ...(videoDir ? { recordVideoDir: videoDir } : {}),
2666
+ },
2667
+ async (page, context) => {
2668
+ videoPage = page;
2669
+ collectFailures(page);
2670
+ // F074.36 — measure the credential BEFORE driving, so a run that cannot
2671
+ // possibly be authenticated says so instead of producing a confident
2672
+ // verdict about a login screen. Only when auth was actually requested:
2673
+ // a deliberately anonymous run is not a problem to report.
2674
+ if (prepare) {
2675
+ try {
2676
+ const held = (await context.cookies()).length;
2677
+ const applicable = (await context.cookies(base)).length;
2678
+ const reach = credentialReach(held, applicable, new URL(base).origin);
2679
+ authReach = reach?.message ?? null;
2680
+ authReachCertain = reach?.certain === true;
2681
+ } catch {
2682
+ // cookie read failed — leave authReach null rather than invent a verdict
2683
+ }
2684
+ }
2685
+ const r = await runSeq(page, flow.steps, {}, 0);
2686
+ if (r.failedHere) aborted = true; // a TOP-LEVEL step failed → flow aborts
2687
+ // ── F213.10 · the window, and why it has to exist at all ──────────────
2688
+ //
2689
+ // MEASURED, and it is the finding that shaped this card: the fixture
2690
+ // bounces 800ms after load, and the whole two-step run finishes 55ms
2691
+ // after load. Every check above is free but sees nothing, because the
2692
+ // run is OVER before the ejection happens. Detecting "we will be thrown
2693
+ // off in 800ms" is not possible without staying to watch.
2694
+ //
2695
+ // So the grace is bounded, and it is charged only where a false green is
2696
+ // actually dangerous:
2697
+ // · the run is otherwise GREEN — a failing run is already telling you
2698
+ // to look, and a red one must not be delayed;
2699
+ // · the run FORMED A VERDICT — a flow that only drives and never
2700
+ // asserts has no opinion to void, so it pays nothing;
2701
+ // · we are still standing where we landed — if the flow's own last act
2702
+ // moved the page, waiting tells us nothing about the page we judged.
2703
+ //
2704
+ // THE REACH, per F213.8's rule: a bounce later than eject_grace_ms is
2705
+ // outside this check and the run stays green. This does not detect a
2706
+ // slow ejection; it detects the ordinary one. Set eject_grace_ms: 0 to
2707
+ // opt out entirely, or higher when you know the app's check is slow —
2708
+ // fd-sundhed's was 3.6s.
2709
+ const grace = flow.eject_grace_ms ?? DEFAULT_EJECT_GRACE_MS;
2710
+ // …and it is charged only for the part of the window we have not ALREADY
2711
+ // watched. The grace exists to make the total observation ≥ grace; a flow
2712
+ // that spent two seconds on this page has already given the app twice the
2713
+ // chance to eject it, so waiting again buys nothing.
2714
+ //
2715
+ // MEASURED — this is what keeps the guard affordable: a fast asserting
2716
+ // flow paid +1030ms before this line existed, and every real E2E flow
2717
+ // (which spends seconds on a page) now pays 0.
2718
+ const remainingGrace = grace - (Date.now() - lastNavAt);
2719
+ if (!aborted && formedAVerdict && urlAtLoad && !firstEjection) {
2720
+ if (remainingGrace > 0) {
2721
+ try {
2722
+ // Resolves the INSTANT the page moves, so a real ejection costs ~0
2723
+ // and only the honest all-clear pays the remainder.
2724
+ // Main frame only: an ad or embed navigating is not our page moving,
2725
+ // and letting one resolve the wait early would cut the window short
2726
+ // for the bounce it exists to catch.
2727
+ await page.waitForEvent('framenavigated', { predicate: (f) => f === page.mainFrame(), timeout: remainingGrace });
2728
+ } catch {
2729
+ // No navigation within the window — the expected, quiet outcome.
2730
+ }
2731
+ }
2732
+ // OUTSIDE the wait, deliberately: when the flow already watched longer
2733
+ // than the grace we skip the WAIT, never the COMPARISON. A page that
2734
+ // ejected us during a long step would otherwise go unreported for the
2735
+ // one reason that it took its time — and reading `page.url()` is free.
2736
+ //
2737
+ // Same free pass as mid-run, and it is NOT redundant here: a flow whose
2738
+ // last act was a click can have that navigation commit during the grace,
2739
+ // and calling the flow's own click an ejection is precisely the red on
2740
+ // working software this guard must never produce (measured: 5/5 false
2741
+ // ejections before classifyUrlChange existed).
2742
+ const end = classifyUrlChange({ loaded: urlAtLoad, current: safeUrl(page) ?? urlAtLoad, navActionPending });
2743
+ firstEjection = end.kind === 'ejection' ? end.message : null;
2744
+ }
2745
+ // F074.36 — WHERE the run ended. Read last, so it survives a mid-flow
2746
+ // redirect; a past green run can then be re-checked for which page it
2747
+ // actually judged.
2748
+ try {
2749
+ finalUrl = page.url();
2750
+ } catch {
2751
+ // page already closed — final_url stays null (not measured)
2752
+ }
2753
+ },
2754
+ );
2755
+ } catch (err) {
2756
+ reason = reason ?? (err instanceof Error ? err.message : String(err));
2757
+ // Keep the recording even on a crash — watching HOW it broke is the whole point.
2758
+ const video = await readVideo();
2759
+ return { name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
2760
+ }
2761
+
2762
+ const video = await readVideo();
2763
+ results.sort((a, b) => a.index - b.index);
2764
+ const anyFail = results.some((s) => s.status === 'fail');
2765
+ const baseReason = reason ?? (aborted ? 'flow aborted on a top-level failure' : null);
2766
+ // The credential mismatch outranks a URL redirect: it explains the redirect.
2767
+ const lead = authReach ?? firstRedirect;
2768
+ // F213.10 — an ejection detected in the grace has no failing STEP behind it:
2769
+ // every step genuinely passed, against a page we turned out not to be allowed
2770
+ // to see. The steps stay truthful and the RUN goes red, because what is void
2771
+ // is the verdict, not the observations.
2772
+ const ejectedRun = !anyFail && firstEjection !== null;
2773
+ return {
2774
+ name: flow.name,
2775
+ status: anyFail || ejectedRun ? 'fail' : 'pass',
2776
+ steps: results,
2777
+ failed_at: firstFailedAt,
2778
+ // Only a FAILING run gets this prepended. A legitimate redirect — or an
2779
+ // anonymous-by-design run — must never be turned into a failure; the guard
2780
+ // adds explanation, it never manufactures a red.
2781
+ reason: ejectedRun ? firstEjection : anyFail ? leadWithRedirect(baseReason, lead) : baseReason,
2782
+ duration_ms: Date.now() - startedAt,
2783
+ video,
2784
+ build: observedBuild,
2785
+ final_url: finalUrl,
2786
+ redirected: firstRedirect,
2787
+ // F074.99 — reported on PASSING runs for the same reason auth_reach is: the
2788
+ // run that prompted this was 12 of 12 GREEN over two empty fields. A finding
2789
+ // that only appears on a red run cannot see the failure it exists for.
2790
+ //
2791
+ // It does NOT fail the run, deliberately and as the plan-doc says. Escalating
2792
+ // to red is defensible — there is no benign reading — but it would turn
2793
+ // currently-green fleet runs red on the day it ships, and that is its own
2794
+ // decision to announce rather than a side effect of this one.
2795
+ password_in_url: passwordInUrl(finalUrl, filledPasswords),
2796
+ // F074.99 part 1 — the FIRST page that was not listening. Reported on a
2797
+ // passing run and never failing it: a plain server-rendered form legitimately
2798
+ // has no listener, so a red here would be a regression for every repo that
2799
+ // drives one. The first finding is what a reader acts on; the memo in
2800
+ // consultReadiness is why there is rarely more than one.
2801
+ not_listening: notListening[0] ?? null,
2802
+ // Reported on PASSING runs too — a green run that was never authenticated is
2803
+ // the more dangerous direction, and it is invisible unless we say so.
2804
+ auth_reach: authReach,
2805
+ ejected: firstEjection,
2806
+ // F074.59 — say which browser this verdict came from. Stamped from what we
2807
+ // ASKED for, defaulted the same way the context does, so the report and the
2808
+ // context can never disagree about it.
2809
+ reduced_motion: opts.reducedMotion ?? 'reduce',
2810
+ };
2811
+ }
2812
+
2813
+ /**
2814
+ * F074.36 — put the redirect in front of the failure message.
2815
+ *
2816
+ * "waiting for [data-testid=board-root] to be visible" is true and useless when
2817
+ * the browser was sitting on the sign-in wall: the reader concludes the board is
2818
+ * broken. Leading with "asked for /board, landed on /" changes the conclusion
2819
+ * from "our app regressed" to "this run was never logged in".
2820
+ *
2821
+ * Pure + exported so the wording itself is testable — a probe that also answers
2822
+ * WHY needs its reason under test, or half the answer is unverified.
2823
+ */
2824
+ /**
2825
+ * F074.36 — can the credential we applied even REACH the host we are driving?
2826
+ *
2827
+ * The first cut of this card compared the requested URL to the landed URL, which
2828
+ * is right for a server-rendered app and BLIND to ours: cardmem is an SPA, so an
2829
+ * unauthenticated `/board` renders the sign-in screen at `/board`. The control
2830
+ * caught it — `final_url` came back as the path we asked for while the
2831
+ * screenshot showed the login wall. A correct check, applied one level from
2832
+ * where the truth sits.
2833
+ *
2834
+ * So measure the credential instead of the routing. Playwright's
2835
+ * `context.cookies(url)` returns exactly the cookies that would be SENT to that
2836
+ * url, which answers the question directly and with no vocabulary of login-page
2837
+ * words: a `localhost`-scoped cookie driven at `https://www.cardmem.com` matches
2838
+ * nothing, and neither does an empty state file.
2839
+ *
2840
+ * `held > 0 && applicable === 0` is the unambiguous case — we are carrying
2841
+ * credentials, none of them for this host. `held === 0` is softer: a
2842
+ * localStorage-only credential legitimately brings no cookies, so it is reported
2843
+ * without being called a mismatch.
2844
+ *
2845
+ * NOT detectable here, and deliberately not implied: a correctly-scoped cookie
2846
+ * that the SERVER has expired or revoked. That truth lives on the other side of
2847
+ * the request. Saying so is the point — a guard that quietly covers four of five
2848
+ * cases while reading as "auth is verified" is the thing this card exists to
2849
+ * remove.
2850
+ */
2851
+ export function credentialReach(
2852
+ held: number,
2853
+ applicable: number,
2854
+ targetOrigin: string,
2855
+ ): { message: string; certain: boolean } | null {
2856
+ if (held > 0 && applicable === 0) {
2857
+ return {
2858
+ message: `the credential applied carries ${held} cookie${held === 1 ? '' : 's'}, none valid for ${targetOrigin} — this run is NOT authenticated`,
2859
+ certain: true,
2860
+ };
2861
+ }
2862
+ if (held === 0) {
2863
+ return {
2864
+ message: `the credential applied left no cookies in the browser — if this app authenticates by cookie, this run is NOT authenticated`,
2865
+ certain: false,
2866
+ };
2867
+ }
2868
+ return null;
2869
+ }
2870
+
2871
+ export function leadWithRedirect(reason: string | null, redirect: string | null): string | null {
2872
+ if (!redirect) return reason;
2873
+ if (!reason) return redirect;
2874
+ if (reason.startsWith(redirect)) return reason; // already led with it
2875
+ return `${redirect} — the failure below may be about that page, not the one you asked for:\n${reason}`;
2876
+ }
2877
+
2878
+ /**
2879
+ * F074.42 — ONE definition of what an assert means, owned by the engine.
2880
+ *
2881
+ * This used to be our own copy. `components` took it verbatim into
2882
+ * `@broberg/lens-engine` 0.4.2 and exported it for exactly this reason (the same
2883
+ * argument as `isEditableElement`): the local daemon and the hosted Cloud Lens
2884
+ * must not each carry their own idea of the contract. **Two copies is how
2885
+ * F074.39 arrived** — our flow surface and our verify surface had drifted apart,
2886
+ * and the engine had drifted from both. Keeping a private copy while shipping
2887
+ * the shared one would recreate that on day one.
2888
+ *
2889
+ * The split with the engine is deliberate: **it owns what an assert MEANS
2890
+ * (compile, run, read `.pass`, report syntax/threw/value separately); we own how
2891
+ * long we wait for one** — `nextAssertState` + `assertTimeoutMessage` below are
2892
+ * our poll loop, which the engine intentionally does not have (its assert is
2893
+ * single-shot, and adding retries would change timing for every consumer).
2894
+ *
2895
+ * Re-exported rather than merely imported so every existing call site — and the
2896
+ * F074.39 tests, which now double as the upgrade gate — keeps one import path.
2897
+ */
2898
+ export { evalAssertBody, type AssertOutcome };
2899
+
2900
+ /** What the assert poll loop does with one evaluation result.
2901
+ *
2902
+ * Pulled out of the loop so the three branches can be sealed by tests without
2903
+ * a browser — the loop itself is just `evaluate → decide → sleep`, and the
2904
+ * deciding is where F074.29's whole point lives. */
2905
+ export type AssertStep =
2906
+ | { action: 'pass' }
2907
+ | { action: 'fail_now'; reason: string }
2908
+ | { action: 'retry'; lastThrow: string; lastDetail?: string };
2909
+
2910
+ export function nextAssertState(out: AssertOutcome): AssertStep {
2911
+ // A SyntaxError never becomes valid by waiting. Polling it to the deadline is
2912
+ // what produced "stayed falsy" for a probe that could not run at all
2913
+ // (torrent-search-api, #18574) — so it fails immediately, and says why.
2914
+ if (out.kind === 'syntax') {
2915
+ return { action: 'fail_now', reason: `assert body is not valid JavaScript (${out.message})` };
2916
+ }
2917
+ // A throw is NOT a verdict. Keep waiting — the element may not have hydrated
2918
+ // yet, which is the case the polling exists for — but carry the message so
2919
+ // the timeout can name it instead of blaming the predicate.
2920
+ if (out.kind === 'threw') return { action: 'retry', lastThrow: out.message };
2921
+ // engine 0.6.0 — a plain object with no `pass` key is an AUTHOR error, not a
2922
+ // timing one, so polling it for 8s would be the old bug in slow motion. Fail
2923
+ // immediately and name the keys it actually carried: that turns the fix into
2924
+ // one line instead of a hunt. `{}` gets its own wording — there is no key to
2925
+ // suggest a rename of, so it reads as a template nobody filled in.
2926
+ if (out.kind === 'no-verdict') {
2927
+ // The wording comes from the ENGINE (0.6.1's noVerdictMessage), not from us.
2928
+ // We had hand-rebuilt both sentences here and in verify.ts — two copies of a
2929
+ // message, which is the same shape as the two copies of the verdict logic
2930
+ // that caused this whole class in the first place, one layer further out.
2931
+ return { action: 'fail_now', reason: noVerdictMessage(out.keys) };
2932
+ }
2933
+ if (out.value) return { action: 'pass' };
2934
+ // A clean false clears any earlier throw: the body is running now, it simply
2935
+ // is not true yet, so the honest timeout is "stayed falsy". F074.39 — an
2936
+ // author who wrote `{ pass: false, detail: 'expected 3 rows, got 0' }` said
2937
+ // exactly what was wrong; carrying it out means the timeout reports THAT
2938
+ // instead of the generic verdict.
2939
+ return { action: 'retry', lastThrow: '', ...(out.detail ? { lastDetail: out.detail } : {}) };
2940
+ }
2941
+
2942
+ export function assertTimeoutMessage(
2943
+ timeoutMs: number,
2944
+ body: string,
2945
+ lastThrow: string,
2946
+ lastDetail?: string,
2947
+ ): string {
2948
+ if (lastThrow) {
2949
+ return `assert never became true within ${timeoutMs}ms; last evaluation threw: ${lastThrow} — ${body.slice(0, 80)}`;
2950
+ }
2951
+ const base = `assert stayed falsy for ${timeoutMs}ms: ${body.slice(0, 80)}`;
2952
+ return lastDetail ? `${base} — detail: ${lastDetail.slice(0, 200)}` : base;
2953
+ }