@specific.dev/spectest 0.20.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/package.json +1 -1
- package/src/browser.ts +301 -214
- package/src/components/replayFake.ts +4 -5
- package/src/daemon.ts +307 -8
- package/src/index.ts +252 -20
- package/src/locator.ts +576 -0
- package/src/mobile.ts +34 -359
- package/src/recorder.ts +11 -14
- package/src/replay-bundle.ts +108 -0
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.
|
|
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
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
-
/**
|
|
140
|
-
|
|
141
|
-
/** Current page `<title
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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>(
|
|
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
|
-
*
|
|
159
|
-
* `
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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 `
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
-
|
|
231
|
+
waitForFunction<T = unknown>(
|
|
195
232
|
description: string,
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* `
|
|
241
|
-
*
|
|
242
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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; `
|
|
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
|
|
334
|
+
/** Default touchStart→touchEnd dwell for touch taps — see `rawTap`. */
|
|
304
335
|
const TAP_DWELL_MS = 60;
|
|
305
336
|
|
|
306
|
-
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
1352
|
-
//
|
|
1353
|
-
//
|
|
1354
|
-
if (seq !== undefined &&
|
|
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
|
-
|
|
1458
|
+
url() {
|
|
1386
1459
|
return holder.page.url();
|
|
1387
1460
|
},
|
|
1388
|
-
|
|
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
|
-
|
|
1472
|
+
keyboard,
|
|
1473
|
+
mouse,
|
|
1474
|
+
touchscreen,
|
|
1475
|
+
goto(url) {
|
|
1395
1476
|
recorder?.noteNavigation?.(url);
|
|
1396
|
-
return instrumented("
|
|
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
|
-
|
|
1527
|
+
fn: string | ((arg?: unknown) => T | Promise<T>),
|
|
1528
|
+
arg?: unknown,
|
|
1415
1529
|
): Promise<Wrapped<T>> {
|
|
1416
|
-
const
|
|
1417
|
-
|
|
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
|
-
|
|
1428
|
-
|
|
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
|
|
1564
|
+
async waitForFunction<T = unknown>(
|
|
1453
1565
|
description: string,
|
|
1454
|
-
|
|
1455
|
-
|
|
1566
|
+
fn: string | ((arg?: unknown) => T),
|
|
1567
|
+
arg?: unknown,
|
|
1568
|
+
options: { timeout?: number; polling?: number } = {},
|
|
1456
1569
|
): Promise<Wrapped<T>> {
|
|
1457
|
-
const timeoutMs = options.
|
|
1458
|
-
const intervalMs = options.
|
|
1459
|
-
const
|
|
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:
|
|
1465
|
-
scriptTruncated:
|
|
1578
|
+
script: t.value,
|
|
1579
|
+
scriptTruncated: t.truncated,
|
|
1466
1580
|
attempts: 0,
|
|
1467
1581
|
};
|
|
1468
|
-
// Normalised once up front —
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
1495
|
-
}
|
|
1496
|
-
|
|
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
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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;
|