decisium 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,539 @@
1
+ // Browser runtime: the only module that knows about Playwright. Swap for CDP behind the same surface.
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { chromium } from "playwright";
5
+ import { recordNetwork, replayNetwork } from "./network.js";
6
+ import { parseSnapshot, renderSnapshot, withDialog, withClipboard, withDownload, withPage } from "./observation.js";
7
+ /** Chrome shows this for beforeunload; the page cannot set its own text. */
8
+ const LEAVE = "Leave site? Changes you made may not be saved.";
9
+ const QUIET_MS = 100; // no requests in flight and no DOM mutations for this long = settled
10
+ // shortcut: long-lived requests (SSE, long polling), endless chains of short timers and endless animations never go
11
+ // quiet, so actions wait up to the full cap (github.com home: open ~5.5 s, some clicks 5 s) and failing checks wait
12
+ // WAIT_CAP_MS; ignore such work by URL/type when the waiting costs too much.
13
+ // Not tracked: Web Workers, and timers inside cross-origin iframes.
14
+ const SETTLE_CAP_MS = 5000; // pages that never go quiet continue after this
15
+ const WAIT_CAP_MS = 5000; // WAIT and assertion retries give up after this
16
+ /** Snapshot links with their `/url` line: [ref, url]. */
17
+ const links = (snapshot) => [...snapshot.matchAll(/- link\b[^\n]*\[ref=(\w+)\][^\n]*\n\s*- \/url: "?(.*?)"?$/gm)].map(m => [m[1], m[2]]);
18
+ /** Adds `[current]` to the given refs' lines (Playwright's snapshot drops aria-current). */
19
+ const markCurrent = (snapshot, refs) => refs.size ? snapshot.replace(/ \[ref=(\w+)\]/g, (m, ref) => (refs.has(ref) ? ` [current]${m}` : m)) : snapshot;
20
+ /**
21
+ * Adds `[collapsed; hidden entries: "A", "B"]` to collapsed controls' lines: the snapshot drops hidden content, so a
22
+ * Step naming an entry inside a collapsed ToC section or accordion had nothing to pick (task 0024). Links only: hidden
23
+ * headings and buttons were mostly error templates of menus ("Uh oh!", "Please reload this page" on GitHub). Only `ui` gets it;
24
+ * names, values and pageText do not, so a check cannot pass on text that is not shown.
25
+ */
26
+ const markCollapsed = (snapshot, hidden) => hidden.size ? snapshot.split("\n").map(line => {
27
+ const m = /^\s*- \w+ "((?:[^"\\]|\\.)*)"[^\n]*?( \[ref=\w+\])/.exec(line);
28
+ const entries = m && !/\[expanded\]/.test(line) ? hidden.get(m[1].replace(/\\(.)/g, "$1")) : undefined;
29
+ return entries ? line.replace(m[2], ` [collapsed; hidden entries: ${entries.map(e => `"${e}"`).join(", ")}]${m[2]}`) : line;
30
+ }).join("\n") : snapshot;
31
+ /**
32
+ * Adds `[description="..."]` to the lines of elements whose `title` differs from their name: the snapshot drops `title`
33
+ * (task 0042). Matched by name, like markCollapsed.
34
+ */
35
+ const markTitles = (snapshot, titles) => titles.size ? snapshot.split("\n").map(line => {
36
+ const m = /^\s*- \w+ "((?:[^"\\]|\\.)*)"[^\n]*?( \[ref=\w+\])/.exec(line);
37
+ const title = m && titles.get(m[1].replace(/\\(.)/g, "$1"));
38
+ return title ? line.replace(m[2], ` [description="${title}"]${m[2]}`) : line;
39
+ }).join("\n") : snapshot;
40
+ /** Adds `[flag]` after the ref of each element in `refs`: parseSnapshot offers it as a target (tasks 0045, 0061). */
41
+ const markRefs = (snapshot, refs, flag) => refs.size ? snapshot.replace(/\[ref=(\w+)\]/g, (m, ref) => refs.has(ref) ? `${m} [${flag}]` : m) : snapshot;
42
+ /**
43
+ * `factsIn`: indexes of `els` with a fact, and which. Runs in the page. Native controls are skipped for the target facts: their
44
+ * role already makes them targets. `pointer`: own cursor, not inherited from the parent (OrangeHRM's select wrapper,
45
+ * not the text inside it).
46
+ */
47
+ const factsIn = (els) => els.flatMap((e, i) => {
48
+ const out = [];
49
+ const style = getComputedStyle(e);
50
+ if (e.getAttribute("draggable") === "true")
51
+ out.push("draggable");
52
+ if (/auto|scroll/.test(style.overflowY) && e.scrollHeight > e.clientHeight && e !== document.scrollingElement)
53
+ out.push("scrollable");
54
+ if (!e.matches("a[href], button, input, select, textarea, summary, label, option, iframe") && e.checkVisibility()) {
55
+ if (style.cursor === "pointer" && (!e.parentElement || getComputedStyle(e.parentElement).cursor !== "pointer"))
56
+ out.push("pointer");
57
+ if (e.hasAttribute("tabindex") && e.tabIndex >= 0)
58
+ out.push("tabindex");
59
+ }
60
+ return out.length ? [[i, out]] : [];
61
+ });
62
+ /** Hover moves the mouse only; an element that never stops moving (a tooltip shifting the layout) fails fast (task 0042). */
63
+ const HOVER_TIMEOUT_MS = 2000;
64
+ export class Browser {
65
+ browser;
66
+ /** Public for client step definitions (`this.context` on the World, task 0048). */
67
+ context;
68
+ /** The tab Steps act on. A tab the page opens (a popup, a target=_blank link) becomes it at once (task 0041). Public as `this.page`. */
69
+ page;
70
+ /** `@replay-network` requests missing from the recording, until the Step that made them fails on them (task 0048). */
71
+ networkMisses = [];
72
+ start; // GOTO paths resolve against it
73
+ inflight = 0;
74
+ last; // refs are valid for the latest Observation only
75
+ /**
76
+ * Browser dialogs (task 0040). While one is open the page is blocked (snapshots, evaluate, screenshots and clicks
77
+ * hang), so the Observation is the page as it was under it; `opened` resolves when one opens.
78
+ */
79
+ open;
80
+ dialog; // open, or the last one shown until the next action Step
81
+ under; // the page under the dialog
82
+ opened;
83
+ signal;
84
+ consoleErrors = [];
85
+ /** Secret placeholders (`$NAME` -> value): typed for the token, masked back to it in every Observation (task 0030). */
86
+ secrets = {};
87
+ /** A secret was typed: the Playwright trace stores typed text unmasked, so close() keeps none. */
88
+ typedSecret = false;
89
+ /** Task 0044: UPLOAD paths resolve against `files` (the feature's folder); downloads are saved to `downloads`. */
90
+ files = ".";
91
+ downloads;
92
+ download; // the last finished download, readable for checks
93
+ /** Task 0062: a headed browser may share the OS clipboard; only text copied since the open is shown. */
94
+ clipboardAtOpen;
95
+ async readClipboard() { return this.page.evaluate(() => navigator.clipboard?.readText()).catch(() => undefined); } // undefined: insecure origin (data:)
96
+ mask(s) { return Object.entries(this.secrets).reduce((t, [token, value]) => t.replaceAll(value, token), s); }
97
+ constructor(browser, page, start) {
98
+ this.browser = browser;
99
+ this.page = page;
100
+ this.start = start;
101
+ // On the context, not the page: they must follow into tabs the page opens.
102
+ const context = this.context = page.context();
103
+ context.on("console", m => { if (m.type() === "error")
104
+ this.consoleErrors.push(m.text()); });
105
+ context.on("weberror", e => this.consoleErrors.push(`uncaught: ${e.error().message}`));
106
+ context.on("request", () => this.inflight++);
107
+ context.on("requestfinished", () => this.inflight--);
108
+ context.on("requestfailed", () => this.inflight--);
109
+ context.on("page", p => { this.page = p; this.watchDownloads(p); });
110
+ this.watchDownloads(page);
111
+ this.rearm();
112
+ context.on("dialog", d => {
113
+ this.open = d;
114
+ this.dialog = { type: d.type(), message: this.mask(d.message()) || (d.type() === "beforeunload" ? LEAVE : ""), state: "open" };
115
+ this.signal();
116
+ });
117
+ }
118
+ /** Saves each download; counted in flight until saved, so the action that started it settles after it. */
119
+ watchDownloads(page) {
120
+ page.on("download", d => {
121
+ if (!this.downloads)
122
+ return;
123
+ this.inflight++;
124
+ const path = join(this.downloads, d.suggestedFilename());
125
+ d.saveAs(path).then(() => {
126
+ const bytes = readFileSync(path);
127
+ // Text: no NUL byte. Binary contents are not compared.
128
+ this.download = { name: d.suggestedFilename(), size: bytes.length, text: bytes.includes(0) ? undefined : this.mask(bytes.toString("utf8")) };
129
+ }).catch(() => { }).finally(() => this.inflight--);
130
+ });
131
+ }
132
+ rearm() { this.opened = new Promise(r => { this.signal = () => r(undefined); }); }
133
+ /** `p`, or undefined once a dialog is open: the page is blocked until it is answered. */
134
+ unblocked(p) {
135
+ p.catch(() => { }); // finishes once the dialog is answered; nobody waits for it then
136
+ return Promise.race([p, this.opened]);
137
+ }
138
+ /** Owns per-Action-step cleanup, including thrown errors. `execute` keeps cleanup actions in the runtime's trace. */
139
+ async actionStep(run, log = console.log, execute = (a) => this.execute(a)) {
140
+ if (!this.open)
141
+ this.dialog = undefined;
142
+ try {
143
+ return await run();
144
+ }
145
+ finally {
146
+ while (this.open) {
147
+ const d = this.dialog;
148
+ await execute({ verb: "DISMISS" });
149
+ log(` Dialog: ${d.type} "${d.message}" was still open when the Step ended: dismissed`);
150
+ }
151
+ }
152
+ }
153
+ /**
154
+ * `trace`: record a Playwright trace, saved by close(tracePath). `secrets`: placeholder token -> value. `files`: the
155
+ * folder UPLOAD paths are relative to; `downloads`: where downloads are saved (none: not saved, not readable).
156
+ * `context`: options that must exist before the page opens (tags, task 0048); `network`: `@replay-network`.
157
+ */
158
+ static async open(url, headed = false, trace = false, secrets = {}, files = ".", downloads, context = {}, network) {
159
+ const browser = await chromium.launch({ headless: !headed, slowMo: Number(process.env.SLOWMO ?? 0) }); // SLOWMO=ms: pause per browser action for demos;
160
+ // Task 0062: copy buttons and paste need the clipboard; granted for every origin (file:// and localhost measured).
161
+ const permissions = [...context.permissions ?? [], "clipboard-read", "clipboard-write"];
162
+ const page = await (await browser.newContext({ ...context, permissions })).newPage();
163
+ if (trace)
164
+ await page.context().tracing.start({ screenshots: true, snapshots: true });
165
+ // In every document, new tabs included: the time of the last DOM mutation, and a count of work that can still change
166
+ // the page on its own (timers, intervals, animation frames, open sockets). Requests are counted from Node.
167
+ await page.context().addInitScript(() => {
168
+ const w = window;
169
+ w.__lastMutation = performance.now();
170
+ new MutationObserver(() => { w.__lastMutation = performance.now(); })
171
+ .observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
172
+ // Pending work, by id -> due time (ms since page start; Infinity for intervals, which never finish).
173
+ const timers = new Map(), frames = new Set(), idles = new Set();
174
+ let sockets = 0;
175
+ const { setTimeout: st, clearTimeout: ct, setInterval: si, clearInterval: ci, requestAnimationFrame: raf, cancelAnimationFrame: caf, requestIdleCallback: ric, cancelIdleCallback: cic } = w;
176
+ w.setTimeout = ((fn, ms, ...args) => {
177
+ const id = st((...a) => { timers.delete(id); if (typeof fn === "function")
178
+ fn(...a); }, ms, ...args);
179
+ timers.set(id, performance.now() + (ms ?? 0));
180
+ return id;
181
+ });
182
+ w.setInterval = ((fn, ms, ...args) => {
183
+ const id = si(fn, ms, ...args);
184
+ timers.set(id, Infinity);
185
+ return id;
186
+ });
187
+ // Browsers let either clear function cancel either kind of timer.
188
+ w.clearTimeout = w.clearInterval = (id) => { timers.delete(id); ct(id); ci(id); };
189
+ w.requestAnimationFrame = cb => { const id = raf(t => { frames.delete(id); cb(t); }); frames.add(id); return id; };
190
+ w.cancelAnimationFrame = id => { frames.delete(id); caf(id); };
191
+ w.requestIdleCallback = (cb, opts) => { const id = ric(d => { idles.delete(id); cb(d); }, opts); idles.add(id); return id; };
192
+ w.cancelIdleCallback = id => { idles.delete(id); cic(id); };
193
+ const g = w;
194
+ const Ws = g.WebSocket, Es = g.EventSource;
195
+ g.WebSocket = class extends Ws {
196
+ constructor(...a) { super(...a); sockets++; this.addEventListener("close", () => sockets--); }
197
+ };
198
+ g.EventSource = class extends Es {
199
+ constructor(...a) { super(...a); sockets++; }
200
+ close() { if (this.readyState !== 2 /* CLOSED */)
201
+ sockets--; super.close(); }
202
+ };
203
+ w.__pending = () => timers.size + frames.size + idles.size + sockets;
204
+ // Work about to change the page: timeouts due within 1 s and idle callbacks. Not intervals, long timers (polling,
205
+ // session refresh) or animation frames (endless animations), which would make every action wait the full cap.
206
+ w.__soon = () => idles.size + [...timers.values()].filter(due => due - performance.now() <= 1000).length;
207
+ });
208
+ const b = new Browser(browser, page, url);
209
+ b.secrets = secrets;
210
+ b.files = files;
211
+ b.downloads = downloads;
212
+ b.clipboardAtOpen = await b.readClipboard();
213
+ try {
214
+ if (network?.mode === "record")
215
+ await recordNetwork(b.context, network);
216
+ if (network?.mode === "replay")
217
+ await replayNetwork(b.context, network, r => b.networkMisses.push(r));
218
+ }
219
+ catch (err) {
220
+ await browser.close();
221
+ throw err;
222
+ }
223
+ try {
224
+ const loading = page.goto(url);
225
+ while ((await b.unblocked(loading)) === undefined)
226
+ await b.execute({ verb: "DISMISS" }); // no Step answers it
227
+ }
228
+ catch (err) {
229
+ await browser.close();
230
+ const why = err.message.split("\n")[0].replace(/^page\.goto:\s*/, "");
231
+ throw new Error(`Cannot open ${url}: ${why}. Is the app running and the URL right?`);
232
+ }
233
+ await b.settle();
234
+ while (b.open)
235
+ await b.execute({ verb: "DISMISS" }); // opened by a timer after the load: no Step answers it either
236
+ return b;
237
+ }
238
+ /**
239
+ * Waits until no request is in flight, no work is due soon (see __soon: lazily loaded code runs idle callbacks and
240
+ * short timers after its scripts arrive, before it renders) and the DOM has not changed for QUIET_MS (capped).
241
+ */
242
+ async settle() {
243
+ const start = Date.now();
244
+ while (Date.now() < start + SETTLE_CAP_MS) {
245
+ if (this.open)
246
+ return; // nothing changes until it is answered
247
+ const [sinceMutation, soon] = await this.unblocked(this.page
248
+ .evaluate(() => {
249
+ const w = window;
250
+ return [performance.now() - w.__lastMutation, w.__soon?.() ?? 0];
251
+ })
252
+ .catch(() => [0, 1])) ?? [0, 1]; // mid-navigation: not settled yet
253
+ // The quiet window starts no earlier than the action: effects may begin a task later (e.g. hashchange).
254
+ const quiet = Math.min(sinceMutation, Date.now() - start);
255
+ if (this.inflight === 0 && soon === 0 && quiet >= QUIET_MS)
256
+ return;
257
+ await this.page.waitForTimeout(25);
258
+ }
259
+ }
260
+ async observe() {
261
+ const page = this.open ? undefined : await this.unblocked(this.read());
262
+ if (page)
263
+ this.under = page;
264
+ const clip = page ? await this.readClipboard() : undefined;
265
+ const copied = clip && clip !== this.clipboardAtOpen ? this.mask(clip) : undefined;
266
+ return (this.last = withDialog(withClipboard(withDownload(this.under ?? withPage(parseSnapshot(""), this.page.url(), ""), this.download), copied), this.dialog));
267
+ }
268
+ stamp; // __lastMutation when the latest Observation was read
269
+ /** The DOM changed since the latest Observation (task 0066): the page moved on its own before an action. */
270
+ async stale() {
271
+ const now = await this.unblocked(this.page.evaluate(() => window.__lastMutation).catch(() => undefined));
272
+ return now === undefined || now !== this.stamp;
273
+ }
274
+ async read() {
275
+ this.stamp = await this.page.evaluate(() => window.__lastMutation).catch(() => undefined);
276
+ const found = await this.facts();
277
+ const refs = (fact) => new Set([...found].filter(([, f]) => f.includes(fact)).map(([ref]) => ref));
278
+ const targets = new Set([...refs("pointer"), ...refs("tabindex")]);
279
+ const snapshot = renderSnapshot(await this.page.ariaSnapshotJSON({ mode: "ai" }));
280
+ // The snapshot cannot tell a password field from a textbox, or an editable combobox (search box with suggestions)
281
+ // from a button-like one; ask the DOM.
282
+ const secret = new Set(), editable = new Set();
283
+ for (const [, role, ref] of snapshot.matchAll(/- (textbox|combobox)\b[^\n]*\[ref=(\w+)\]/g)) {
284
+ const kind = await this.page.locator(`aria-ref=${ref}`)
285
+ .evaluate(el => el.type === "password" ? "secret"
286
+ : el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el.isContentEditable ? "editable" : "", undefined, { timeout: 1000 })
287
+ .catch(() => "");
288
+ if (kind === "secret")
289
+ secret.add(ref);
290
+ else if (kind === "editable" && role === "combobox")
291
+ editable.add(ref);
292
+ }
293
+ // shortcut: masks the value as the snapshot spells it; a value with quotes or backslashes (escaped there) or shown
294
+ // reformatted would slip through, and screenshots show a secret typed into a non-password field; mask those if needed.
295
+ const marked = this.mask(markRefs(markRefs(markTitles(markCollapsed(markCurrent(snapshot, await this.currentLinks(snapshot)), await this.collapsed()), await this.titles()), refs("draggable"), "draggable"), refs("scrollable"), "scrollable"));
296
+ const title = this.mask(await this.page.title().catch(() => "")); // mid-navigation
297
+ const tabs = this.context.pages();
298
+ const where = tabs.length > 1 ? `${tabs.length} open, this is tab ${tabs.indexOf(this.page) + 1}` : undefined;
299
+ return withPage(parseSnapshot(marked, secret, editable, targets), this.mask(this.page.url()), title, where);
300
+ }
301
+ /** Collapsed controls (`aria-expanded=false` + `aria-controls`, closed `<details>`) by name -> hidden entries they hold. */
302
+ async collapsed() {
303
+ // Every frame (iframes are in the snapshot); shortcut: open shadow roots are not searched, walk them if a page needs it.
304
+ const found = (await Promise.all(this.page.frames().map(f => f.evaluate(() => {
305
+ // Accessible-name approximation: aria-label, else text without aria-hidden parts (Wikipedia's "1.1" numbers).
306
+ const text = (n) => n.nodeType === Node.TEXT_NODE ? n.textContent ?? ""
307
+ : n.getAttribute?.("aria-hidden") === "true" ? "" : [...n.childNodes].map(text).join(" ");
308
+ const name = (e) => (e.getAttribute("aria-label") || text(e)).replace(/\s+/g, " ").trim();
309
+ const controls = [...document.querySelectorAll('[aria-expanded="false"][aria-controls], details:not([open]) > summary')];
310
+ return controls.map(c => {
311
+ const scope = c.tagName === "SUMMARY" ? [c.parentElement]
312
+ : c.getAttribute("aria-controls").split(/\s+/).map(id => document.getElementById(id)).filter(e => e !== null);
313
+ const entries = [...new Set(scope.flatMap(s => [...s.querySelectorAll("a[href]")])
314
+ .filter(e => !e.checkVisibility()).map(name).filter(Boolean))];
315
+ return [name(c), entries.slice(0, 10).map(e => (e.length > 60 ? e.slice(0, 60) + "…" : e).replace(/["\]]/g, "'"))];
316
+ }).filter(([n, entries]) => n && entries.length);
317
+ }).catch(() => [])))).flat(); // mid-navigation: no hints this time
318
+ // shortcut: matched by name, so two collapsed controls with the same name and different entries get the first
319
+ // one's; resolve by ref (one locator call per candidate) if a page shows that.
320
+ const out = new Map();
321
+ for (const [n, entries] of found)
322
+ if (!out.has(n))
323
+ out.set(n, entries);
324
+ return out;
325
+ }
326
+ /** `title` attributes of visible elements by name, where the title says more than the name. */
327
+ async titles() {
328
+ const found = (await Promise.all(this.page.frames().map(f => f.locator("[title]").evaluateAll(els => els.filter(e => e.checkVisibility()).map(e => {
329
+ const name = (e.getAttribute("aria-label") || e.innerText || "").replace(/\s+/g, " ").trim();
330
+ const title = e.getAttribute("title").replace(/\s+/g, " ").trim();
331
+ return [name, (title.length > 60 ? title.slice(0, 60) + "…" : title).replace(/["\]]/g, "'")];
332
+ }).filter(([n, t]) => n && t && n !== t)).catch(() => [])))).flat(); // mid-navigation
333
+ // shortcut: matched by name, so two same-named elements with different titles get the first one's.
334
+ const out = new Map();
335
+ for (const [n, t] of found)
336
+ if (!out.has(n))
337
+ out.set(n, t);
338
+ return out;
339
+ }
340
+ /**
341
+ * Facts by ref, for elements `factsIn` picks in any frame (open shadow roots included): one DOM read per frame, then
342
+ * one element snapshot per match. Run before the page snapshot: an element snapshot invalidates older refs, and the
343
+ * page snapshot after it reuses the refs it gave. Reading per ref instead took ~2 s per Observation on Wikipedia.
344
+ */
345
+ async facts() {
346
+ const out = new Map();
347
+ for (const f of this.page.frames()) {
348
+ const all = f.locator("body *");
349
+ const hits = await all.evaluateAll(factsIn).catch(() => []);
350
+ for (const [i, found] of hits) {
351
+ const line = await all.nth(i).ariaSnapshot({ mode: "ai", depth: 0, timeout: 1000 }).catch(() => "");
352
+ const ref = /\[ref=(\w+)\]/.exec(line)?.[1];
353
+ if (ref)
354
+ out.set(ref, found);
355
+ }
356
+ }
357
+ return out;
358
+ }
359
+ /**
360
+ * Refs of links marked aria-current (the current page or tab in a navigation). The hrefs of aria-current links pick
361
+ * the candidates; each candidate is then read by ref, since links that share an href ("#") can differ.
362
+ */
363
+ async currentLinks(snapshot) {
364
+ const hrefs = (await Promise.all(this.page.frames().map(f => f.locator('a[aria-current]:not([aria-current="false"])')
365
+ .evaluateAll(els => els.map(e => e.getAttribute("href") ?? ""))
366
+ .catch(() => [])))).flat();
367
+ const current = new Set();
368
+ for (const [ref, url] of links(snapshot)) {
369
+ if (!hrefs.includes(url))
370
+ continue;
371
+ const value = await this.page.locator(`aria-ref=${ref}`).getAttribute("aria-current", { timeout: 1000 }).catch(() => null);
372
+ if (value !== null && value !== "false")
373
+ current.add(ref);
374
+ }
375
+ return current;
376
+ }
377
+ /**
378
+ * Executes one action and checks its effect where the effect is deterministic (TYPE/SELECT). An action that opens a
379
+ * dialog returns once it is open (its effect check is skipped); ACCEPT/DISMISS answer it.
380
+ */
381
+ async execute(action) {
382
+ const { verb, value = "" } = action;
383
+ if (verb === "ACCEPT" || verb === "DISMISS") {
384
+ const d = this.open;
385
+ if (!d)
386
+ throw new Error("no browser dialog is open");
387
+ this.open = undefined;
388
+ this.dialog = { ...this.dialog, state: verb === "ACCEPT" ? "accepted" : "dismissed" };
389
+ this.rearm(); // before answering: the next dialog may open at once
390
+ if (value in this.secrets)
391
+ this.typedSecret = true;
392
+ await (verb === "ACCEPT" ? d.accept(this.secrets[value] ?? value) : d.dismiss());
393
+ }
394
+ else
395
+ await this.unblocked(this.perform(action));
396
+ await this.settle();
397
+ }
398
+ async perform({ verb, ref, to, value = "" }) {
399
+ const opts = { timeout: 5000 };
400
+ if ((verb === "SCROLL" || verb === "SCROLL_UP") && ref)
401
+ await this.page.locator(`aria-ref=${ref}`).hover(opts); // the wheel scrolls what is under the mouse
402
+ if (verb === "SCROLL" || verb === "SCROLL_UP")
403
+ await this.page.mouse.wheel(0, verb === "SCROLL" ? 600 : -600);
404
+ else if (verb === "ENTER")
405
+ await this.page.keyboard.press("Enter");
406
+ else if (verb === "PRESS")
407
+ await this.page.keyboard.press(value); // to whatever has focus
408
+ else if (verb === "WAIT")
409
+ await this.waitForChange((await this.observe()).text, WAIT_CAP_MS);
410
+ else if (verb === "GOTO")
411
+ await this.page.goto(new URL(value, this.start).href);
412
+ else if (verb === "BACK")
413
+ await this.page.goBack();
414
+ else if (verb === "FORWARD")
415
+ await this.page.goForward();
416
+ else if (verb === "RELOAD")
417
+ await this.page.reload();
418
+ else if (verb === "CLOSE_TAB" || verb === "SWITCH_TAB") {
419
+ const tabs = this.context.pages();
420
+ if (tabs.length < 2)
421
+ throw new Error("only one tab is open");
422
+ const from = this.page;
423
+ this.page = tabs[(tabs.indexOf(from) + tabs.length - 1) % tabs.length]; // the previous tab
424
+ await this.page.bringToFront();
425
+ if (verb === "CLOSE_TAB")
426
+ await from.close();
427
+ }
428
+ else {
429
+ const target = this.page.locator(`aria-ref=${ref}`);
430
+ if (verb === "CLICK")
431
+ await target.click(opts);
432
+ else if (verb === "HOVER")
433
+ await target.hover({ timeout: HOVER_TIMEOUT_MS });
434
+ else if (verb === "DBLCLICK")
435
+ await target.dblclick(opts);
436
+ else if (verb === "RIGHTCLICK")
437
+ await target.click({ ...opts, button: "right" });
438
+ else if (verb === "DRAG")
439
+ await target.dragTo(this.page.locator(`aria-ref=${to}`), opts);
440
+ else if (verb === "PASTE") {
441
+ await target.click(opts);
442
+ await this.page.keyboard.press("ControlOrMeta+V");
443
+ }
444
+ else if (verb === "UPLOAD") {
445
+ const path = resolve(this.files, value);
446
+ if (!existsSync(path) || !statSync(path).isFile())
447
+ throw new Error(`no file "${value}" next to the feature file`);
448
+ // File inputs are often hidden behind a button: the click opens the chooser, which gets the file.
449
+ const [chooser] = await Promise.all([this.page.waitForEvent("filechooser", opts), target.click(opts)]);
450
+ await chooser.setFiles(path, opts);
451
+ }
452
+ else {
453
+ // The app may replace the control while re-rendering (a sorted list redrawn on change): the effect is then
454
+ // read from the control with the same role and name.
455
+ const el = this.last?.elements.find(e => e.ref === ref);
456
+ const now = async () => (await target.count()) || !el ? target
457
+ : this.page.getByRole(el.role, { name: el.name, exact: true }).first();
458
+ if (verb === "TYPE") {
459
+ const typed = this.secrets[value] ?? value;
460
+ if (value in this.secrets)
461
+ this.typedSecret = true;
462
+ await target.fill(typed, opts);
463
+ const field = await (await now()).elementHandle();
464
+ if (field) {
465
+ try {
466
+ const left = await this.unblocked(field.evaluate(el => { el.blur(); return true; }));
467
+ if (!left || this.open)
468
+ return; // A blur dialog is answered in a later Turn; never refocus behind it.
469
+ await field.evaluate(el => {
470
+ // Keep TYPE → Enter working unless on-leave validation took focus or removed the field.
471
+ if (document.activeElement === document.body && el.isConnected && el.checkVisibility()
472
+ && !el.matches(":disabled"))
473
+ el.focus();
474
+ });
475
+ }
476
+ finally {
477
+ await field.dispose();
478
+ }
479
+ }
480
+ // Message omits the values: the field may be a password.
481
+ if ((await (await now()).inputValue(opts)) !== typed)
482
+ throw new Error("field does not hold the typed value after TYPE");
483
+ }
484
+ else {
485
+ await target.selectOption({ label: value }, opts);
486
+ const shown = await (await now()).evaluate(el => el.selectedOptions[0]?.label, undefined, opts);
487
+ if (shown !== value)
488
+ throw new Error(`select shows "${shown}" after SELECT "${value}"`);
489
+ }
490
+ }
491
+ }
492
+ }
493
+ /**
494
+ * Waits until the Observation differs from `since` (true), the timeout passes, or the page cannot change on its
495
+ * own any more (false): no request, timer, animation frame or socket pending on two polls in a row.
496
+ */
497
+ async waitForChange(since, timeoutMs = WAIT_CAP_MS) {
498
+ const deadline = Date.now() + timeoutMs;
499
+ let idleBefore = false;
500
+ let seen; // __lastMutation at the last snapshot: no DOM mutation since means no new snapshot
501
+ while (Date.now() < deadline) {
502
+ await this.page.waitForTimeout(100);
503
+ const stamp = await this.unblocked(this.page.evaluate(() => window.__lastMutation).catch(() => undefined));
504
+ const moved = stamp === undefined || stamp !== seen;
505
+ seen = stamp;
506
+ if (moved && (await this.observe()).text !== since) {
507
+ await this.settle();
508
+ return true;
509
+ }
510
+ // Two idle polls 100 ms apart: promise chains (e.g. reading a fetch body) finish in between.
511
+ const idle = await this.idle();
512
+ if (idle && idleBefore)
513
+ return false;
514
+ idleBefore = idle;
515
+ }
516
+ return false;
517
+ }
518
+ /** True when nothing pending in the page can change it without user input. */
519
+ async idle() {
520
+ if (this.open)
521
+ return true; // blocked until answered
522
+ if (this.inflight > 0)
523
+ return false;
524
+ const pending = await this.unblocked(this.page
525
+ .evaluate(() => window.__pending?.() ?? 1)
526
+ .catch(() => 1)) ?? 1; // mid-navigation, or a dialog opened: not idle
527
+ return pending === 0;
528
+ }
529
+ screenshot(path) {
530
+ return this.unblocked(this.page.screenshot({ path }).catch(() => undefined)); // a missing screenshot must not fail the test
531
+ }
532
+ /** `tracePath`: where to save the Playwright trace started by open(..., trace); omitted, or a secret typed = discard it. */
533
+ async close(tracePath) {
534
+ if (tracePath && !this.typedSecret)
535
+ await this.context.tracing.stop({ path: tracePath }).catch(() => undefined);
536
+ await this.context.close(); // writes a @replay-network recording
537
+ await this.browser.close();
538
+ }
539
+ }
@@ -0,0 +1,58 @@
1
+ import { type Decider } from "./decision.ts";
2
+ import type { Element } from "./observation.ts";
3
+ /** none: replay only, a missing or broken resolution fails. new: replay, resolve missing/broken Steps live. all: always live. */
4
+ export type RecordMode = "none" | "new" | "all";
5
+ export declare const RECORD_MODES: RecordMode[];
6
+ /**
7
+ * What a Step resolved to (ADR 0002, reopened by task 0032): the element by role, name, its nearest named container,
8
+ * and, when it has same-named peers there, `near`: the text of its closest own-or-ancestor node that no peer shares
9
+ * (the product card "Backpack $29.99 Add to cart"). Only twins no text tells apart fall back to a position (`nth` of
10
+ * `of`): a reorder of them is invisible, but so is any difference between them.
11
+ */
12
+ export interface Resolution {
13
+ role: string;
14
+ name: string;
15
+ container: string;
16
+ near?: string;
17
+ nth?: number;
18
+ of?: number;
19
+ }
20
+ type Target = Resolution | string;
21
+ /** Replay-only mode and a Step whose recorded resolution is missing or no longer holds. */
22
+ export declare class UiChanged extends Error {
23
+ reason: string;
24
+ constructor(reason: string);
25
+ }
26
+ export declare function resolve(el: Element, among: Element[]): Resolution;
27
+ /** The element a Resolution names on this Observation, or why none does: exactly one must match all of it. */
28
+ export declare function find(r: Resolution, among: Element[]): Element | string;
29
+ export declare const describe: (t: Target) => string;
30
+ /**
31
+ * A Step's cassette key: scenario and label, plus which occurrence of that label it is in the scenario, so adding or
32
+ * removing other Steps keeps every other key.
33
+ */
34
+ export declare function stepKey(scenario: string, labels: string[], i: number): string;
35
+ /** Recorded resolutions per Step, in `<test>.cassette.json`. Steps are keyed by scenario, text and occurrence (stepKey). */
36
+ export declare class Cassette {
37
+ #private;
38
+ replayed: number;
39
+ recorded: number;
40
+ healed: number;
41
+ path: string;
42
+ model: string;
43
+ mode: RecordMode;
44
+ /**
45
+ * `run`: id of this run. Parallel workers each open the feature's cassette; a file last saved by this run holds
46
+ * another worker's scenarios, so it is not stale for `all`.
47
+ */
48
+ constructor(path: string, model: string, mode: RecordMode, run?: string);
49
+ /**
50
+ * The Decider for one Step: replays its recorded resolutions while each still matches exactly one element, else
51
+ * (from the first that does not) asks `live`. Call `done` when the Step ends.
52
+ */
53
+ step(key: string, live: Decider, log?: (line: string) => void): Decider & {
54
+ done(keep: boolean): "replayed" | "recorded" | "healed" | undefined;
55
+ };
56
+ save(): void;
57
+ }
58
+ export {};