@specific.dev/spectest 0.39.0 → 0.41.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.
Files changed (100) hide show
  1. package/dist/components/supabase.d.ts +87 -27
  2. package/dist/components/supabase.js +352 -69
  3. package/dist/daemon.d.ts +38 -0
  4. package/dist/daemon.js +388 -941
  5. package/dist/harness/build-context.d.ts +82 -0
  6. package/dist/harness/build-context.js +113 -0
  7. package/dist/harness/buildkit-progress.d.ts +37 -0
  8. package/dist/harness/buildkit-progress.js +66 -0
  9. package/dist/harness/container-run.d.ts +89 -0
  10. package/dist/harness/container-run.js +118 -0
  11. package/dist/harness/file-mounts.d.ts +91 -0
  12. package/dist/harness/file-mounts.js +119 -0
  13. package/dist/harness/hostmatch.d.ts +65 -0
  14. package/dist/harness/hostmatch.js +108 -0
  15. package/dist/harness/http-proxy.d.ts +62 -0
  16. package/dist/harness/http-proxy.js +104 -0
  17. package/dist/harness/ingress-table.d.ts +148 -0
  18. package/dist/harness/ingress-table.js +129 -0
  19. package/dist/harness/log-delta.d.ts +54 -0
  20. package/dist/harness/log-delta.js +83 -0
  21. package/dist/harness/main.d.ts +47 -0
  22. package/dist/harness/main.js +164 -0
  23. package/dist/harness/methods.d.ts +54 -0
  24. package/dist/harness/methods.js +65 -0
  25. package/dist/harness/names-registry.d.ts +63 -0
  26. package/dist/harness/names-registry.js +90 -0
  27. package/dist/harness/protocol.d.ts +88 -0
  28. package/dist/harness/protocol.js +96 -0
  29. package/dist/harness/ready-poll.d.ts +47 -0
  30. package/dist/harness/ready-poll.js +67 -0
  31. package/dist/harness/service-graph.d.ts +29 -0
  32. package/dist/harness/service-graph.js +92 -0
  33. package/dist/harness/volume-paths.d.ts +70 -0
  34. package/dist/harness/volume-paths.js +81 -0
  35. package/dist/index.d.ts +3 -3
  36. package/dist/ingress.d.ts +1 -1
  37. package/dist/resolver.js +5 -8
  38. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  39. package/dist/vendor/rrweb-record.min.js +5061 -0
  40. package/package.json +7 -1
  41. package/src/aws-sigv4.ts +218 -0
  42. package/src/browser.ts +2040 -0
  43. package/src/components/aws.ts +554 -0
  44. package/src/components/email.ts +398 -0
  45. package/src/components/expo.ts +167 -0
  46. package/src/components/index.ts +81 -0
  47. package/src/components/k3s.ts +2061 -0
  48. package/src/components/postgres.ts +132 -0
  49. package/src/components/replayFake.ts +1015 -0
  50. package/src/components/s3.ts +132 -0
  51. package/src/components/supabase.ts +1699 -0
  52. package/src/daemon.ts +5489 -0
  53. package/src/harness/build-context.test.ts +0 -0
  54. package/src/harness/build-context.ts +146 -0
  55. package/src/harness/buildkit-progress.test.ts +98 -0
  56. package/src/harness/buildkit-progress.ts +74 -0
  57. package/src/harness/container-run.test.ts +209 -0
  58. package/src/harness/container-run.ts +158 -0
  59. package/src/harness/file-mounts.test.ts +185 -0
  60. package/src/harness/file-mounts.ts +145 -0
  61. package/src/harness/hostmatch.test.ts +148 -0
  62. package/src/harness/hostmatch.ts +109 -0
  63. package/src/harness/http-proxy.test.ts +156 -0
  64. package/src/harness/http-proxy.ts +119 -0
  65. package/src/harness/ingress-rebind.test.ts +125 -0
  66. package/src/harness/ingress-table.test.ts +172 -0
  67. package/src/harness/ingress-table.ts +186 -0
  68. package/src/harness/log-delta.test.ts +125 -0
  69. package/src/harness/log-delta.ts +100 -0
  70. package/src/harness/main.test.ts +211 -0
  71. package/src/harness/main.ts +196 -0
  72. package/src/harness/methods.test.ts +63 -0
  73. package/src/harness/methods.ts +92 -0
  74. package/src/harness/names-registry.test.ts +137 -0
  75. package/src/harness/names-registry.ts +108 -0
  76. package/src/harness/protocol.test.ts +148 -0
  77. package/src/harness/protocol.ts +163 -0
  78. package/src/harness/ready-poll.test.ts +172 -0
  79. package/src/harness/ready-poll.ts +93 -0
  80. package/src/harness/service-graph.test.ts +97 -0
  81. package/src/harness/service-graph.ts +97 -0
  82. package/src/harness/volume-paths.test.ts +102 -0
  83. package/src/harness/volume-paths.ts +112 -0
  84. package/src/ids.ts +89 -0
  85. package/src/index.ts +2725 -0
  86. package/src/ingress.ts +305 -0
  87. package/src/inspect.ts +739 -0
  88. package/src/locator.ts +716 -0
  89. package/src/mobile.ts +133 -0
  90. package/src/record-secrets.ts +41 -0
  91. package/src/recorder.ts +846 -0
  92. package/src/redis.ts +202 -0
  93. package/src/replay-bundle.ts +108 -0
  94. package/src/resolver.ts +348 -0
  95. package/src/s3.ts +333 -0
  96. package/src/sql.ts +243 -0
  97. package/src/terminal.ts +740 -0
  98. package/src/url-match.ts +67 -0
  99. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  100. package/src/vendor/rrweb-record.min.js +5061 -0
