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