@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/package.json +1 -1
- package/src/browser.ts +371 -240
- package/src/daemon.ts +4 -4
- package/src/index.ts +277 -9
- package/src/locator.ts +594 -0
- package/src/mobile.ts +34 -359
- package/src/recorder.ts +11 -14
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.
|
|
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
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
-
/**
|
|
140
|
-
|
|
141
|
-
/** Current page `<title
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* `
|
|
160
|
-
*
|
|
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`
|
|
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
|
-
|
|
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 `
|
|
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,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
|
-
*
|
|
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>;
|
|
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
|
|
350
|
+
/** Default touchStart→touchEnd dwell for touch taps — see `rawTap`. */
|
|
304
351
|
const TAP_DWELL_MS = 60;
|
|
305
352
|
|
|
306
|
-
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
-
/**
|
|
930
|
-
*
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
1352
|
-
//
|
|
1353
|
-
//
|
|
1354
|
-
if (seq !== undefined &&
|
|
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
|
-
|
|
1502
|
+
url() {
|
|
1386
1503
|
return holder.page.url();
|
|
1387
1504
|
},
|
|
1388
|
-
|
|
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
|
-
|
|
1516
|
+
keyboard,
|
|
1517
|
+
mouse,
|
|
1518
|
+
touchscreen,
|
|
1519
|
+
goto(url) {
|
|
1395
1520
|
recorder?.noteNavigation?.(url);
|
|
1396
|
-
return instrumented("
|
|
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
|
-
|
|
1571
|
+
fn: string | ((arg?: unknown) => T | Promise<T>),
|
|
1572
|
+
arg?: unknown,
|
|
1415
1573
|
): Promise<Wrapped<T>> {
|
|
1416
|
-
const
|
|
1417
|
-
|
|
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
|
-
|
|
1428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1455
|
-
|
|
1590
|
+
fn: string | ((arg?: unknown) => T),
|
|
1591
|
+
arg?: unknown,
|
|
1592
|
+
options: { timeout?: number; polling?: number } = {},
|
|
1456
1593
|
): Promise<Wrapped<T>> {
|
|
1457
|
-
const timeoutMs = options.
|
|
1458
|
-
const intervalMs = options.
|
|
1459
|
-
const
|
|
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:
|
|
1465
|
-
scriptTruncated:
|
|
1602
|
+
script: t.value,
|
|
1603
|
+
scriptTruncated: t.truncated,
|
|
1466
1604
|
attempts: 0,
|
|
1467
1605
|
};
|
|
1468
|
-
// Normalised once up front —
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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;
|