@specific.dev/spectest 0.21.0 → 0.22.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 {
@@ -112,7 +124,7 @@ export interface BrowserSessionRecorder {
112
124
  readonly sessionId: string;
113
125
  /** Called for each drained chunk of rrweb events. */
114
126
  recordStep(step: BrowserSessionStep): void;
115
- /** Optional: called whenever `Browser.navigate(url)` is invoked. */
127
+ /** Optional: called whenever `Browser.goto(url)` is invoked. */
116
128
  noteNavigation?(url: string): void;
117
129
  /**
118
130
  * Optional: register a captured artifact (screenshot bytes) for upload.
@@ -130,126 +142,138 @@ export interface BrowserSessionRecorder {
130
142
  }): void;
131
143
  }
132
144
 
145
+ /** The page keyboard — Playwright's `page.keyboard`. Each method records one
146
+ * browser event. */
147
+ export interface Keyboard {
148
+ /** Press a key or chord by name (`"Enter"`, `"Tab"`, `"Control+A"`). */
149
+ press(key: string): Promise<void>;
150
+ /** Type character-by-character (fires keydown/keyup per char). */
151
+ type(text: string): Promise<void>;
152
+ /** Insert text in one shot (no per-char keydown — the paste path). */
153
+ insertText(text: string): Promise<void>;
154
+ }
155
+
156
+ /** The page mouse — Playwright's `page.mouse`. Coordinates are viewport CSS
157
+ * pixels. Prefer locators; use these only for canvas/coordinate targets. */
158
+ export interface Mouse {
159
+ click(x: number, y: number, opts?: { button?: "left" | "right" | "middle"; clickCount?: number }): Promise<void>;
160
+ dblclick(x: number, y: number): Promise<void>;
161
+ move(x: number, y: number): Promise<void>;
162
+ /** Wheel-scroll by a pixel delta. */
163
+ wheel(dx: number, dy: number): Promise<void>;
164
+ }
165
+
133
166
  /**
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.
167
+ * Headless browser session — Playwright `Page`-shaped. Select elements with
168
+ * the `getBy*`/`locator` roots (returning a {@link Locator}) and act on them;
169
+ * drive raw input via `keyboard`/`mouse`. Operations are sequential per
170
+ * session — the recorder attributes each op's rrweb drain to it — so for
171
+ * parallel browsing open multiple sessions.
137
172
  */
138
173
  export interface Browser {
139
- /** Last-navigated URL (updated on navigate completion). */
140
- readonly url: string;
141
- /** Current page `<title>`. */
142
- readonly title: string;
174
+ /** Current page URL (Playwright's synchronous `page.url()`). */
175
+ url(): string;
176
+ /** Current page `<title>` (async, like Playwright's `page.title()`). */
177
+ title(): Promise<string>;
143
178
  /** Navigate to a URL; resolves when the main frame's load completes. */
144
- navigate(url: string): Promise<void>;
179
+ goto(url: string): Promise<void>;
180
+ /** Navigate back in session history. */
181
+ goBack(): Promise<void>;
182
+ /** Navigate forward in session history. */
183
+ goForward(): Promise<void>;
184
+ /** Reload the current page. */
185
+ reload(): Promise<void>;
186
+
187
+ readonly keyboard: Keyboard;
188
+ readonly mouse: Mouse;
189
+
190
+ /** Root CSS query. */
191
+ locator(css: string): Locator;
192
+ getByRole(role: string, opts?: GetByRoleOptions): Locator;
193
+ getByText(text: string | RegExp, opts?: GetByTextOptions): Locator;
194
+ getByLabel(text: string | RegExp, opts?: GetByTextOptions): Locator;
195
+ getByPlaceholder(text: string | RegExp, opts?: GetByTextOptions): Locator;
196
+ getByAltText(text: string | RegExp, opts?: GetByTextOptions): Locator;
197
+ getByTitle(text: string | RegExp, opts?: GetByTextOptions): Locator;
198
+ getByTestId(testId: string): Locator;
199
+
145
200
  /**
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.
201
+ * Evaluate JS in the page and return the JSON-deserialised, provenance-
202
+ * wrapped result. `fn` is a real function (serialized by Playwright, with an
203
+ * optional serializable `arg`) or a string expression / statement body
204
+ * (auto-wrapped in an async IIFE, so `return`/`await` work).
150
205
  *
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.
206
+ * `description` is a short human label ("read rendered todo list") surfaced
207
+ * in the timeline so the step list isn't a wall of minified code — spectest
208
+ * keeps it (the one deviation from Playwright's bare `evaluate`).
154
209
  */
155
- evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
210
+ evaluate<T = unknown>(
211
+ description: string,
212
+ fn: string | ((arg?: unknown) => T | Promise<T>),
213
+ arg?: unknown,
214
+ ): Promise<Wrapped<T>>;
156
215
  /**
157
216
  * 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.
170
- *
171
- * `description` labels the step in the test event log.
217
+ * the document's own scripts (CDP `Page.addScriptToEvaluateOnNewDocument` —
218
+ * Playwright's `addInitScript`). The deterministic way to plant shims that
219
+ * must beat the app bundle. Does NOT run in the *current* document — call it
220
+ * before the `goto`/`location.assign` whose document needs it. Persists for
221
+ * the session's lifetime (rides snapshots into `dependsOn` children).
222
+ * `description` labels the step.
172
223
  */
173
224
  addInitScript(description: string, source: string): Promise<void>;
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,22 +282,29 @@ 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>;
279
310
  }
@@ -300,14 +331,12 @@ const CHROME_ARGV = [
300
331
  "--dns-over-https-mode=off",
301
332
  ];
302
333
 
303
- /** Default touchStart→touchEnd dwell for `tapAt` — see the comment there. */
334
+ /** Default touchStart→touchEnd dwell for touch taps — see `rawTap`. */
304
335
  const TAP_DWELL_MS = 60;
305
336
 
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;
337
+ /** Navigations keep a longer deadline than locator actions (cold app
338
+ * servers). The action default lives in `locator.ts`
339
+ * ({@link DEFAULT_ACTION_TIMEOUT_MS}) since the locator layer owns it. */
311
340
  const NAVIGATION_TIMEOUT_MS = 30_000;
312
341
 
313
342
  // ────────────────────────────────────────────────────────────────────────
@@ -1068,7 +1097,7 @@ export async function openMobileBackend(
1068
1097
  // through our own `navigate()` keeps the recorder log uniform (one
1069
1098
  // event per navigation, with timing) and drains rrweb after the load.
1070
1099
  if (opts.url !== undefined) {
1071
- await backend.navigate(opts.url);
1100
+ await backend.goto(opts.url);
1072
1101
  }
1073
1102
  return backend;
1074
1103
  }
@@ -1182,7 +1211,7 @@ export async function acquirePersistentBrowser(
1182
1211
  if (SHARED_BROWSER === holder) SHARED_BROWSER = null;
1183
1212
  },
1184
1213
  });
