@specific.dev/spectest 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/browser.ts CHANGED
@@ -37,6 +37,18 @@ import { generateId } from "./ids.js";
37
37
  import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
38
38
  import { wrap } from "./inspect.js";
39
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";
40
52
 
41
53
  import { chromium } from "playwright-core";
42
54
  import type {
@@ -66,6 +78,16 @@ export interface BrowserOptions {
66
78
  frame?: "browser" | "mobile";
67
79
  /** Initial URL to navigate to before the constructor returns. */
68
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;
69
91
  /**
70
92
  * Sink that receives rrweb event chunks. Each Browser op (navigate,
71
93
  * click, …) calls `recordStep` with the events that landed in
@@ -112,7 +134,7 @@ export interface BrowserSessionRecorder {
112
134
  readonly sessionId: string;
113
135
  /** Called for each drained chunk of rrweb events. */
114
136
  recordStep(step: BrowserSessionStep): void;
115
- /** Optional: called whenever `Browser.navigate(url)` is invoked. */
137
+ /** Optional: called whenever `Browser.goto(url)` is invoked. */
116
138
  noteNavigation?(url: string): void;
117
139
  /**
118
140
  * Optional: register a captured artifact (screenshot bytes) for upload.
@@ -130,126 +152,128 @@ export interface BrowserSessionRecorder {
130
152
  }): void;
131
153
  }
132
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
+
133
176
  /**
134
- * Headless browser handle. Drive operations sequentially per view — the
135
- * recorder assumes op-at-a-time semantics (each op's rrweb drain is
136
- * attributed to it). For parallel browsing open multiple views.
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.
137
182
  */
138
183
  export interface Browser {
139
- /** Last-navigated URL (updated on navigate completion). */
140
- readonly url: string;
141
- /** Current page `<title>`. */
142
- readonly title: string;
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>;
143
188
  /** Navigate to a URL; resolves when the main frame's load completes. */
144
- navigate(url: string): Promise<void>;
145
- /**
146
- * Evaluate JS in the page and return the JSON-deserialised result.
147
- * Accepts a single expression or a statement body (`const x = …;
148
- * return x;`) — statement bodies are auto-wrapped in an async IIFE, so
149
- * `return` and `await` work without manual wrapping.
150
- *
151
- * `description` is a short human-readable label for what the
152
- * snippet is doing ("read rendered todo list"); it surfaces in the
153
- * test event log so the step list isn't a wall of minified code.
154
- */
155
- evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
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
+
156
210
  /**
157
- * Install a script that runs in every document loaded from now on, BEFORE
158
- * any of the document's own scripts execute (CDP
159
- * `Page.addScriptToEvaluateOnNewDocument` — the Playwright `addInitScript`
160
- * equivalent). The deterministic way to plant shims and instrumentation
161
- * (`fetch`/`XMLHttpRequest` wrappers, clock stubs, feature flags): unlike an
162
- * `evaluate` racing the app bundle after a navigation, an init script is
163
- * guaranteed to win.
164
- *
165
- * Does NOT run in the *current* document — call it before the `navigate`
166
- * (or in-page `location.assign`) whose document needs it. Installed
167
- * scripts persist for the browser session's lifetime, which for the
168
- * persistent `ctx.browser()`/`ctx.mobile()` sessions means they ride
169
- * snapshots into `dependsOn` children like the rest of the session state.
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).
170
215
  *
171
- * `description` labels the step in the test event log.
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`).
172
219
  */
173
- addInitScript(description: string, source: string): Promise<void>;
220
+ evaluate<T = unknown>(
221
+ description: string,
222
+ fn: string | ((arg?: unknown) => T | Promise<T>),
223
+ arg?: unknown,
224
+ ): Promise<Wrapped<T>>;
174
225
  /**
175
- * Poll `expression` in the page until it returns a truthy value
176
- * (the returned value is what `waitFor` resolves with). Useful for
177
- * UI assertions that need to wait for an async render — instead of
178
- * a manual `while (Date.now() < deadline) await evaluate(...)` loop
179
- * which clutters the test log with one event per poll, this records
180
- * a single `waitFor` event with the total wait time and how many
181
- * attempts it took.
182
- *
183
- * Express the predicate as "return the data if ready, else falsy":
184
- *
185
- * ```ts
186
- * const items = await browser.waitFor<string[]>(
187
- * "todo appears",
188
- * "(() => { const xs = [...document.querySelectorAll('li')].map(l => l.textContent); return xs.includes('hi') ? xs : null; })()",
189
- * );
190
- * ```
191
- *
192
- * Defaults: 5 s total timeout, 100 ms between polls.
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.
193
230
  */
194
- waitFor<T = unknown>(
231
+ waitForFunction<T = unknown>(
195
232
  description: string,
196
- expression: string,
197
- options?: { timeoutMs?: number; intervalMs?: number },
233
+ fn: string | ((arg?: unknown) => T),
234
+ arg?: unknown,
235
+ options?: { timeout?: number; polling?: number },
198
236
  ): Promise<Wrapped<T>>;
199
- /** Wait for `selector` to be actionable and click its center. */
200
- click(selector: string): Promise<void>;
201
- /** Click at the given viewport coordinates. */
202
- clickAt(x: number, y: number): Promise<void>;
203
- /** Insert text into the focused element (no `keydown` — same path as paste). */
204
- type(text: string): Promise<void>;
205
- /** Press a named key (`"Enter"`, `"Tab"`, …) or single character. */
206
- press(key: string): Promise<void>;
207
- /** Scroll the viewport by the given pixel delta. */
208
- scroll(dx: number, dy: number): Promise<void>;
209
- /** Wait for `selector` to exist and scroll it into view. */
210
- scrollTo(selector: string): Promise<void>;
211
- /** Navigate back in session history. */
212
- back(): Promise<void>;
213
- /** Navigate forward in session history. */
214
- forward(): Promise<void>;
215
- /** Reload the current page. */
216
- reload(): Promise<void>;
217
237
  /**
218
- * Capture a PNG screenshot of the viewport and upload it as a
219
- * downloadable **artifact**. Resolves to the artifact's `art_…` id —
220
- * fetch the image locally with `spectest artifact download <id>`.
221
- * Only available inside an eval (`spectest_eval` / `spectest env eval`);
222
- * throws with a clear message during test runs.
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.
223
242
  */
224
243
  screenshot(): Promise<string>;
225
244
  /**
226
- * Destroy the underlying view. Idempotent; drains any pending rrweb
227
- * events first. For the persistent session behind `ctx.browser()` /
228
- * `ctx.mobile()` this is the escape hatch to a FRESH browser — the
229
- * shared instance is discarded and the next call creates a new one.
230
- * Don't call it for routine cleanup: the daemon detaches recording at
231
- * test end automatically and deliberately keeps the browser alive so
232
- * dependent tests inherit its state.
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.
233
251
  */
234
252
  close(): Promise<void>;
235
253
  }
236
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
+
237
262
  /**
238
- * A {@link Browser} with the lower-level touch primitives the mobile
239
- * (`ctx.mobile`) facade is built on. Not exposed to test authors directly —
240
- * `sdk/src/mobile.ts` wraps it in the ergonomic locator/gesture API. The
241
- * touch ops dispatch real `Input.dispatchTouchEvent` sequences (so RN-Web's
242
- * responder system sees genuine touches) and ride the same recorder + rrweb
243
- * drain as the desktop verbs.
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.
244
268
  */
245
269
  export interface MobileBackend extends Browser {
246
- /** Safe-area insets emulated on this view (`null` on desktop views or
247
- * when the CDP override is unavailable). The daemon stamps these onto
248
- * the session record for the dashboard's replay. */
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. */
249
272
  readonly safeAreaInsets: SafeAreaInsets | null;
250
- /** Touch-tap at viewport CSS coordinates (touchStart → short dwell →
251
- * touchEnd; `durationMs` overrides the dwell). */
252
- tapAt(x: number, y: number, opts?: { durationMs?: number }): Promise<void>;
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>;
253
277
  /** Touch-drag from (x,y) by (dx,dy) over a short move sequence. */
254
278
  swipeBy(x: number, y: number, dx: number, dy: number): Promise<void>;
255
279
  /**
@@ -258,24 +282,47 @@ export interface MobileBackend extends Browser {
258
282
  * drains it.
259
283
  */
260
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>;
261
291
  /**
262
292
  * Run `fn` against the live playwright {@link Page}, recorded as a single
263
- * `action` event (with the usual rrweb drain). The locator layer's hook:
264
- * one author-facing locator action = one recorded event, however many
265
- * playwright calls it composes. `evaluate`/`waitFor` actions get their
266
- * return value provenance-wrapped like the first-class verbs.
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.
267
297
  */
268
298
  pageOp<T>(
269
299
  action: BrowserAction,
270
300
  fields: Partial<RecordableFields>,
271
301
  fn: (page: Page) => Promise<T>,
302
+ opts?: { wrap?: boolean },
272
303
  ): Promise<T>;
273
304
  /**
274
305
  * Unrecorded CDP touch tap (touchStart → dwell → touchEnd). The locator
275
306
  * layer composes it inside a {@link pageOp} so a locator `tap()` stays a
276
- * single recorded event; `tapAt` is the recorded public twin.
307
+ * single recorded event; `touchscreen.tap` is the recorded public twin.
277
308
  */
278
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>;
279
326
  }
280
327
 
281
328
  // Default extra flags for headless Chromium inside a Firecracker microVM.
@@ -300,14 +347,12 @@ const CHROME_ARGV = [
300
347
  "--dns-over-https-mode=off",
301
348
  ];
302
349
 
303
- /** Default touchStart→touchEnd dwell for `tapAt` — see the comment there. */
350
+ /** Default touchStart→touchEnd dwell for touch taps — see `rawTap`. */
304
351
  const TAP_DWELL_MS = 60;
305
352
 
306
- /** Default deadline for element-targeting ops (click/scrollTo and the mobile
307
- * locator actions). Playwright's own default is 30 s — far too slow-failing
308
- * for tests; 5 s matches the pre-Playwright behavior. Navigations keep a
309
- * longer 30 s deadline (cold app servers). */
310
- const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
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. */
311
356
  const NAVIGATION_TIMEOUT_MS = 30_000;
312
357
 
313
358
  // ────────────────────────────────────────────────────────────────────────
@@ -926,8 +971,9 @@ interface ViewHolder {
926
971
  * views or when the CDP override is unavailable). Stamped onto the
927
972
  * session record so the replay can mirror them. */
928
973
  safeAreaInsets: SafeAreaInsets | null;
929
- /** User scripts installed via `addInitScript`, kept so the DNS-recovery
930
- * rebuild can re-install them on the replacement page. */
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. */
931
977
  initScripts: string[];
932
978
  }
933
979
 
@@ -1067,8 +1113,9 @@ export async function openMobileBackend(
1067
1113
  // We deliberately don't forward `opts.url` to the constructor — going
1068
1114
  // through our own `navigate()` keeps the recorder log uniform (one
1069
1115
  // event per navigation, with timing) and drains rrweb after the load.
1116
+ if (opts.initScript !== undefined) await installInitScript(holder, opts.initScript);
1070
1117
  if (opts.url !== undefined) {
1071
- await backend.navigate(opts.url);
1118
+ await backend.goto(opts.url);
1072
1119
  }
1073
1120
  return backend;
1074
1121
  }
@@ -1112,6 +1159,20 @@ async function newHolder(
1112
1159
  return holder;
1113
1160
  }
1114
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
+
1115
1176
  /**
1116
1177
  * What acquiring a persistent session returns. `detach` is the test-end
1117
1178
  * hook (final rrweb drain, stop writing to this test's recorder, keep the
@@ -1182,7 +1243,13 @@ export async function acquirePersistentBrowser(
1182
1243
  if (SHARED_BROWSER === holder) SHARED_BROWSER = null;
1183
1244
  },
1184
1245
  });
1185
- if (!attached && opts.url !== undefined) await backend.navigate(opts.url);
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);
1186
1253
  return { browser: backend, attached, detach };
1187
1254
  }
1188
1255
 
@@ -1193,6 +1260,7 @@ export async function acquirePersistentBrowser(
1193
1260
  export async function acquirePersistentMobileBackend(
1194
1261
  url: string,
1195
1262
  recorder: BrowserSessionRecorder | null,
1263
+ initScript?: string,
1196
1264
  ): Promise<PersistentBrowser> {
1197
1265
  const existing = SHARED_MOBILE.get(url);
1198
1266
  const holder =
@@ -1213,7 +1281,12 @@ export async function acquirePersistentMobileBackend(
1213
1281
  if (SHARED_MOBILE.get(url) === holder) SHARED_MOBILE.delete(url);
1214
1282
  },
1215
1283
  });
1216
- if (!existing) await backend.navigate(url);
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
+ }
1217
1290
  return { browser: backend, attached: existing !== undefined, detach };
1218
1291
  }
1219
1292
 
@@ -1322,6 +1395,7 @@ function buildBackend(
1322
1395
  action: BrowserAction,
1323
1396
  fields: Partial<RecordableFields>,
1324
1397
  fn: () => Promise<T>,
1398
+ opts?: { wrap?: boolean },
1325
1399
  ): Promise<T> {
1326
1400
  const t = Date.now();
1327
1401
  const resv = reserveEvent();
@@ -1348,10 +1422,10 @@ function buildBackend(
1348
1422
  durationMs: endT - t,
1349
1423
  }, resv);
1350
1424
  await drain(action);
1351
- // Only `evaluate` and `waitFor` return user-visible JS values
1352
- // that someone is likely to assert on. Other actions return void
1353
- // or browser internals; leave them raw to avoid Proxy surprises.
1354
- if (seq !== undefined && (action === "evaluate" || action === "waitFor")) {
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) {
1355
1429
  return wrap(result, seq) as T;
1356
1430
  }
1357
1431
  return result;
@@ -1381,19 +1455,70 @@ function buildBackend(
1381
1455
  recordingEnded = true;
1382
1456
  }
1383
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
+
1384
1501
  const backend: MobileBackend = {
1385
- get url() {
1502
+ url() {
1386
1503
  return holder.page.url();
1387
1504
  },
1388
- get title() {
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
+ }
1389
1511
  return holder.lastTitle;
1390
1512
  },
1391
1513
  get safeAreaInsets() {
1392
1514
  return holder.safeAreaInsets;
1393
1515
  },
1394
- navigate(url) {
1516
+ keyboard,
1517
+ mouse,
1518
+ touchscreen,
1519
+ goto(url) {
1395
1520
  recorder?.noteNavigation?.(url);
1396
- return instrumented("navigate", { url }, async () => {
1521
+ return instrumented("goto", { url }, async () => {
1397
1522
  try {
1398
1523
  await holder.page.goto(url, { waitUntil: "load" });
1399
1524
  } catch (err) {
@@ -1409,137 +1534,112 @@ function buildBackend(
1409
1534
  }
1410
1535
  });
1411
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
+
1412
1569
  async evaluate<T = unknown>(
1413
1570
  description: string,
1414
- script: string,
1571
+ fn: string | ((arg?: unknown) => T | Promise<T>),
1572
+ arg?: unknown,
1415
1573
  ): Promise<Wrapped<T>> {
1416
- const truncatedScript = truncateUtf8(script);
1417
- // `instrumented` wraps the result for evaluate/waitFor (see line ~622),
1418
- // so the value is a `Wrapped<T>` at runtime; the cast matches the type.
1574
+ const src = typeof fn === "string" ? fn : fn.toString();
1575
+ const t = truncateUtf8(src);
1419
1576
  return instrumented<T>(
1420
1577
  "evaluate",
1421
- {
1422
- description,
1423
- script: truncatedScript.value,
1424
- scriptTruncated: truncatedScript.truncated,
1425
- },
1578
+ { description, script: t.value, scriptTruncated: t.truncated },
1426
1579
  async () => {
1427
- const v = (await holder.page.evaluate(toEvaluable(script))) as T;
1428
- return v;
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;
1429
1584
  },
1585
+ { wrap: true },
1430
1586
  ) as Promise<Wrapped<T>>;
1431
1587
  },
1432
- addInitScript(description: string, source: string): Promise<void> {
1433
- const truncated = truncateUtf8(source);
1434
- return instrumented(
1435
- "addInitScript",
1436
- {
1437
- description,
1438
- script: truncated.value,
1439
- scriptTruncated: truncated.truncated,
1440
- },
1441
- async () => {
1442
- // Raw CDP (not context.addInitScript) so the script stays scoped
1443
- // to THIS page — the desktop context is shared across views.
1444
- await holder.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
1445
- source,
1446
- });
1447
- // Remember it so a DNS-recovery page rebuild re-installs it.
1448
- holder.initScripts.push(source);
1449
- },
1450
- );
1451
- },
1452
- async waitFor<T = unknown>(
1588
+ async waitForFunction<T = unknown>(
1453
1589
  description: string,
1454
- expression: string,
1455
- options: { timeoutMs?: number; intervalMs?: number } = {},
1590
+ fn: string | ((arg?: unknown) => T),
1591
+ arg?: unknown,
1592
+ options: { timeout?: number; polling?: number } = {},
1456
1593
  ): Promise<Wrapped<T>> {
1457
- const timeoutMs = options.timeoutMs ?? 5_000;
1458
- const intervalMs = options.intervalMs ?? 100;
1459
- const truncatedScript = truncateUtf8(expression);
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);
1460
1598
  // Pass `fields` by reference so the loop can stamp the final
1461
1599
  // attempt count onto the event before instrumented records it.
1462
1600
  const fields: Partial<RecordableFields> = {
1463
1601
  description,
1464
- script: truncatedScript.value,
1465
- scriptTruncated: truncatedScript.truncated,
1602
+ script: t.value,
1603
+ scriptTruncated: t.truncated,
1466
1604
  attempts: 0,
1467
1605
  };
1468
- // Normalised once up front — see `toEvaluable` (statement bodies work).
1469
- const evaluable = toEvaluable(expression);
1470
- return instrumented<T>("waitFor", fields, async () => {
1471
- const deadline = Date.now() + timeoutMs;
1472
- // (return value wrapped by `instrumented`; cast below matches.)
1473
- // The polling loop calls `view.evaluate` directly (not the
1474
- // wrapped `evaluate`) so it doesn't fan out into N events in
1475
- // the log or N rrweb drains. rrweb keeps buffering page-side
1476
- // throughout the wait; the wrapper's single drain at the end
1477
- // collects everything.
1478
- for (;;) {
1479
- fields.attempts = (fields.attempts ?? 0) + 1;
1480
- let v: unknown;
1481
- try {
1482
- v = await holder.page.evaluate(evaluable);
1483
- } catch (err) {
1484
- if (Date.now() >= deadline) throw err;
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
+ }
1485
1638
  await new Promise((r) => setTimeout(r, intervalMs));
1486
- continue;
1487
- }
1488
- if (v) return v as T;
1489
- if (Date.now() >= deadline) {
1490
- throw new Error(
1491
- `waitFor ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${fields.attempts} attempts)`,
1492
- );
1493
1639
  }
1494
- await new Promise((r) => setTimeout(r, intervalMs));
1495
- }
1496
- }) as Promise<Wrapped<T>>;
1497
- },
1498
- click(selector) {
1499
- return instrumented("click", { selector }, () =>
1500
- holder.page.click(selector, { timeout: DEFAULT_ACTION_TIMEOUT_MS }),
1501
- );
1502
- },
1503
- clickAt(x, y) {
1504
- return instrumented("click", { x, y }, () => holder.page.mouse.click(x, y));
1505
- },
1506
- type(text) {
1507
- // insertText path (no per-char keydown) — same semantics as before.
1508
- const t = truncateUtf8(text);
1509
- return instrumented(
1510
- "type",
1511
- { text: t.value, textTruncated: t.truncated },
1512
- () => holder.page.keyboard.insertText(text),
1513
- );
1514
- },
1515
- press(key) {
1516
- return instrumented("press", { key }, () => holder.page.keyboard.press(key));
1517
- },
1518
- scroll(dx, dy) {
1519
- return instrumented("scroll", { dx, dy }, () => holder.page.mouse.wheel(dx, dy));
1520
- },
1521
- scrollTo(selector) {
1522
- return instrumented("scrollTo", { selector }, () =>
1523
- holder.page
1524
- .locator(selector)
1525
- .first()
1526
- .scrollIntoViewIfNeeded({ timeout: DEFAULT_ACTION_TIMEOUT_MS }),
1527
- );
1528
- },
1529
- back() {
1530
- return instrumented("back", {}, async () => {
1531
- await holder.page.goBack();
1532
- });
1533
- },
1534
- forward() {
1535
- return instrumented("forward", {}, async () => {
1536
- await holder.page.goForward();
1537
- });
1538
- },
1539
- reload() {
1540
- return instrumented("reload", {}, async () => {
1541
- await holder.page.reload();
1542
- });
1640
+ },
1641
+ { wrap: true },
1642
+ ) as Promise<Wrapped<T>>;
1543
1643
  },
1544
1644
  async screenshot() {
1545
1645
  const fields: Partial<RecordableFields> = { format: "png" };
@@ -1619,10 +1719,17 @@ function buildBackend(
1619
1719
  touchPoints: [],
1620
1720
  });
1621
1721
  },
1622
- tapAt(x, y, opts) {
1623
- // Recorded as a "click" (the event schema stays desktop-shaped; the
1624
- // mobile facade owns the author-facing "tap" vocabulary).
1625
- return instrumented("click", { x, y }, () => backend.rawTap(x, y, opts?.durationMs));
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);
1626
1733
  },
1627
1734
  swipeBy(x, y, dx, dy) {
1628
1735
  return instrumented("scroll", { dx, dy }, async () => {
@@ -1646,18 +1753,42 @@ function buildBackend(
1646
1753
  probe<T = unknown>(expression: string): Promise<T> {
1647
1754
  return holder.page.evaluate(expression) as Promise<T>;
1648
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
+ },
1649
1779
  pageOp<T>(
1650
1780
  action: BrowserAction,
1651
1781
  fields: Partial<RecordableFields>,
1652
1782
  fn: (page: Page) => Promise<T>,
1783
+ opts?: { wrap?: boolean },
1653
1784
  ): Promise<T> {
1654
- return instrumented(action, fields, () => fn(holder.page));
1785
+ return instrumented(action, fields, () => fn(holder.page), opts);
1655
1786
  },
1656
1787
  };
1657
1788
  return { backend, detach: endRecording };
1658
1789
  }
1659
1790
 
1660
- interface RecordableFields {
1791
+ export interface RecordableFields {
1661
1792
  url: string;
1662
1793
  selector: string;
1663
1794
  description: string;