package/src/browser.ts ADDED
@@ -0,0 +1,2040 @@
1
+ // Headless browser handle for tests. Thin wrapper around playwright-core
2
+ // driving the guest's system Chromium (pipe transport — `chromium.launch`
3
+ // with an explicit `executablePath`; `connectOverCDP` is off-limits, its
4
+ // bundled `ws` client hangs under Bun). The wrapper keeps the public
5
+ // surface stable (we swapped from Bun.WebView to Playwright without
6
+ // changing tests) and routes every call through the recorder so browser
7
+ // actions show up in a test's event log alongside exec/fetch/assertion
8
+ // events. Playwright's client state (browser/context/page objects, the
9
+ // launched Chromium) lives in daemon memory + the VM, so the whole pair
10
+ // forks with snapshots like everything else — validated 2026-07-14.
11
+ //
12
+ // On top of the per-op event recording, every Browser also captures an
13
+ // rrweb session: rrweb-record is injected into every document via CDP
14
+ // `Page.addScriptToEvaluateOnNewDocument`, the page buffers events on
15
+ // `window.__spectestRrwebEvents`, and we drain that buffer after every
16
+ // Browser op (and a final time on close). Drained chunks are tagged
17
+ // with the op that triggered the drain — that's the per-step structure
18
+ // the persistence layer stores so the dashboard can correlate replay
19
+ // timeline with the test's browser actions.
20
+ //
21
+ // Headless-Linux specifics live here: we force the chrome backend, add
22
+ // `--no-sandbox` (Chrome refuses to run as root otherwise) and
23
+ // `--disable-dev-shm-usage` (Firecracker's /dev/shm is tiny).
24
+ //
25
+ // Sessions are PERSISTENT by default: `ctx.browser()` always hands back the
26
+ // one shared desktop browser and `ctx.mobile(app)` the one session for that
27
+ // app, kept alive across tests so snapshots capture the live Chromium and
28
+ // dependsOn children resume exactly where the parent left off (signed-in
29
+ // SPA state included). See the "Persistent sessions" section below.
30
+
31
+ import { promises as dns } from "node:dns";
32
+ import { readFileSync } from "node:fs";
33
+ import path from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+
36
+ import { generateId } from "./ids.js";
37
+ import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
38
+ import { wrap } from "./inspect.js";
39
+ import type { Wrapped } from "./inspect.js";
40
+ import { describeUrlPattern, matchesUrl } from "./url-match.js";
41
+ import type { UrlPattern } from "./url-match.js";
42
+ export type { UrlPattern } from "./url-match.js";
43
+ import {
44
+ attachBrowserProbe,
45
+ DEFAULT_ACTION_TIMEOUT_MS,
46
+ desktopStrategy,
47
+ makeLocator,
48
+ mobileStrategy,
49
+ } from "./locator.js";
50
+ import type {
51
+ ActionStrategy,
52
+ GetByRoleOptions,
53
+ GetByTextOptions,
54
+ Locator,
55
+ } from "./locator.js";
56
+
57
+ import { chromium } from "playwright-core";
58
+ import type {
59
+ Browser as PlaywrightBrowser,
60
+ BrowserContext,
61
+ CDPSession,
62
+ Page,
63
+ } from "playwright-core";
64
+
65
+ // Minimal declaration of the one Bun global we use (executable lookup), so
66
+ // the SDK type-checks in projects that don't install `@types/bun`.
67
+ declare const Bun: { which(bin: string): string | null };
68
+
69
+ export interface BrowserOptions {
70
+ /** Viewport width in pixels. Default 1280. Ignored when `frame: "mobile"`
71
+ * (the device preset's viewport wins). */
72
+ width?: number;
73
+ /** Viewport height in pixels. Default 720. Ignored when `frame: "mobile"`. */
74
+ height?: number;
75
+ /**
76
+ * Which device frame this session represents. `"browser"` (default) is a
77
+ * desktop viewport rendered as a browser window in the replay; `"mobile"`
78
+ * emulates a phone (viewport + DPR + mobile UA + touch via CDP) and the
79
+ * replay wraps the capture in a phone bezel. The mobile device is fixed
80
+ * (latest iPhone) and not caller-configurable — see `ctx.mobile`.
81
+ */
82
+ frame?: "browser" | "mobile";
83
+ /** Initial URL to navigate to before the constructor returns. */
84
+ url?: string;
85
+ /**
86
+ * Script installed (before the session's first navigation) to run in every
87
+ * document loaded from now on, BEFORE the document's own scripts — the
88
+ * deterministic way to plant shims (reduced-motion, `Notification`, …) that
89
+ * must beat the app bundle. Unlike installing one after the session is
90
+ * handed back, this wins the race on the FIRST document too, so no relaunch
91
+ * is needed. Rides snapshots into `dependsOn` children. For a mobile app,
92
+ * declare it once on the handle instead — `expo({ initScript })`.
93
+ */
94
+ initScript?: string;
95
+ /**
96
+ * Sink that receives rrweb event chunks. Each Browser op (navigate,
97
+ * click, …) calls `recordStep` with the events that landed in
98
+ * `window.__spectestRrwebEvents` since the last drain. The daemon
99
+ * passes a per-test, per-session sink; if `null` (e.g. tests calling
100
+ * `openBrowser` directly without a test context), rrweb still
101
+ * records page-side but the buffer is discarded on close.
102
+ */
103
+ recorder?: BrowserSessionRecorder | null;
104
+ }
105
+
106
+ /** Decoded byte count of a base64 string, without decoding it. */
107
+ function base64ByteLength(b64: string): number {
108
+ let padding = 0;
109
+ if (b64.endsWith("==")) padding = 2;
110
+ else if (b64.endsWith("=")) padding = 1;
111
+ return (b64.length * 3) / 4 - padding;
112
+ }
113
+
114
+ /** One Browser-action's worth of rrweb events, in the order rrweb emitted them. */
115
+ export interface BrowserSessionStep {
116
+ /** Monotonic counter within the session. */
117
+ stepSeq: number;
118
+ /** Browser action that triggered this drain — same set as in the
119
+ * per-op event log, plus `"close"` for the final pre-teardown drain. */
120
+ action: BrowserAction | "close";
121
+ /** Ms since the session was opened. */
122
+ tOffsetMs: number;
123
+ /** rrweb events as emitted by `rrweb.record`'s `emit` callback. */
124
+ events: unknown[];
125
+ }
126
+
127
+ // The set of actions that show up in the test event log. The session
128
+ // step type extends this with "close" for the final drain.
129
+ import type { BrowserAction } from "./recorder.js";
130
+ export type { BrowserAction } from "./recorder.js";
131
+
132
+ /** Sink the daemon passes in to collect per-session rrweb event chunks. */
133
+ export interface BrowserSessionRecorder {
134
+ /**
135
+ * Stable ID of this session — echoed into the per-op event log so
136
+ * the dashboard can link a browser event to its replay player.
137
+ */
138
+ readonly sessionId: string;
139
+ /** Called for each drained chunk of rrweb events. */
140
+ recordStep(step: BrowserSessionStep): void;
141
+ /** Optional: called whenever `Browser.goto(url)` is invoked. */
142
+ noteNavigation?(url: string): void;
143
+ /**
144
+ * Optional: register a captured artifact (screenshot bytes) for upload.
145
+ * Present only in eval context — the daemon wires it to the per-eval
146
+ * collector, and its absence is what makes `screenshot()` throw during
147
+ * test runs (artifacts are eval-only for now). Throws when the eval's
148
+ * artifact byte budget is exhausted.
149
+ */
150
+ registerArtifact?(artifact: {
151
+ id: string;
152
+ kind: "screenshot";
153
+ contentType: string;
154
+ sizeBytes: number;
155
+ bytesBase64: string;
156
+ }): void;
157
+ }
158
+
159
+ /** The page keyboard — Playwright's `page.keyboard`. Each method records one
160
+ * browser event. */
161
+ export interface Keyboard {
162
+ /** Press a key or chord by name (`"Enter"`, `"Tab"`, `"Control+A"`). */
163
+ press(key: string): Promise<void>;
164
+ /** Type character-by-character (fires keydown/keyup per char). */
165
+ type(text: string): Promise<void>;
166
+ /** Insert text in one shot (no per-char keydown — the paste path). */
167
+ insertText(text: string): Promise<void>;
168
+ }
169
+
170
+ /** The page mouse — Playwright's `page.mouse`. Coordinates are viewport CSS
171
+ * pixels. Prefer locators; use these only for canvas/coordinate targets. */
172
+ export interface Mouse {
173
+ click(x: number, y: number, opts?: { button?: "left" | "right" | "middle"; clickCount?: number }): Promise<void>;
174
+ dblclick(x: number, y: number): Promise<void>;
175
+ move(x: number, y: number): Promise<void>;
176
+ /** Wheel-scroll by a pixel delta. */
177
+ wheel(dx: number, dy: number): Promise<void>;
178
+ }
179
+
180
+ /**
181
+ * Headless browser session — Playwright `Page`-shaped. Select elements with
182
+ * the `getBy*`/`locator` roots (returning a {@link Locator}) and act on them;
183
+ * drive raw input via `keyboard`/`mouse`. Operations are sequential per
184
+ * session — the recorder attributes each op's rrweb drain to it — so for
185
+ * parallel browsing open multiple sessions.
186
+ */
187
+ export interface Browser {
188
+ /** Current page URL (Playwright's synchronous `page.url()`). */
189
+ url(): string;
190
+ /** Current page `<title>` (async, like Playwright's `page.title()`). */
191
+ title(): Promise<string>;
192
+ /** Navigate to a URL; resolves when the main frame's load completes. */
193
+ goto(url: string): Promise<void>;
194
+ /** Navigate back in session history. */
195
+ goBack(): Promise<void>;
196
+ /** Navigate forward in session history. */
197
+ goForward(): Promise<void>;
198
+ /** Reload the current page. */
199
+ reload(): Promise<void>;
200
+
201
+ readonly keyboard: Keyboard;
202
+ readonly mouse: Mouse;
203
+
204
+ /** Root CSS query. */
205
+ locator(css: string): Locator;
206
+ getByRole(role: string, opts?: GetByRoleOptions): Locator;
207
+ getByText(text: string | RegExp, opts?: GetByTextOptions): Locator;
208
+ getByLabel(text: string | RegExp, opts?: GetByTextOptions): Locator;
209
+ getByPlaceholder(text: string | RegExp, opts?: GetByTextOptions): Locator;
210
+ getByAltText(text: string | RegExp, opts?: GetByTextOptions): Locator;
211
+ getByTitle(text: string | RegExp, opts?: GetByTextOptions): Locator;
212
+ getByTestId(testId: string): Locator;
213
+
214
+ /**
215
+ * Evaluate JS in the page and return the JSON-deserialised, provenance-
216
+ * wrapped result. `fn` is a real function (serialized by Playwright, with an
217
+ * optional serializable `arg`) or a string expression / statement body
218
+ * (auto-wrapped in an async IIFE, so `return`/`await` work).
219
+ *
220
+ * `description` is a short human label ("read rendered todo list") surfaced
221
+ * in the timeline so the step list isn't a wall of minified code — spectest
222
+ * keeps it (the one deviation from Playwright's bare `evaluate`).
223
+ */
224
+ evaluate<T = unknown>(
225
+ description: string,
226
+ fn: string | ((arg?: unknown) => T | Promise<T>),
227
+ arg?: unknown,
228
+ ): Promise<Wrapped<T>>;
229
+ /**
230
+ * Wait until the page's URL matches `pattern` — Playwright's
231
+ * `page.waitForURL`. Resolves to the **matched URL** (provenance-wrapped,
232
+ * so `expect(...)` on it nests under this step), and returns immediately if
233
+ * the current URL already matches.
234
+ *
235
+ * This is the primitive for "the app redirected somewhere": an auth
236
+ * handoff, an OAuth callback, a post-login bounce. Reach for it instead of
237
+ * `waitForFunction("…", () => location.href.startsWith(…))` — that polls
238
+ * *inside* the page, so it races the navigation it's watching for (the
239
+ * `Execution context was destroyed` flake), misses any URL the browser only
240
+ * passes through between polls, and needs the destination to run JS at all.
241
+ * This one is driven by frame-navigation events, so it sees every commit.
242
+ *
243
+ * ```ts
244
+ * const url = await browser.waitForURL("https://api.workos.com/**");
245
+ * expect(url).toContain("code=");
246
+ * ```
247
+ *
248
+ * `waitUntil` defaults to **`"commit"`**, not Playwright's `"load"`: the
249
+ * destination is usually somewhere the hermetic VM can't fully load, and
250
+ * waiting for its load event would time out on exactly the case this
251
+ * exists for. Pass `"load"`/`"domcontentloaded"` when you own the page and
252
+ * intend to keep driving it. Timeout defaults to 5 s.
253
+ */
254
+ waitForURL(
255
+ pattern: UrlPattern,
256
+ options?: {
257
+ timeout?: number;
258
+ waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
259
+ },
260
+ ): Promise<Wrapped<string>>;
261
+ /**
262
+ * Poll `fn` in the page until it returns a truthy value (Playwright's
263
+ * `page.waitForFunction`), recorded as ONE step with the total wait + poll
264
+ * count. `fn` is a function (with optional `arg`) or a string expression.
265
+ * `description` labels the step. Defaults: 5 s timeout, 100 ms polling.
266
+ *
267
+ * For "wait until the page navigates somewhere" use {@link waitForURL} —
268
+ * an in-page poll is the wrong tool for watching a navigation.
269
+ */
270
+ waitForFunction<T = unknown>(
271
+ description: string,
272
+ fn: string | ((arg?: unknown) => T),
273
+ arg?: unknown,
274
+ options?: { timeout?: number; polling?: number },
275
+ ): Promise<Wrapped<T>>;
276
+ /**
277
+ * Capture a PNG screenshot of the viewport and upload it as a downloadable
278
+ * **artifact**. Resolves to the artifact's `art_…` id — fetch it locally
279
+ * with `spectest artifact download <id>`. Eval-only (`spectest env eval`);
280
+ * throws with a clear message during test runs.
281
+ */
282
+ screenshot(): Promise<string>;
283
+ /**
284
+ * Destroy the underlying view. Idempotent; drains pending rrweb events
285
+ * first. For the persistent session behind `ctx.browser()`/`ctx.mobile()`
286
+ * this is the escape hatch to a FRESH browser — the shared instance is
287
+ * discarded and the next call creates a new one. Don't call it for routine
288
+ * cleanup: the daemon detaches recording at test end and keeps the browser
289
+ * alive so dependent tests inherit its state.
290
+ */
291
+ close(): Promise<void>;
292
+ }
293
+
294
+ /** The touchscreen — Playwright's `page.touchscreen`, but with the press
295
+ * dwell RN Pressables need. Mobile sessions only. */
296
+ export interface Touchscreen {
297
+ /** Touch-tap at viewport CSS coordinates. `duration` overrides the dwell. */
298
+ tap(x: number, y: number, opts?: { duration?: number }): Promise<void>;
299
+ }
300
+
301
+ /**
302
+ * The internal impl type `buildBackend` returns — the {@link Browser} surface
303
+ * plus the mobile extensions and the low-level primitives the locator layer
304
+ * composes on. `ctx.browser()` exposes the narrower {@link Browser} view;
305
+ * `ctx.mobile()` the {@link import("./mobile.js").Mobile} view (adds
306
+ * `touchscreen`/`swipe`). The extras below are never in a public type.
307
+ */
308
+ export interface MobileBackend extends Browser {
309
+ /** Safe-area insets emulated on this view (`null` on desktop views or when
310
+ * the CDP override is unavailable). Stamped onto the session record. */
311
+ readonly safeAreaInsets: SafeAreaInsets | null;
312
+ readonly touchscreen: Touchscreen;
313
+ /** Swipe the screen (a touch drag from the center). Mobile extension —
314
+ * Playwright has no swipe. */
315
+ swipe(direction: "up" | "down" | "left" | "right", opts?: { distance?: number }): Promise<void>;
316
+ /** Touch-drag from (x,y) by (dx,dy) over a short move sequence. */
317
+ swipeBy(x: number, y: number, dx: number, dy: number): Promise<void>;
318
+ /**
319
+ * Evaluate a JS expression in the page WITHOUT recording an event or
320
+ * draining rrweb. rrweb keeps buffering page-side; the next recorded op
321
+ * drains it.
322
+ */
323
+ probe<T = unknown>(expression: string): Promise<T>;
324
+ /**
325
+ * Run `fn` against the live page WITHOUT recording an event or draining
326
+ * rrweb — the poll path for `expect(locator)` matchers (one browser event
327
+ * per retry would flood the timeline).
328
+ */
329
+ silentRead<T>(fn: (page: Page) => Promise<T>): Promise<T>;
330
+ /**
331
+ * Run `fn` against the live playwright {@link Page}, recorded as a single
332
+ * event (with the usual rrweb drain). The locator layer's hook: one
333
+ * author-facing action = one recorded event, however many playwright calls
334
+ * it composes. When `opts.wrap` the return value is provenance-wrapped
335
+ * (reads), so a later `expect(...)` nests under the step.
336
+ */
337
+ pageOp<T>(
338
+ action: BrowserAction,
339
+ fields: Partial<RecordableFields>,
340
+ fn: (page: Page) => Promise<T>,
341
+ opts?: { wrap?: boolean },
342
+ ): Promise<T>;
343
+ /**
344
+ * Unrecorded CDP touch tap (touchStart → dwell → touchEnd). The locator
345
+ * layer composes it inside a {@link pageOp} so a locator `tap()` stays a
346
+ * single recorded event; `touchscreen.tap` is the recorded public twin.
347
+ */
348
+ rawTap(x: number, y: number, durationMs?: number): Promise<void>;
349
+ /**
350
+ * Record ONE settled browser event for an `expect(locator)` web-first
351
+ * matcher and return its seq. The matcher already read the value by polling
352
+ * {@link silentRead} (one event per retry would flood the timeline); this
353
+ * emits the single timeline step — the locator label + the session seek
354
+ * point (`sessionTimestamp`) to the settled frame — so the assertion the
355
+ * caller records next nests under it via `sourceSeq`, exactly as
356
+ * `expect(await loc.isVisible())` does. Drains rrweb like any recorded op.
357
+ * Returns `undefined` when nothing is recording.
358
+ */
359
+ recordSettled(
360
+ action: BrowserAction,
361
+ fields: Partial<RecordableFields>,
362
+ waitedMs: number,
363
+ error?: string,
364
+ ): Promise<number | undefined>;
365
+ }
366
+
367
+ // Default extra flags for headless Chromium inside a Firecracker microVM.
368
+ // --no-sandbox: Chrome refuses to launch as root otherwise (no user
369
+ // namespaces in the guest).
370
+ // --disable-dev-shm-usage: /dev/shm in the microVM defaults to ~64MB,
371
+ // which Chromium will exhaust on non-trivial pages.
372
+ // --disable-features=AsyncDns,DnsOverHttps + --dns-over-https-mode=off:
373
+ // force Chromium onto glibc getaddrinfo for name resolution — the exact
374
+ // path `fetch()` uses (→ /etc/resolv.conf → 127.0.0.53 → spectest-resolver).
375
+ // Chromium's built-in async DNS client (and DoH auto-upgrade) bypass the
376
+ // loopback nameserver in /etc/resolv.conf and query public DNS directly,
377
+ // which has never heard of our service names (`dashboard`, `web`,
378
+ // `api.todos.local`, …). On a cold first navigate in a fresh fork that
379
+ // surfaces as an intermittent `net::ERR_NAME_NOT_RESOLVED`. getaddrinfo
380
+ // reads resolv.conf synchronously per lookup, so it has no startup race
381
+ // and always reaches the resolver. See run 878d0054 (dashboard:3000).
382
+ //
383
+ // The two media flags below make `getUserMedia` work at all. The microVM
384
+ // has no camera and no sound card, so without them a page that asks for one
385
+ // fails on `NotFoundError: Requested device not found` — an app that gates
386
+ // its UI on microphone access can't be driven past that point.
387
+ //
388
+ // What the app gets is Chromium's own synthetic devices: the microphone is a
389
+ // generated beep pattern (a live, real MediaStreamTrack — MediaRecorder,
390
+ // WebRTC and AudioContext analysis all work on it), the camera a rolling
391
+ // test pattern at 640x480. There is deliberately NO way to feed a recording
392
+ // in; this is device availability, not audio fixtures.
393
+ //
394
+ // --use-fake-device-for-media-stream: the synthetic camera + microphone.
395
+ // Also what makes `enumerateDevices()` return anything at all.
396
+ // --use-fake-ui-for-media-stream: auto-accept the permission prompt.
397
+ // Headless has no UI to accept it with, so without this `getUserMedia`
398
+ // fails on `NotAllowedError` even when the fake devices are present.
399
+ //
400
+ // `--autoplay-policy=no-user-gesture-required` is deliberately NOT here.
401
+ // WebAudio over the captured stream works without it: holding a live
402
+ // getUserMedia stream is itself one of the conditions under which Chromium
403
+ // lets an AudioContext start, so an app's level meter or VAD reads the fake
404
+ // device fine (measured). Adding it would also un-block ordinary media
405
+ // autoplay, which is a behaviour change in the app under test, not in us.
406
+ const CHROME_ARGV = [
407
+ "--no-sandbox",
408
+ "--disable-dev-shm-usage",
409
+ "--disable-features=AsyncDns,DnsOverHttps",
410
+ "--dns-over-https-mode=off",
411
+ "--use-fake-device-for-media-stream",
412
+ "--use-fake-ui-for-media-stream",
413
+ ];
414
+
415
+ /** Default touchStart→touchEnd dwell for touch taps — see `rawTap`. */
416
+ const TAP_DWELL_MS = 60;
417
+
418
+ /** Navigations keep a longer deadline than locator actions (cold app
419
+ * servers). The action default lives in `locator.ts`
420
+ * ({@link DEFAULT_ACTION_TIMEOUT_MS}) since the locator layer owns it. */
421
+ const NAVIGATION_TIMEOUT_MS = 30_000;
422
+
423
+ // ────────────────────────────────────────────────────────────────────────
424
+ // Shared Chromium (playwright-core)
425
+ // ────────────────────────────────────────────────────────────────────────
426
+
427
+ // One Chromium per daemon, launched lazily on first view creation and kept
428
+ // for the daemon's lifetime. It rides snapshots: the process, playwright's
429
+ // pipe connection to it, and every open page freeze into the VM snapshot
430
+ // and resume in each fork (like dockerd and the daemon itself). Profile
431
+ // state — cookies, localStorage — is per-Chromium, and the in-VM CA trust
432
+ // comes from the HOME-scoped NSS user DB, so neither cares that playwright
433
+ // runs a temp --user-data-dir.
434
+ let PW_BROWSER: PlaywrightBrowser | null = null;
435
+ // Every view gets its OWN context, desktop included. A context is the cookie
436
+ // jar + storage, so one-context-per-view is what makes `close()` mean what
437
+ // the docs promise: the browsing session is destroyed and the next
438
+ // `ctx.browser()` starts signed out. Desktop views used to share one context
439
+ // (mirroring the old one-Chrome-profile model), which left `close()` closing
440
+ // the page only — cookies survived and the next session was still
441
+ // authenticated.
442
+
443
+ function chromiumPath(): string {
444
+ return (
445
+ Bun.which("chromium") ??
446
+ Bun.which("chromium-browser") ??
447
+ Bun.which("google-chrome") ??
448
+ "/usr/bin/chromium"
449
+ );
450
+ }
451
+
452
+ async function ensurePlaywrightBrowser(): Promise<PlaywrightBrowser> {
453
+ if (PW_BROWSER?.isConnected()) return PW_BROWSER;
454
+ PW_BROWSER = await chromium.launch({
455
+ executablePath: chromiumPath(),
456
+ headless: true,
457
+ args: CHROME_ARGV,
458
+ });
459
+ return PW_BROWSER;
460
+ }
461
+
462
+ /** Context options for the mobile device preset — playwright's native
463
+ * emulation (viewport/DPR/UA/touch are context-scoped). Safe-area insets
464
+ * have no context option; they stay a raw-CDP override per page. */
465
+ function deviceContextOptions(d: DevicePreset) {
466
+ return {
467
+ viewport: { width: d.viewport.width, height: d.viewport.height },
468
+ deviceScaleFactor: d.deviceScaleFactor,
469
+ isMobile: d.isMobile,
470
+ hasTouch: d.hasTouch,
471
+ userAgent: d.userAgent,
472
+ // Emulate `prefers-reduced-motion: reduce` for replay fidelity — an app
473
+ // that honors it skips entrance animations/transitions, so rrweb's
474
+ // per-op FullSnapshot captures the settled DOM instead of a mid-fade
475
+ // (opacity 0) frame that a paused seek would freeze on. Applied at every
476
+ // newContext site below so all sessions record the same way.
477
+ reducedMotion: "reduce" as const,
478
+ };
479
+ }
480
+
481
+ /** Create the context one view lives in — device emulation when the view is
482
+ * phone-emulated, a plain viewport otherwise. */
483
+ async function newViewContext(
484
+ width: number,
485
+ height: number,
486
+ device: DevicePreset | null,
487
+ ): Promise<BrowserContext> {
488
+ const browser = await ensurePlaywrightBrowser();
489
+ const context = await browser.newContext(
490
+ device
491
+ ? deviceContextOptions(device)
492
+ : {
493
+ viewport: { width, height },
494
+ // Reduced-motion for replay fidelity — see deviceContextOptions.
495
+ reducedMotion: "reduce",
496
+ },
497
+ );
498
+ context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
499
+ context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
500
+ return context;
501
+ }
502
+
503
+ /**
504
+ * Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
505
+ * single EXPRESSION (it wraps the source in `await (...)`), so a statement
506
+ * body (`const x = …; return x;`) is a syntax error. Rather than forcing
507
+ * authors to IIFE-wrap by hand, wrap it for them when it isn't an expression.
508
+ *
509
+ * The check is parse-only (`new Function` compiles without executing) and
510
+ * happens BEFORE the script runs — deciding by catching the page-side error
511
+ * and retrying would re-execute side-effecting expressions whose *runtime*
512
+ * error happens to look syntactic (`JSON.parse` throws SyntaxError too).
513
+ */
514
+ function toEvaluable(script: string): string {
515
+ try {
516
+ new Function(`return (${script}\n);`);
517
+ return script;
518
+ } catch {
519
+ // Statement body — an async IIFE makes `return`, declarations, and
520
+ // multi-statement snippets valid, with `await` still available. The
521
+ // newlines keep a trailing line comment from eating the wrapper.
522
+ return `(async () => {\n${script}\n})()`;
523
+ }
524
+ }
525
+
526
+ // ────────────────────────────────────────────────────────────────────────
527
+ // Device emulation (mobile frame)
528
+ // ────────────────────────────────────────────────────────────────────────
529
+
530
+ /** A device descriptor in the shape of Playwright's `devices[...]` entries
531
+ * — the subset we feed to CDP. Not caller-configurable today; a single
532
+ * fixed preset (latest iPhone) backs every `ctx.mobile(...)` session. */
533
+ interface DevicePreset {
534
+ name: string;
535
+ viewport: { width: number; height: number };
536
+ deviceScaleFactor: number;
537
+ isMobile: boolean;
538
+ hasTouch: boolean;
539
+ userAgent: string;
540
+ safeAreaInsets: SafeAreaInsets;
541
+ }
542
+
543
+ /** iOS safe-area insets (CSS `env(safe-area-inset-*)`), in CSS px. */
544
+ export interface SafeAreaInsets {
545
+ top: number;
546
+ right: number;
547
+ bottom: number;
548
+ left: number;
549
+ }
550
+
551
+ /** The fixed mobile device. Logical resolution + DPR of a current iPhone;
552
+ * the UA mirrors what Playwright emits for iOS so RN-Web's mobile branches
553
+ * fire. We run Chromium under the hood, so the Safari UA is a deliberate
554
+ * emulation lie (same as every device-emulation tool). */
555
+ const LATEST_IPHONE: DevicePreset = {
556
+ name: "iPhone 15 Pro",
557
+ viewport: { width: 393, height: 852 },
558
+ deviceScaleFactor: 3,
559
+ isMobile: true,
560
+ hasTouch: true,
561
+ userAgent:
562
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " +
563
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
564
+ // Portrait safe area of the 393×852 iPhones (14 Pro through 16): 59pt
565
+ // status-bar/Dynamic-Island clearance on top, 34pt home-indicator strip
566
+ // at the bottom. Emulated via CDP so the app's `env(safe-area-inset-*)`
567
+ // padding fires exactly like on the real device.
568
+ safeAreaInsets: { top: 59, right: 0, bottom: 34, left: 0 },
569
+ };
570
+
571
+ /** Apply the one device-emulation piece playwright's context options can't
572
+ * express: iOS safe-area insets, via raw CDP on the page's session. The
573
+ * override is session-global, so it persists across the app navigation
574
+ * that follows. Best-effort — Chromium < ~135 lacks the method.
575
+ *
576
+ * Returns the insets that actually took effect (`null` when the override
577
+ * failed). The caller stamps them onto the session record so the dashboard
578
+ * can substitute the same values for `env(safe-area-inset-*)` in the
579
+ * replayed CSS; stamping only what was really applied keeps capture layout
580
+ * and replay layout in lockstep (recorded touch coordinates would
581
+ * misalign otherwise). */
582
+ async function applySafeAreaInsets(
583
+ cdp: CDPSession,
584
+ d: DevicePreset,
585
+ ): Promise<SafeAreaInsets | null> {
586
+ try {
587
+ await cdp.send(
588
+ // Not in playwright's Protocol types yet (Chromium ≥ ~135 method).
589
+ "Emulation.setSafeAreaInsetsOverride" as Parameters<CDPSession["send"]>[0],
590
+ { insets: { ...d.safeAreaInsets } } as never,
591
+ );
592
+ return d.safeAreaInsets;
593
+ } catch (err) {
594
+ // eslint-disable-next-line no-console
595
+ console.warn("[spectest] safe-area inset emulation unavailable:", err);
596
+ return null;
597
+ }
598
+ }
599
+
600
+ // ────────────────────────────────────────────────────────────────────────
601
+ // rrweb bootstrap
602
+ // ────────────────────────────────────────────────────────────────────────
603
+
604
+ // Vendored `@rrweb/record` UMD bundle (rrweb 2.x). Its global `rrwebRecord`
605
+ // is a module object — the record function is `rrwebRecord.record` (the
606
+ // bootstrap resolves both this and the legacy function-shaped global). Read
607
+ // once at module init — the SDK ships this file in the base snapshot so
608
+ // no network fetch happens inside the VM.
609
+ const RRWEB_BUNDLE: string = (() => {
610
+ try {
611
+ const here = path.dirname(fileURLToPath(import.meta.url));
612
+ const file = path.join(here, "vendor", "rrweb-record.min.js");
613
+ return readFileSync(file, "utf8");
614
+ } catch (err) {
615
+ // If the vendored bundle is missing the SDK still works — the
616
+ // browser just records no rrweb events. Surface a warning so the
617
+ // mistake is visible.
618
+ // eslint-disable-next-line no-console
619
+ console.warn(
620
+ "[spectest] rrweb-record.min.js missing from SDK vendor dir; browser sessions will be empty",
621
+ err,
622
+ );
623
+ return "";
624
+ }
625
+ })();
626
+
627
+ // Vendored console-record plugin bundle (UMD, exposes
628
+ // `rrwebPluginConsoleRecord` global). Captures the page's
629
+ // `console.{log,info,warn,error,debug,...}` calls and uncaught errors as
630
+ // rrweb Plugin events (`type: 6`, `data.plugin: "rrweb/console@1"`),
631
+ // which ride in the same `emit` stream as DOM mutations. Optional —
632
+ // missing bundle just means no console capture; the recorder still runs.
633
+ const RRWEB_CONSOLE_PLUGIN_BUNDLE: string = (() => {
634
+ try {
635
+ const here = path.dirname(fileURLToPath(import.meta.url));
636
+ const file = path.join(here, "vendor", "rrweb-plugin-console-record.umd.js");
637
+ return readFileSync(file, "utf8");
638
+ } catch {
639
+ return "";
640
+ }
641
+ })();
642
+
643
+ // Bootstrap that runs after the bundle defines `rrwebRecord`. Idempotent —
644
+ // the same script is added with `Page.addScriptToEvaluateOnNewDocument`
645
+ // and runs on every new document, so the `__spectestRrwebInit` guard keeps
646
+ // us from double-starting.
647
+ //
648
+ // `lengthThreshold` caps per-arg serialized size (default 1000 → bumped to
649
+ // 10 KiB so longer error stacks survive without dwarfing the event stream).
650
+ const RRWEB_BOOTSTRAP = `
651
+ ;(function () {
652
+ // rrweb 2.x ships \`@rrweb/record\` whose UMD global is a module object
653
+ // (\`rrwebRecord.record\`); the pre-2.0 bundle exposed the record function
654
+ // directly. Resolve either shape so the bootstrap is version-agnostic,
655
+ // and stash it on \`window.__spectestRec\` for the drain/attach helpers
656
+ // (which run as separate injected expressions).
657
+ var rec = (typeof rrwebRecord === "function")
658
+ ? rrwebRecord
659
+ : (rrwebRecord && (rrwebRecord.record || rrwebRecord.default));
660
+ if (typeof rec !== "function") return;
661
+ if (window.__spectestRrwebInit) return;
662
+ window.__spectestRrwebInit = true;
663
+ window.__spectestRec = rec;
664
+ window.__spectestRrwebEvents = [];
665
+ var plugins = [];
666
+ try {
667
+ if (typeof rrwebPluginConsoleRecord === "object" &&
668
+ typeof rrwebPluginConsoleRecord.getRecordConsolePlugin === "function") {
669
+ plugins.push(rrwebPluginConsoleRecord.getRecordConsolePlugin({
670
+ level: ["assert", "debug", "error", "info", "log", "trace", "warn"],
671
+ lengthThreshold: 10000,
672
+ logger: window.console,
673
+ }));
674
+ }
675
+ } catch (e) { /* plugin init failed — keep recording DOM only. */ }
676
+ try {
677
+ rec({
678
+ emit: function (ev) { window.__spectestRrwebEvents.push(ev); },
679
+ plugins: plugins,
680
+ // Capture page assets into the event stream so the replay renders
681
+ // faithfully offline (the dashboard reconstructs the DOM with no
682
+ // access to the env's origin). \`inlineStylesheet\` (default, set
683
+ // explicitly) bakes <style>/<link> CSS into the snapshot;
684
+ // \`inlineImages\` turns <img> elements into data URIs — but ONLY
685
+ // synchronously, for images already decoded at serialize time
686
+ // (its late-load path patches the emitted event in place, which
687
+ // loses the race against our per-op drains; the image inliner
688
+ // below covers that case with synthetic mutation events).
689
+ // \`collectFonts\` only captures fonts added via the JS
690
+ // FontFace API — it does NOT inline static \`@font-face { src:url() }\`
691
+ // CSS (what next/font emits), so it's a no-op for most apps; the
692
+ // url()-based fonts are handled by the inliner below instead. All
693
+ // degrade gracefully — an asset that can't be read is skipped,
694
+ // never fatal to recording.
695
+ inlineStylesheet: true,
696
+ collectFonts: true,
697
+ inlineImages: true,
698
+ });
699
+ } catch (e) {
700
+ /* DOM not yet ready or rrweb mis-init — skip. */
701
+ }
702
+ // ── Inline @font-face fonts as base64 ──────────────────────────────
703
+ // The replay is viewed OUTSIDE this hermetic VM, so a @font-face whose
704
+ // src points at a same-origin URL (e.g. next/font's
705
+ // /_next/static/media/*.woff2) is unreachable at replay time and the
706
+ // page silently falls back to a system font. collectFonts above won't
707
+ // help (FontFace-API fonts only), so inline the bytes ourselves: fetch
708
+ // each url() (same-origin -> allowed, and the page already trusts the
709
+ // in-VM CA), rewrite the rule's src to a data:
710
+ // URI, then take a fresh full snapshot so the now-self-contained CSS
711
+ // is captured. Plain string ops, no regex — this whole script ships
712
+ // inside a TS template literal where regex backslashes get mangled.
713
+ //
714
+ // Do NOT start this scan from document.fonts.ready. This script runs at
715
+ // document-start. At that moment the font set is idle, so Chrome settles
716
+ // that first ready promise immediately, before the parser has registered
717
+ // one stylesheet. The scan then saw 0 @font-face rules, and a one-shot
718
+ // latch stopped it from looking again, so no font was ever inlined.
719
+ // (Found on run_03j0mxtvp6x0p16d9y83e: every next/font face fell back.
720
+ // The diagnostics were invisible too, because rrweb's console plugin
721
+ // only patches console at the load event.)
722
+ //
723
+ // Two triggers instead: the load event, by which all render-blocking CSS
724
+ // is parsed, and each \`loadingdone\` on document.fonts, which catches CSS
725
+ // added later by an HMR update or a client-side route change. The scan
726
+ // skips a rule it already inlined, so repeated triggers are cheap.
727
+ (function () {
728
+ if (window.__spectestFontInliner) return;
729
+ window.__spectestFontInliner = true;
730
+ // Diagnostics ride the console-record plugin, so they land in the
731
+ // recording (and state.db) — our only debugging window into the
732
+ // headless page. Prefix is grep-able; keep them terse.
733
+ function log(m) { try { console.log("[spectest-fonts] " + m); } catch (e) {} }
734
+ var cache = {};
735
+ function fetchDataUri(url) {
736
+ if (cache[url]) return cache[url];
737
+ cache[url] = fetch(url).then(function (r) {
738
+ if (!r.ok) throw new Error("HTTP " + r.status);
739
+ return r.blob();
740
+ }).then(function (blob) {
741
+ return new Promise(function (resolve, reject) {
742
+ var fr = new FileReader();
743
+ fr.onload = function () { resolve(fr.result); };
744
+ fr.onerror = reject;
745
+ fr.readAsDataURL(blob);
746
+ });
747
+ });
748
+ return cache[url];
749
+ }
750
+ // Extract every url() token in cssText, resolved against baseHref.
751
+ // CSSOM cssText keeps urls as authored — next/font emits
752
+ // absolute-path url(/_next/static/media/x.woff2) (no scheme), so a
753
+ // bare http prefix check misses them (that's why run b04cab1f / 6987
754
+ // saw withHttpUrl=0). Resolve against the sheet href (or the document
755
+ // base for inline style) before fetching. token is the raw string as
756
+ // it appears in cssText, used for the later string replace.
757
+ function urlsIn(text, baseHref) {
758
+ var out = [], i = 0;
759
+ while (true) {
760
+ var u = text.indexOf("url(", i);
761
+ if (u < 0) break;
762
+ var start = u + 4, end = text.indexOf(")", start);
763
+ if (end < 0) break;
764
+ var raw = text.slice(start, end).trim();
765
+ if (raw && (raw.charAt(0) === '"' || raw.charAt(0) === "'")) {
766
+ raw = raw.slice(1, raw.length - 1);
767
+ }
768
+ i = end + 1;
769
+ if (!raw || raw.lastIndexOf("data:", 0) === 0) continue; // already inline
770
+ var abs;
771
+ try { abs = new URL(raw, baseHref).href; } catch (e) { continue; }
772
+ if (abs.lastIndexOf("http", 0) !== 0) continue; // only fetchable http(s)
773
+ out.push({ token: raw, abs: abs });
774
+ }
775
+ return out;
776
+ }
777
+ function eachFontFace(rules, fn) {
778
+ for (var i = 0; i < rules.length; i++) {
779
+ var rule = rules[i];
780
+ if (rule.type === 5) fn(rule); // CSSRule.FONT_FACE_RULE
781
+ else if (rule.cssRules) { // @media / @supports nesting
782
+ try { eachFontFace(rule.cssRules, fn); } catch (e) {}
783
+ }
784
+ }
785
+ }
786
+ // Every @font-face cssText this run already rewrote. A rule stays out
787
+ // of later scans, so an HMR update that adds one stylesheet costs one
788
+ // fetch, not a re-inline of the whole page.
789
+ var inlined = {};
790
+ var scans = 0, busy = false, rescan = false;
791
+ function collect() {
792
+ // Collect each @font-face's *cssText* (reliable for any CSSRule —
793
+ // unlike .style.setProperty('src',…), which silently no-ops on
794
+ // CSSFontFaceRule in Chrome) plus the http url()s inside it.
795
+ var faces = [], sheets = document.styleSheets, readable = 0, faceCount = 0;
796
+ for (var s = 0; s < sheets.length; s++) {
797
+ var rules;
798
+ try { rules = sheets[s].cssRules; } catch (e) { continue; } // cross-origin
799
+ if (!rules) continue;
800
+ readable++;
801
+ var base = sheets[s].href || document.baseURI;
802
+ eachFontFace(rules, function (rule) {
803
+ faceCount++;
804
+ var cssText = rule.cssText || "";
805
+ if (inlined[cssText]) return;
806
+ var urls = urlsIn(cssText, base);
807
+ if (urls.length) faces.push({ cssText: cssText, urls: urls });
808
+ });
809
+ }
810
+ // Log the first scan (so a page with no web fonts still says so) and
811
+ // every scan with work to do. A quiet re-trigger stays silent.
812
+ if (!scans++ || faces.length) {
813
+ log("sheets=" + sheets.length + " readable=" + readable +
814
+ " fontFaces=" + faceCount + " new=" + faces.length);
815
+ }
816
+ return faces;
817
+ }
818
+ function run() {
819
+ // One pass at a time. A trigger during a pass queues exactly one
820
+ // more, which then sees the marks the pass just wrote.
821
+ if (busy) { rescan = true; return; }
822
+ var faces = collect();
823
+ if (!faces.length) return;
824
+ busy = true;
825
+ var jobs = faces.map(function (face) {
826
+ return Promise.all(face.urls.map(function (u) {
827
+ return fetchDataUri(u.abs)
828
+ .then(function (d) { return { token: u.token, dataUri: d }; })
829
+ .catch(function (e) { log("fetch FAIL " + u.abs + " : " + (e && e.message)); return null; });
830
+ })).then(function (pairs) {
831
+ var text = face.cssText, changed = false;
832
+ for (var k = 0; k < pairs.length; k++) {
833
+ if (!pairs[k]) continue;
834
+ text = text.split(pairs[k].token).join(pairs[k].dataUri);
835
+ changed = true;
836
+ }
837
+ if (!changed) return null;
838
+ inlined[face.cssText] = true;
839
+ return text;
840
+ });
841
+ });
842
+ Promise.all(jobs).then(function (texts) {
843
+ var ok = texts.filter(function (t) { return t; });
844
+ if (!ok.length) { log("nothing inlined (all fetches failed?)"); return; }
845
+ // Inject the rewritten @font-face rules as a NEW <style> appended
846
+ // last. A later @font-face with identical descriptors wins, and a
847
+ // freshly-added node is captured reliably by rrweb's mutation
848
+ // observer (no dependence on CSSOM edits being serialized).
849
+ try {
850
+ var st = document.createElement("style");
851
+ st.setAttribute("data-spectest-inlined-fonts", "1");
852
+ st.appendChild(document.createTextNode(ok.join("\\n")));
853
+ (document.head || document.documentElement).appendChild(st);
854
+ log("injected " + ok.length + " @font-face rule(s) as data: URIs");
855
+ } catch (e) { log("inject FAIL " + (e && e.message)); return; }
856
+ try {
857
+ if (rec && typeof rec.takeFullSnapshot === "function") {
858
+ rec.takeFullSnapshot();
859
+ log("took full snapshot");
860
+ }
861
+ } catch (e) { log("snapshot FAIL " + (e && e.message)); }
862
+ }).catch(function (e) {
863
+ log("run FAIL " + (e && e.message));
864
+ }).then(function () {
865
+ busy = false;
866
+ if (rescan) { rescan = false; setTimeout(run, 0); }
867
+ });
868
+ }
869
+ function schedule() { setTimeout(run, 0); }
870
+ try {
871
+ if (document.readyState === "complete") schedule();
872
+ else window.addEventListener("load", schedule);
873
+ // Fires each time a batch of fonts finishes loading, including fonts
874
+ // pulled in by CSS that arrives after the load event.
875
+ if (document.fonts && document.fonts.addEventListener) {
876
+ document.fonts.addEventListener("loadingdone", schedule);
877
+ }
878
+ } catch (e) { log("init FAIL " + (e && e.message)); }
879
+ })();
880
+ // ── Inline late-loading <img>s as synthetic src mutations ──────────
881
+ // rrweb's inlineImages only bakes a data: URI in synchronously when
882
+ // the image is already decoded at serialize time. An image that
883
+ // finishes loading AFTER its node was serialized is patched by rrweb
884
+ // IN PLACE on the already-emitted event object — lost whenever a
885
+ // drain (op boundary) ships the buffer first. Classic case: a tap
886
+ // reveals a screen full of first-seen images; the step's drain fires
887
+ // right after the tap, the images decode a beat later, and the replay
888
+ // is left with raw in-VM URLs it can't fetch (hermetic env).
889
+ //
890
+ // Fix: listen for image loads ourselves (capture phase on document —
891
+ // runs BEFORE rrweb's target-phase listener) and, once the element
892
+ // has an id in rrweb's mirror, push a synthetic incremental mutation
893
+ // rewriting the img's src to a data: URI. A mutation event rides
894
+ // whatever drain comes next, so it survives the race even when the
895
+ // add event is long gone. Bytes come from a same-origin fetch of the
896
+ // ORIGINAL file (compact and lossless — rrweb's canvas path
897
+ // re-encodes JPEGs as much larger PNGs). It must be a plain \`src\`
898
+ // rewrite: the replayer honors \`rr_dataURL\` only when BUILDING a
899
+ // node; in the attribute-mutation path it just sets the literal
900
+ // attribute (only canvas is special-cased there).
901
+ (function () {
902
+ if (window.__spectestImgInliner) return;
903
+ window.__spectestImgInliner = true;
904
+ function log(m) { try { console.log("[spectest-img] " + m); } catch (e) {} }
905
+ var cache = {};
906
+ function fetchDataUri(url) {
907
+ if (cache[url]) return cache[url];
908
+ cache[url] = fetch(url).then(function (r) {
909
+ if (!r.ok) throw new Error("HTTP " + r.status);
910
+ return r.blob();
911
+ }).then(function (blob) {
912
+ return new Promise(function (resolve, reject) {
913
+ var fr = new FileReader();
914
+ fr.onload = function () { resolve(fr.result); };
915
+ fr.onerror = reject;
916
+ fr.readAsDataURL(blob);
917
+ });
918
+ });
919
+ return cache[url];
920
+ }
921
+ // The serialized-node meta when rrweb already inlined THIS src on
922
+ // it (rr_dataURL present and the recorded src matches — a stale
923
+ // bake from a previous src must not count).
924
+ function bakedMeta(mirror, img, src) {
925
+ try {
926
+ var meta = mirror.getMeta(img);
927
+ if (meta && meta.attributes && meta.attributes.rr_dataURL &&
928
+ meta.attributes.src === src) return meta;
929
+ } catch (e) {}
930
+ return null;
931
+ }
932
+ function onImgLoad(img, src) {
933
+ var mirror = rec && rec.mirror;
934
+ if (!mirror || typeof mirror.getId !== "function" ||
935
+ typeof mirror.getMeta !== "function") return;
936
+ // A patch visible NOW — before rrweb's own load listener has run
937
+ // (we're capture phase, it's target phase) — can only be the
938
+ // synchronous serialize-time bake, which shipped inside the event
939
+ // itself. Nothing to do.
940
+ var preId = mirror.getId(img);
941
+ if (preId > 0 && bakedMeta(mirror, img, src)) return;
942
+ var tries = 0;
943
+ function finish() {
944
+ var id = mirror.getId(img);
945
+ if (!(id > 0)) {
946
+ // Not serialized yet (mutation batch pending, or the initial
947
+ // full snapshot hasn't run — rrweb defers it to window load).
948
+ tries++;
949
+ if (tries < 40) setTimeout(finish, 50);
950
+ else log("drop (never serialized) " + src);
951
+ return;
952
+ }
953
+ var meta = bakedMeta(mirror, img, src);
954
+ if (meta && preId < 1) return; // serialized after load → sync bake
955
+ if (meta) {
956
+ // rrweb's async in-place patch landed after our capture-phase
957
+ // check. If the add is still in the buffer it now carries a
958
+ // PNG re-encode; strip it and ship ours instead (original
959
+ // bytes, and immune to a drain having already taken the add).
960
+ try { delete meta.attributes.rr_dataURL; } catch (e) {}
961
+ }
962
+ fetchDataUri(src).then(function (dataUri) {
963
+ var attrs = { src: dataUri };
964
+ // A srcset would out-rank the patched src in the replay
965
+ // iframe and point back at the unreachable original.
966
+ if (img.getAttribute && img.getAttribute("srcset")) attrs.srcset = "";
967
+ window.__spectestRrwebEvents.push({
968
+ type: 3,
969
+ data: { source: 0, texts: [], attributes: [{ id: id, attributes: attrs }], removes: [], adds: [] },
970
+ timestamp: Date.now(),
971
+ });
972
+ log("inlined id=" + id + " bytes=" + dataUri.length + " " + src);
973
+ }).catch(function (e) {
974
+ log("fetch FAIL " + src + " : " + (e && e.message));
975
+ });
976
+ }
977
+ // Next task, so rrweb's own load listener has run and its patch
978
+ // (if any) is observable above.
979
+ setTimeout(finish, 0);
980
+ }
981
+ try {
982
+ document.addEventListener("load", function (ev) {
983
+ var el = ev.target;
984
+ if (!el || !el.tagName || String(el.tagName).toLowerCase() !== "img") return;
985
+ var src = el.currentSrc || el.src || "";
986
+ if (!src || src.lastIndexOf("http", 0) !== 0) return; // data:/blob: already replayable
987
+ if (el.__spectestImgSrc === src) return; // this src already handled
988
+ el.__spectestImgSrc = src;
989
+ onImgLoad(el, src);
990
+ }, true);
991
+ } catch (e) { log("init FAIL " + (e && e.message)); }
992
+ })();
993
+ // ── Record form resets as synthetic Input events ───────────────────
994
+ // rrweb learns that a control's value changed through exactly two
995
+ // channels: the \`input\`/\`change\` events, and setters it hooks on the
996
+ // HTMLInput/TextArea/SelectElement prototypes. A **form reset** uses
997
+ // neither — the browser clears the controls internally — so the
998
+ // recording keeps the last typed value forever while the real page
999
+ // shows an empty field. Measured, not assumed: \`el.value = x\` IS
1000
+ // captured (even through React's own per-node \`value\` accessor, since
1001
+ // that delegates to the prototype descriptor we hooked first), and
1002
+ // \`form.reset()\` is NOT. The shape is everywhere in chat/prompt UIs —
1003
+ // shadcn/ai-elements' PromptInput resets the form on submit, as does
1004
+ // React 19's automatic \`<form action>\` reset — where it reads as
1005
+ // "clicking send didn't clear the box".
1006
+ //
1007
+ // The \`reset\` event is the hook: it fires (cancelable) BEFORE the
1008
+ // controls are reset, for both \`form.reset()\` and a user-clicked
1009
+ // \`type="reset"\` button. Snapshot the values there, diff on the next
1010
+ // task, and push an Input event per control that actually moved —
1011
+ // byte-identical in shape to what rrweb emits for a real input, so
1012
+ // the replayer applies it the same way. A prevented or no-op reset
1013
+ // changes nothing and therefore emits nothing. We never touch the
1014
+ // page's DOM to provoke a recording (\`el.value = el.value\` would
1015
+ // work, but it sets the control's dirty-value flag and would change
1016
+ // how the app's own later attribute writes render).
1017
+ (function () {
1018
+ if (window.__spectestFormReset) return;
1019
+ window.__spectestFormReset = true;
1020
+ var INPUT_TAGS = ["INPUT", "TEXTAREA", "SELECT"];
1021
+ function snap(form) {
1022
+ var out = [], els;
1023
+ try { els = form.elements; } catch (e) { return out; }
1024
+ if (!els) return out;
1025
+ for (var i = 0; i < els.length; i++) {
1026
+ var el = els[i];
1027
+ if (!el || !el.tagName) continue;
1028
+ if (INPUT_TAGS.indexOf(String(el.tagName).toUpperCase()) < 0) continue;
1029
+ out.push({ el: el, value: el.value, checked: !!el.checked });
1030
+ }
1031
+ return out;
1032
+ }
1033
+ function emitChanged(before) {
1034
+ var mirror = rec && rec.mirror;
1035
+ if (!mirror || typeof mirror.getId !== "function") return;
1036
+ for (var i = 0; i < before.length; i++) {
1037
+ var el = before[i].el;
1038
+ if (el.value === before[i].value && !!el.checked === before[i].checked) continue;
1039
+ if (el.classList && el.classList.contains("rr-ignore")) continue;
1040
+ var id = mirror.getId(el);
1041
+ if (!(id > 0)) continue;
1042
+ var type = el.type ? String(el.type).toLowerCase() : "";
1043
+ var text = String(el.value);
1044
+ // rrweb masks password inputs by default (maskInputOptions
1045
+ // \`{ password: true }\`, default maskInputFn = same-length '*').
1046
+ if (type === "password") {
1047
+ var masked = "";
1048
+ for (var k = 0; k < text.length; k++) masked += "*";
1049
+ text = masked;
1050
+ }
1051
+ window.__spectestRrwebEvents.push({
1052
+ type: 3,
1053
+ data: {
1054
+ source: 5,
1055
+ text: text,
1056
+ isChecked: (type === "radio" || type === "checkbox") ? !!el.checked : false,
1057
+ id: id,
1058
+ },
1059
+ timestamp: Date.now(),
1060
+ });
1061
+ }
1062
+ }
1063
+ try {
1064
+ document.addEventListener("reset", function (ev) {
1065
+ var form = ev.target;
1066
+ if (!form || !form.tagName || String(form.tagName).toUpperCase() !== "FORM") return;
1067
+ var before = snap(form);
1068
+ if (!before.length) return;
1069
+ // The controls are reset after dispatch returns — diff then.
1070
+ setTimeout(function () { emitChanged(before); }, 0);
1071
+ }, true);
1072
+ } catch (e) { /* recording continues without reset capture. */ }
1073
+ })();
1074
+ })();
1075
+ `;
1076
+
1077
+ // The `;` between parts is load-bearing: a vendored bundle that ends
1078
+ // without a trailing semicolon (rrweb 2.1.0's UMD ends in `}))`) would
1079
+ // otherwise ASI-merge with the next part's leading `(function...` into a
1080
+ // call expression — the TypeError kills everything after the bundle, so
1081
+ // the globals define but the bootstrap never runs (zero rrweb events,
1082
+ // empty replays).
1083
+ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
1084
+ ? `${RRWEB_BUNDLE}\n;\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n;\n${RRWEB_BOOTSTRAP}`
1085
+ : "";
1086
+
1087
+ // Page-side expression that atomically swaps in a fresh buffer and
1088
+ // returns the old one. Wrapped as a single expression so view.evaluate's
1089
+ // `await (...)` wrapper accepts it.
1090
+ //
1091
+ // `forceFullIfMissing` (inlined by `drainExpr`) guards a recovery path
1092
+ // for the most common replay failure: a post-navigation page whose full
1093
+ // snapshot never made it into the stream. rrweb defers its *initial*
1094
+ // full snapshot to the page's `load` event, but our drains fire at op
1095
+ // boundaries — `waitFor` in particular returns the instant its predicate
1096
+ // is truthy, which is usually right after `DOMContentLoaded`, *before*
1097
+ // `load`. Login/redirect chains compound this: every new document resets
1098
+ // `window.__spectestRrwebEvents`, so a partial chunk from an intermediate
1099
+ // page is discarded. The net effect is a final step that carries only a
1100
+ // `DOMContentLoaded` (type 0) and no Meta/FullSnapshot — the player then
1101
+ // has no DOM for the new page and keeps showing the previous one (e.g.
1102
+ // the auth provider's login screen instead of the post-login dashboard).
1103
+ //
1104
+ // When the caller detects the document changed since the last drain
1105
+ // (`view.url` differs) it passes `force=true`; if the outgoing buffer
1106
+ // then lacks any FullSnapshot (type 2), we call `rec.takeFullSnapshot()`
1107
+ // to synthesize one for the *current* DOM. That emits a fresh Meta
1108
+ // (with the correct href) + FullSnapshot, so the step is self-contained
1109
+ // and seeking to it shows the right page. Gating on url-change + missing
1110
+ // snapshot keeps steady-state steps (clicks/types on an unchanged page,
1111
+ // or navigations where `load` already fired) from bloating the stream
1112
+ // with redundant snapshots.
1113
+ function drainExpr(forceFullIfMissing: boolean): string {
1114
+ return `(function () {
1115
+ try {
1116
+ var rec = window.__spectestRec;
1117
+ if (${forceFullIfMissing ? "true" : "false"} &&
1118
+ window.__spectestRrwebInit &&
1119
+ rec && typeof rec.takeFullSnapshot === "function") {
1120
+ var b = window.__spectestRrwebEvents || [];
1121
+ var hasFull = false;
1122
+ for (var i = 0; i < b.length; i++) {
1123
+ if (b[i] && b[i].type === 2) { hasFull = true; break; }
1124
+ }
1125
+ if (!hasFull) rec.takeFullSnapshot();
1126
+ }
1127
+ } catch (e) { /* recording inactive or DOM detached — drain what's there. */ }
1128
+ var buf = window.__spectestRrwebEvents;
1129
+ if (!buf || !buf.length) return [];
1130
+ window.__spectestRrwebEvents = [];
1131
+ return buf;
1132
+ })()`;
1133
+ }
1134
+
1135
+ // ────────────────────────────────────────────────────────────────────────
1136
+ // Factory
1137
+ // ────────────────────────────────────────────────────────────────────────
1138
+
1139
+ /** A page freshly spawned into a context: renderer up, CDP session open,
1140
+ * rrweb init script installed, parked on about:blank. */
1141
+ interface SpawnedPage {
1142
+ page: Page;
1143
+ cdp: CDPSession;
1144
+ recordingInstalled: boolean;
1145
+ }
1146
+
1147
+ /** A pre-opened, never-used desktop view (with the context it lives in —
1148
+ * one per view, handed to the holder that claims it). */
1149
+ interface PooledView extends SpawnedPage {
1150
+ context: BrowserContext;
1151
+ }
1152
+
1153
+ /**
1154
+ * The unit a Browser/Mobile handle drives. `page`/`cdp` are deliberately
1155
+ * mutable: the DNS-recovery path (see `navigate` in {@link buildBackend})
1156
+ * replaces a broken restored renderer with a freshly-spawned page in the
1157
+ * SAME context (cookies/localStorage survive), and every wrapper reads
1158
+ * through the holder so the swap is transparent. `device`/`width`/`height`
1159
+ * are kept so a rebuilt page comes back with the same emulation.
1160
+ */
1161
+ interface ViewHolder {
1162
+ /** Context this view lives in — its own, never shared, so `close()` can
1163
+ * take the cookie jar down with the page. */
1164
+ context: BrowserContext;
1165
+ page: Page;
1166
+ cdp: CDPSession;
1167
+ /** Cached page title — playwright's `title()` is async but our public
1168
+ * surface is a sync getter; refreshed after every recorded op. */
1169
+ lastTitle: string;
1170
+ recordingInstalled: boolean;
1171
+ device: DevicePreset | null;
1172
+ width: number;
1173
+ height: number;
1174
+ /** Safe-area insets actually applied to the view (`null` for desktop
1175
+ * views or when the CDP override is unavailable). Stamped onto the
1176
+ * session record so the replay can mirror them. */
1177
+ safeAreaInsets: SafeAreaInsets | null;
1178
+ /** Declared init scripts installed on this session (via
1179
+ * `BrowserOptions.initScript` / a mobile app's `initScript`), kept so the
1180
+ * DNS-recovery rebuild can re-install them on the replacement page. */
1181
+ initScripts: string[];
1182
+ }
1183
+
1184
+ // Pre-opened view pool. Renderer spawn is the expensive part of
1185
+ // `ctx.browser()` — ~1.8s for the first view in a fresh Chrome and
1186
+ // (measured 2026-06-05) a constant ~1.2-1.5s per view in a Chrome that
1187
+ // lived through a snapshot restore, i.e. in every test fork. The daemon
1188
+ // fills this pool once at the end of /bootstrap; the warm-template and
1189
+ // pretest snapshots are captured after that, so EVERY fork inherits a
1190
+ // live renderer and the first `ctx.browser()` of a test skips the spawn
1191
+ // entirely. Lives in daemon memory → forks each get the pristine pool,
1192
+ // and a test consuming it never affects its siblings (same isolation as
1193
+ // fake state).
1194
+ const VIEW_POOL: PooledView[] = [];
1195
+
1196
+ /** Spawn a page (+ CDP session + rrweb init script) into `context` — the
1197
+ * slow part (renderer process spawn). */
1198
+ async function spawnPage(context: BrowserContext): Promise<SpawnedPage> {
1199
+ const page = await context.newPage();
1200
+ const cdp = await context.newCDPSession(page);
1201
+ // Without Page.enable the addScriptToEvaluateOnNewDocument registration
1202
+ // silently never fires on this session (verified in the fork spike).
1203
+ await cdp.send("Page.enable");
1204
+
1205
+ let recordingInstalled = false;
1206
+ if (PAGE_INIT_SCRIPT) {
1207
+ try {
1208
+ await cdp.send("Page.addScriptToEvaluateOnNewDocument", {
1209
+ source: PAGE_INIT_SCRIPT,
1210
+ });
1211
+ recordingInstalled = true;
1212
+ } catch (err) {
1213
+ // Without rrweb the browser still works; just no replay.
1214
+ // eslint-disable-next-line no-console
1215
+ console.warn("[spectest] failed to install rrweb recorder:", err);
1216
+ }
1217
+ }
1218
+ return { page, cdp, recordingInstalled };
1219
+ }
1220
+
1221
+ /** Create a default-desktop view (plus its context) for the pool. */
1222
+ async function createView(width: number, height: number): Promise<PooledView> {
1223
+ const context = await newViewContext(width, height, null);
1224
+ return { context, ...(await spawnPage(context)) };
1225
+ }
1226
+
1227
+ /**
1228
+ * Pre-open `n` views into the pool (called by the daemon at the end of
1229
+ * /bootstrap, before the warm-template snapshot is captured). Only
1230
+ * default-viewport views are pooled — `openBrowser` with a custom
1231
+ * width/height bypasses the pool.
1232
+ */
1233
+ export async function prewarmViewPool(n = 1): Promise<void> {
1234
+ for (let i = 0; i < n; i++) {
1235
+ VIEW_POOL.push(await createView(1280, 720));
1236
+ }
1237
+ }
1238
+
1239
+ /**
1240
+ * Open a browser view (a page in the shared Chromium). Serves from the
1241
+ * pre-opened pool when the caller uses the default viewport.
1242
+ *
1243
+ * This is the EPHEMERAL path — `close()` destroys the view. The daemon's
1244
+ * `ctx.browser()`/`ctx.mobile()` go through {@link acquirePersistentBrowser}
1245
+ * / {@link acquirePersistentMobileBackend} instead, which keep one view
1246
+ * alive across tests so it rides snapshots/forks.
1247
+ */
1248
+ export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
1249
+ return openMobileBackend(opts);
1250
+ }
1251
+
1252
+ /**
1253
+ * Like {@link openBrowser} but returns the {@link MobileBackend} superset
1254
+ * (touch + probe). When `opts.frame === "mobile"` the view is created at the
1255
+ * fixed device viewport, bypasses the (desktop-sized) pool, and has CDP
1256
+ * device emulation applied before the first navigation. Desktop callers go
1257
+ * through {@link openBrowser} and get the narrower {@link Browser} view of
1258
+ * the same object.
1259
+ */
1260
+ export async function openMobileBackend(
1261
+ opts: BrowserOptions = {},
1262
+ ): Promise<MobileBackend> {
1263
+ const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
1264
+ const wantW = device ? device.viewport.width : opts.width ?? 1280;
1265
+ const wantH = device ? device.viewport.height : opts.height ?? 720;
1266
+ // Mobile views are never pooled — the pool holds only default-desktop
1267
+ // views (in the shared desktop context), and a mobile view needs its own
1268
+ // emulated context anyway.
1269
+ const pooled =
1270
+ !device && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
1271
+ let holder: ViewHolder;
1272
+ if (pooled) {
1273
+ holder = {
1274
+ context: pooled.context,
1275
+ page: pooled.page,
1276
+ cdp: pooled.cdp,
1277
+ lastTitle: "",
1278
+ recordingInstalled: pooled.recordingInstalled,
1279
+ device,
1280
+ width: wantW,
1281
+ height: wantH,
1282
+ safeAreaInsets: null,
1283
+ initScripts: [],
1284
+ };
1285
+ } else {
1286
+ holder = await newHolder(wantW, wantH, device);
1287
+ }
1288
+
1289
+ const { backend } = buildBackend(holder, opts.recorder ?? null, {
1290
+ persistent: false,
1291
+ });
1292
+
1293
+ // We deliberately don't forward `opts.url` to the constructor — going
1294
+ // through our own `navigate()` keeps the recorder log uniform (one
1295
+ // event per navigation, with timing) and drains rrweb after the load.
1296
+ if (opts.initScript !== undefined) await installInitScript(holder, opts.initScript);
1297
+ if (opts.url !== undefined) {
1298
+ await backend.goto(opts.url);
1299
+ }
1300
+ return backend;
1301
+ }
1302
+
1303
+ // ────────────────────────────────────────────────────────────────────────
1304
+ // Persistent sessions (the default behind ctx.browser / ctx.mobile)
1305
+ // ────────────────────────────────────────────────────────────────────────
1306
+
1307
+ // One long-lived desktop browser plus one mobile session per app URL.
1308
+ // Module state lives in daemon memory, so it forks with the snapshot the
1309
+ // same way fake `state` and TEST_DATA do: a test's browser — its live
1310
+ // page, cookies, localStorage, in-memory SPA state — is captured in the
1311
+ // post-test snapshot and inherited by `dependsOn` children, while sibling
1312
+ // forks never see each other's sessions. That's what lets a child test
1313
+ // continue where its parent left off (e.g. already signed in) instead of
1314
+ // re-navigating and re-authenticating.
1315
+ let SHARED_BROWSER: ViewHolder | null = null;
1316
+ const SHARED_MOBILE = new Map<string, ViewHolder>();
1317
+
1318
+ async function newHolder(
1319
+ width: number,
1320
+ height: number,
1321
+ device: DevicePreset | null,
1322
+ ): Promise<ViewHolder> {
1323
+ const context = await newViewContext(width, height, device);
1324
+ const spawned = await spawnPage(context);
1325
+ const holder: ViewHolder = {
1326
+ context,
1327
+ page: spawned.page,
1328
+ cdp: spawned.cdp,
1329
+ lastTitle: "",
1330
+ recordingInstalled: spawned.recordingInstalled,
1331
+ device,
1332
+ width,
1333
+ height,
1334
+ safeAreaInsets: null,
1335
+ initScripts: [],
1336
+ };
1337
+ if (device) holder.safeAreaInsets = await applySafeAreaInsets(holder.cdp, device);
1338
+ return holder;
1339
+ }
1340
+
1341
+ /**
1342
+ * Install a declared init script (from `BrowserOptions.initScript` / a mobile
1343
+ * app's `initScript`) on a fresh holder's page: it runs before every document
1344
+ * loaded from now on, ahead of the document's own scripts. Called BEFORE the
1345
+ * session's first navigation, so it wins the race on the first document too.
1346
+ * Recorded via `holder.initScripts` so a DNS-recovery page rebuild
1347
+ * (`rebuildView`) re-installs it. Declared config, not a test action, so it
1348
+ * emits no timeline event.
1349
+ */
1350
+ async function installInitScript(holder: ViewHolder, source: string): Promise<void> {
1351
+ await holder.cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
1352
+ holder.initScripts.push(source);
1353
+ }
1354
+
1355
+ /**
1356
+ * What acquiring a persistent session returns. `detach` is the test-end
1357
+ * hook (final rrweb drain, stop writing to this test's recorder, keep the
1358
+ * view alive); `browser.close()` is the author-facing escape hatch that
1359
+ * actually destroys the view (the next `ctx.browser()` starts fresh).
1360
+ */
1361
+ export interface PersistentBrowser {
1362
+ browser: MobileBackend;
1363
+ /** True when this call attached to a view inherited from an earlier
1364
+ * test (possibly across a snapshot fork) rather than creating one. */
1365
+ attached: boolean;
1366
+ /** Final rrweb drain + detach from the current recorder. The view stays
1367
+ * alive so the post-test snapshot captures it. Idempotent. */
1368
+ detach(): Promise<void>;
1369
+ }
1370
+
1371
+ // Runs in the page when a persistent view is attached to a new test's
1372
+ // recorder: drop whatever rrweb buffered since the previous detach (idle
1373
+ // mutations; the previous test's session already drained everything it
1374
+ // owns) and emit a fresh Meta + FullSnapshot so the new session's replay
1375
+ // is self-contained from its first event.
1376
+ const ATTACH_RESET_EXPR = `(function () {
1377
+ window.__spectestRrwebEvents = [];
1378
+ try {
1379
+ var rec = window.__spectestRec;
1380
+ if (rec && typeof rec.takeFullSnapshot === "function") {
1381
+ rec.takeFullSnapshot();
1382
+ }
1383
+ } catch (e) { /* recording not active on this document */ }
1384
+ return true;
1385
+ })()`;
1386
+
1387
+ async function attachReset(holder: ViewHolder): Promise<void> {
1388
+ if (!holder.recordingInstalled) return;
1389
+ try {
1390
+ await holder.page.evaluate(ATTACH_RESET_EXPR);
1391
+ } catch {
1392
+ // Page mid-navigation or renderer unhappy — the first drain forces a
1393
+ // full snapshot when one is missing (drainExpr), so replay still works.
1394
+ }
1395
+ }
1396
+
1397
+ /**
1398
+ * Acquire THE persistent desktop browser (creating it on first use). There
1399
+ * is deliberately a single one — `ctx.browser()` always returns it — so a
1400
+ * test DAG shares one browsing session along each branch. The first call's
1401
+ * options win; later calls attach to the existing view as-is.
1402
+ */
1403
+ export async function acquirePersistentBrowser(
1404
+ opts: BrowserOptions = {},
1405
+ ): Promise<PersistentBrowser> {
1406
+ const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
1407
+ let attached = true;
1408
+ if (!SHARED_BROWSER) {
1409
+ attached = false;
1410
+ SHARED_BROWSER = await newHolder(
1411
+ device ? device.viewport.width : opts.width ?? 1280,
1412
+ device ? device.viewport.height : opts.height ?? 720,
1413
+ device,
1414
+ );
1415
+ } else {
1416
+ await attachReset(SHARED_BROWSER);
1417
+ }
1418
+ const holder = SHARED_BROWSER;
1419
+ const { backend, detach } = buildBackend(holder, opts.recorder ?? null, {
1420
+ persistent: true,
1421
+ onDestroy: () => {
1422
+ if (SHARED_BROWSER === holder) SHARED_BROWSER = null;
1423
+ },
1424
+ });
1425
+ // Fresh session only: an attached view already carries the init script on
1426
+ // its (forked) holder, and first-call-wins means a later call's options
1427
+ // don't retroactively apply.
1428
+ if (!attached && opts.initScript !== undefined) {
1429
+ await installInitScript(holder, opts.initScript);
1430
+ }
1431
+ if (!attached && opts.url !== undefined) await backend.goto(opts.url);
1432
+ return { browser: backend, attached, detach };
1433
+ }
1434
+
1435
+ /**
1436
+ * Acquire the persistent mobile session for an app URL (one per app). A
1437
+ * fresh session navigates to the app; an attach continues on the live page.
1438
+ */
1439
+ export async function acquirePersistentMobileBackend(
1440
+ url: string,
1441
+ recorder: BrowserSessionRecorder | null,
1442
+ initScript?: string,
1443
+ ): Promise<PersistentBrowser> {
1444
+ const existing = SHARED_MOBILE.get(url);
1445
+ const holder =
1446
+ existing ??
1447
+ (await newHolder(
1448
+ LATEST_IPHONE.viewport.width,
1449
+ LATEST_IPHONE.viewport.height,
1450
+ LATEST_IPHONE,
1451
+ ));
1452
+ if (existing) {
1453
+ await attachReset(holder);
1454
+ } else {
1455
+ SHARED_MOBILE.set(url, holder);
1456
+ }
1457
+ const { backend, detach } = buildBackend(holder, recorder, {
1458
+ persistent: true,
1459
+ onDestroy: () => {
1460
+ if (SHARED_MOBILE.get(url) === holder) SHARED_MOBILE.delete(url);
1461
+ },
1462
+ });
1463
+ if (!existing) {
1464
+ // Fresh session: install the app's init script before the first
1465
+ // navigation so its first document already has the shims.
1466
+ if (initScript !== undefined) await installInitScript(holder, initScript);
1467
+ await backend.goto(url);
1468
+ }
1469
+ return { browser: backend, attached: existing !== undefined, detach };
1470
+ }
1471
+
1472
+ /** True when a navigation failed on Chromium name resolution. */
1473
+ function isNameNotResolved(err: unknown): boolean {
1474
+ return String((err as Error)?.message ?? err).includes("ERR_NAME_NOT_RESOLVED");
1475
+ }
1476
+
1477
+ /**
1478
+ * Whether the daemon's own resolver can look the URL's host up. Chromium
1479
+ * runs with AsyncDns disabled (see CHROME_ARGV) so it uses the same
1480
+ * getaddrinfo path — a host the daemon resolves but Chrome can't means
1481
+ * the RENDERER is broken, not the name.
1482
+ */
1483
+ async function daemonResolves(url: string): Promise<boolean> {
1484
+ try {
1485
+ await dns.lookup(new URL(url).hostname);
1486
+ return true;
1487
+ } catch {
1488
+ return false;
1489
+ }
1490
+ }
1491
+
1492
+ /**
1493
+ * Replace a persistent holder's page with a freshly-spawned one in the SAME
1494
+ * context. Context state — cookies, localStorage — survives; only
1495
+ * renderer-held page state is lost, and this path only runs when that
1496
+ * renderer already can't navigate.
1497
+ *
1498
+ * Known trigger: a renderer created before a snapshot fails its first
1499
+ * post-restore navigation with `net::ERR_NAME_NOT_RESOLVED` even though a
1500
+ * fresh page in the SAME restored Chromium resolves fine (root cause never
1501
+ * found — see the disabled-prewarm note at the end of /bootstrap in
1502
+ * daemon.ts). Persistent sessions walk into exactly that scenario whenever
1503
+ * a child test navigates, so the recovery lives here: rebuild the page,
1504
+ * retry once.
1505
+ */
1506
+ async function rebuildView(holder: ViewHolder): Promise<void> {
1507
+ try {
1508
+ await holder.page.close();
1509
+ } catch {
1510
+ /* page may already be gone */
1511
+ }
1512
+ const fresh = await spawnPage(holder.context);
1513
+ holder.page = fresh.page;
1514
+ holder.cdp = fresh.cdp;
1515
+ holder.recordingInstalled = fresh.recordingInstalled;
1516
+ if (holder.device) {
1517
+ holder.safeAreaInsets = await applySafeAreaInsets(holder.cdp, holder.device);
1518
+ }
1519
+ for (const source of holder.initScripts) {
1520
+ await holder.cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
1521
+ }
1522
+ }
1523
+
1524
+ interface BackendBuildOptions {
1525
+ /** Persistent views get the DNS-recovery navigate and a close() that
1526
+ * clears them out of the shared registry; ephemeral views just close. */
1527
+ persistent: boolean;
1528
+ /** Called when close() destroys the underlying view — the acquire
1529
+ * functions use it to drop the holder from the shared registry. */
1530
+ onDestroy?: () => void;
1531
+ }
1532
+
1533
+ function buildBackend(
1534
+ holder: ViewHolder,
1535
+ recorder: BrowserSessionRecorder | null,
1536
+ buildOpts: BackendBuildOptions,
1537
+ ): { backend: MobileBackend; detach(): Promise<void> } {
1538
+ // All page access goes through `holder.page` — never capture the page in
1539
+ // a local — because the DNS-recovery rebuild swaps it mid-wrapper.
1540
+ // `recordingEnded` stops this wrapper's recorder writes (test end);
1541
+ // `viewClosed` tracks actual destruction (author called close()).
1542
+ let recordingEnded = false;
1543
+ let viewClosed = false;
1544
+ const sessionStart = Date.now();
1545
+ let stepSeq = 0;
1546
+ // URL observed at the previous drain. A change means the main frame
1547
+ // navigated to a new document since we last looked, which is exactly
1548
+ // when rrweb's load-deferred full snapshot is most likely to be
1549
+ // missing from the chunk we're about to drain (see `drainExpr`).
1550
+ let lastDrainUrl: string | null = null;
1551
+
1552
+ async function drain(action: BrowserAction | "close"): Promise<void> {
1553
+ if (!holder.recordingInstalled || !recorder || recordingEnded) return;
1554
+ try {
1555
+ const urlChanged = holder.page.url() !== lastDrainUrl;
1556
+ const events = (await holder.page.evaluate(drainExpr(urlChanged))) as unknown[];
1557
+ lastDrainUrl = holder.page.url();
1558
+ if (Array.isArray(events) && events.length > 0) {
1559
+ recorder.recordStep({
1560
+ stepSeq: stepSeq++,
1561
+ action,
1562
+ tOffsetMs: Date.now() - sessionStart,
1563
+ events,
1564
+ });
1565
+ }
1566
+ } catch {
1567
+ // Page may be mid-navigation, detached, or the view is closing.
1568
+ // Losing an occasional drain is acceptable — the next op picks up
1569
+ // the rest of the buffer.
1570
+ }
1571
+ }
1572
+
1573
+ async function instrumented<T>(
1574
+ action: BrowserAction,
1575
+ fields: Partial<RecordableFields>,
1576
+ fn: () => Promise<T>,
1577
+ opts?: { wrap?: boolean },
1578
+ ): Promise<T> {
1579
+ const t = Date.now();
1580
+ const resv = reserveEvent();
1581
+ try {
1582
+ const result = await fn();
1583
+ // Refresh the sync title cache (playwright's title() is async).
1584
+ try {
1585
+ holder.lastTitle = await holder.page.title();
1586
+ } catch {
1587
+ /* page mid-navigation or closed — keep the stale cache */
1588
+ }
1589
+ // `sessionTimestamp` is the post-op wall clock — that's where we
1590
+ // want the dashboard's seek to land, so clicking "type 'foo'"
1591
+ // shows the input *with* the text, not the empty field just
1592
+ // before the keystrokes. The dashboard converts to a player
1593
+ // offset by subtracting `events[0].timestamp`.
1594
+ const endT = Date.now();
1595
+ const seq = recordBrowser({
1596
+ action,
1597
+ ...fields,
1598
+ ...(recorder
1599
+ ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
1600
+ : {}),
1601
+ durationMs: endT - t,
1602
+ }, resv);
1603
+ await drain(action);
1604
+ // Reads (`opts.wrap`) return user-visible JS values someone is likely to
1605
+ // assert on — provenance-wrap so `expect(...)` nests under this step.
1606
+ // Actions return void/internals; leave them raw to avoid Proxy surprises.
1607
+ if (seq !== undefined && opts?.wrap) {
1608
+ return wrap(result, seq) as T;
1609
+ }
1610
+ return result;
1611
+ } catch (err) {
1612
+ const e = err as Error;
1613
+ const endT = Date.now();
1614
+ recordBrowser({
1615
+ action,
1616
+ ...fields,
1617
+ ...(recorder
1618
+ ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
1619
+ : {}),
1620
+ durationMs: endT - t,
1621
+ error: e?.message ?? String(err),
1622
+ }, resv);
1623
+ // Still try to drain — the failure itself may have produced
1624
+ // useful rrweb events (mutations from a half-loaded page, etc.).
1625
+ await drain(action);
1626
+ throw err;
1627
+ }
1628
+ }
1629
+
1630
+ async function endRecording(): Promise<void> {
1631
+ if (recordingEnded) return;
1632
+ // Final drain before we stop writing to this recorder.
1633
+ await drain("close");
1634
+ recordingEnded = true;
1635
+ }
1636
+
1637
+ const strategy: ActionStrategy = holder.device ? mobileStrategy : desktopStrategy;
1638
+
1639
+ const keyboard: Keyboard = {
1640
+ press(key) {
1641
+ return instrumented("press", { key }, () => holder.page.keyboard.press(key));
1642
+ },
1643
+ type(text) {
1644
+ const t = truncateUtf8(text);
1645
+ return instrumented("type", { text: t.value, textTruncated: t.truncated }, () =>
1646
+ holder.page.keyboard.type(text),
1647
+ );
1648
+ },
1649
+ insertText(text) {
1650
+ // insertText path (no per-char keydown) — the paste path.
1651
+ const t = truncateUtf8(text);
1652
+ return instrumented("type", { text: t.value, textTruncated: t.truncated }, () =>
1653
+ holder.page.keyboard.insertText(text),
1654
+ );
1655
+ },
1656
+ };
1657
+
1658
+ const mouse: Mouse = {
1659
+ click(x, y, opts) {
1660
+ return instrumented("click", { x, y }, () => holder.page.mouse.click(x, y, opts));
1661
+ },
1662
+ dblclick(x, y) {
1663
+ return instrumented("dblclick", { x, y }, () => holder.page.mouse.dblclick(x, y));
1664
+ },
1665
+ move(x, y) {
1666
+ return instrumented("mouse.move", { x, y }, () => holder.page.mouse.move(x, y));
1667
+ },
1668
+ wheel(dx, dy) {
1669
+ return instrumented("scroll", { dx, dy }, () => holder.page.mouse.wheel(dx, dy));
1670
+ },
1671
+ };
1672
+
1673
+ const touchscreen: Touchscreen = {
1674
+ tap(x, y, opts) {
1675
+ // Recorded as "tap"; the CDP touch (with dwell) fires RN-Web responders.
1676
+ return instrumented("tap", { x, y }, () => backend.rawTap(x, y, opts?.duration));
1677
+ },
1678
+ };
1679
+
1680
+ const backend: MobileBackend = {
1681
+ url() {
1682
+ return holder.page.url();
1683
+ },
1684
+ async title() {
1685
+ try {
1686
+ holder.lastTitle = await holder.page.title();
1687
+ } catch {
1688
+ /* page mid-navigation or closed — return the last cached title */
1689
+ }
1690
+ return holder.lastTitle;
1691
+ },
1692
+ get safeAreaInsets() {
1693
+ return holder.safeAreaInsets;
1694
+ },
1695
+ keyboard,
1696
+ mouse,
1697
+ touchscreen,
1698
+ goto(url) {
1699
+ recorder?.noteNavigation?.(url);
1700
+ return instrumented("goto", { url }, async () => {
1701
+ try {
1702
+ await holder.page.goto(url, { waitUntil: "load" });
1703
+ } catch (err) {
1704
+ // Restored-renderer DNS bug (see `rebuildView`): only when the
1705
+ // view is persistent (so it may have lived through a snapshot
1706
+ // restore) and the daemon itself CAN resolve the host — a name
1707
+ // that's genuinely unknown must fail without discarding the live
1708
+ // page state a rebuild would cost.
1709
+ if (!buildOpts.persistent || !isNameNotResolved(err)) throw err;
1710
+ if (!(await daemonResolves(url))) throw err;
1711
+ await rebuildView(holder);
1712
+ await holder.page.goto(url, { waitUntil: "load" });
1713
+ }
1714
+ });
1715
+ },
1716
+ goBack() {
1717
+ return instrumented("goBack", {}, async () => {
1718
+ await holder.page.goBack();
1719
+ });
1720
+ },
1721
+ goForward() {
1722
+ return instrumented("goForward", {}, async () => {
1723
+ await holder.page.goForward();
1724
+ });
1725
+ },
1726
+ reload() {
1727
+ return instrumented("reload", {}, async () => {
1728
+ await holder.page.reload();
1729
+ });
1730
+ },
1731
+
1732
+ locator: (css) => makeLocator(backend, strategy, { steps: [{ m: "locator", args: [css] }] }),
1733
+ getByRole: (role, opts) =>
1734
+ makeLocator(backend, strategy, { steps: [{ m: "getByRole", args: [role, opts] }] }),
1735
+ getByText: (text, opts) =>
1736
+ makeLocator(backend, strategy, { steps: [{ m: "getByText", args: [text, opts] }] }),
1737
+ getByLabel: (text, opts) =>
1738
+ makeLocator(backend, strategy, { steps: [{ m: "getByLabel", args: [text, opts] }] }),
1739
+ getByPlaceholder: (text, opts) =>
1740
+ makeLocator(backend, strategy, { steps: [{ m: "getByPlaceholder", args: [text, opts] }] }),
1741
+ getByAltText: (text, opts) =>
1742
+ makeLocator(backend, strategy, { steps: [{ m: "getByAltText", args: [text, opts] }] }),
1743
+ getByTitle: (text, opts) =>
1744
+ makeLocator(backend, strategy, { steps: [{ m: "getByTitle", args: [text, opts] }] }),
1745
+ getByTestId: (id) =>
1746
+ makeLocator(backend, strategy, { steps: [{ m: "getByTestId", args: [id] }] }),
1747
+
1748
+ async evaluate<T = unknown>(
1749
+ description: string,
1750
+ fn: string | ((arg?: unknown) => T | Promise<T>),
1751
+ arg?: unknown,
1752
+ ): Promise<Wrapped<T>> {
1753
+ const src = typeof fn === "string" ? fn : fn.toString();
1754
+ const t = truncateUtf8(src);
1755
+ return instrumented<T>(
1756
+ "evaluate",
1757
+ { description, script: t.value, scriptTruncated: t.truncated },
1758
+ async () => {
1759
+ if (typeof fn === "string") {
1760
+ return (await holder.page.evaluate(toEvaluable(fn))) as T;
1761
+ }
1762
+ return (await holder.page.evaluate(fn as never, arg)) as T;
1763
+ },
1764
+ { wrap: true },
1765
+ ) as Promise<Wrapped<T>>;
1766
+ },
1767
+ async waitForURL(
1768
+ pattern: UrlPattern,
1769
+ options: {
1770
+ timeout?: number;
1771
+ waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
1772
+ } = {},
1773
+ ): Promise<Wrapped<string>> {
1774
+ const timeoutMs = options.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
1775
+ const label = describeUrlPattern(pattern);
1776
+ // `url` is stamped with the MATCHED url once we have it (the timeline
1777
+ // wants where we landed, not the pattern); `description` carries the
1778
+ // pattern so a timed-out step still says what it was waiting for.
1779
+ const fields: Partial<RecordableFields> = { description: label };
1780
+ return instrumented<string>(
1781
+ "waitForURL",
1782
+ fields,
1783
+ async () => {
1784
+ try {
1785
+ // Always a predicate, never playwright's own glob: `matchesUrl`
1786
+ // is then the single matching implementation shared with
1787
+ // `expect(browser).toHaveURL`.
1788
+ await holder.page.waitForURL((u: URL) => matchesUrl(u.href, pattern), {
1789
+ timeout: timeoutMs,
1790
+ waitUntil: options.waitUntil ?? "commit",
1791
+ });
1792
+ } catch (err) {
1793
+ // Playwright's timeout message is a wall of call-log; ours names
1794
+ // the pattern and where the page actually sat.
1795
+ if ((err as Error)?.name !== "TimeoutError") throw err;
1796
+ throw new Error(
1797
+ `waitForURL ${label} timed out after ${timeoutMs}ms (current URL: ${holder.page.url()})`,
1798
+ );
1799
+ }
1800
+ const matched = holder.page.url();
1801
+ fields.url = matched;
1802
+ return matched;
1803
+ },
1804
+ { wrap: true },
1805
+ // `wrap: true` hands back a Carrier<string>, which the concrete
1806
+ // (non-generic) return type can't be narrowed to directly.
1807
+ ) as unknown as Promise<Wrapped<string>>;
1808
+ },
1809
+ async waitForFunction<T = unknown>(
1810
+ description: string,
1811
+ fn: string | ((arg?: unknown) => T),
1812
+ arg?: unknown,
1813
+ options: { timeout?: number; polling?: number } = {},
1814
+ ): Promise<Wrapped<T>> {
1815
+ const timeoutMs = options.timeout ?? 5_000;
1816
+ const intervalMs = options.polling ?? 100;
1817
+ const src = typeof fn === "string" ? fn : fn.toString();
1818
+ const t = truncateUtf8(src);
1819
+ // Pass `fields` by reference so the loop can stamp the final
1820
+ // attempt count onto the event before instrumented records it.
1821
+ const fields: Partial<RecordableFields> = {
1822
+ description,
1823
+ script: t.value,
1824
+ scriptTruncated: t.truncated,
1825
+ attempts: 0,
1826
+ };
1827
+ // Normalised once up front — string bodies go through `toEvaluable`
1828
+ // (statement bodies work); functions run with `arg`.
1829
+ const evaluable = typeof fn === "string" ? toEvaluable(fn) : null;
1830
+ const evalOnce = (): Promise<unknown> =>
1831
+ evaluable !== null
1832
+ ? holder.page.evaluate(evaluable)
1833
+ : holder.page.evaluate(fn as never, arg);
1834
+ return instrumented<T>(
1835
+ "waitForFunction",
1836
+ fields,
1837
+ async () => {
1838
+ const deadline = Date.now() + timeoutMs;
1839
+ // The polling loop calls `page.evaluate` directly (not the wrapped
1840
+ // `evaluate`) so it doesn't fan out into N events or N rrweb drains.
1841
+ // rrweb keeps buffering page-side; the wrapper's single drain at the
1842
+ // end collects everything.
1843
+ for (;;) {
1844
+ fields.attempts = (fields.attempts ?? 0) + 1;
1845
+ let v: unknown;
1846
+ try {
1847
+ v = await evalOnce();
1848
+ } catch (err) {
1849
+ if (Date.now() >= deadline) throw err;
1850
+ await new Promise((r) => setTimeout(r, intervalMs));
1851
+ continue;
1852
+ }
1853
+ if (v) return v as T;
1854
+ if (Date.now() >= deadline) {
1855
+ throw new Error(
1856
+ `waitForFunction ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${fields.attempts} attempts)`,
1857
+ );
1858
+ }
1859
+ await new Promise((r) => setTimeout(r, intervalMs));
1860
+ }
1861
+ },
1862
+ { wrap: true },
1863
+ ) as Promise<Wrapped<T>>;
1864
+ },
1865
+ async screenshot() {
1866
+ const fields: Partial<RecordableFields> = { format: "png" };
1867
+ return instrumented("screenshot", fields, async () => {
1868
+ // Artifacts are eval-only for now: the daemon wires a
1869
+ // registerArtifact sink onto eval sessions' recorders and nothing
1870
+ // else's, so its absence means "test run / recorder-less browser".
1871
+ const register = recorder?.registerArtifact;
1872
+ if (!register) {
1873
+ throw new Error(
1874
+ "screenshot() uploads the capture as a downloadable artifact and is " +
1875
+ "currently only available inside an eval (`spectest env eval`) — " +
1876
+ "it cannot be used in test runs.",
1877
+ );
1878
+ }
1879
+ // Raw CDP rather than page.screenshot(): captures the viewport
1880
+ // exactly like the pre-Playwright backend.
1881
+ const res = (await holder.cdp.send("Page.captureScreenshot", {
1882
+ format: "png",
1883
+ } as never)) as { data: string };
1884
+ const id = generateId("art");
1885
+ register({
1886
+ id,
1887
+ kind: "screenshot",
1888
+ contentType: "image/png",
1889
+ // CDP already hands us base64 — pass it through un-recoded and
1890
+ // derive the raw byte count from the encoding.
1891
+ sizeBytes: base64ByteLength(res.data),
1892
+ bytesBase64: res.data,
1893
+ });
1894
+ // Stamped onto the browser event before instrumented() records it —
1895
+ // same mutate-fields-inside-fn() precedent as waitFor's `attempts`.
1896
+ fields.artifactId = id;
1897
+ return id;
1898
+ });
1899
+ },
1900
+ async close() {
1901
+ // Ends this wrapper's recording AND destroys the view. For a
1902
+ // persistent view this is the author-facing escape hatch to a fresh
1903
+ // browser: `onDestroy` drops the holder from the shared registry, so
1904
+ // the next `ctx.browser()`/`ctx.mobile()` creates a new one. The
1905
+ // routine test-end path is `detach` (recording stops, view lives on
1906
+ // into the post-test snapshot).
1907
+ await endRecording();
1908
+ if (viewClosed) return;
1909
+ viewClosed = true;
1910
+ buildOpts.onDestroy?.();
1911
+ try {
1912
+ await holder.page.close();
1913
+ } catch {
1914
+ /* already closed by the runtime */
1915
+ }
1916
+ // The context, not the page, holds the cookies and storage — closing
1917
+ // only the page would leave the next session signed in as this one.
1918
+ try {
1919
+ await holder.context.close();
1920
+ } catch {
1921
+ /* context already gone (browser died) */
1922
+ }
1923
+ },
1924
+ // ── Mobile-only primitives ──────────────────────────────────────────
1925
+ async rawTap(x, y, durationMs) {
1926
+ // Dispatches a real touch so RN-Web's responder system fires.
1927
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1928
+ type: "touchStart",
1929
+ touchPoints: [{ x, y, id: 0 }],
1930
+ });
1931
+ // Dwell between start and end, like a real finger. An instant
1932
+ // touchStart→touchEnd starves RN Pressables whose `onPressIn`
1933
+ // mutates state (optimistic label flips, scale animations): React
1934
+ // re-renders mid-gesture and the press never completes. The dwell
1935
+ // lets that commit land before release; well under any long-press
1936
+ // threshold (RN default 500ms).
1937
+ await new Promise((r) => setTimeout(r, durationMs ?? TAP_DWELL_MS));
1938
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1939
+ type: "touchEnd",
1940
+ touchPoints: [],
1941
+ });
1942
+ },
1943
+ async swipe(direction, opts) {
1944
+ const vp = await backend.probe<{ w: number; h: number }>(
1945
+ "({ w: window.innerWidth, h: window.innerHeight })",
1946
+ );
1947
+ const cx = vp.w / 2;
1948
+ const cy = vp.h / 2;
1949
+ const horiz = direction === "left" || direction === "right";
1950
+ const dist = opts?.distance ?? Math.round((horiz ? vp.w : vp.h) * 0.5);
1951
+ const dx = direction === "left" ? -dist : direction === "right" ? dist : 0;
1952
+ const dy = direction === "up" ? -dist : direction === "down" ? dist : 0;
1953
+ await backend.swipeBy(cx, cy, dx, dy);
1954
+ },
1955
+ swipeBy(x, y, dx, dy) {
1956
+ return instrumented("scroll", { dx, dy }, async () => {
1957
+ const steps = 8;
1958
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1959
+ type: "touchStart",
1960
+ touchPoints: [{ x, y, id: 0 }],
1961
+ });
1962
+ for (let i = 1; i <= steps; i++) {
1963
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1964
+ type: "touchMove",
1965
+ touchPoints: [{ x: x + (dx * i) / steps, y: y + (dy * i) / steps, id: 0 }],
1966
+ });
1967
+ }
1968
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1969
+ type: "touchEnd",
1970
+ touchPoints: [],
1971
+ });
1972
+ });
1973
+ },
1974
+ probe<T = unknown>(expression: string): Promise<T> {
1975
+ return holder.page.evaluate(expression) as Promise<T>;
1976
+ },
1977
+ silentRead<T>(fn: (page: Page) => Promise<T>): Promise<T> {
1978
+ return fn(holder.page);
1979
+ },
1980
+ async recordSettled(action, fields, waitedMs, error) {
1981
+ // No page work — the matcher already read the value via silentRead. We
1982
+ // only mint the timeline anchor: the seq the assertion nests under, plus
1983
+ // `sessionTimestamp` (post-settle wall clock) so the dashboard seeks the
1984
+ // replay to the frame the assertion observed. The offset is backdated to
1985
+ // where the matcher started polling, since that's what `tOffsetMs` means
1986
+ // everywhere else (the ops that reserve up front stamp their start).
1987
+ const endT = Date.now();
1988
+ const seq = recordBrowser({
1989
+ action,
1990
+ ...fields,
1991
+ ...(recorder
1992
+ ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
1993
+ : {}),
1994
+ durationMs: waitedMs,
1995
+ ...(error ? { error } : {}),
1996
+ }, reserveBackdated(waitedMs));
1997
+ // Drain the rrweb the page buffered while the matcher waited into this
1998
+ // step's chunk, so `settledTarget` has bounds to seek into.
1999
+ await drain(action);
2000
+ return seq;
2001
+ },
2002
+ pageOp<T>(
2003
+ action: BrowserAction,
2004
+ fields: Partial<RecordableFields>,
2005
+ fn: (page: Page) => Promise<T>,
2006
+ opts?: { wrap?: boolean },
2007
+ ): Promise<T> {
2008
+ return instrumented(action, fields, () => fn(holder.page), opts);
2009
+ },
2010
+ };
2011
+ // The seam `expect(browser).toHaveURL(...)` polls: a silent URL read plus
2012
+ // the single settled step its assertion nests under — the session twin of
2013
+ // the locator probe, and what brands this object as a browser session for
2014
+ // `expect`'s overload dispatch.
2015
+ attachBrowserProbe(backend, {
2016
+ url: () => holder.page.url(),
2017
+ settle: (action, waitedMs, error) =>
2018
+ backend.recordSettled(action, { url: holder.page.url() }, waitedMs, error),
2019
+ });
2020
+ return { backend, detach: endRecording };
2021
+ }
2022
+
2023
+ export interface RecordableFields {
2024
+ url: string;
2025
+ selector: string;
2026
+ description: string;
2027
+ script: string;
2028
+ scriptTruncated: boolean;
2029
+ text: string;
2030
+ textTruncated: boolean;
2031
+ key: string;
2032
+ dx: number;
2033
+ dy: number;
2034
+ x: number;
2035
+ y: number;
2036
+ format: string;
2037
+ attempts: number;
2038
+ artifactId: string;
2039
+ attribute: string;
2040
+ }