1185
- if (!attached && opts.url !== undefined) await backend.navigate(opts.url);
1214
+ if (!attached && opts.url !== undefined) await backend.goto(opts.url);
1186
1215
  return { browser: backend, attached, detach };
1187
1216
  }
1188
1217
 
@@ -1213,7 +1242,7 @@ export async function acquirePersistentMobileBackend(
1213
1242
  if (SHARED_MOBILE.get(url) === holder) SHARED_MOBILE.delete(url);
1214
1243
  },
1215
1244
  });
1216
- if (!existing) await backend.navigate(url);
1245
+ if (!existing) await backend.goto(url);
1217
1246
  return { browser: backend, attached: existing !== undefined, detach };
1218
1247
  }
1219
1248
 
@@ -1322,6 +1351,7 @@ function buildBackend(
1322
1351
  action: BrowserAction,
1323
1352
  fields: Partial<RecordableFields>,
1324
1353
  fn: () => Promise<T>,
1354
+ opts?: { wrap?: boolean },
1325
1355
  ): Promise<T> {
1326
1356
  const t = Date.now();
1327
1357
  const resv = reserveEvent();
@@ -1348,10 +1378,10 @@ function buildBackend(
1348
1378
  durationMs: endT - t,
1349
1379
  }, resv);
1350
1380
  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")) {
