@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/README.md +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +253 -0
- package/src/artifact-uploads.ts +370 -0
- package/src/auth/index.ts +294 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2869 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1483 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
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
|
+
}
|