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