1381
+ // Reads (`opts.wrap`) return user-visible JS values someone is likely to
1382
+ // assert on — provenance-wrap so `expect(...)` nests under this step.
1383
+ // Actions return void/internals; leave them raw to avoid Proxy surprises.
1384
+ if (seq !== undefined && opts?.wrap) {
1355
1385
  return wrap(result, seq) as T;
1356
1386
  }
1357
1387
  return result;
@@ -1381,19 +1411,70 @@ function buildBackend(
1381
1411
  recordingEnded = true;
1382
1412
  }
1383
1413
 
1414
+ const strategy: ActionStrategy = holder.device ? mobileStrategy : desktopStrategy;
1415
+
1416
+ const keyboard: Keyboard = {
1417
+ press(key) {
1418
+ return instrumented("press", { key }, () => holder.page.keyboard.press(key));
1419
+ },
1420
+ type(text) {
1421
+ const t = truncateUtf8(text);
1422
+ return instrumented("type", { text: t.value, textTruncated: t.truncated }, () =>
1423
+ holder.page.keyboard.type(text),
1424
+ );
1425
+ },
1426
+ insertText(text) {
1427
+ // insertText path (no per-char keydown) — the paste path.
1428
+ const t = truncateUtf8(text);
1429
+ return instrumented("type", { text: t.value, textTruncated: t.truncated }, () =>
1430
+ holder.page.keyboard.insertText(text),
1431
+ );
1432
+ },
1433
+ };
1434
+
1435
+ const mouse: Mouse = {
1436
+ click(x, y, opts) {
1437
+ return instrumented("click", { x, y }, () => holder.page.mouse.click(x, y, opts));
1438
+ },
1439
+ dblclick(x, y) {
1440
+ return instrumented("dblclick", { x, y }, () => holder.page.mouse.dblclick(x, y));
1441
+ },
1442
+ move(x, y) {
1443
+ return instrumented("mouse.move", { x, y }, () => holder.page.mouse.move(x, y));
1444
+ },
1445
+ wheel(dx, dy) {
1446
+ return instrumented("scroll", { dx, dy }, () => holder.page.mouse.wheel(dx, dy));
1447
+ },
1448
+ };
1449
+
1450
+ const touchscreen: Touchscreen = {
1451
+ tap(x, y, opts) {
1452
+ // Recorded as "tap"; the CDP touch (with dwell) fires RN-Web responders.
1453
+ return instrumented("tap", { x, y }, () => backend.rawTap(x, y, opts?.duration));
1454
+ },
1455
+ };
1456
+
1384
1457
  const backend: MobileBackend = {
1385
- get url() {
1458
+ url() {
1386
1459
  return holder.page.url();
1387
1460
  },
1388
- get title() {
1461
+ async title() {
1462
+ try {
1463
+ holder.lastTitle = await holder.page.title();
1464
+ } catch {
1465
+ /* page mid-navigation or closed — return the last cached title */
1466
+ }
1389
1467
  return holder.lastTitle;
1390
1468
  },
1391
1469
  get safeAreaInsets() {
1392
1470
  return holder.safeAreaInsets;
1393
1471
  },
1394
- navigate(url) {
1472
+ keyboard,
1473
+ mouse,
1474
+ touchscreen,
1475
+ goto(url) {
1395
1476
  recorder?.noteNavigation?.(url);
1396
- return instrumented("navigate", { url }, async () => {
1477
+ return instrumented("goto", { url }, async () => {
1397
1478
  try {
1398
1479
  await holder.page.goto(url, { waitUntil: "load" });
1399
1480
  } catch (err) {
@@ -1409,24 +1490,55 @@ function buildBackend(
1409
1490
  }
1410
1491
  });
1411
1492
  },
1493
+ goBack() {
1494
+ return instrumented("goBack", {}, async () => {
1495
+ await holder.page.goBack();
1496
+ });
1497
+ },
1498
+ goForward() {
1499
+ return instrumented("goForward", {}, async () => {
1500
+ await holder.page.goForward();
1501
+ });
1502
+ },
1503
+ reload() {
1504
+ return instrumented("reload", {}, async () => {
1505
+ await holder.page.reload();
1506
+ });
1507
+ },
1508
+
1509
+ locator: (css) => makeLocator(backend, strategy, { steps: [{ m: "locator", args: [css] }] }),
1510
+ getByRole: (role, opts) =>
1511
+ makeLocator(backend, strategy, { steps: [{ m: "getByRole", args: [role, opts] }] }),
1512
+ getByText: (text, opts) =>
1513
+ makeLocator(backend, strategy, { steps: [{ m: "getByText", args: [text, opts] }] }),
1514
+ getByLabel: (text, opts) =>
1515
+ makeLocator(backend, strategy, { steps: [{ m: "getByLabel", args: [text, opts] }] }),
1516
+ getByPlaceholder: (text, opts) =>
1517
+ makeLocator(backend, strategy, { steps: [{ m: "getByPlaceholder", args: [text, opts] }] }),
1518
+ getByAltText: (text, opts) =>
1519
+ makeLocator(backend, strategy, { steps: [{ m: "getByAltText", args: [text, opts] }] }),
1520
+ getByTitle: (text, opts) =>
1521
+ makeLocator(backend, strategy, { steps: [{ m: "getByTitle", args: [text, opts] }] }),
1522
+ getByTestId: (id) =>
1523
+ makeLocator(backend, strategy, { steps: [{ m: "getByTestId", args: [id] }] }),
1524
+
1412
1525
  async evaluate<T = unknown>(
1413
1526
  description: string,
1414
- script: string,
1527
+ fn: string | ((arg?: unknown) => T | Promise<T>),
1528
+ arg?: unknown,
1415
1529
  ): 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.
1530
+ const src = typeof fn === "string" ? fn : fn.toString();
1531
+ const t = truncateUtf8(src);
1419
1532
  return instrumented<T>(
1420
1533
  "evaluate",
1421
- {
1422
- description,
1423
- script: truncatedScript.value,
1424
- scriptTruncated: truncatedScript.truncated,
1425
- },
1534
+ { description, script: t.value, scriptTruncated: t.truncated },
1426
1535
  async () => {
1427
- const v = (await holder.page.evaluate(toEvaluable(script))) as T;
1428
- return v;
1536
+ if (typeof fn === "string") {
1537
+ return (await holder.page.evaluate(toEvaluable(fn))) as T;
1538
+ }
1539
+ return (await holder.page.evaluate(fn as never, arg)) as T;
1429
1540
  },
1541
+ { wrap: true },
1430
1542
  ) as Promise<Wrapped<T>>;
1431
1543
  },
1432
1544
  addInitScript(description: string, source: string): Promise<void> {
@@ -1449,97 +1561,61 @@ function buildBackend(
1449
1561
  },
1450
1562
  );
1451
1563
  },
1452
- async waitFor<T = unknown>(
1564
+ async waitForFunction<T = unknown>(
1453
1565
  description: string,
1454
- expression: string,
1455
- options: { timeoutMs?: number; intervalMs?: number } = {},
1566
+ fn: string | ((arg?: unknown) => T),
1567
+ arg?: unknown,
1568
+ options: { timeout?: number; polling?: number } = {},
1456
1569
  ): Promise<Wrapped<T>> {
1457
- const timeoutMs = options.timeoutMs ?? 5_000;
1458
- const intervalMs = options.intervalMs ?? 100;
1459
- const truncatedScript = truncateUtf8(expression);
1570
+ const timeoutMs = options.timeout ?? 5_000;
1571
+ const intervalMs = options.polling ?? 100;
1572
+ const src = typeof fn === "string" ? fn : fn.toString();
1573
+ const t = truncateUtf8(src);
1460
1574
  // Pass `fields` by reference so the loop can stamp the final
1461
1575
  // attempt count onto the event before instrumented records it.
1462
1576
  const fields: Partial<RecordableFields> = {
1463
1577
  description,
1464
- script: truncatedScript.value,
1465
- scriptTruncated: truncatedScript.truncated,
1578
+ script: t.value,
1579
+ scriptTruncated: t.truncated,
1466
1580
  attempts: 0,
1467
1581
  };
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;
1582
+ // Normalised once up front — string bodies go through `toEvaluable`
1583
+ // (statement bodies work); functions run with `arg`.
1584
+ const evaluable = typeof fn === "string" ? toEvaluable(fn) : null;
1585
+ const evalOnce = (): Promise<unknown> =>
1586
+ evaluable !== null
1587
+ ? holder.page.evaluate(evaluable)
1588
+ : holder.page.evaluate(fn as never, arg);
1589
+ return instrumented<T>(
1590
+ "waitForFunction",
1591
+ fields,
1592
+ async () => {
1593
+ const deadline = Date.now() + timeoutMs;
1594
+ // The polling loop calls `page.evaluate` directly (not the wrapped
1595
+ // `evaluate`) so it doesn't fan out into N events or N rrweb drains.
1596
+ // rrweb keeps buffering page-side; the wrapper's single drain at the
1597
+ // end collects everything.
1598
+ for (;;) {
1599
+ fields.attempts = (fields.attempts ?? 0) + 1;
1600
+ let v: unknown;
1601
+ try {
1602
+ v = await evalOnce();
1603
+ } catch (err) {
1604
+ if (Date.now() >= deadline) throw err;
1605
+ await new Promise((r) => setTimeout(r, intervalMs));
1606
+ continue;
1607
+ }
1608
+ if (v) return v as T;
1609
+ if (Date.now() >= deadline) {
1610
+ throw new Error(
1611
+ `waitForFunction ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${fields.attempts} attempts)`,
1612
+ );
1613
+ }
1485
1614
  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
1615
  }
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
- });
1616
+ },
1617
+ { wrap: true },
1618
+ ) as Promise<Wrapped<T>>;
1543
1619
  },
1544
1620
  async screenshot() {
1545
1621
  const fields: Partial<RecordableFields> = { format: "png" };
@@ -1619,10 +1695,17 @@ function buildBackend(
1619
1695
  touchPoints: [],
1620
1696
  });
1621
1697
  },
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));
1698
+ async swipe(direction, opts) {
1699
+ const vp = await backend.probe<{ w: number; h: number }>(
1700
+ "({ w: window.innerWidth, h: window.innerHeight })",
1701
+ );
1702
+ const cx = vp.w / 2;
1703
+ const cy = vp.h / 2;
1704
+ const horiz = direction === "left" || direction === "right";
1705
+ const dist = opts?.distance ?? Math.round((horiz ? vp.w : vp.h) * 0.5);
1706
+ const dx = direction === "left" ? -dist : direction === "right" ? dist : 0;
1707
+ const dy = direction === "up" ? -dist : direction === "down" ? dist : 0;
1708
+ await backend.swipeBy(cx, cy, dx, dy);
1626
1709
  },
1627
1710
  swipeBy(x, y, dx, dy) {
1628
1711
  return instrumented("scroll", { dx, dy }, async () => {
@@ -1646,18 +1729,22 @@ function buildBackend(
1646
1729
  probe<T = unknown>(expression: string): Promise<T> {
1647
1730
  return holder.page.evaluate(expression) as Promise<T>;
1648
1731
  },
1732
+ silentRead<T>(fn: (page: Page) => Promise<T>): Promise<T> {
1733
+ return fn(holder.page);
1734
+ },
1649
1735
  pageOp<T>(
1650
1736
  action: BrowserAction,
1651
1737
  fields: Partial<RecordableFields>,
1652
1738
  fn: (page: Page) => Promise<T>,
1739
+ opts?: { wrap?: boolean },
1653
1740
  ): Promise<T> {
1654
- return instrumented(action, fields, () => fn(holder.page));
1741
+ return instrumented(action, fields, () => fn(holder.page), opts);
1655
1742
  },
1656
1743
  };
1657
1744
  return { backend, detach: endRecording };
1658
1745
  }
1659
1746
 
1660
- interface RecordableFields {
1747
+ export interface RecordableFields {
1661
1748
  url: string;
1662
1749
  selector: string;
1663
1750
  description: string;