@specific.dev/spectest 0.26.0 → 0.28.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.
Files changed (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +172 -0
  12. package/dist/components/k3s.js +1124 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4611 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1328 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +527 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1819
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. package/src/vendor/rrweb-record.min.js +0 -5061
@@ -0,0 +1,1320 @@
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
+ import { promises as dns } from "node:dns";
31
+ import { readFileSync } from "node:fs";
32
+ import path from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import { generateId } from "./ids.js";
35
+ import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
36
+ import { wrap } from "./inspect.js";
37
+ import { DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
38
+ import { chromium } from "playwright-core";
39
+ /** Decoded byte count of a base64 string, without decoding it. */
40
+ function base64ByteLength(b64) {
41
+ let padding = 0;
42
+ if (b64.endsWith("=="))
43
+ padding = 2;
44
+ else if (b64.endsWith("="))
45
+ padding = 1;
46
+ return (b64.length * 3) / 4 - padding;
47
+ }
48
+ // Default extra flags for headless Chromium inside a Firecracker microVM.
49
+ // --no-sandbox: Chrome refuses to launch as root otherwise (no user
50
+ // namespaces in the guest).
51
+ // --disable-dev-shm-usage: /dev/shm in the microVM defaults to ~64MB,
52
+ // which Chromium will exhaust on non-trivial pages.
53
+ // --disable-features=AsyncDns,DnsOverHttps + --dns-over-https-mode=off:
54
+ // force Chromium onto glibc getaddrinfo for name resolution — the exact
55
+ // path `fetch()` uses (→ /etc/resolv.conf → 127.0.0.53 → spectest-resolver).
56
+ // Chromium's built-in async DNS client (and DoH auto-upgrade) bypass the
57
+ // loopback nameserver in /etc/resolv.conf and query public DNS directly,
58
+ // which has never heard of our service names (`dashboard`, `web`,
59
+ // `api.todos.local`, …). On a cold first navigate in a fresh fork that
60
+ // surfaces as an intermittent `net::ERR_NAME_NOT_RESOLVED`. getaddrinfo
61
+ // reads resolv.conf synchronously per lookup, so it has no startup race
62
+ // and always reaches the resolver. See run 878d0054 (dashboard:3000).
63
+ const CHROME_ARGV = [
64
+ "--no-sandbox",
65
+ "--disable-dev-shm-usage",
66
+ "--disable-features=AsyncDns,DnsOverHttps",
67
+ "--dns-over-https-mode=off",
68
+ ];
69
+ /** Default touchStart→touchEnd dwell for touch taps — see `rawTap`. */
70
+ const TAP_DWELL_MS = 60;
71
+ /** Navigations keep a longer deadline than locator actions (cold app
72
+ * servers). The action default lives in `locator.ts`
73
+ * ({@link DEFAULT_ACTION_TIMEOUT_MS}) since the locator layer owns it. */
74
+ const NAVIGATION_TIMEOUT_MS = 30_000;
75
+ // ────────────────────────────────────────────────────────────────────────
76
+ // Shared Chromium (playwright-core)
77
+ // ────────────────────────────────────────────────────────────────────────
78
+ // One Chromium per daemon, launched lazily on first view creation and kept
79
+ // for the daemon's lifetime. It rides snapshots: the process, playwright's
80
+ // pipe connection to it, and every open page freeze into the VM snapshot
81
+ // and resume in each fork (like dockerd and the daemon itself). Profile
82
+ // state — cookies, localStorage — is per-Chromium, and the in-VM CA trust
83
+ // comes from the HOME-scoped NSS user DB, so neither cares that playwright
84
+ // runs a temp --user-data-dir.
85
+ let PW_BROWSER = null;
86
+ // The shared desktop context (default 1280×720 viewport). All desktop views
87
+ // live here so they share one cookie jar, mirroring the old one-Chrome-
88
+ // profile model. Mobile sessions and custom-viewport views get their own
89
+ // contexts (viewport/DPR/UA/touch are context-scoped in playwright).
90
+ let DESKTOP_CTX = null;
91
+ function chromiumPath() {
92
+ return (Bun.which("chromium") ??
93
+ Bun.which("chromium-browser") ??
94
+ Bun.which("google-chrome") ??
95
+ "/usr/bin/chromium");
96
+ }
97
+ async function ensurePlaywrightBrowser() {
98
+ if (PW_BROWSER?.isConnected())
99
+ return PW_BROWSER;
100
+ PW_BROWSER = await chromium.launch({
101
+ executablePath: chromiumPath(),
102
+ headless: true,
103
+ args: CHROME_ARGV,
104
+ });
105
+ DESKTOP_CTX = null; // contexts died with the old browser (if any)
106
+ return PW_BROWSER;
107
+ }
108
+ /** Context options for the mobile device preset — playwright's native
109
+ * emulation (viewport/DPR/UA/touch are context-scoped). Safe-area insets
110
+ * have no context option; they stay a raw-CDP override per page. */
111
+ function deviceContextOptions(d) {
112
+ return {
113
+ viewport: { width: d.viewport.width, height: d.viewport.height },
114
+ deviceScaleFactor: d.deviceScaleFactor,
115
+ isMobile: d.isMobile,
116
+ hasTouch: d.hasTouch,
117
+ userAgent: d.userAgent,
118
+ // Emulate `prefers-reduced-motion: reduce` for replay fidelity — an app
119
+ // that honors it skips entrance animations/transitions, so rrweb's
120
+ // per-op FullSnapshot captures the settled DOM instead of a mid-fade
121
+ // (opacity 0) frame that a paused seek would freeze on. Applied at every
122
+ // newContext site below so all sessions record the same way.
123
+ reducedMotion: "reduce",
124
+ };
125
+ }
126
+ async function ensureDesktopContext() {
127
+ const browser = await ensurePlaywrightBrowser();
128
+ if (DESKTOP_CTX)
129
+ return DESKTOP_CTX;
130
+ DESKTOP_CTX = await browser.newContext({
131
+ viewport: { width: 1280, height: 720 },
132
+ // Reduced-motion for replay fidelity — see deviceContextOptions.
133
+ reducedMotion: "reduce",
134
+ });
135
+ DESKTOP_CTX.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
136
+ DESKTOP_CTX.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
137
+ return DESKTOP_CTX;
138
+ }
139
+ /**
140
+ * Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
141
+ * single EXPRESSION (it wraps the source in `await (...)`), so a statement
142
+ * body (`const x = …; return x;`) is a syntax error. Rather than forcing
143
+ * authors to IIFE-wrap by hand, wrap it for them when it isn't an expression.
144
+ *
145
+ * The check is parse-only (`new Function` compiles without executing) and
146
+ * happens BEFORE the script runs — deciding by catching the page-side error
147
+ * and retrying would re-execute side-effecting expressions whose *runtime*
148
+ * error happens to look syntactic (`JSON.parse` throws SyntaxError too).
149
+ */
150
+ function toEvaluable(script) {
151
+ try {
152
+ new Function(`return (${script}\n);`);
153
+ return script;
154
+ }
155
+ catch {
156
+ // Statement body — an async IIFE makes `return`, declarations, and
157
+ // multi-statement snippets valid, with `await` still available. The
158
+ // newlines keep a trailing line comment from eating the wrapper.
159
+ return `(async () => {\n${script}\n})()`;
160
+ }
161
+ }
162
+ /** The fixed mobile device. Logical resolution + DPR of a current iPhone;
163
+ * the UA mirrors what Playwright emits for iOS so RN-Web's mobile branches
164
+ * fire. We run Chromium under the hood, so the Safari UA is a deliberate
165
+ * emulation lie (same as every device-emulation tool). */
166
+ const LATEST_IPHONE = {
167
+ name: "iPhone 15 Pro",
168
+ viewport: { width: 393, height: 852 },
169
+ deviceScaleFactor: 3,
170
+ isMobile: true,
171
+ hasTouch: true,
172
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " +
173
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
174
+ // Portrait safe area of the 393×852 iPhones (14 Pro through 16): 59pt
175
+ // status-bar/Dynamic-Island clearance on top, 34pt home-indicator strip
176
+ // at the bottom. Emulated via CDP so the app's `env(safe-area-inset-*)`
177
+ // padding fires exactly like on the real device.
178
+ safeAreaInsets: { top: 59, right: 0, bottom: 34, left: 0 },
179
+ };
180
+ /** Apply the one device-emulation piece playwright's context options can't
181
+ * express: iOS safe-area insets, via raw CDP on the page's session. The
182
+ * override is session-global, so it persists across the app navigation
183
+ * that follows. Best-effort — Chromium < ~135 lacks the method.
184
+ *
185
+ * Returns the insets that actually took effect (`null` when the override
186
+ * failed). The caller stamps them onto the session record so the dashboard
187
+ * can substitute the same values for `env(safe-area-inset-*)` in the
188
+ * replayed CSS; stamping only what was really applied keeps capture layout
189
+ * and replay layout in lockstep (recorded touch coordinates would
190
+ * misalign otherwise). */
191
+ async function applySafeAreaInsets(cdp, d) {
192
+ try {
193
+ await cdp.send(
194
+ // Not in playwright's Protocol types yet (Chromium ≥ ~135 method).
195
+ "Emulation.setSafeAreaInsetsOverride", { insets: { ...d.safeAreaInsets } });
196
+ return d.safeAreaInsets;
197
+ }
198
+ catch (err) {
199
+ // eslint-disable-next-line no-console
200
+ console.warn("[spectest] safe-area inset emulation unavailable:", err);
201
+ return null;
202
+ }
203
+ }
204
+ // ────────────────────────────────────────────────────────────────────────
205
+ // rrweb bootstrap
206
+ // ────────────────────────────────────────────────────────────────────────
207
+ // Vendored `@rrweb/record` UMD bundle (rrweb 2.x). Its global `rrwebRecord`
208
+ // is a module object — the record function is `rrwebRecord.record` (the
209
+ // bootstrap resolves both this and the legacy function-shaped global). Read
210
+ // once at module init — the SDK ships this file in the base snapshot so
211
+ // no network fetch happens inside the VM.
212
+ const RRWEB_BUNDLE = (() => {
213
+ try {
214
+ const here = path.dirname(fileURLToPath(import.meta.url));
215
+ const file = path.join(here, "vendor", "rrweb-record.min.js");
216
+ return readFileSync(file, "utf8");
217
+ }
218
+ catch (err) {
219
+ // If the vendored bundle is missing the SDK still works — the
220
+ // browser just records no rrweb events. Surface a warning so the
221
+ // mistake is visible.
222
+ // eslint-disable-next-line no-console
223
+ console.warn("[spectest] rrweb-record.min.js missing from SDK vendor dir; browser sessions will be empty", err);
224
+ return "";
225
+ }
226
+ })();
227
+ // Vendored console-record plugin bundle (UMD, exposes
228
+ // `rrwebPluginConsoleRecord` global). Captures the page's
229
+ // `console.{log,info,warn,error,debug,...}` calls and uncaught errors as
230
+ // rrweb Plugin events (`type: 6`, `data.plugin: "rrweb/console@1"`),
231
+ // which ride in the same `emit` stream as DOM mutations. Optional —
232
+ // missing bundle just means no console capture; the recorder still runs.
233
+ const RRWEB_CONSOLE_PLUGIN_BUNDLE = (() => {
234
+ try {
235
+ const here = path.dirname(fileURLToPath(import.meta.url));
236
+ const file = path.join(here, "vendor", "rrweb-plugin-console-record.umd.js");
237
+ return readFileSync(file, "utf8");
238
+ }
239
+ catch {
240
+ return "";
241
+ }
242
+ })();
243
+ // Bootstrap that runs after the bundle defines `rrwebRecord`. Idempotent —
244
+ // the same script is added with `Page.addScriptToEvaluateOnNewDocument`
245
+ // and runs on every new document, so the `__spectestRrwebInit` guard keeps
246
+ // us from double-starting.
247
+ //
248
+ // `lengthThreshold` caps per-arg serialized size (default 1000 → bumped to
249
+ // 10 KiB so longer error stacks survive without dwarfing the event stream).
250
+ const RRWEB_BOOTSTRAP = `
251
+ ;(function () {
252
+ // rrweb 2.x ships \`@rrweb/record\` whose UMD global is a module object
253
+ // (\`rrwebRecord.record\`); the pre-2.0 bundle exposed the record function
254
+ // directly. Resolve either shape so the bootstrap is version-agnostic,
255
+ // and stash it on \`window.__spectestRec\` for the drain/attach helpers
256
+ // (which run as separate injected expressions).
257
+ var rec = (typeof rrwebRecord === "function")
258
+ ? rrwebRecord
259
+ : (rrwebRecord && (rrwebRecord.record || rrwebRecord.default));
260
+ if (typeof rec !== "function") return;
261
+ if (window.__spectestRrwebInit) return;
262
+ window.__spectestRrwebInit = true;
263
+ window.__spectestRec = rec;
264
+ window.__spectestRrwebEvents = [];
265
+ var plugins = [];
266
+ try {
267
+ if (typeof rrwebPluginConsoleRecord === "object" &&
268
+ typeof rrwebPluginConsoleRecord.getRecordConsolePlugin === "function") {
269
+ plugins.push(rrwebPluginConsoleRecord.getRecordConsolePlugin({
270
+ level: ["assert", "debug", "error", "info", "log", "trace", "warn"],
271
+ lengthThreshold: 10000,
272
+ logger: window.console,
273
+ }));
274
+ }
275
+ } catch (e) { /* plugin init failed — keep recording DOM only. */ }
276
+ try {
277
+ rec({
278
+ emit: function (ev) { window.__spectestRrwebEvents.push(ev); },
279
+ plugins: plugins,
280
+ // Capture page assets into the event stream so the replay renders
281
+ // faithfully offline (the dashboard reconstructs the DOM with no
282
+ // access to the env's origin). \`inlineStylesheet\` (default, set
283
+ // explicitly) bakes <style>/<link> CSS into the snapshot;
284
+ // \`inlineImages\` turns <img> elements into data URIs — but ONLY
285
+ // synchronously, for images already decoded at serialize time
286
+ // (its late-load path patches the emitted event in place, which
287
+ // loses the race against our per-op drains; the image inliner
288
+ // below covers that case with synthetic mutation events).
289
+ // \`collectFonts\` only captures fonts added via the JS
290
+ // FontFace API — it does NOT inline static \`@font-face { src:url() }\`
291
+ // CSS (what next/font emits), so it's a no-op for most apps; the
292
+ // url()-based fonts are handled by the inliner below instead. All
293
+ // degrade gracefully — an asset that can't be read is skipped,
294
+ // never fatal to recording.
295
+ inlineStylesheet: true,
296
+ collectFonts: true,
297
+ inlineImages: true,
298
+ });
299
+ } catch (e) {
300
+ /* DOM not yet ready or rrweb mis-init — skip. */
301
+ }
302
+ // ── Inline @font-face fonts as base64 ──────────────────────────────
303
+ // The replay is viewed OUTSIDE this hermetic VM, so a @font-face whose
304
+ // src points at a same-origin URL (e.g. next/font's
305
+ // /_next/static/media/*.woff2) is unreachable at replay time and the
306
+ // page silently falls back to a system font. collectFonts above won't
307
+ // help (FontFace-API fonts only), so inline the bytes ourselves: once
308
+ // fonts have loaded, fetch each url() (same-origin -> allowed, and the
309
+ // page already trusts the in-VM CA), rewrite the rule's src to a data:
310
+ // URI, then take a fresh full snapshot so the now-self-contained CSS
311
+ // is captured. Plain string ops, no regex — this whole script ships
312
+ // inside a TS template literal where regex backslashes get mangled.
313
+ (function () {
314
+ if (window.__spectestFontsInlined) return;
315
+ window.__spectestFontsInlined = true;
316
+ // Diagnostics ride the console-record plugin, so they land in the
317
+ // recording (and state.db) — our only debugging window into the
318
+ // headless page. Prefix is grep-able; keep them terse.
319
+ function log(m) { try { console.log("[spectest-fonts] " + m); } catch (e) {} }
320
+ var cache = {};
321
+ function fetchDataUri(url) {
322
+ if (cache[url]) return cache[url];
323
+ cache[url] = fetch(url).then(function (r) {
324
+ if (!r.ok) throw new Error("HTTP " + r.status);
325
+ return r.blob();
326
+ }).then(function (blob) {
327
+ return new Promise(function (resolve, reject) {
328
+ var fr = new FileReader();
329
+ fr.onload = function () { resolve(fr.result); };
330
+ fr.onerror = reject;
331
+ fr.readAsDataURL(blob);
332
+ });
333
+ });
334
+ return cache[url];
335
+ }
336
+ // Extract every url() token in cssText, resolved against baseHref.
337
+ // CSSOM cssText keeps urls as authored — next/font emits
338
+ // absolute-path url(/_next/static/media/x.woff2) (no scheme), so a
339
+ // bare http prefix check misses them (that's why run b04cab1f / 6987
340
+ // saw withHttpUrl=0). Resolve against the sheet href (or the document
341
+ // base for inline style) before fetching. token is the raw string as
342
+ // it appears in cssText, used for the later string replace.
343
+ function urlsIn(text, baseHref) {
344
+ var out = [], i = 0;
345
+ while (true) {
346
+ var u = text.indexOf("url(", i);
347
+ if (u < 0) break;
348
+ var start = u + 4, end = text.indexOf(")", start);
349
+ if (end < 0) break;
350
+ var raw = text.slice(start, end).trim();
351
+ if (raw && (raw.charAt(0) === '"' || raw.charAt(0) === "'")) {
352
+ raw = raw.slice(1, raw.length - 1);
353
+ }
354
+ i = end + 1;
355
+ if (!raw || raw.lastIndexOf("data:", 0) === 0) continue; // already inline
356
+ var abs;
357
+ try { abs = new URL(raw, baseHref).href; } catch (e) { continue; }
358
+ if (abs.lastIndexOf("http", 0) !== 0) continue; // only fetchable http(s)
359
+ out.push({ token: raw, abs: abs });
360
+ }
361
+ return out;
362
+ }
363
+ function eachFontFace(rules, fn) {
364
+ for (var i = 0; i < rules.length; i++) {
365
+ var rule = rules[i];
366
+ if (rule.type === 5) fn(rule); // CSSRule.FONT_FACE_RULE
367
+ else if (rule.cssRules) { // @media / @supports nesting
368
+ try { eachFontFace(rule.cssRules, fn); } catch (e) {}
369
+ }
370
+ }
371
+ }
372
+ function run() {
373
+ // Collect each @font-face's *cssText* (reliable for any CSSRule —
374
+ // unlike .style.setProperty('src',…), which silently no-ops on
375
+ // CSSFontFaceRule in Chrome) plus the http url()s inside it.
376
+ var faces = [], sheets = document.styleSheets, readable = 0, faceCount = 0;
377
+ for (var s = 0; s < sheets.length; s++) {
378
+ var rules;
379
+ try { rules = sheets[s].cssRules; } catch (e) { continue; } // cross-origin
380
+ if (!rules) continue;
381
+ readable++;
382
+ var base = sheets[s].href || document.baseURI;
383
+ eachFontFace(rules, function (rule) {
384
+ faceCount++;
385
+ var cssText = rule.cssText || "";
386
+ var urls = urlsIn(cssText, base);
387
+ if (urls.length) faces.push({ cssText: cssText, urls: urls });
388
+ });
389
+ }
390
+ log("sheets=" + sheets.length + " readable=" + readable +
391
+ " fontFaces=" + faceCount + " withUrl=" + faces.length);
392
+ if (!faces.length) return;
393
+ var jobs = faces.map(function (face) {
394
+ return Promise.all(face.urls.map(function (u) {
395
+ return fetchDataUri(u.abs)
396
+ .then(function (d) { return { token: u.token, dataUri: d }; })
397
+ .catch(function (e) { log("fetch FAIL " + u.abs + " : " + (e && e.message)); return null; });
398
+ })).then(function (pairs) {
399
+ var text = face.cssText, changed = false;
400
+ for (var k = 0; k < pairs.length; k++) {
401
+ if (!pairs[k]) continue;
402
+ text = text.split(pairs[k].token).join(pairs[k].dataUri);
403
+ changed = true;
404
+ }
405
+ return changed ? text : null;
406
+ });
407
+ });
408
+ Promise.all(jobs).then(function (texts) {
409
+ var ok = texts.filter(function (t) { return t; });
410
+ if (!ok.length) { log("nothing inlined (all fetches failed?)"); return; }
411
+ // Inject the rewritten @font-face rules as a NEW <style> appended
412
+ // last. A later @font-face with identical descriptors wins, and a
413
+ // freshly-added node is captured reliably by rrweb's mutation
414
+ // observer (no dependence on CSSOM edits being serialized).
415
+ try {
416
+ var st = document.createElement("style");
417
+ st.setAttribute("data-spectest-inlined-fonts", "1");
418
+ st.appendChild(document.createTextNode(ok.join("\\n")));
419
+ (document.head || document.documentElement).appendChild(st);
420
+ log("injected " + ok.length + " @font-face rule(s) as data: URIs");
421
+ } catch (e) { log("inject FAIL " + (e && e.message)); return; }
422
+ try {
423
+ if (rec && typeof rec.takeFullSnapshot === "function") {
424
+ rec.takeFullSnapshot();
425
+ log("took full snapshot");
426
+ }
427
+ } catch (e) { log("snapshot FAIL " + (e && e.message)); }
428
+ });
429
+ }
430
+ try {
431
+ if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
432
+ document.fonts.ready.then(function () { setTimeout(run, 0); });
433
+ } else if (document.readyState === "complete") {
434
+ run();
435
+ } else {
436
+ window.addEventListener("load", run);
437
+ }
438
+ } catch (e) { log("init FAIL " + (e && e.message)); }
439
+ })();
440
+ // ── Inline late-loading <img>s as synthetic src mutations ──────────
441
+ // rrweb's inlineImages only bakes a data: URI in synchronously when
442
+ // the image is already decoded at serialize time. An image that
443
+ // finishes loading AFTER its node was serialized is patched by rrweb
444
+ // IN PLACE on the already-emitted event object — lost whenever a
445
+ // drain (op boundary) ships the buffer first. Classic case: a tap
446
+ // reveals a screen full of first-seen images; the step's drain fires
447
+ // right after the tap, the images decode a beat later, and the replay
448
+ // is left with raw in-VM URLs it can't fetch (hermetic env).
449
+ //
450
+ // Fix: listen for image loads ourselves (capture phase on document —
451
+ // runs BEFORE rrweb's target-phase listener) and, once the element
452
+ // has an id in rrweb's mirror, push a synthetic incremental mutation
453
+ // rewriting the img's src to a data: URI. A mutation event rides
454
+ // whatever drain comes next, so it survives the race even when the
455
+ // add event is long gone. Bytes come from a same-origin fetch of the
456
+ // ORIGINAL file (compact and lossless — rrweb's canvas path
457
+ // re-encodes JPEGs as much larger PNGs). It must be a plain \`src\`
458
+ // rewrite: the replayer honors \`rr_dataURL\` only when BUILDING a
459
+ // node; in the attribute-mutation path it just sets the literal
460
+ // attribute (only canvas is special-cased there).
461
+ (function () {
462
+ if (window.__spectestImgInliner) return;
463
+ window.__spectestImgInliner = true;
464
+ function log(m) { try { console.log("[spectest-img] " + m); } catch (e) {} }
465
+ var cache = {};
466
+ function fetchDataUri(url) {
467
+ if (cache[url]) return cache[url];
468
+ cache[url] = fetch(url).then(function (r) {
469
+ if (!r.ok) throw new Error("HTTP " + r.status);
470
+ return r.blob();
471
+ }).then(function (blob) {
472
+ return new Promise(function (resolve, reject) {
473
+ var fr = new FileReader();
474
+ fr.onload = function () { resolve(fr.result); };
475
+ fr.onerror = reject;
476
+ fr.readAsDataURL(blob);
477
+ });
478
+ });
479
+ return cache[url];
480
+ }
481
+ // The serialized-node meta when rrweb already inlined THIS src on
482
+ // it (rr_dataURL present and the recorded src matches — a stale
483
+ // bake from a previous src must not count).
484
+ function bakedMeta(mirror, img, src) {
485
+ try {
486
+ var meta = mirror.getMeta(img);
487
+ if (meta && meta.attributes && meta.attributes.rr_dataURL &&
488
+ meta.attributes.src === src) return meta;
489
+ } catch (e) {}
490
+ return null;
491
+ }
492
+ function onImgLoad(img, src) {
493
+ var mirror = rec && rec.mirror;
494
+ if (!mirror || typeof mirror.getId !== "function" ||
495
+ typeof mirror.getMeta !== "function") return;
496
+ // A patch visible NOW — before rrweb's own load listener has run
497
+ // (we're capture phase, it's target phase) — can only be the
498
+ // synchronous serialize-time bake, which shipped inside the event
499
+ // itself. Nothing to do.
500
+ var preId = mirror.getId(img);
501
+ if (preId > 0 && bakedMeta(mirror, img, src)) return;
502
+ var tries = 0;
503
+ function finish() {
504
+ var id = mirror.getId(img);
505
+ if (!(id > 0)) {
506
+ // Not serialized yet (mutation batch pending, or the initial
507
+ // full snapshot hasn't run — rrweb defers it to window load).
508
+ tries++;
509
+ if (tries < 40) setTimeout(finish, 50);
510
+ else log("drop (never serialized) " + src);
511
+ return;
512
+ }
513
+ var meta = bakedMeta(mirror, img, src);
514
+ if (meta && preId < 1) return; // serialized after load → sync bake
515
+ if (meta) {
516
+ // rrweb's async in-place patch landed after our capture-phase
517
+ // check. If the add is still in the buffer it now carries a
518
+ // PNG re-encode; strip it and ship ours instead (original
519
+ // bytes, and immune to a drain having already taken the add).
520
+ try { delete meta.attributes.rr_dataURL; } catch (e) {}
521
+ }
522
+ fetchDataUri(src).then(function (dataUri) {
523
+ var attrs = { src: dataUri };
524
+ // A srcset would out-rank the patched src in the replay
525
+ // iframe and point back at the unreachable original.
526
+ if (img.getAttribute && img.getAttribute("srcset")) attrs.srcset = "";
527
+ window.__spectestRrwebEvents.push({
528
+ type: 3,
529
+ data: { source: 0, texts: [], attributes: [{ id: id, attributes: attrs }], removes: [], adds: [] },
530
+ timestamp: Date.now(),
531
+ });
532
+ log("inlined id=" + id + " bytes=" + dataUri.length + " " + src);
533
+ }).catch(function (e) {
534
+ log("fetch FAIL " + src + " : " + (e && e.message));
535
+ });
536
+ }
537
+ // Next task, so rrweb's own load listener has run and its patch
538
+ // (if any) is observable above.
539
+ setTimeout(finish, 0);
540
+ }
541
+ try {
542
+ document.addEventListener("load", function (ev) {
543
+ var el = ev.target;
544
+ if (!el || !el.tagName || String(el.tagName).toLowerCase() !== "img") return;
545
+ var src = el.currentSrc || el.src || "";
546
+ if (!src || src.lastIndexOf("http", 0) !== 0) return; // data:/blob: already replayable
547
+ if (el.__spectestImgSrc === src) return; // this src already handled
548
+ el.__spectestImgSrc = src;
549
+ onImgLoad(el, src);
550
+ }, true);
551
+ } catch (e) { log("init FAIL " + (e && e.message)); }
552
+ })();
553
+ })();
554
+ `;
555
+ // The `;` between parts is load-bearing: a vendored bundle that ends
556
+ // without a trailing semicolon (rrweb 2.1.0's UMD ends in `}))`) would
557
+ // otherwise ASI-merge with the next part's leading `(function...` into a
558
+ // call expression — the TypeError kills everything after the bundle, so
559
+ // the globals define but the bootstrap never runs (zero rrweb events,
560
+ // empty replays).
561
+ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
562
+ ? `${RRWEB_BUNDLE}\n;\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n;\n${RRWEB_BOOTSTRAP}`
563
+ : "";
564
+ // Page-side expression that atomically swaps in a fresh buffer and
565
+ // returns the old one. Wrapped as a single expression so view.evaluate's
566
+ // `await (...)` wrapper accepts it.
567
+ //
568
+ // `forceFullIfMissing` (inlined by `drainExpr`) guards a recovery path
569
+ // for the most common replay failure: a post-navigation page whose full
570
+ // snapshot never made it into the stream. rrweb defers its *initial*
571
+ // full snapshot to the page's `load` event, but our drains fire at op
572
+ // boundaries — `waitFor` in particular returns the instant its predicate
573
+ // is truthy, which is usually right after `DOMContentLoaded`, *before*
574
+ // `load`. Login/redirect chains compound this: every new document resets
575
+ // `window.__spectestRrwebEvents`, so a partial chunk from an intermediate
576
+ // page is discarded. The net effect is a final step that carries only a
577
+ // `DOMContentLoaded` (type 0) and no Meta/FullSnapshot — the player then
578
+ // has no DOM for the new page and keeps showing the previous one (e.g.
579
+ // the auth provider's login screen instead of the post-login dashboard).
580
+ //
581
+ // When the caller detects the document changed since the last drain
582
+ // (`view.url` differs) it passes `force=true`; if the outgoing buffer
583
+ // then lacks any FullSnapshot (type 2), we call `rec.takeFullSnapshot()`
584
+ // to synthesize one for the *current* DOM. That emits a fresh Meta
585
+ // (with the correct href) + FullSnapshot, so the step is self-contained
586
+ // and seeking to it shows the right page. Gating on url-change + missing
587
+ // snapshot keeps steady-state steps (clicks/types on an unchanged page,
588
+ // or navigations where `load` already fired) from bloating the stream
589
+ // with redundant snapshots.
590
+ function drainExpr(forceFullIfMissing) {
591
+ return `(function () {
592
+ try {
593
+ var rec = window.__spectestRec;
594
+ if (${forceFullIfMissing ? "true" : "false"} &&
595
+ window.__spectestRrwebInit &&
596
+ rec && typeof rec.takeFullSnapshot === "function") {
597
+ var b = window.__spectestRrwebEvents || [];
598
+ var hasFull = false;
599
+ for (var i = 0; i < b.length; i++) {
600
+ if (b[i] && b[i].type === 2) { hasFull = true; break; }
601
+ }
602
+ if (!hasFull) rec.takeFullSnapshot();
603
+ }
604
+ } catch (e) { /* recording inactive or DOM detached — drain what's there. */ }
605
+ var buf = window.__spectestRrwebEvents;
606
+ if (!buf || !buf.length) return [];
607
+ window.__spectestRrwebEvents = [];
608
+ return buf;
609
+ })()`;
610
+ }
611
+ // Pre-opened view pool. Renderer spawn is the expensive part of
612
+ // `ctx.browser()` — ~1.8s for the first view in a fresh Chrome and
613
+ // (measured 2026-06-05) a constant ~1.2-1.5s per view in a Chrome that
614
+ // lived through a snapshot restore, i.e. in every test fork. The daemon
615
+ // fills this pool once at the end of /bootstrap; the warm-template and
616
+ // pretest snapshots are captured after that, so EVERY fork inherits a
617
+ // live renderer and the first `ctx.browser()` of a test skips the spawn
618
+ // entirely. Lives in daemon memory → forks each get the pristine pool,
619
+ // and a test consuming it never affects its siblings (same isolation as
620
+ // fake state).
621
+ const VIEW_POOL = [];
622
+ /** Spawn a page (+ CDP session + rrweb init script) into `context` — the
623
+ * slow part (renderer process spawn). */
624
+ async function spawnPage(context) {
625
+ const page = await context.newPage();
626
+ const cdp = await context.newCDPSession(page);
627
+ // Without Page.enable the addScriptToEvaluateOnNewDocument registration
628
+ // silently never fires on this session (verified in the fork spike).
629
+ await cdp.send("Page.enable");
630
+ let recordingInstalled = false;
631
+ if (PAGE_INIT_SCRIPT) {
632
+ try {
633
+ await cdp.send("Page.addScriptToEvaluateOnNewDocument", {
634
+ source: PAGE_INIT_SCRIPT,
635
+ });
636
+ recordingInstalled = true;
637
+ }
638
+ catch (err) {
639
+ // Without rrweb the browser still works; just no replay.
640
+ // eslint-disable-next-line no-console
641
+ console.warn("[spectest] failed to install rrweb recorder:", err);
642
+ }
643
+ }
644
+ return { page, cdp, recordingInstalled };
645
+ }
646
+ /** Acquire the context a view of this shape lives in. */
647
+ async function contextFor(width, height, device) {
648
+ if (device) {
649
+ const browser = await ensurePlaywrightBrowser();
650
+ const context = await browser.newContext(deviceContextOptions(device));
651
+ context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
652
+ context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
653
+ return { context, ownsContext: true };
654
+ }
655
+ if (width === 1280 && height === 720) {
656
+ return { context: await ensureDesktopContext(), ownsContext: false };
657
+ }
658
+ const browser = await ensurePlaywrightBrowser();
659
+ const context = await browser.newContext({
660
+ viewport: { width, height },
661
+ // Reduced-motion for replay fidelity — see deviceContextOptions.
662
+ reducedMotion: "reduce",
663
+ });
664
+ context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
665
+ context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
666
+ return { context, ownsContext: true };
667
+ }
668
+ /** Create a default-desktop view for the pool. */
669
+ async function createView(width, height) {
670
+ const { context } = await contextFor(width, height, null);
671
+ return spawnPage(context);
672
+ }
673
+ /**
674
+ * Pre-open `n` views into the pool (called by the daemon at the end of
675
+ * /bootstrap, before the warm-template snapshot is captured). Only
676
+ * default-viewport views are pooled — `openBrowser` with a custom
677
+ * width/height bypasses the pool.
678
+ */
679
+ export async function prewarmViewPool(n = 1) {
680
+ for (let i = 0; i < n; i++) {
681
+ VIEW_POOL.push(await createView(1280, 720));
682
+ }
683
+ }
684
+ /**
685
+ * Open a browser view (a page in the shared Chromium). Serves from the
686
+ * pre-opened pool when the caller uses the default viewport.
687
+ *
688
+ * This is the EPHEMERAL path — `close()` destroys the view. The daemon's
689
+ * `ctx.browser()`/`ctx.mobile()` go through {@link acquirePersistentBrowser}
690
+ * / {@link acquirePersistentMobileBackend} instead, which keep one view
691
+ * alive across tests so it rides snapshots/forks.
692
+ */
693
+ export async function openBrowser(opts = {}) {
694
+ return openMobileBackend(opts);
695
+ }
696
+ /**
697
+ * Like {@link openBrowser} but returns the {@link MobileBackend} superset
698
+ * (touch + probe). When `opts.frame === "mobile"` the view is created at the
699
+ * fixed device viewport, bypasses the (desktop-sized) pool, and has CDP
700
+ * device emulation applied before the first navigation. Desktop callers go
701
+ * through {@link openBrowser} and get the narrower {@link Browser} view of
702
+ * the same object.
703
+ */
704
+ export async function openMobileBackend(opts = {}) {
705
+ const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
706
+ const wantW = device ? device.viewport.width : opts.width ?? 1280;
707
+ const wantH = device ? device.viewport.height : opts.height ?? 720;
708
+ // Mobile views are never pooled — the pool holds only default-desktop
709
+ // views (in the shared desktop context), and a mobile view needs its own
710
+ // emulated context anyway.
711
+ const pooled = !device && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
712
+ let holder;
713
+ if (pooled) {
714
+ holder = {
715
+ context: await ensureDesktopContext(),
716
+ ownsContext: false,
717
+ page: pooled.page,
718
+ cdp: pooled.cdp,
719
+ lastTitle: "",
720
+ recordingInstalled: pooled.recordingInstalled,
721
+ device,
722
+ width: wantW,
723
+ height: wantH,
724
+ safeAreaInsets: null,
725
+ initScripts: [],
726
+ };
727
+ }
728
+ else {
729
+ holder = await newHolder(wantW, wantH, device);
730
+ }
731
+ const { backend } = buildBackend(holder, opts.recorder ?? null, {
732
+ persistent: false,
733
+ });
734
+ // We deliberately don't forward `opts.url` to the constructor — going
735
+ // through our own `navigate()` keeps the recorder log uniform (one
736
+ // event per navigation, with timing) and drains rrweb after the load.
737
+ if (opts.initScript !== undefined)
738
+ await installInitScript(holder, opts.initScript);
739
+ if (opts.url !== undefined) {
740
+ await backend.goto(opts.url);
741
+ }
742
+ return backend;
743
+ }
744
+ // ────────────────────────────────────────────────────────────────────────
745
+ // Persistent sessions (the default behind ctx.browser / ctx.mobile)
746
+ // ────────────────────────────────────────────────────────────────────────
747
+ // One long-lived desktop browser plus one mobile session per app URL.
748
+ // Module state lives in daemon memory, so it forks with the snapshot the
749
+ // same way fake `state` and TEST_DATA do: a test's browser — its live
750
+ // page, cookies, localStorage, in-memory SPA state — is captured in the
751
+ // post-test snapshot and inherited by `dependsOn` children, while sibling
752
+ // forks never see each other's sessions. That's what lets a child test
753
+ // continue where its parent left off (e.g. already signed in) instead of
754
+ // re-navigating and re-authenticating.
755
+ let SHARED_BROWSER = null;
756
+ const SHARED_MOBILE = new Map();
757
+ async function newHolder(width, height, device) {
758
+ const { context, ownsContext } = await contextFor(width, height, device);
759
+ const spawned = await spawnPage(context);
760
+ const holder = {
761
+ context,
762
+ ownsContext,
763
+ page: spawned.page,
764
+ cdp: spawned.cdp,
765
+ lastTitle: "",
766
+ recordingInstalled: spawned.recordingInstalled,
767
+ device,
768
+ width,
769
+ height,
770
+ safeAreaInsets: null,
771
+ initScripts: [],
772
+ };
773
+ if (device)
774
+ holder.safeAreaInsets = await applySafeAreaInsets(holder.cdp, device);
775
+ return holder;
776
+ }
777
+ /**
778
+ * Install a declared init script (from `BrowserOptions.initScript` / a mobile
779
+ * app's `initScript`) on a fresh holder's page: it runs before every document
780
+ * loaded from now on, ahead of the document's own scripts. Called BEFORE the
781
+ * session's first navigation, so it wins the race on the first document too.
782
+ * Recorded via `holder.initScripts` so a DNS-recovery page rebuild
783
+ * (`rebuildView`) re-installs it. Declared config, not a test action, so it
784
+ * emits no timeline event.
785
+ */
786
+ async function installInitScript(holder, source) {
787
+ await holder.cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
788
+ holder.initScripts.push(source);
789
+ }
790
+ // Runs in the page when a persistent view is attached to a new test's
791
+ // recorder: drop whatever rrweb buffered since the previous detach (idle
792
+ // mutations; the previous test's session already drained everything it
793
+ // owns) and emit a fresh Meta + FullSnapshot so the new session's replay
794
+ // is self-contained from its first event.
795
+ const ATTACH_RESET_EXPR = `(function () {
796
+ window.__spectestRrwebEvents = [];
797
+ try {
798
+ var rec = window.__spectestRec;
799
+ if (rec && typeof rec.takeFullSnapshot === "function") {
800
+ rec.takeFullSnapshot();
801
+ }
802
+ } catch (e) { /* recording not active on this document */ }
803
+ return true;
804
+ })()`;
805
+ async function attachReset(holder) {
806
+ if (!holder.recordingInstalled)
807
+ return;
808
+ try {
809
+ await holder.page.evaluate(ATTACH_RESET_EXPR);
810
+ }
811
+ catch {
812
+ // Page mid-navigation or renderer unhappy — the first drain forces a
813
+ // full snapshot when one is missing (drainExpr), so replay still works.
814
+ }
815
+ }
816
+ /**
817
+ * Acquire THE persistent desktop browser (creating it on first use). There
818
+ * is deliberately a single one — `ctx.browser()` always returns it — so a
819
+ * test DAG shares one browsing session along each branch. The first call's
820
+ * options win; later calls attach to the existing view as-is.
821
+ */
822
+ export async function acquirePersistentBrowser(opts = {}) {
823
+ const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
824
+ let attached = true;
825
+ if (!SHARED_BROWSER) {
826
+ attached = false;
827
+ SHARED_BROWSER = await newHolder(device ? device.viewport.width : opts.width ?? 1280, device ? device.viewport.height : opts.height ?? 720, device);
828
+ }
829
+ else {
830
+ await attachReset(SHARED_BROWSER);
831
+ }
832
+ const holder = SHARED_BROWSER;
833
+ const { backend, detach } = buildBackend(holder, opts.recorder ?? null, {
834
+ persistent: true,
835
+ onDestroy: () => {
836
+ if (SHARED_BROWSER === holder)
837
+ SHARED_BROWSER = null;
838
+ },
839
+ });
840
+ // Fresh session only: an attached view already carries the init script on
841
+ // its (forked) holder, and first-call-wins means a later call's options
842
+ // don't retroactively apply.
843
+ if (!attached && opts.initScript !== undefined) {
844
+ await installInitScript(holder, opts.initScript);
845
+ }
846
+ if (!attached && opts.url !== undefined)
847
+ await backend.goto(opts.url);
848
+ return { browser: backend, attached, detach };
849
+ }
850
+ /**
851
+ * Acquire the persistent mobile session for an app URL (one per app). A
852
+ * fresh session navigates to the app; an attach continues on the live page.
853
+ */
854
+ export async function acquirePersistentMobileBackend(url, recorder, initScript) {
855
+ const existing = SHARED_MOBILE.get(url);
856
+ const holder = existing ??
857
+ (await newHolder(LATEST_IPHONE.viewport.width, LATEST_IPHONE.viewport.height, LATEST_IPHONE));
858
+ if (existing) {
859
+ await attachReset(holder);
860
+ }
861
+ else {
862
+ SHARED_MOBILE.set(url, holder);
863
+ }
864
+ const { backend, detach } = buildBackend(holder, recorder, {
865
+ persistent: true,
866
+ onDestroy: () => {
867
+ if (SHARED_MOBILE.get(url) === holder)
868
+ SHARED_MOBILE.delete(url);
869
+ },
870
+ });
871
+ if (!existing) {
872
+ // Fresh session: install the app's init script before the first
873
+ // navigation so its first document already has the shims.
874
+ if (initScript !== undefined)
875
+ await installInitScript(holder, initScript);
876
+ await backend.goto(url);
877
+ }
878
+ return { browser: backend, attached: existing !== undefined, detach };
879
+ }
880
+ /** True when a navigation failed on Chromium name resolution. */
881
+ function isNameNotResolved(err) {
882
+ return String(err?.message ?? err).includes("ERR_NAME_NOT_RESOLVED");
883
+ }
884
+ /**
885
+ * Whether the daemon's own resolver can look the URL's host up. Chromium
886
+ * runs with AsyncDns disabled (see CHROME_ARGV) so it uses the same
887
+ * getaddrinfo path — a host the daemon resolves but Chrome can't means
888
+ * the RENDERER is broken, not the name.
889
+ */
890
+ async function daemonResolves(url) {
891
+ try {
892
+ await dns.lookup(new URL(url).hostname);
893
+ return true;
894
+ }
895
+ catch {
896
+ return false;
897
+ }
898
+ }
899
+ /**
900
+ * Replace a persistent holder's page with a freshly-spawned one in the SAME
901
+ * context. Context state — cookies, localStorage — survives; only
902
+ * renderer-held page state is lost, and this path only runs when that
903
+ * renderer already can't navigate.
904
+ *
905
+ * Known trigger: a renderer created before a snapshot fails its first
906
+ * post-restore navigation with `net::ERR_NAME_NOT_RESOLVED` even though a
907
+ * fresh page in the SAME restored Chromium resolves fine (root cause never
908
+ * found — see the disabled-prewarm note at the end of /bootstrap in
909
+ * daemon.ts). Persistent sessions walk into exactly that scenario whenever
910
+ * a child test navigates, so the recovery lives here: rebuild the page,
911
+ * retry once.
912
+ */
913
+ async function rebuildView(holder) {
914
+ try {
915
+ await holder.page.close();
916
+ }
917
+ catch {
918
+ /* page may already be gone */
919
+ }
920
+ const fresh = await spawnPage(holder.context);
921
+ holder.page = fresh.page;
922
+ holder.cdp = fresh.cdp;
923
+ holder.recordingInstalled = fresh.recordingInstalled;
924
+ if (holder.device) {
925
+ holder.safeAreaInsets = await applySafeAreaInsets(holder.cdp, holder.device);
926
+ }
927
+ for (const source of holder.initScripts) {
928
+ await holder.cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
929
+ }
930
+ }
931
+ function buildBackend(holder, recorder, buildOpts) {
932
+ // All page access goes through `holder.page` — never capture the page in
933
+ // a local — because the DNS-recovery rebuild swaps it mid-wrapper.
934
+ // `recordingEnded` stops this wrapper's recorder writes (test end);
935
+ // `viewClosed` tracks actual destruction (author called close()).
936
+ let recordingEnded = false;
937
+ let viewClosed = false;
938
+ const sessionStart = Date.now();
939
+ let stepSeq = 0;
940
+ // URL observed at the previous drain. A change means the main frame
941
+ // navigated to a new document since we last looked, which is exactly
942
+ // when rrweb's load-deferred full snapshot is most likely to be
943
+ // missing from the chunk we're about to drain (see `drainExpr`).
944
+ let lastDrainUrl = null;
945
+ async function drain(action) {
946
+ if (!holder.recordingInstalled || !recorder || recordingEnded)
947
+ return;
948
+ try {
949
+ const urlChanged = holder.page.url() !== lastDrainUrl;
950
+ const events = (await holder.page.evaluate(drainExpr(urlChanged)));
951
+ lastDrainUrl = holder.page.url();
952
+ if (Array.isArray(events) && events.length > 0) {
953
+ recorder.recordStep({
954
+ stepSeq: stepSeq++,
955
+ action,
956
+ tOffsetMs: Date.now() - sessionStart,
957
+ events,
958
+ });
959
+ }
960
+ }
961
+ catch {
962
+ // Page may be mid-navigation, detached, or the view is closing.
963
+ // Losing an occasional drain is acceptable — the next op picks up
964
+ // the rest of the buffer.
965
+ }
966
+ }
967
+ async function instrumented(action, fields, fn, opts) {
968
+ const t = Date.now();
969
+ const resv = reserveEvent();
970
+ try {
971
+ const result = await fn();
972
+ // Refresh the sync title cache (playwright's title() is async).
973
+ try {
974
+ holder.lastTitle = await holder.page.title();
975
+ }
976
+ catch {
977
+ /* page mid-navigation or closed — keep the stale cache */
978
+ }
979
+ // `sessionTimestamp` is the post-op wall clock — that's where we
980
+ // want the dashboard's seek to land, so clicking "type 'foo'"
981
+ // shows the input *with* the text, not the empty field just
982
+ // before the keystrokes. The dashboard converts to a player
983
+ // offset by subtracting `events[0].timestamp`.
984
+ const endT = Date.now();
985
+ const seq = recordBrowser({
986
+ action,
987
+ ...fields,
988
+ ...(recorder
989
+ ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
990
+ : {}),
991
+ durationMs: endT - t,
992
+ }, resv);
993
+ await drain(action);
994
+ // Reads (`opts.wrap`) return user-visible JS values someone is likely to
995
+ // assert on — provenance-wrap so `expect(...)` nests under this step.
996
+ // Actions return void/internals; leave them raw to avoid Proxy surprises.
997
+ if (seq !== undefined && opts?.wrap) {
998
+ return wrap(result, seq);
999
+ }
1000
+ return result;
1001
+ }
1002
+ catch (err) {
1003
+ const e = err;
1004
+ const endT = Date.now();
1005
+ recordBrowser({
1006
+ action,
1007
+ ...fields,
1008
+ ...(recorder
1009
+ ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
1010
+ : {}),
1011
+ durationMs: endT - t,
1012
+ error: e?.message ?? String(err),
1013
+ }, resv);
1014
+ // Still try to drain — the failure itself may have produced
1015
+ // useful rrweb events (mutations from a half-loaded page, etc.).
1016
+ await drain(action);
1017
+ throw err;
1018
+ }
1019
+ }
1020
+ async function endRecording() {
1021
+ if (recordingEnded)
1022
+ return;
1023
+ // Final drain before we stop writing to this recorder.
1024
+ await drain("close");
1025
+ recordingEnded = true;
1026
+ }
1027
+ const strategy = holder.device ? mobileStrategy : desktopStrategy;
1028
+ const keyboard = {
1029
+ press(key) {
1030
+ return instrumented("press", { key }, () => holder.page.keyboard.press(key));
1031
+ },
1032
+ type(text) {
1033
+ const t = truncateUtf8(text);
1034
+ return instrumented("type", { text: t.value, textTruncated: t.truncated }, () => holder.page.keyboard.type(text));
1035
+ },
1036
+ insertText(text) {
1037
+ // insertText path (no per-char keydown) — the paste path.
1038
+ const t = truncateUtf8(text);
1039
+ return instrumented("type", { text: t.value, textTruncated: t.truncated }, () => holder.page.keyboard.insertText(text));
1040
+ },
1041
+ };
1042
+ const mouse = {
1043
+ click(x, y, opts) {
1044
+ return instrumented("click", { x, y }, () => holder.page.mouse.click(x, y, opts));
1045
+ },
1046
+ dblclick(x, y) {
1047
+ return instrumented("dblclick", { x, y }, () => holder.page.mouse.dblclick(x, y));
1048
+ },
1049
+ move(x, y) {
1050
+ return instrumented("mouse.move", { x, y }, () => holder.page.mouse.move(x, y));
1051
+ },
1052
+ wheel(dx, dy) {
1053
+ return instrumented("scroll", { dx, dy }, () => holder.page.mouse.wheel(dx, dy));
1054
+ },
1055
+ };
1056
+ const touchscreen = {
1057
+ tap(x, y, opts) {
1058
+ // Recorded as "tap"; the CDP touch (with dwell) fires RN-Web responders.
1059
+ return instrumented("tap", { x, y }, () => backend.rawTap(x, y, opts?.duration));
1060
+ },
1061
+ };
1062
+ const backend = {
1063
+ url() {
1064
+ return holder.page.url();
1065
+ },
1066
+ async title() {
1067
+ try {
1068
+ holder.lastTitle = await holder.page.title();
1069
+ }
1070
+ catch {
1071
+ /* page mid-navigation or closed — return the last cached title */
1072
+ }
1073
+ return holder.lastTitle;
1074
+ },
1075
+ get safeAreaInsets() {
1076
+ return holder.safeAreaInsets;
1077
+ },
1078
+ keyboard,
1079
+ mouse,
1080
+ touchscreen,
1081
+ goto(url) {
1082
+ recorder?.noteNavigation?.(url);
1083
+ return instrumented("goto", { url }, async () => {
1084
+ try {
1085
+ await holder.page.goto(url, { waitUntil: "load" });
1086
+ }
1087
+ catch (err) {
1088
+ // Restored-renderer DNS bug (see `rebuildView`): only when the
1089
+ // view is persistent (so it may have lived through a snapshot
1090
+ // restore) and the daemon itself CAN resolve the host — a name
1091
+ // that's genuinely unknown must fail without discarding the live
1092
+ // page state a rebuild would cost.
1093
+ if (!buildOpts.persistent || !isNameNotResolved(err))
1094
+ throw err;
1095
+ if (!(await daemonResolves(url)))
1096
+ throw err;
1097
+ await rebuildView(holder);
1098
+ await holder.page.goto(url, { waitUntil: "load" });
1099
+ }
1100
+ });
1101
+ },
1102
+ goBack() {
1103
+ return instrumented("goBack", {}, async () => {
1104
+ await holder.page.goBack();
1105
+ });
1106
+ },
1107
+ goForward() {
1108
+ return instrumented("goForward", {}, async () => {
1109
+ await holder.page.goForward();
1110
+ });
1111
+ },
1112
+ reload() {
1113
+ return instrumented("reload", {}, async () => {
1114
+ await holder.page.reload();
1115
+ });
1116
+ },
1117
+ locator: (css) => makeLocator(backend, strategy, { steps: [{ m: "locator", args: [css] }] }),
1118
+ getByRole: (role, opts) => makeLocator(backend, strategy, { steps: [{ m: "getByRole", args: [role, opts] }] }),
1119
+ getByText: (text, opts) => makeLocator(backend, strategy, { steps: [{ m: "getByText", args: [text, opts] }] }),
1120
+ getByLabel: (text, opts) => makeLocator(backend, strategy, { steps: [{ m: "getByLabel", args: [text, opts] }] }),
1121
+ getByPlaceholder: (text, opts) => makeLocator(backend, strategy, { steps: [{ m: "getByPlaceholder", args: [text, opts] }] }),
1122
+ getByAltText: (text, opts) => makeLocator(backend, strategy, { steps: [{ m: "getByAltText", args: [text, opts] }] }),
1123
+ getByTitle: (text, opts) => makeLocator(backend, strategy, { steps: [{ m: "getByTitle", args: [text, opts] }] }),
1124
+ getByTestId: (id) => makeLocator(backend, strategy, { steps: [{ m: "getByTestId", args: [id] }] }),
1125
+ async evaluate(description, fn, arg) {
1126
+ const src = typeof fn === "string" ? fn : fn.toString();
1127
+ const t = truncateUtf8(src);
1128
+ return instrumented("evaluate", { description, script: t.value, scriptTruncated: t.truncated }, async () => {
1129
+ if (typeof fn === "string") {
1130
+ return (await holder.page.evaluate(toEvaluable(fn)));
1131
+ }
1132
+ return (await holder.page.evaluate(fn, arg));
1133
+ }, { wrap: true });
1134
+ },
1135
+ async waitForFunction(description, fn, arg, options = {}) {
1136
+ const timeoutMs = options.timeout ?? 5_000;
1137
+ const intervalMs = options.polling ?? 100;
1138
+ const src = typeof fn === "string" ? fn : fn.toString();
1139
+ const t = truncateUtf8(src);
1140
+ // Pass `fields` by reference so the loop can stamp the final
1141
+ // attempt count onto the event before instrumented records it.
1142
+ const fields = {
1143
+ description,
1144
+ script: t.value,
1145
+ scriptTruncated: t.truncated,
1146
+ attempts: 0,
1147
+ };
1148
+ // Normalised once up front — string bodies go through `toEvaluable`
1149
+ // (statement bodies work); functions run with `arg`.
1150
+ const evaluable = typeof fn === "string" ? toEvaluable(fn) : null;
1151
+ const evalOnce = () => evaluable !== null
1152
+ ? holder.page.evaluate(evaluable)
1153
+ : holder.page.evaluate(fn, arg);
1154
+ return instrumented("waitForFunction", fields, async () => {
1155
+ const deadline = Date.now() + timeoutMs;
1156
+ // The polling loop calls `page.evaluate` directly (not the wrapped
1157
+ // `evaluate`) so it doesn't fan out into N events or N rrweb drains.
1158
+ // rrweb keeps buffering page-side; the wrapper's single drain at the
1159
+ // end collects everything.
1160
+ for (;;) {
1161
+ fields.attempts = (fields.attempts ?? 0) + 1;
1162
+ let v;
1163
+ try {
1164
+ v = await evalOnce();
1165
+ }
1166
+ catch (err) {
1167
+ if (Date.now() >= deadline)
1168
+ throw err;
1169
+ await new Promise((r) => setTimeout(r, intervalMs));
1170
+ continue;
1171
+ }
1172
+ if (v)
1173
+ return v;
1174
+ if (Date.now() >= deadline) {
1175
+ throw new Error(`waitForFunction ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${fields.attempts} attempts)`);
1176
+ }
1177
+ await new Promise((r) => setTimeout(r, intervalMs));
1178
+ }
1179
+ }, { wrap: true });
1180
+ },
1181
+ async screenshot() {
1182
+ const fields = { format: "png" };
1183
+ return instrumented("screenshot", fields, async () => {
1184
+ // Artifacts are eval-only for now: the daemon wires a
1185
+ // registerArtifact sink onto eval sessions' recorders and nothing
1186
+ // else's, so its absence means "test run / recorder-less browser".
1187
+ const register = recorder?.registerArtifact;
1188
+ if (!register) {
1189
+ throw new Error("screenshot() uploads the capture as a downloadable artifact and is " +
1190
+ "currently only available inside an eval (spectest_eval / `spectest env eval`) — " +
1191
+ "it cannot be used in test runs.");
1192
+ }
1193
+ // Raw CDP rather than page.screenshot(): captures the viewport
1194
+ // exactly like the pre-Playwright backend.
1195
+ const res = (await holder.cdp.send("Page.captureScreenshot", {
1196
+ format: "png",
1197
+ }));
1198
+ const id = generateId("art");
1199
+ register({
1200
+ id,
1201
+ kind: "screenshot",
1202
+ contentType: "image/png",
1203
+ // CDP already hands us base64 — pass it through un-recoded and
1204
+ // derive the raw byte count from the encoding.
1205
+ sizeBytes: base64ByteLength(res.data),
1206
+ bytesBase64: res.data,
1207
+ });
1208
+ // Stamped onto the browser event before instrumented() records it —
1209
+ // same mutate-fields-inside-fn() precedent as waitFor's `attempts`.
1210
+ fields.artifactId = id;
1211
+ return id;
1212
+ });
1213
+ },
1214
+ async close() {
1215
+ // Ends this wrapper's recording AND destroys the view. For a
1216
+ // persistent view this is the author-facing escape hatch to a fresh
1217
+ // browser: `onDestroy` drops the holder from the shared registry, so
1218
+ // the next `ctx.browser()`/`ctx.mobile()` creates a new one. The
1219
+ // routine test-end path is `detach` (recording stops, view lives on
1220
+ // into the post-test snapshot).
1221
+ await endRecording();
1222
+ if (viewClosed)
1223
+ return;
1224
+ viewClosed = true;
1225
+ buildOpts.onDestroy?.();
1226
+ try {
1227
+ await holder.page.close();
1228
+ }
1229
+ catch {
1230
+ /* already closed by the runtime */
1231
+ }
1232
+ if (holder.ownsContext) {
1233
+ try {
1234
+ await holder.context.close();
1235
+ }
1236
+ catch {
1237
+ /* context already gone (browser died) */
1238
+ }
1239
+ }
1240
+ },
1241
+ // ── Mobile-only primitives ──────────────────────────────────────────
1242
+ async rawTap(x, y, durationMs) {
1243
+ // Dispatches a real touch so RN-Web's responder system fires.
1244
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1245
+ type: "touchStart",
1246
+ touchPoints: [{ x, y, id: 0 }],
1247
+ });
1248
+ // Dwell between start and end, like a real finger. An instant
1249
+ // touchStart→touchEnd starves RN Pressables whose `onPressIn`
1250
+ // mutates state (optimistic label flips, scale animations): React
1251
+ // re-renders mid-gesture and the press never completes. The dwell
1252
+ // lets that commit land before release; well under any long-press
1253
+ // threshold (RN default 500ms).
1254
+ await new Promise((r) => setTimeout(r, durationMs ?? TAP_DWELL_MS));
1255
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1256
+ type: "touchEnd",
1257
+ touchPoints: [],
1258
+ });
1259
+ },
1260
+ async swipe(direction, opts) {
1261
+ const vp = await backend.probe("({ w: window.innerWidth, h: window.innerHeight })");
1262
+ const cx = vp.w / 2;
1263
+ const cy = vp.h / 2;
1264
+ const horiz = direction === "left" || direction === "right";
1265
+ const dist = opts?.distance ?? Math.round((horiz ? vp.w : vp.h) * 0.5);
1266
+ const dx = direction === "left" ? -dist : direction === "right" ? dist : 0;
1267
+ const dy = direction === "up" ? -dist : direction === "down" ? dist : 0;
1268
+ await backend.swipeBy(cx, cy, dx, dy);
1269
+ },
1270
+ swipeBy(x, y, dx, dy) {
1271
+ return instrumented("scroll", { dx, dy }, async () => {
1272
+ const steps = 8;
1273
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1274
+ type: "touchStart",
1275
+ touchPoints: [{ x, y, id: 0 }],
1276
+ });
1277
+ for (let i = 1; i <= steps; i++) {
1278
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1279
+ type: "touchMove",
1280
+ touchPoints: [{ x: x + (dx * i) / steps, y: y + (dy * i) / steps, id: 0 }],
1281
+ });
1282
+ }
1283
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1284
+ type: "touchEnd",
1285
+ touchPoints: [],
1286
+ });
1287
+ });
1288
+ },
1289
+ probe(expression) {
1290
+ return holder.page.evaluate(expression);
1291
+ },
1292
+ silentRead(fn) {
1293
+ return fn(holder.page);
1294
+ },
1295
+ async recordSettled(action, fields, waitedMs, error) {
1296
+ // No page work — the matcher already read the value via silentRead. We
1297
+ // only mint the timeline anchor: the seq the assertion nests under, plus
1298
+ // `sessionTimestamp` (post-settle wall clock) so the dashboard seeks the
1299
+ // replay to the frame the assertion observed.
1300
+ const endT = Date.now();
1301
+ const seq = recordBrowser({
1302
+ action,
1303
+ ...fields,
1304
+ ...(recorder
1305
+ ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
1306
+ : {}),
1307
+ durationMs: waitedMs,
1308
+ ...(error ? { error } : {}),
1309
+ });
1310
+ // Drain the rrweb the page buffered while the matcher waited into this
1311
+ // step's chunk, so `settledTarget` has bounds to seek into.
1312
+ await drain(action);
1313
+ return seq;
1314
+ },
1315
+ pageOp(action, fields, fn, opts) {
1316
+ return instrumented(action, fields, () => fn(holder.page), opts);
1317
+ },
1318
+ };
1319
+ return { backend, detach: endRecording };
1320
+ }