@broberg/lens-daemon 0.0.0-stage → 0.1.0

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