@specific.dev/spectest 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/browser.ts ADDED
@@ -0,0 +1,824 @@
1
+ // Headless browser handle for tests. Thin wrapper around Bun.WebView
2
+ // driving Chromium-over-CDP. The wrapper keeps the public surface stable
3
+ // (we'd swap to a different backend without changing tests) and routes
4
+ // every call through the recorder so browser actions show up in a test's
5
+ // event log alongside exec/fetch/assertion events.
6
+ //
7
+ // On top of the per-op event recording, every Browser also captures an
8
+ // rrweb session: rrweb-record is injected into every document via CDP
9
+ // `Page.addScriptToEvaluateOnNewDocument`, the page buffers events on
10
+ // `window.__spectestRrwebEvents`, and we drain that buffer after every
11
+ // Browser op (and a final time on close). Drained chunks are tagged
12
+ // with the op that triggered the drain — that's the per-step structure
13
+ // the persistence layer stores so the dashboard can correlate replay
14
+ // timeline with the test's browser actions.
15
+ //
16
+ // Headless-Linux specifics live here: we force the chrome backend, add
17
+ // `--no-sandbox` (Chrome refuses to run as root otherwise) and
18
+ // `--disable-dev-shm-usage` (Firecracker's /dev/shm is tiny).
19
+
20
+ import { readFileSync } from "node:fs";
21
+ import path from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
25
+ import { wrap } from "./inspect.js";
26
+ import type { Wrapped } from "./inspect.js";
27
+
28
+ // Minimal local declaration of the bits of Bun.WebView we use, so the SDK
29
+ // type-checks in projects that don't install `@types/bun` themselves. At
30
+ // runtime Bun supplies the real implementation.
31
+ interface BunWebViewBackendChrome {
32
+ type: "chrome";
33
+ path?: string;
34
+ argv?: string[];
35
+ }
36
+
37
+ interface BunWebViewOptions {
38
+ width?: number;
39
+ height?: number;
40
+ url?: string;
41
+ backend?: "chrome" | "webkit" | BunWebViewBackendChrome;
42
+ }
43
+
44
+ interface BunWebViewScreenshotOptions {
45
+ encoding?: "buffer";
46
+ format?: ScreenshotFormat;
47
+ quality?: number;
48
+ }
49
+
50
+ interface BunWebViewInstance {
51
+ readonly url: string;
52
+ readonly title: string;
53
+ navigate(url: string): Promise<void>;
54
+ evaluate<T = unknown>(script: string): Promise<T>;
55
+ click(selector: string): Promise<void>;
56
+ click(x: number, y: number): Promise<void>;
57
+ type(text: string): Promise<void>;
58
+ press(key: string): Promise<void>;
59
+ scroll(dx: number, dy: number): Promise<void>;
60
+ scrollTo(selector: string): Promise<void>;
61
+ back(): Promise<void>;
62
+ forward(): Promise<void>;
63
+ reload(): Promise<void>;
64
+ screenshot(opts?: BunWebViewScreenshotOptions): Promise<Buffer>;
65
+ cdp<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
66
+ close(): void;
67
+ }
68
+
69
+ interface BunWebViewCtor {
70
+ new (options?: BunWebViewOptions): BunWebViewInstance;
71
+ }
72
+
73
+ declare const Bun: { WebView: BunWebViewCtor };
74
+
75
+ export interface BrowserOptions {
76
+ /** Viewport width in pixels. Default 1280. */
77
+ width?: number;
78
+ /** Viewport height in pixels. Default 720. */
79
+ height?: number;
80
+ /** Initial URL to navigate to before the constructor returns. */
81
+ url?: string;
82
+ /**
83
+ * Sink that receives rrweb event chunks. Each Browser op (navigate,
84
+ * click, …) calls `recordStep` with the events that landed in
85
+ * `window.__spectestRrwebEvents` since the last drain. The daemon
86
+ * passes a per-test, per-session sink; if `null` (e.g. tests calling
87
+ * `openBrowser` directly without a test context), rrweb still
88
+ * records page-side but the buffer is discarded on close.
89
+ */
90
+ recorder?: BrowserSessionRecorder | null;
91
+ }
92
+
93
+ export type ScreenshotFormat = "png" | "jpeg" | "webp";
94
+
95
+ export interface ScreenshotOptions {
96
+ /** Image format. WebP requires Chrome. Default PNG. */
97
+ format?: ScreenshotFormat;
98
+ /** JPEG/WebP quality 0–100. Ignored for PNG. */
99
+ quality?: number;
100
+ }
101
+
102
+ /** One Browser-action's worth of rrweb events, in the order rrweb emitted them. */
103
+ export interface BrowserSessionStep {
104
+ /** Monotonic counter within the session. */
105
+ stepSeq: number;
106
+ /** Browser action that triggered this drain — same set as in the
107
+ * per-op event log, plus `"close"` for the final pre-teardown drain. */
108
+ action: BrowserAction | "close";
109
+ /** Ms since the session was opened. */
110
+ tOffsetMs: number;
111
+ /** rrweb events as emitted by `rrweb.record`'s `emit` callback. */
112
+ events: unknown[];
113
+ }
114
+
115
+ // The set of actions that show up in the test event log. The session
116
+ // step type extends this with "close" for the final drain.
117
+ import type { BrowserAction } from "./recorder.js";
118
+ export type { BrowserAction } from "./recorder.js";
119
+
120
+ /** Sink the daemon passes in to collect per-session rrweb event chunks. */
121
+ export interface BrowserSessionRecorder {
122
+ /**
123
+ * Stable ID of this session — echoed into the per-op event log so
124
+ * the dashboard can link a browser event to its replay player.
125
+ */
126
+ readonly sessionId: string;
127
+ /** Called for each drained chunk of rrweb events. */
128
+ recordStep(step: BrowserSessionStep): void;
129
+ /** Optional: called whenever `Browser.navigate(url)` is invoked. */
130
+ noteNavigation?(url: string): void;
131
+ }
132
+
133
+ /**
134
+ * Headless browser handle. Operations are sequential per view — Bun rejects
135
+ * a second concurrent `evaluate()` with `ERR_INVALID_STATE`, and our wrapper
136
+ * inherits that. For parallel browsing open multiple views.
137
+ */
138
+ export interface Browser {
139
+ /** Last-navigated URL (updated on navigate completion). */
140
+ readonly url: string;
141
+ /** Current page `<title>`. */
142
+ readonly title: string;
143
+ /** Navigate to a URL; resolves when the main frame's load completes. */
144
+ navigate(url: string): Promise<void>;
145
+ /**
146
+ * Evaluate a JS expression in the page and return the
147
+ * JSON-deserialised result.
148
+ *
149
+ * `description` is a short human-readable label for what the
150
+ * snippet is doing ("read rendered todo list"); it surfaces in the
151
+ * test event log so the step list isn't a wall of minified code.
152
+ */
153
+ evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
154
+ /**
155
+ * Poll `expression` in the page until it returns a truthy value
156
+ * (the returned value is what `waitFor` resolves with). Useful for
157
+ * UI assertions that need to wait for an async render — instead of
158
+ * a manual `while (Date.now() < deadline) await evaluate(...)` loop
159
+ * which clutters the test log with one event per poll, this records
160
+ * a single `waitFor` event with the total wait time and how many
161
+ * attempts it took.
162
+ *
163
+ * Express the predicate as "return the data if ready, else falsy":
164
+ *
165
+ * ```ts
166
+ * const items = await browser.waitFor<string[]>(
167
+ * "todo appears",
168
+ * "(() => { const xs = [...document.querySelectorAll('li')].map(l => l.textContent); return xs.includes('hi') ? xs : null; })()",
169
+ * );
170
+ * ```
171
+ *
172
+ * Defaults: 5 s total timeout, 100 ms between polls.
173
+ */
174
+ waitFor<T = unknown>(
175
+ description: string,
176
+ expression: string,
177
+ options?: { timeoutMs?: number; intervalMs?: number },
178
+ ): Promise<Wrapped<T>>;
179
+ /** Wait for `selector` to be actionable and click its center. */
180
+ click(selector: string): Promise<void>;
181
+ /** Click at the given viewport coordinates. */
182
+ clickAt(x: number, y: number): Promise<void>;
183
+ /** Insert text into the focused element (no `keydown` — same path as paste). */
184
+ type(text: string): Promise<void>;
185
+ /** Press a named key (`"Enter"`, `"Tab"`, …) or single character. */
186
+ press(key: string): Promise<void>;
187
+ /** Scroll the viewport by the given pixel delta. */
188
+ scroll(dx: number, dy: number): Promise<void>;
189
+ /** Wait for `selector` to exist and scroll it into view. */
190
+ scrollTo(selector: string): Promise<void>;
191
+ /** Navigate back in session history. */
192
+ back(): Promise<void>;
193
+ /** Navigate forward in session history. */
194
+ forward(): Promise<void>;
195
+ /** Reload the current page. */
196
+ reload(): Promise<void>;
197
+ /** Capture a PNG/JPEG/WebP screenshot of the viewport. Returns raw bytes. */
198
+ screenshot(options?: ScreenshotOptions): Promise<Uint8Array>;
199
+ /** Close the underlying view. Idempotent. Drains any pending rrweb events. */
200
+ close(): Promise<void>;
201
+ }
202
+
203
+ // Default extra flags for headless Chromium inside a Firecracker microVM.
204
+ // --no-sandbox: Chrome refuses to launch as root otherwise (no user
205
+ // namespaces in the guest).
206
+ // --disable-dev-shm-usage: /dev/shm in the microVM defaults to ~64MB,
207
+ // which Chromium will exhaust on non-trivial pages.
208
+ // --disable-features=AsyncDns,DnsOverHttps + --dns-over-https-mode=off:
209
+ // force Chromium onto glibc getaddrinfo for name resolution — the exact
210
+ // path `fetch()` uses (→ /etc/resolv.conf → 127.0.0.53 → spectest-resolver).
211
+ // Chromium's built-in async DNS client (and DoH auto-upgrade) bypass the
212
+ // loopback nameserver in /etc/resolv.conf and query public DNS directly,
213
+ // which has never heard of our service names (`dashboard`, `web`,
214
+ // `api.todos.local`, …). On a cold first navigate in a fresh fork that
215
+ // surfaces as an intermittent `net::ERR_NAME_NOT_RESOLVED`. getaddrinfo
216
+ // reads resolv.conf synchronously per lookup, so it has no startup race
217
+ // and always reaches the resolver. See run 878d0054 (dashboard:3000).
218
+ const CHROME_ARGV = [
219
+ "--no-sandbox",
220
+ "--disable-dev-shm-usage",
221
+ "--disable-features=AsyncDns,DnsOverHttps",
222
+ "--dns-over-https-mode=off",
223
+ ];
224
+
225
+ // ────────────────────────────────────────────────────────────────────────
226
+ // rrweb bootstrap
227
+ // ────────────────────────────────────────────────────────────────────────
228
+
229
+ // Vendored rrweb-record bundle (UMD, exposes `rrwebRecord` global). Read
230
+ // once at module init — the SDK ships this file in the base snapshot so
231
+ // no network fetch happens inside the VM.
232
+ const RRWEB_BUNDLE: string = (() => {
233
+ try {
234
+ const here = path.dirname(fileURLToPath(import.meta.url));
235
+ const file = path.join(here, "vendor", "rrweb-record.min.js");
236
+ return readFileSync(file, "utf8");
237
+ } catch (err) {
238
+ // If the vendored bundle is missing the SDK still works — the
239
+ // browser just records no rrweb events. Surface a warning so the
240
+ // mistake is visible.
241
+ // eslint-disable-next-line no-console
242
+ console.warn(
243
+ "[spectest] rrweb-record.min.js missing from SDK vendor dir; browser sessions will be empty",
244
+ err,
245
+ );
246
+ return "";
247
+ }
248
+ })();
249
+
250
+ // Vendored console-record plugin bundle (UMD, exposes
251
+ // `rrwebPluginConsoleRecord` global). Captures the page's
252
+ // `console.{log,info,warn,error,debug,...}` calls and uncaught errors as
253
+ // rrweb Plugin events (`type: 6`, `data.plugin: "rrweb/console@1"`),
254
+ // which ride in the same `emit` stream as DOM mutations. Optional —
255
+ // missing bundle just means no console capture; the recorder still runs.
256
+ const RRWEB_CONSOLE_PLUGIN_BUNDLE: string = (() => {
257
+ try {
258
+ const here = path.dirname(fileURLToPath(import.meta.url));
259
+ const file = path.join(here, "vendor", "rrweb-plugin-console-record.umd.js");
260
+ return readFileSync(file, "utf8");
261
+ } catch {
262
+ return "";
263
+ }
264
+ })();
265
+
266
+ // Bootstrap that runs after the bundle defines `rrwebRecord`. Idempotent —
267
+ // the same script is added with `Page.addScriptToEvaluateOnNewDocument`
268
+ // and runs on every new document, so the `__spectestRrwebInit` guard keeps
269
+ // us from double-starting.
270
+ //
271
+ // `lengthThreshold` caps per-arg serialized size (default 1000 → bumped to
272
+ // 10 KiB so longer error stacks survive without dwarfing the event stream).
273
+ const RRWEB_BOOTSTRAP = `
274
+ ;(function () {
275
+ if (typeof rrwebRecord !== "function") return;
276
+ if (window.__spectestRrwebInit) return;
277
+ window.__spectestRrwebInit = true;
278
+ window.__spectestRrwebEvents = [];
279
+ var plugins = [];
280
+ try {
281
+ if (typeof rrwebPluginConsoleRecord === "object" &&
282
+ typeof rrwebPluginConsoleRecord.getRecordConsolePlugin === "function") {
283
+ plugins.push(rrwebPluginConsoleRecord.getRecordConsolePlugin({
284
+ level: ["assert", "debug", "error", "info", "log", "trace", "warn"],
285
+ lengthThreshold: 10000,
286
+ logger: window.console,
287
+ }));
288
+ }
289
+ } catch (e) { /* plugin init failed — keep recording DOM only. */ }
290
+ try {
291
+ rrwebRecord({
292
+ emit: function (ev) { window.__spectestRrwebEvents.push(ev); },
293
+ plugins: plugins,
294
+ // Capture page assets into the event stream so the replay renders
295
+ // faithfully offline (the dashboard reconstructs the DOM with no
296
+ // access to the env's origin). \`inlineStylesheet\` (default, set
297
+ // explicitly) bakes <style>/<link> CSS into the snapshot;
298
+ // \`inlineImages\` turns <img> and CSS background images into data
299
+ // URIs. \`collectFonts\` only captures fonts added via the JS
300
+ // FontFace API — it does NOT inline static \`@font-face { src:url() }\`
301
+ // CSS (what next/font emits), so it's a no-op for most apps; the
302
+ // url()-based fonts are handled by the inliner below instead. All
303
+ // degrade gracefully — an asset that can't be read is skipped,
304
+ // never fatal to recording.
305
+ inlineStylesheet: true,
306
+ collectFonts: true,
307
+ inlineImages: true,
308
+ });
309
+ } catch (e) {
310
+ /* DOM not yet ready or rrweb mis-init — skip. */
311
+ }
312
+ // ── Inline @font-face fonts as base64 ──────────────────────────────
313
+ // The replay is viewed OUTSIDE this hermetic VM, so a @font-face whose
314
+ // src points at a same-origin URL (e.g. next/font's
315
+ // /_next/static/media/*.woff2) is unreachable at replay time and the
316
+ // page silently falls back to a system font. collectFonts above won't
317
+ // help (FontFace-API fonts only), so inline the bytes ourselves: once
318
+ // fonts have loaded, fetch each url() (same-origin -> allowed, and the
319
+ // page already trusts the in-VM CA), rewrite the rule's src to a data:
320
+ // URI, then take a fresh full snapshot so the now-self-contained CSS
321
+ // is captured. Plain string ops, no regex — this whole script ships
322
+ // inside a TS template literal where regex backslashes get mangled.
323
+ (function () {
324
+ if (window.__spectestFontsInlined) return;
325
+ window.__spectestFontsInlined = true;
326
+ // Diagnostics ride the console-record plugin, so they land in the
327
+ // recording (and state.db) — our only debugging window into the
328
+ // headless page. Prefix is grep-able; keep them terse.
329
+ function log(m) { try { console.log("[spectest-fonts] " + m); } catch (e) {} }
330
+ var cache = {};
331
+ function fetchDataUri(url) {
332
+ if (cache[url]) return cache[url];
333
+ cache[url] = fetch(url).then(function (r) {
334
+ if (!r.ok) throw new Error("HTTP " + r.status);
335
+ return r.blob();
336
+ }).then(function (blob) {
337
+ return new Promise(function (resolve, reject) {
338
+ var fr = new FileReader();
339
+ fr.onload = function () { resolve(fr.result); };
340
+ fr.onerror = reject;
341
+ fr.readAsDataURL(blob);
342
+ });
343
+ });
344
+ return cache[url];
345
+ }
346
+ // Extract every url() token in cssText, resolved against baseHref.
347
+ // CSSOM cssText keeps urls as authored — next/font emits
348
+ // absolute-path url(/_next/static/media/x.woff2) (no scheme), so a
349
+ // bare http prefix check misses them (that's why run b04cab1f / 6987
350
+ // saw withHttpUrl=0). Resolve against the sheet href (or the document
351
+ // base for inline style) before fetching. token is the raw string as
352
+ // it appears in cssText, used for the later string replace.
353
+ function urlsIn(text, baseHref) {
354
+ var out = [], i = 0;
355
+ while (true) {
356
+ var u = text.indexOf("url(", i);
357
+ if (u < 0) break;
358
+ var start = u + 4, end = text.indexOf(")", start);
359
+ if (end < 0) break;
360
+ var raw = text.slice(start, end).trim();
361
+ if (raw && (raw.charAt(0) === '"' || raw.charAt(0) === "'")) {
362
+ raw = raw.slice(1, raw.length - 1);
363
+ }
364
+ i = end + 1;
365
+ if (!raw || raw.lastIndexOf("data:", 0) === 0) continue; // already inline
366
+ var abs;
367
+ try { abs = new URL(raw, baseHref).href; } catch (e) { continue; }
368
+ if (abs.lastIndexOf("http", 0) !== 0) continue; // only fetchable http(s)
369
+ out.push({ token: raw, abs: abs });
370
+ }
371
+ return out;
372
+ }
373
+ function eachFontFace(rules, fn) {
374
+ for (var i = 0; i < rules.length; i++) {
375
+ var rule = rules[i];
376
+ if (rule.type === 5) fn(rule); // CSSRule.FONT_FACE_RULE
377
+ else if (rule.cssRules) { // @media / @supports nesting
378
+ try { eachFontFace(rule.cssRules, fn); } catch (e) {}
379
+ }
380
+ }
381
+ }
382
+ function run() {
383
+ // Collect each @font-face's *cssText* (reliable for any CSSRule —
384
+ // unlike .style.setProperty('src',…), which silently no-ops on
385
+ // CSSFontFaceRule in Chrome) plus the http url()s inside it.
386
+ var faces = [], sheets = document.styleSheets, readable = 0, faceCount = 0;
387
+ for (var s = 0; s < sheets.length; s++) {
388
+ var rules;
389
+ try { rules = sheets[s].cssRules; } catch (e) { continue; } // cross-origin
390
+ if (!rules) continue;
391
+ readable++;
392
+ var base = sheets[s].href || document.baseURI;
393
+ eachFontFace(rules, function (rule) {
394
+ faceCount++;
395
+ var cssText = rule.cssText || "";
396
+ var urls = urlsIn(cssText, base);
397
+ if (urls.length) faces.push({ cssText: cssText, urls: urls });
398
+ });
399
+ }
400
+ log("sheets=" + sheets.length + " readable=" + readable +
401
+ " fontFaces=" + faceCount + " withUrl=" + faces.length);
402
+ if (!faces.length) return;
403
+ var jobs = faces.map(function (face) {
404
+ return Promise.all(face.urls.map(function (u) {
405
+ return fetchDataUri(u.abs)
406
+ .then(function (d) { return { token: u.token, dataUri: d }; })
407
+ .catch(function (e) { log("fetch FAIL " + u.abs + " : " + (e && e.message)); return null; });
408
+ })).then(function (pairs) {
409
+ var text = face.cssText, changed = false;
410
+ for (var k = 0; k < pairs.length; k++) {
411
+ if (!pairs[k]) continue;
412
+ text = text.split(pairs[k].token).join(pairs[k].dataUri);
413
+ changed = true;
414
+ }
415
+ return changed ? text : null;
416
+ });
417
+ });
418
+ Promise.all(jobs).then(function (texts) {
419
+ var ok = texts.filter(function (t) { return t; });
420
+ if (!ok.length) { log("nothing inlined (all fetches failed?)"); return; }
421
+ // Inject the rewritten @font-face rules as a NEW <style> appended
422
+ // last. A later @font-face with identical descriptors wins, and a
423
+ // freshly-added node is captured reliably by rrweb's mutation
424
+ // observer (no dependence on CSSOM edits being serialized).
425
+ try {
426
+ var st = document.createElement("style");
427
+ st.setAttribute("data-spectest-inlined-fonts", "1");
428
+ st.appendChild(document.createTextNode(ok.join("\\n")));
429
+ (document.head || document.documentElement).appendChild(st);
430
+ log("injected " + ok.length + " @font-face rule(s) as data: URIs");
431
+ } catch (e) { log("inject FAIL " + (e && e.message)); return; }
432
+ try {
433
+ if (typeof rrwebRecord === "function" &&
434
+ typeof rrwebRecord.takeFullSnapshot === "function") {
435
+ rrwebRecord.takeFullSnapshot();
436
+ log("took full snapshot");
437
+ }
438
+ } catch (e) { log("snapshot FAIL " + (e && e.message)); }
439
+ });
440
+ }
441
+ try {
442
+ if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
443
+ document.fonts.ready.then(function () { setTimeout(run, 0); });
444
+ } else if (document.readyState === "complete") {
445
+ run();
446
+ } else {
447
+ window.addEventListener("load", run);
448
+ }
449
+ } catch (e) { log("init FAIL " + (e && e.message)); }
450
+ })();
451
+ })();
452
+ `;
453
+
454
+ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
455
+ ? `${RRWEB_BUNDLE}\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n${RRWEB_BOOTSTRAP}`
456
+ : "";
457
+
458
+ // Page-side expression that atomically swaps in a fresh buffer and
459
+ // returns the old one. Wrapped as a single expression so view.evaluate's
460
+ // `await (...)` wrapper accepts it.
461
+ //
462
+ // `forceFullIfMissing` (inlined by `drainExpr`) guards a recovery path
463
+ // for the most common replay failure: a post-navigation page whose full
464
+ // snapshot never made it into the stream. rrweb defers its *initial*
465
+ // full snapshot to the page's `load` event, but our drains fire at op
466
+ // boundaries — `waitFor` in particular returns the instant its predicate
467
+ // is truthy, which is usually right after `DOMContentLoaded`, *before*
468
+ // `load`. Login/redirect chains compound this: every new document resets
469
+ // `window.__spectestRrwebEvents`, so a partial chunk from an intermediate
470
+ // page is discarded. The net effect is a final step that carries only a
471
+ // `DOMContentLoaded` (type 0) and no Meta/FullSnapshot — the player then
472
+ // has no DOM for the new page and keeps showing the previous one (e.g.
473
+ // the auth provider's login screen instead of the post-login dashboard).
474
+ //
475
+ // When the caller detects the document changed since the last drain
476
+ // (`view.url` differs) it passes `force=true`; if the outgoing buffer
477
+ // then lacks any FullSnapshot (type 2), we call `rrwebRecord.takeFullSnapshot()`
478
+ // to synthesize one for the *current* DOM. That emits a fresh Meta
479
+ // (with the correct href) + FullSnapshot, so the step is self-contained
480
+ // and seeking to it shows the right page. Gating on url-change + missing
481
+ // snapshot keeps steady-state steps (clicks/types on an unchanged page,
482
+ // or navigations where `load` already fired) from bloating the stream
483
+ // with redundant snapshots.
484
+ function drainExpr(forceFullIfMissing: boolean): string {
485
+ return `(function () {
486
+ try {
487
+ if (${forceFullIfMissing ? "true" : "false"} &&
488
+ window.__spectestRrwebInit &&
489
+ typeof rrwebRecord === "function" &&
490
+ typeof rrwebRecord.takeFullSnapshot === "function") {
491
+ var b = window.__spectestRrwebEvents || [];
492
+ var hasFull = false;
493
+ for (var i = 0; i < b.length; i++) {
494
+ if (b[i] && b[i].type === 2) { hasFull = true; break; }
495
+ }
496
+ if (!hasFull) rrwebRecord.takeFullSnapshot();
497
+ }
498
+ } catch (e) { /* recording inactive or DOM detached — drain what's there. */ }
499
+ var buf = window.__spectestRrwebEvents;
500
+ if (!buf || !buf.length) return [];
501
+ window.__spectestRrwebEvents = [];
502
+ return buf;
503
+ })()`;
504
+ }
505
+
506
+ // ────────────────────────────────────────────────────────────────────────
507
+ // Factory
508
+ // ────────────────────────────────────────────────────────────────────────
509
+
510
+ /** A pre-opened, never-used view: renderer already spawned, rrweb init
511
+ * script installed, parked on about:blank. */
512
+ interface PooledView {
513
+ view: BunWebViewInstance;
514
+ recordingInstalled: boolean;
515
+ }
516
+
517
+ // Pre-opened view pool. Renderer spawn is the expensive part of
518
+ // `ctx.browser()` — ~1.8s for the first view in a fresh Chrome and
519
+ // (measured 2026-06-05) a constant ~1.2-1.5s per view in a Chrome that
520
+ // lived through a snapshot restore, i.e. in every test fork. The daemon
521
+ // fills this pool once at the end of /bootstrap; the warm-template and
522
+ // pretest snapshots are captured after that, so EVERY fork inherits a
523
+ // live renderer and the first `ctx.browser()` of a test skips the spawn
524
+ // entirely. Lives in daemon memory → forks each get the pristine pool,
525
+ // and a test consuming it never affects its siblings (same isolation as
526
+ // fake state).
527
+ const VIEW_POOL: PooledView[] = [];
528
+
529
+ /** Create a view + CDP session + rrweb init script — the slow part. */
530
+ async function createView(width: number, height: number): Promise<PooledView> {
531
+ const view = new Bun.WebView({
532
+ width,
533
+ height,
534
+ backend: { type: "chrome", argv: CHROME_ARGV },
535
+ });
536
+
537
+ // Bun's docs: `cdp()` requires at least one navigate first to set up
538
+ // the CDP session. about:blank is the cheapest bootstrap target.
539
+ await view.navigate("about:blank");
540
+
541
+ let recordingInstalled = false;
542
+ if (PAGE_INIT_SCRIPT) {
543
+ try {
544
+ await view.cdp("Page.addScriptToEvaluateOnNewDocument", {
545
+ source: PAGE_INIT_SCRIPT,
546
+ });
547
+ recordingInstalled = true;
548
+ } catch (err) {
549
+ // Without rrweb the browser still works; just no replay.
550
+ // eslint-disable-next-line no-console
551
+ console.warn("[spectest] failed to install rrweb recorder:", err);
552
+ }
553
+ }
554
+ return { view, recordingInstalled };
555
+ }
556
+
557
+ /**
558
+ * Pre-open `n` views into the pool (called by the daemon at the end of
559
+ * /bootstrap, before the warm-template snapshot is captured). Only
560
+ * default-viewport views are pooled — `openBrowser` with a custom
561
+ * width/height bypasses the pool.
562
+ */
563
+ export async function prewarmViewPool(n = 1): Promise<void> {
564
+ for (let i = 0; i < n; i++) {
565
+ VIEW_POOL.push(await createView(1280, 720));
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Open a browser view. Always uses the Chrome backend in the daemon
571
+ * (Firecracker guest is Linux; WKWebView isn't available). Serves from
572
+ * the pre-opened pool when the caller uses the default viewport.
573
+ */
574
+ export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
575
+ const wantW = opts.width ?? 1280;
576
+ const wantH = opts.height ?? 720;
577
+ const pooled =
578
+ wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
579
+ const { view, recordingInstalled } = pooled ?? (await createView(wantW, wantH));
580
+
581
+ const browser = wrapView(view, opts.recorder ?? null, recordingInstalled);
582
+
583
+ // We deliberately don't forward `opts.url` to the constructor — going
584
+ // through our own `navigate()` keeps the recorder log uniform (one
585
+ // event per navigation, with timing) and drains rrweb after the load.
586
+ if (opts.url !== undefined) {
587
+ await browser.navigate(opts.url);
588
+ }
589
+ return browser;
590
+ }
591
+
592
+ function wrapView(
593
+ view: BunWebViewInstance,
594
+ recorder: BrowserSessionRecorder | null,
595
+ recordingInstalled: boolean,
596
+ ): Browser {
597
+ let closed = false;
598
+ const sessionStart = Date.now();
599
+ let stepSeq = 0;
600
+ // URL observed at the previous drain. A change means the main frame
601
+ // navigated to a new document since we last looked, which is exactly
602
+ // when rrweb's load-deferred full snapshot is most likely to be
603
+ // missing from the chunk we're about to drain (see `drainExpr`).
604
+ let lastDrainUrl: string | null = null;
605
+
606
+ async function drain(action: BrowserAction | "close"): Promise<void> {
607
+ if (!recordingInstalled || !recorder || closed) return;
608
+ try {
609
+ const urlChanged = view.url !== lastDrainUrl;
610
+ const events = await view.evaluate<unknown[]>(drainExpr(urlChanged));
611
+ lastDrainUrl = view.url;
612
+ if (Array.isArray(events) && events.length > 0) {
613
+ recorder.recordStep({
614
+ stepSeq: stepSeq++,
615
+ action,
616
+ tOffsetMs: Date.now() - sessionStart,
617
+ events,
618
+ });
619
+ }
620
+ } catch {
621
+ // Page may be mid-navigation, detached, or the view is closing.
622
+ // Losing an occasional drain is acceptable — the next op picks up
623
+ // the rest of the buffer.
624
+ }
625
+ }
626
+
627
+ async function instrumented<T>(
628
+ action: BrowserAction,
629
+ fields: Partial<RecordableFields>,
630
+ fn: () => Promise<T>,
631
+ ): Promise<T> {
632
+ const t = Date.now();
633
+ const resv = reserveEvent();
634
+ try {
635
+ const result = await fn();
636
+ // `sessionTimestamp` is the post-op wall clock — that's where we
637
+ // want the dashboard's seek to land, so clicking "type 'foo'"
638
+ // shows the input *with* the text, not the empty field just
639
+ // before the keystrokes. The dashboard converts to a player
640
+ // offset by subtracting `events[0].timestamp`.
641
+ const endT = Date.now();
642
+ const seq = recordBrowser({
643
+ action,
644
+ ...fields,
645
+ ...(recorder
646
+ ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
647
+ : {}),
648
+ durationMs: endT - t,
649
+ }, resv);
650
+ await drain(action);
651
+ // Only `evaluate` and `waitFor` return user-visible JS values
652
+ // that someone is likely to assert on. Other actions return void
653
+ // or browser internals; leave them raw to avoid Proxy surprises.
654
+ if (seq !== undefined && (action === "evaluate" || action === "waitFor")) {
655
+ return wrap(result, seq) as T;
656
+ }
657
+ return result;
658
+ } catch (err) {
659
+ const e = err as Error;
660
+ const endT = Date.now();
661
+ recordBrowser({
662
+ action,
663
+ ...fields,
664
+ ...(recorder
665
+ ? { sessionId: recorder.sessionId, sessionTimestamp: endT }
666
+ : {}),
667
+ durationMs: endT - t,
668
+ error: e?.message ?? String(err),
669
+ }, resv);
670
+ // Still try to drain — the failure itself may have produced
671
+ // useful rrweb events (mutations from a half-loaded page, etc.).
672
+ await drain(action);
673
+ throw err;
674
+ }
675
+ }
676
+
677
+ return {
678
+ get url() {
679
+ return view.url;
680
+ },
681
+ get title() {
682
+ return view.title;
683
+ },
684
+ navigate(url) {
685
+ recorder?.noteNavigation?.(url);
686
+ return instrumented("navigate", { url }, () => view.navigate(url));
687
+ },
688
+ async evaluate<T = unknown>(
689
+ description: string,
690
+ script: string,
691
+ ): Promise<Wrapped<T>> {
692
+ const truncatedScript = truncateUtf8(script);
693
+ // `instrumented` wraps the result for evaluate/waitFor (see line ~622),
694
+ // so the value is a `Wrapped<T>` at runtime; the cast matches the type.
695
+ return instrumented<T>(
696
+ "evaluate",
697
+ {
698
+ description,
699
+ script: truncatedScript.value,
700
+ scriptTruncated: truncatedScript.truncated,
701
+ },
702
+ async () => {
703
+ const v = await view.evaluate<T>(script);
704
+ return v;
705
+ },
706
+ ) as Promise<Wrapped<T>>;
707
+ },
708
+ async waitFor<T = unknown>(
709
+ description: string,
710
+ expression: string,
711
+ options: { timeoutMs?: number; intervalMs?: number } = {},
712
+ ): Promise<Wrapped<T>> {
713
+ const timeoutMs = options.timeoutMs ?? 5_000;
714
+ const intervalMs = options.intervalMs ?? 100;
715
+ const truncatedScript = truncateUtf8(expression);
716
+ // Pass `fields` by reference so the loop can stamp the final
717
+ // attempt count onto the event before instrumented records it.
718
+ const fields: Partial<RecordableFields> = {
719
+ description,
720
+ script: truncatedScript.value,
721
+ scriptTruncated: truncatedScript.truncated,
722
+ attempts: 0,
723
+ };
724
+ return instrumented<T>("waitFor", fields, async () => {
725
+ const deadline = Date.now() + timeoutMs;
726
+ // (return value wrapped by `instrumented`; cast below matches.)
727
+ // The polling loop calls `view.evaluate` directly (not the
728
+ // wrapped `evaluate`) so it doesn't fan out into N events in
729
+ // the log or N rrweb drains. rrweb keeps buffering page-side
730
+ // throughout the wait; the wrapper's single drain at the end
731
+ // collects everything.
732
+ for (;;) {
733
+ fields.attempts = (fields.attempts ?? 0) + 1;
734
+ let v: unknown;
735
+ try {
736
+ v = await view.evaluate<unknown>(expression);
737
+ } catch (err) {
738
+ if (Date.now() >= deadline) throw err;
739
+ await new Promise((r) => setTimeout(r, intervalMs));
740
+ continue;
741
+ }
742
+ if (v) return v as T;
743
+ if (Date.now() >= deadline) {
744
+ throw new Error(
745
+ `waitFor ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${fields.attempts} attempts)`,
746
+ );
747
+ }
748
+ await new Promise((r) => setTimeout(r, intervalMs));
749
+ }
750
+ }) as Promise<Wrapped<T>>;
751
+ },
752
+ click(selector) {
753
+ return instrumented("click", { selector }, () => view.click(selector));
754
+ },
755
+ clickAt(x, y) {
756
+ return instrumented("click", { x, y }, () => view.click(x, y));
757
+ },
758
+ type(text) {
759
+ const t = truncateUtf8(text);
760
+ return instrumented(
761
+ "type",
762
+ { text: t.value, textTruncated: t.truncated },
763
+ () => view.type(text),
764
+ );
765
+ },
766
+ press(key) {
767
+ return instrumented("press", { key }, () => view.press(key));
768
+ },
769
+ scroll(dx, dy) {
770
+ return instrumented("scroll", { dx, dy }, () => view.scroll(dx, dy));
771
+ },
772
+ scrollTo(selector) {
773
+ return instrumented("scrollTo", { selector }, () => view.scrollTo(selector));
774
+ },
775
+ back() {
776
+ return instrumented("back", {}, () => view.back());
777
+ },
778
+ forward() {
779
+ return instrumented("forward", {}, () => view.forward());
780
+ },
781
+ reload() {
782
+ return instrumented("reload", {}, () => view.reload());
783
+ },
784
+ async screenshot(options) {
785
+ const format = options?.format ?? "png";
786
+ return instrumented("screenshot", { format }, async () => {
787
+ const buf = await view.screenshot({
788
+ encoding: "buffer",
789
+ format,
790
+ quality: options?.quality,
791
+ });
792
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
793
+ });
794
+ },
795
+ async close() {
796
+ if (closed) return;
797
+ // Final drain before the page evaporates.
798
+ await drain("close");
799
+ closed = true;
800
+ try {
801
+ view.close();
802
+ } catch {
803
+ /* already closed by the runtime */
804
+ }
805
+ },
806
+ };
807
+ }
808
+
809
+ interface RecordableFields {
810
+ url: string;
811
+ selector: string;
812
+ description: string;
813
+ script: string;
814
+ scriptTruncated: boolean;
815
+ text: string;
816
+ textTruncated: boolean;
817
+ key: string;
818
+ dx: number;
819
+ dy: number;
820
+ x: number;
821
+ y: number;
822
+ format: ScreenshotFormat;
823
+ attempts: number;
824
+ }