@specific.dev/spectest 0.17.0 → 0.18.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 CHANGED
@@ -1,8 +1,13 @@
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.
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.
6
11
  //
7
12
  // On top of the per-op event recording, every Browser also captures an
8
13
  // rrweb session: rrweb-record is injected into every document via CDP
@@ -32,52 +37,17 @@ import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
32
37
  import { wrap } from "./inspect.js";
33
38
  import type { Wrapped } from "./inspect.js";
34
39
 
35
- // Minimal local declaration of the bits of Bun.WebView we use, so the SDK
36
- // type-checks in projects that don't install `@types/bun` themselves. At
37
- // runtime Bun supplies the real implementation.
38
- interface BunWebViewBackendChrome {
39
- type: "chrome";
40
- path?: string;
41
- argv?: string[];
42
- }
43
-
44
- interface BunWebViewOptions {
45
- width?: number;
46
- height?: number;
47
- url?: string;
48
- backend?: "chrome" | "webkit" | BunWebViewBackendChrome;
49
- }
50
-
51
- interface BunWebViewScreenshotOptions {
52
- encoding?: "buffer";
53
- format?: ScreenshotFormat;
54
- quality?: number;
55
- }
56
-
57
- interface BunWebViewInstance {
58
- readonly url: string;
59
- readonly title: string;
60
- navigate(url: string): Promise<void>;
61
- evaluate<T = unknown>(script: string): Promise<T>;
62
- click(selector: string): Promise<void>;
63
- click(x: number, y: number): Promise<void>;
64
- type(text: string): Promise<void>;
65
- press(key: string): Promise<void>;
66
- scroll(dx: number, dy: number): Promise<void>;
67
- scrollTo(selector: string): Promise<void>;
68
- back(): Promise<void>;
69
- forward(): Promise<void>;
70
- reload(): Promise<void>;
71
- screenshot(opts?: BunWebViewScreenshotOptions): Promise<Buffer>;
72
- cdp<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
73
- close(): void;
74
- }
75
-
76
- interface BunWebViewCtor {
77
- new (options?: BunWebViewOptions): BunWebViewInstance;
78
- }
40
+ import { chromium } from "playwright-core";
41
+ import type {
42
+ Browser as PlaywrightBrowser,
43
+ BrowserContext,
44
+ CDPSession,
45
+ Page,
46
+ } from "playwright-core";
79
47
 
80
- declare const Bun: { WebView: BunWebViewCtor };
48
+ // Minimal declaration of the one Bun global we use (executable lookup), so
49
+ // the SDK type-checks in projects that don't install `@types/bun`.
50
+ declare const Bun: { which(bin: string): string | null };
81
51
 
82
52
  export interface BrowserOptions {
83
53
  /** Viewport width in pixels. Default 1280. Ignored when `frame: "mobile"`
@@ -147,9 +117,9 @@ export interface BrowserSessionRecorder {
147
117
  }
148
118
 
149
119
  /**
150
- * Headless browser handle. Operations are sequential per view — Bun rejects
151
- * a second concurrent `evaluate()` with `ERR_INVALID_STATE`, and our wrapper
152
- * inherits that. For parallel browsing open multiple views.
120
+ * Headless browser handle. Drive operations sequentially per view — the
121
+ * recorder assumes op-at-a-time semantics (each op's rrweb drain is
122
+ * attributed to it). For parallel browsing open multiple views.
153
123
  */
154
124
  export interface Browser {
155
125
  /** Last-navigated URL (updated on navigate completion). */
@@ -264,10 +234,28 @@ export interface MobileBackend extends Browser {
264
234
  swipeBy(x: number, y: number, dx: number, dy: number): Promise<void>;
265
235
  /**
266
236
  * Evaluate a JS expression in the page WITHOUT recording an event or
267
- * draining rrweb — the locator layer's internal element resolver. rrweb
268
- * keeps buffering page-side; the next recorded op drains it.
237
+ * draining rrweb. rrweb keeps buffering page-side; the next recorded op
238
+ * drains it.
269
239
  */
270
240
  probe<T = unknown>(expression: string): Promise<T>;
241
+ /**
242
+ * Run `fn` against the live playwright {@link Page}, recorded as a single
243
+ * `action` event (with the usual rrweb drain). The locator layer's hook:
244
+ * one author-facing locator action = one recorded event, however many
245
+ * playwright calls it composes. `evaluate`/`waitFor` actions get their
246
+ * return value provenance-wrapped like the first-class verbs.
247
+ */
248
+ pageOp<T>(
249
+ action: BrowserAction,
250
+ fields: Partial<RecordableFields>,
251
+ fn: (page: Page) => Promise<T>,
252
+ ): Promise<T>;
253
+ /**
254
+ * Unrecorded CDP touch tap (touchStart → dwell → touchEnd). The locator
255
+ * layer composes it inside a {@link pageOp} so a locator `tap()` stays a
256
+ * single recorded event; `tapAt` is the recorded public twin.
257
+ */
258
+ rawTap(x: number, y: number, durationMs?: number): Promise<void>;
271
259
  }
272
260
 
273
261
  // Default extra flags for headless Chromium inside a Firecracker microVM.
@@ -295,6 +283,75 @@ const CHROME_ARGV = [
295
283
  /** Default touchStart→touchEnd dwell for `tapAt` — see the comment there. */
296
284
  const TAP_DWELL_MS = 60;
297
285
 
286
+ /** Default deadline for element-targeting ops (click/scrollTo and the mobile
287
+ * locator actions). Playwright's own default is 30 s — far too slow-failing
288
+ * for tests; 5 s matches the pre-Playwright behavior. Navigations keep a
289
+ * longer 30 s deadline (cold app servers). */
290
+ const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
291
+ const NAVIGATION_TIMEOUT_MS = 30_000;
292
+
293
+ // ────────────────────────────────────────────────────────────────────────
294
+ // Shared Chromium (playwright-core)
295
+ // ────────────────────────────────────────────────────────────────────────
296
+
297
+ // One Chromium per daemon, launched lazily on first view creation and kept
298
+ // for the daemon's lifetime. It rides snapshots: the process, playwright's
299
+ // pipe connection to it, and every open page freeze into the VM snapshot
300
+ // and resume in each fork (like dockerd and the daemon itself). Profile
301
+ // state — cookies, localStorage — is per-Chromium, and the in-VM CA trust
302
+ // comes from the HOME-scoped NSS user DB, so neither cares that playwright
303
+ // runs a temp --user-data-dir.
304
+ let PW_BROWSER: PlaywrightBrowser | null = null;
305
+ // The shared desktop context (default 1280×720 viewport). All desktop views
306
+ // live here so they share one cookie jar, mirroring the old one-Chrome-
307
+ // profile model. Mobile sessions and custom-viewport views get their own
308
+ // contexts (viewport/DPR/UA/touch are context-scoped in playwright).
309
+ let DESKTOP_CTX: BrowserContext | null = null;
310
+
311
+ function chromiumPath(): string {
312
+ return (
313
+ Bun.which("chromium") ??
314
+ Bun.which("chromium-browser") ??
315
+ Bun.which("google-chrome") ??
316
+ "/usr/bin/chromium"
317
+ );
318
+ }
319
+
320
+ async function ensurePlaywrightBrowser(): Promise<PlaywrightBrowser> {
321
+ if (PW_BROWSER?.isConnected()) return PW_BROWSER;
322
+ PW_BROWSER = await chromium.launch({
323
+ executablePath: chromiumPath(),
324
+ headless: true,
325
+ args: CHROME_ARGV,
326
+ });
327
+ DESKTOP_CTX = null; // contexts died with the old browser (if any)
328
+ return PW_BROWSER;
329
+ }
330
+
331
+ /** Context options for the mobile device preset — playwright's native
332
+ * emulation (viewport/DPR/UA/touch are context-scoped). Safe-area insets
333
+ * have no context option; they stay a raw-CDP override per page. */
334
+ function deviceContextOptions(d: DevicePreset) {
335
+ return {
336
+ viewport: { width: d.viewport.width, height: d.viewport.height },
337
+ deviceScaleFactor: d.deviceScaleFactor,
338
+ isMobile: d.isMobile,
339
+ hasTouch: d.hasTouch,
340
+ userAgent: d.userAgent,
341
+ };
342
+ }
343
+
344
+ async function ensureDesktopContext(): Promise<BrowserContext> {
345
+ const browser = await ensurePlaywrightBrowser();
346
+ if (DESKTOP_CTX) return DESKTOP_CTX;
347
+ DESKTOP_CTX = await browser.newContext({
348
+ viewport: { width: 1280, height: 720 },
349
+ });
350
+ DESKTOP_CTX.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
351
+ DESKTOP_CTX.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
352
+ return DESKTOP_CTX;
353
+ }
354
+
298
355
  /**
299
356
  * Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
300
357
  * single EXPRESSION (it wraps the source in `await (...)`), so a statement
@@ -363,44 +420,27 @@ const LATEST_IPHONE: DevicePreset = {
363
420
  safeAreaInsets: { top: 59, right: 0, bottom: 34, left: 0 },
364
421
  };
365
422
 
366
- /** Apply CDP device emulation to a freshly-created view. The overrides are
367
- * CDP-session-global, so they persist across the app navigation that
368
- * follows (we set them on the about:blank bootstrap page). Best-effort:
369
- * a CDP failure degrades to a plain desktop view rather than aborting.
423
+ /** Apply the one device-emulation piece playwright's context options can't
424
+ * express: iOS safe-area insets, via raw CDP on the page's session. The
425
+ * override is session-global, so it persists across the app navigation
426
+ * that follows. Best-effort — Chromium < ~135 lacks the method.
370
427
  *
371
- * Returns the safe-area insets that actually took effect (`null` when the
372
- * override failed — Chromium < 135 lacks the CDP method). The caller
373
- * stamps them onto the session record so the dashboard can substitute the
374
- * same values for `env(safe-area-inset-*)` in the replayed CSS; stamping
375
- * only what was really applied keeps capture layout and replay layout in
376
- * lockstep (recorded touch coordinates would misalign otherwise). */
377
- async function applyDeviceEmulation(
378
- view: BunWebViewInstance,
428
+ * Returns the insets that actually took effect (`null` when the override
429
+ * failed). The caller stamps them onto the session record so the dashboard
430
+ * can substitute the same values for `env(safe-area-inset-*)` in the
431
+ * replayed CSS; stamping only what was really applied keeps capture layout
432
+ * and replay layout in lockstep (recorded touch coordinates would
433
+ * misalign otherwise). */
434
+ async function applySafeAreaInsets(
435
+ cdp: CDPSession,
379
436
  d: DevicePreset,
380
437
  ): Promise<SafeAreaInsets | null> {
381
438
  try {
382
- await view.cdp("Emulation.setDeviceMetricsOverride", {
383
- width: d.viewport.width,
384
- height: d.viewport.height,
385
- deviceScaleFactor: d.deviceScaleFactor,
386
- mobile: d.isMobile,
387
- screenWidth: d.viewport.width,
388
- screenHeight: d.viewport.height,
389
- });
390
- await view.cdp("Emulation.setUserAgentOverride", { userAgent: d.userAgent });
391
- await view.cdp("Emulation.setTouchEmulationEnabled", {
392
- enabled: d.hasTouch,
393
- maxTouchPoints: 5,
394
- });
395
- } catch (err) {
396
- // eslint-disable-next-line no-console
397
- console.warn("[spectest] device emulation failed; using desktop view:", err);
398
- return null;
399
- }
400
- try {
401
- await view.cdp("Emulation.setSafeAreaInsetsOverride", {
402
- insets: { ...d.safeAreaInsets },
403
- });
439
+ await cdp.send(
440
+ // Not in playwright's Protocol types yet (Chromium ≥ ~135 method).
441
+ "Emulation.setSafeAreaInsetsOverride" as Parameters<CDPSession["send"]>[0],
442
+ { insets: { ...d.safeAreaInsets } } as never,
443
+ );
404
444
  return d.safeAreaInsets;
405
445
  } catch (err) {
406
446
  // eslint-disable-next-line no-console
@@ -413,7 +453,9 @@ async function applyDeviceEmulation(
413
453
  // rrweb bootstrap
414
454
  // ────────────────────────────────────────────────────────────────────────
415
455
 
416
- // Vendored rrweb-record bundle (UMD, exposes `rrwebRecord` global). Read
456
+ // Vendored `@rrweb/record` UMD bundle (rrweb 2.x). Its global `rrwebRecord`
457
+ // is a module object — the record function is `rrwebRecord.record` (the
458
+ // bootstrap resolves both this and the legacy function-shaped global). Read
417
459
  // once at module init — the SDK ships this file in the base snapshot so
418
460
  // no network fetch happens inside the VM.
419
461
  const RRWEB_BUNDLE: string = (() => {
@@ -459,9 +501,18 @@ const RRWEB_CONSOLE_PLUGIN_BUNDLE: string = (() => {
459
501
  // 10 KiB so longer error stacks survive without dwarfing the event stream).
460
502
  const RRWEB_BOOTSTRAP = `
461
503
  ;(function () {
462
- if (typeof rrwebRecord !== "function") return;
504
+ // rrweb 2.x ships \`@rrweb/record\` whose UMD global is a module object
505
+ // (\`rrwebRecord.record\`); the pre-2.0 bundle exposed the record function
506
+ // directly. Resolve either shape so the bootstrap is version-agnostic,
507
+ // and stash it on \`window.__spectestRec\` for the drain/attach helpers
508
+ // (which run as separate injected expressions).
509
+ var rec = (typeof rrwebRecord === "function")
510
+ ? rrwebRecord
511
+ : (rrwebRecord && (rrwebRecord.record || rrwebRecord.default));
512
+ if (typeof rec !== "function") return;
463
513
  if (window.__spectestRrwebInit) return;
464
514
  window.__spectestRrwebInit = true;
515
+ window.__spectestRec = rec;
465
516
  window.__spectestRrwebEvents = [];
466
517
  var plugins = [];
467
518
  try {
@@ -475,7 +526,7 @@ const RRWEB_BOOTSTRAP = `
475
526
  }
476
527
  } catch (e) { /* plugin init failed — keep recording DOM only. */ }
477
528
  try {
478
- rrwebRecord({
529
+ rec({
479
530
  emit: function (ev) { window.__spectestRrwebEvents.push(ev); },
480
531
  plugins: plugins,
481
532
  // Capture page assets into the event stream so the replay renders
@@ -617,9 +668,8 @@ const RRWEB_BOOTSTRAP = `
617
668
  log("injected " + ok.length + " @font-face rule(s) as data: URIs");
618
669
  } catch (e) { log("inject FAIL " + (e && e.message)); return; }
619
670
  try {
620
- if (typeof rrwebRecord === "function" &&
621
- typeof rrwebRecord.takeFullSnapshot === "function") {
622
- rrwebRecord.takeFullSnapshot();
671
+ if (rec && typeof rec.takeFullSnapshot === "function") {
672
+ rec.takeFullSnapshot();
623
673
  log("took full snapshot");
624
674
  }
625
675
  } catch (e) { log("snapshot FAIL " + (e && e.message)); }
@@ -638,8 +688,14 @@ const RRWEB_BOOTSTRAP = `
638
688
  })();
639
689
  `;
640
690
 
691
+ // The `;` between parts is load-bearing: a vendored bundle that ends
692
+ // without a trailing semicolon (rrweb 2.1.0's UMD ends in `}))`) would
693
+ // otherwise ASI-merge with the next part's leading `(function...` into a
694
+ // call expression — the TypeError kills everything after the bundle, so
695
+ // the globals define but the bootstrap never runs (zero rrweb events,
696
+ // empty replays).
641
697
  const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
642
- ? `${RRWEB_BUNDLE}\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n${RRWEB_BOOTSTRAP}`
698
+ ? `${RRWEB_BUNDLE}\n;\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n;\n${RRWEB_BOOTSTRAP}`
643
699
  : "";
644
700
 
645
701
  // Page-side expression that atomically swaps in a fresh buffer and
@@ -661,7 +717,7 @@ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
661
717
  //
662
718
  // When the caller detects the document changed since the last drain
663
719
  // (`view.url` differs) it passes `force=true`; if the outgoing buffer
664
- // then lacks any FullSnapshot (type 2), we call `rrwebRecord.takeFullSnapshot()`
720
+ // then lacks any FullSnapshot (type 2), we call `rec.takeFullSnapshot()`
665
721
  // to synthesize one for the *current* DOM. That emits a fresh Meta
666
722
  // (with the correct href) + FullSnapshot, so the step is self-contained
667
723
  // and seeking to it shows the right page. Gating on url-change + missing
@@ -671,16 +727,16 @@ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
671
727
  function drainExpr(forceFullIfMissing: boolean): string {
672
728
  return `(function () {
673
729
  try {
730
+ var rec = window.__spectestRec;
674
731
  if (${forceFullIfMissing ? "true" : "false"} &&
675
732
  window.__spectestRrwebInit &&
676
- typeof rrwebRecord === "function" &&
677
- typeof rrwebRecord.takeFullSnapshot === "function") {
733
+ rec && typeof rec.takeFullSnapshot === "function") {
678
734
  var b = window.__spectestRrwebEvents || [];
679
735
  var hasFull = false;
680
736
  for (var i = 0; i < b.length; i++) {
681
737
  if (b[i] && b[i].type === 2) { hasFull = true; break; }
682
738
  }
683
- if (!hasFull) rrwebRecord.takeFullSnapshot();
739
+ if (!hasFull) rec.takeFullSnapshot();
684
740
  }
685
741
  } catch (e) { /* recording inactive or DOM detached — drain what's there. */ }
686
742
  var buf = window.__spectestRrwebEvents;
@@ -694,23 +750,37 @@ function drainExpr(forceFullIfMissing: boolean): string {
694
750
  // Factory
695
751
  // ────────────────────────────────────────────────────────────────────────
696
752
 
697
- /** A pre-opened, never-used view: renderer already spawned, rrweb init
698
- * script installed, parked on about:blank. */
699
- interface PooledView {
700
- view: BunWebViewInstance;
753
+ /** A page freshly spawned into a context: renderer up, CDP session open,
754
+ * rrweb init script installed, parked on about:blank. */
755
+ interface SpawnedPage {
756
+ page: Page;
757
+ cdp: CDPSession;
701
758
  recordingInstalled: boolean;
702
759
  }
703
760
 
761
+ /** A pre-opened, never-used desktop view (lives in the shared desktop
762
+ * context). */
763
+ interface PooledView extends SpawnedPage {}
764
+
704
765
  /**
705
- * The unit a Browser/Mobile handle drives. `view` is deliberately mutable:
706
- * the DNS-recovery path (see `navigate` in {@link buildBackend}) replaces a
707
- * broken restored renderer with a freshly-spawned view in the same Chrome,
708
- * and every wrapper reads through the holder so the swap is transparent.
709
- * `device`/`width`/`height` are kept so a rebuilt view comes back with the
710
- * same viewport and emulation.
766
+ * The unit a Browser/Mobile handle drives. `page`/`cdp` are deliberately
767
+ * mutable: the DNS-recovery path (see `navigate` in {@link buildBackend})
768
+ * replaces a broken restored renderer with a freshly-spawned page in the
769
+ * SAME context (cookies/localStorage survive), and every wrapper reads
770
+ * through the holder so the swap is transparent. `device`/`width`/`height`
771
+ * are kept so a rebuilt page comes back with the same emulation.
711
772
  */
712
773
  interface ViewHolder {
713
- view: BunWebViewInstance;
774
+ /** Context this view lives in. Shared desktop context for default-size
775
+ * desktop views; a private one for mobile/custom-viewport views. */
776
+ context: BrowserContext;
777
+ /** Whether close() should also close the context (private contexts). */
778
+ ownsContext: boolean;
779
+ page: Page;
780
+ cdp: CDPSession;
781
+ /** Cached page title — playwright's `title()` is async but our public
782
+ * surface is a sync getter; refreshed after every recorded op. */
783
+ lastTitle: string;
714
784
  recordingInstalled: boolean;
715
785
  device: DevicePreset | null;
716
786
  width: number;
@@ -720,7 +790,7 @@ interface ViewHolder {
720
790
  * session record so the replay can mirror them. */
721
791
  safeAreaInsets: SafeAreaInsets | null;
722
792
  /** User scripts installed via `addInitScript`, kept so the DNS-recovery
723
- * rebuild can re-install them on the replacement view. */
793
+ * rebuild can re-install them on the replacement page. */
724
794
  initScripts: string[];
725
795
  }
726
796
 
@@ -736,22 +806,19 @@ interface ViewHolder {
736
806
  // fake state).
737
807
  const VIEW_POOL: PooledView[] = [];
738
808
 
739
- /** Create a view + CDP session + rrweb init script — the slow part. */
740
- async function createView(width: number, height: number): Promise<PooledView> {
741
- const view = new Bun.WebView({
742
- width,
743
- height,
744
- backend: { type: "chrome", argv: CHROME_ARGV },
745
- });
746
-
747
- // Bun's docs: `cdp()` requires at least one navigate first to set up
748
- // the CDP session. about:blank is the cheapest bootstrap target.
749
- await view.navigate("about:blank");
809
+ /** Spawn a page (+ CDP session + rrweb init script) into `context` — the
810
+ * slow part (renderer process spawn). */
811
+ async function spawnPage(context: BrowserContext): Promise<SpawnedPage> {
812
+ const page = await context.newPage();
813
+ const cdp = await context.newCDPSession(page);
814
+ // Without Page.enable the addScriptToEvaluateOnNewDocument registration
815
+ // silently never fires on this session (verified in the fork spike).
816
+ await cdp.send("Page.enable");
750
817
 
751
818
  let recordingInstalled = false;
752
819
  if (PAGE_INIT_SCRIPT) {
753
820
  try {
754
- await view.cdp("Page.addScriptToEvaluateOnNewDocument", {
821
+ await cdp.send("Page.addScriptToEvaluateOnNewDocument", {
755
822
  source: PAGE_INIT_SCRIPT,
756
823
  });
757
824
  recordingInstalled = true;
@@ -761,7 +828,36 @@ async function createView(width: number, height: number): Promise<PooledView> {
761
828
  console.warn("[spectest] failed to install rrweb recorder:", err);
762
829
  }
763
830
  }
764
- return { view, recordingInstalled };
831
+ return { page, cdp, recordingInstalled };
832
+ }
833
+
834
+ /** Acquire the context a view of this shape lives in. */
835
+ async function contextFor(
836
+ width: number,
837
+ height: number,
838
+ device: DevicePreset | null,
839
+ ): Promise<{ context: BrowserContext; ownsContext: boolean }> {
840
+ if (device) {
841
+ const browser = await ensurePlaywrightBrowser();
842
+ const context = await browser.newContext(deviceContextOptions(device));
843
+ context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
844
+ context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
845
+ return { context, ownsContext: true };
846
+ }
847
+ if (width === 1280 && height === 720) {
848
+ return { context: await ensureDesktopContext(), ownsContext: false };
849
+ }
850
+ const browser = await ensurePlaywrightBrowser();
851
+ const context = await browser.newContext({ viewport: { width, height } });
852
+ context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
853
+ context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
854
+ return { context, ownsContext: true };
855
+ }
856
+
857
+ /** Create a default-desktop view for the pool. */
858
+ async function createView(width: number, height: number): Promise<PooledView> {
859
+ const { context } = await contextFor(width, height, null);
860
+ return spawnPage(context);
765
861
  }
766
862
 
767
863
  /**
@@ -777,9 +873,8 @@ export async function prewarmViewPool(n = 1): Promise<void> {
777
873
  }
778
874
 
779
875
  /**
780
- * Open a browser view. Always uses the Chrome backend in the daemon
781
- * (Firecracker guest is Linux; WKWebView isn't available). Serves from
782
- * the pre-opened pool when the caller uses the default viewport.
876
+ * Open a browser view (a page in the shared Chromium). Serves from the
877
+ * pre-opened pool when the caller uses the default viewport.
783
878
  *
784
879
  * This is the EPHEMERAL path — `close()` destroys the view. The daemon's
785
880
  * `ctx.browser()`/`ctx.mobile()` go through {@link acquirePersistentBrowser}
@@ -805,21 +900,28 @@ export async function openMobileBackend(
805
900
  const wantW = device ? device.viewport.width : opts.width ?? 1280;
806
901
  const wantH = device ? device.viewport.height : opts.height ?? 720;
807
902
  // Mobile views are never pooled — the pool holds only default-desktop
808
- // views, and a mobile view needs its emulation applied fresh anyway.
903
+ // views (in the shared desktop context), and a mobile view needs its own
904
+ // emulated context anyway.
809
905
  const pooled =
810
906
  !device && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
811
- const base = pooled ?? (await createView(wantW, wantH));
812
- const holder: ViewHolder = {
813
- view: base.view,
814
- recordingInstalled: base.recordingInstalled,
815
- device,
816
- width: wantW,
817
- height: wantH,
818
- safeAreaInsets: null,
819
- initScripts: [],
820
- };
821
-
822
- if (device) holder.safeAreaInsets = await applyDeviceEmulation(holder.view, device);
907
+ let holder: ViewHolder;
908
+ if (pooled) {
909
+ holder = {
910
+ context: await ensureDesktopContext(),
911
+ ownsContext: false,
912
+ page: pooled.page,
913
+ cdp: pooled.cdp,
914
+ lastTitle: "",
915
+ recordingInstalled: pooled.recordingInstalled,
916
+ device,
917
+ width: wantW,
918
+ height: wantH,
919
+ safeAreaInsets: null,
920
+ initScripts: [],
921
+ };
922
+ } else {
923
+ holder = await newHolder(wantW, wantH, device);
924
+ }
823
925
 
824
926
  const { backend } = buildBackend(holder, opts.recorder ?? null, {
825
927
  persistent: false,
@@ -849,6 +951,30 @@ export async function openMobileBackend(
849
951
  let SHARED_BROWSER: ViewHolder | null = null;
850
952
  const SHARED_MOBILE = new Map<string, ViewHolder>();
851
953
 
954
+ async function newHolder(
955
+ width: number,
956
+ height: number,
957
+ device: DevicePreset | null,
958
+ ): Promise<ViewHolder> {
959
+ const { context, ownsContext } = await contextFor(width, height, device);
960
+ const spawned = await spawnPage(context);
961
+ const holder: ViewHolder = {
962
+ context,
963
+ ownsContext,
964
+ page: spawned.page,
965
+ cdp: spawned.cdp,
966
+ lastTitle: "",
967
+ recordingInstalled: spawned.recordingInstalled,
968
+ device,
969
+ width,
970
+ height,
971
+ safeAreaInsets: null,
972
+ initScripts: [],
973
+ };
974
+ if (device) holder.safeAreaInsets = await applySafeAreaInsets(holder.cdp, device);
975
+ return holder;
976
+ }
977
+
852
978
  /**
853
979
  * What acquiring a persistent session returns. `detach` is the test-end
854
980
  * hook (final rrweb drain, stop writing to this test's recorder, keep the
@@ -873,37 +999,18 @@ export interface PersistentBrowser {
873
999
  const ATTACH_RESET_EXPR = `(function () {
874
1000
  window.__spectestRrwebEvents = [];
875
1001
  try {
876
- if (typeof rrwebRecord === "function" &&
877
- typeof rrwebRecord.takeFullSnapshot === "function") {
878
- rrwebRecord.takeFullSnapshot();
1002
+ var rec = window.__spectestRec;
1003
+ if (rec && typeof rec.takeFullSnapshot === "function") {
1004
+ rec.takeFullSnapshot();
879
1005
  }
880
1006
  } catch (e) { /* recording not active on this document */ }
881
1007
  return true;
882
1008
  })()`;
883
1009
 
884
- async function newHolder(
885
- width: number,
886
- height: number,
887
- device: DevicePreset | null,
888
- ): Promise<ViewHolder> {
889
- const { view, recordingInstalled } = await createView(width, height);
890
- const holder: ViewHolder = {
891
- view,
892
- recordingInstalled,
893
- device,
894
- width,
895
- height,
896
- safeAreaInsets: null,
897
- initScripts: [],
898
- };
899
- if (device) holder.safeAreaInsets = await applyDeviceEmulation(view, device);
900
- return holder;
901
- }
902
-
903
1010
  async function attachReset(holder: ViewHolder): Promise<void> {
904
1011
  if (!holder.recordingInstalled) return;
905
1012
  try {
906
- await holder.view.evaluate(ATTACH_RESET_EXPR);
1013
+ await holder.page.evaluate(ATTACH_RESET_EXPR);
907
1014
  } catch {
908
1015
  // Page mid-navigation or renderer unhappy — the first drain forces a
909
1016
  // full snapshot when one is missing (drainExpr), so replay still works.
@@ -994,33 +1101,34 @@ async function daemonResolves(url: string): Promise<boolean> {
994
1101
  }
995
1102
 
996
1103
  /**
997
- * Replace a persistent holder's view with a freshly-spawned one in the same
998
- * Chrome. Profile state — cookies, localStorage, the in-VM CA trust — is
999
- * per-Chrome, so it survives; only renderer-held page state is lost, and
1000
- * this path only runs when that renderer already can't navigate.
1104
+ * Replace a persistent holder's page with a freshly-spawned one in the SAME
1105
+ * context. Context state — cookies, localStorage — survives; only
1106
+ * renderer-held page state is lost, and this path only runs when that
1107
+ * renderer already can't navigate.
1001
1108
  *
1002
1109
  * Known trigger: a renderer created before a snapshot fails its first
1003
1110
  * post-restore navigation with `net::ERR_NAME_NOT_RESOLVED` even though a
1004
- * fresh view in the SAME restored Chrome resolves fine (root cause never
1111
+ * fresh page in the SAME restored Chromium resolves fine (root cause never
1005
1112
  * found — see the disabled-prewarm note at the end of /bootstrap in
1006
1113
  * daemon.ts). Persistent sessions walk into exactly that scenario whenever
1007
- * a child test navigates, so the recovery lives here: rebuild the view,
1114
+ * a child test navigates, so the recovery lives here: rebuild the page,
1008
1115
  * retry once.
1009
1116
  */
1010
1117
  async function rebuildView(holder: ViewHolder): Promise<void> {
1011
1118
  try {
1012
- holder.view.close();
1119
+ await holder.page.close();
1013
1120
  } catch {
1014
- /* view may already be gone */
1121
+ /* page may already be gone */
1015
1122
  }
1016
- const fresh = await createView(holder.width, holder.height);
1017
- holder.view = fresh.view;
1123
+ const fresh = await spawnPage(holder.context);
1124
+ holder.page = fresh.page;
1125
+ holder.cdp = fresh.cdp;
1018
1126
  holder.recordingInstalled = fresh.recordingInstalled;
1019
1127
  if (holder.device) {
1020
- holder.safeAreaInsets = await applyDeviceEmulation(holder.view, holder.device);
1128
+ holder.safeAreaInsets = await applySafeAreaInsets(holder.cdp, holder.device);
1021
1129
  }
1022
1130
  for (const source of holder.initScripts) {
1023
- await holder.view.cdp("Page.addScriptToEvaluateOnNewDocument", { source });
1131
+ await holder.cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
1024
1132
  }
1025
1133
  }
1026
1134
 
@@ -1038,7 +1146,7 @@ function buildBackend(
1038
1146
  recorder: BrowserSessionRecorder | null,
1039
1147
  buildOpts: BackendBuildOptions,
1040
1148
  ): { backend: MobileBackend; detach(): Promise<void> } {
1041
- // All page access goes through `holder.view` — never capture the view in
1149
+ // All page access goes through `holder.page` — never capture the page in
1042
1150
  // a local — because the DNS-recovery rebuild swaps it mid-wrapper.
1043
1151
  // `recordingEnded` stops this wrapper's recorder writes (test end);
1044
1152
  // `viewClosed` tracks actual destruction (author called close()).
@@ -1055,9 +1163,9 @@ function buildBackend(
1055
1163
  async function drain(action: BrowserAction | "close"): Promise<void> {
1056
1164
  if (!holder.recordingInstalled || !recorder || recordingEnded) return;
1057
1165
  try {
1058
- const urlChanged = holder.view.url !== lastDrainUrl;
1059
- const events = await holder.view.evaluate<unknown[]>(drainExpr(urlChanged));
1060
- lastDrainUrl = holder.view.url;
1166
+ const urlChanged = holder.page.url() !== lastDrainUrl;
1167
+ const events = (await holder.page.evaluate(drainExpr(urlChanged))) as unknown[];
1168
+ lastDrainUrl = holder.page.url();
1061
1169
  if (Array.isArray(events) && events.length > 0) {
1062
1170
  recorder.recordStep({
1063
1171
  stepSeq: stepSeq++,
@@ -1082,6 +1190,12 @@ function buildBackend(
1082
1190
  const resv = reserveEvent();
1083
1191
  try {
1084
1192
  const result = await fn();
1193
+ // Refresh the sync title cache (playwright's title() is async).
1194
+ try {
1195
+ holder.lastTitle = await holder.page.title();
1196
+ } catch {
1197
+ /* page mid-navigation or closed — keep the stale cache */
1198
+ }
1085
1199
  // `sessionTimestamp` is the post-op wall clock — that's where we
1086
1200
  // want the dashboard's seek to land, so clicking "type 'foo'"
1087
1201
  // shows the input *with* the text, not the empty field just
@@ -1132,10 +1246,10 @@ function buildBackend(
1132
1246
 
1133
1247
  const backend: MobileBackend = {
1134
1248
  get url() {
1135
- return holder.view.url;
1249
+ return holder.page.url();
1136
1250
  },
1137
1251
  get title() {
1138
- return holder.view.title;
1252
+ return holder.lastTitle;
1139
1253
  },
1140
1254
  get safeAreaInsets() {
1141
1255
  return holder.safeAreaInsets;
@@ -1144,7 +1258,7 @@ function buildBackend(
1144
1258
  recorder?.noteNavigation?.(url);
1145
1259
  return instrumented("navigate", { url }, async () => {
1146
1260
  try {
1147
- await holder.view.navigate(url);
1261
+ await holder.page.goto(url, { waitUntil: "load" });
1148
1262
  } catch (err) {
1149
1263
  // Restored-renderer DNS bug (see `rebuildView`): only when the
1150
1264
  // view is persistent (so it may have lived through a snapshot
@@ -1154,7 +1268,7 @@ function buildBackend(
1154
1268
  if (!buildOpts.persistent || !isNameNotResolved(err)) throw err;
1155
1269
  if (!(await daemonResolves(url))) throw err;
1156
1270
  await rebuildView(holder);
1157
- await holder.view.navigate(url);
1271
+ await holder.page.goto(url, { waitUntil: "load" });
1158
1272
  }
1159
1273
  });
1160
1274
  },
@@ -1173,7 +1287,7 @@ function buildBackend(
1173
1287
  scriptTruncated: truncatedScript.truncated,
1174
1288
  },
1175
1289
  async () => {
1176
- const v = await holder.view.evaluate<T>(toEvaluable(script));
1290
+ const v = (await holder.page.evaluate(toEvaluable(script))) as T;
1177
1291
  return v;
1178
1292
  },
1179
1293
  ) as Promise<Wrapped<T>>;
@@ -1188,10 +1302,12 @@ function buildBackend(
1188
1302
  scriptTruncated: truncated.truncated,
1189
1303
  },
1190
1304
  async () => {
1191
- await holder.view.cdp("Page.addScriptToEvaluateOnNewDocument", {
1305
+ // Raw CDP (not context.addInitScript) so the script stays scoped
1306
+ // to THIS page — the desktop context is shared across views.
1307
+ await holder.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
1192
1308
  source,
1193
1309
  });
1194
- // Remember it so a DNS-recovery view rebuild re-installs it.
1310
+ // Remember it so a DNS-recovery page rebuild re-installs it.
1195
1311
  holder.initScripts.push(source);
1196
1312
  },
1197
1313
  );
@@ -1226,7 +1342,7 @@ function buildBackend(
1226
1342
  fields.attempts = (fields.attempts ?? 0) + 1;
1227
1343
  let v: unknown;
1228
1344
  try {
1229
- v = await holder.view.evaluate<unknown>(evaluable);
1345
+ v = await holder.page.evaluate(evaluable);
1230
1346
  } catch (err) {
1231
1347
  if (Date.now() >= deadline) throw err;
1232
1348
  await new Promise((r) => setTimeout(r, intervalMs));
@@ -1243,46 +1359,61 @@ function buildBackend(
1243
1359
  }) as Promise<Wrapped<T>>;
1244
1360
  },
1245
1361
  click(selector) {
1246
- return instrumented("click", { selector }, () => holder.view.click(selector));
1362
+ return instrumented("click", { selector }, () =>
1363
+ holder.page.click(selector, { timeout: DEFAULT_ACTION_TIMEOUT_MS }),
1364
+ );
1247
1365
  },
1248
1366
  clickAt(x, y) {
1249
- return instrumented("click", { x, y }, () => holder.view.click(x, y));
1367
+ return instrumented("click", { x, y }, () => holder.page.mouse.click(x, y));
1250
1368
  },
1251
1369
  type(text) {
1370
+ // insertText path (no per-char keydown) — same semantics as before.
1252
1371
  const t = truncateUtf8(text);
1253
1372
  return instrumented(
1254
1373
  "type",
1255
1374
  { text: t.value, textTruncated: t.truncated },
1256
- () => holder.view.type(text),
1375
+ () => holder.page.keyboard.insertText(text),
1257
1376
  );
1258
1377
  },
1259
1378
  press(key) {
1260
- return instrumented("press", { key }, () => holder.view.press(key));
1379
+ return instrumented("press", { key }, () => holder.page.keyboard.press(key));
1261
1380
  },
1262
1381
  scroll(dx, dy) {
1263
- return instrumented("scroll", { dx, dy }, () => holder.view.scroll(dx, dy));
1382
+ return instrumented("scroll", { dx, dy }, () => holder.page.mouse.wheel(dx, dy));
1264
1383
  },
1265
1384
  scrollTo(selector) {
1266
- return instrumented("scrollTo", { selector }, () => holder.view.scrollTo(selector));
1385
+ return instrumented("scrollTo", { selector }, () =>
1386
+ holder.page
1387
+ .locator(selector)
1388
+ .first()
1389
+ .scrollIntoViewIfNeeded({ timeout: DEFAULT_ACTION_TIMEOUT_MS }),
1390
+ );
1267
1391
  },
1268
1392
  back() {
1269
- return instrumented("back", {}, () => holder.view.back());
1393
+ return instrumented("back", {}, async () => {
1394
+ await holder.page.goBack();
1395
+ });
1270
1396
  },
1271
1397
  forward() {
1272
- return instrumented("forward", {}, () => holder.view.forward());
1398
+ return instrumented("forward", {}, async () => {
1399
+ await holder.page.goForward();
1400
+ });
1273
1401
  },
1274
1402
  reload() {
1275
- return instrumented("reload", {}, () => holder.view.reload());
1403
+ return instrumented("reload", {}, async () => {
1404
+ await holder.page.reload();
1405
+ });
1276
1406
  },
1277
1407
  async screenshot(options) {
1278
1408
  const format = options?.format ?? "png";
1279
1409
  return instrumented("screenshot", { format }, async () => {
1280
- const buf = await holder.view.screenshot({
1281
- encoding: "buffer",
1410
+ // Raw CDP rather than page.screenshot(): CDP supports webp too, and
1411
+ // captures the viewport exactly like the pre-Playwright backend.
1412
+ const res = (await holder.cdp.send("Page.captureScreenshot", {
1282
1413
  format,
1283
- quality: options?.quality,
1284
- });
1285
- return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
1414
+ ...(format === "png" ? {} : { quality: options?.quality ?? 90 }),
1415
+ } as never)) as { data: string };
1416
+ return new Uint8Array(Buffer.from(res.data, "base64"));
1286
1417
  });
1287
1418
  },
1288
1419
  async close() {
@@ -1297,55 +1428,70 @@ function buildBackend(
1297
1428
  viewClosed = true;
1298
1429
  buildOpts.onDestroy?.();
1299
1430
  try {
1300
- holder.view.close();
1431
+ await holder.page.close();
1301
1432
  } catch {
1302
1433
  /* already closed by the runtime */
1303
1434
  }
1435
+ if (holder.ownsContext) {
1436
+ try {
1437
+ await holder.context.close();
1438
+ } catch {
1439
+ /* context already gone (browser died) */
1440
+ }
1441
+ }
1304
1442
  },
1305
1443
  // ── Mobile-only primitives ──────────────────────────────────────────
1444
+ async rawTap(x, y, durationMs) {
1445
+ // Dispatches a real touch so RN-Web's responder system fires.
1446
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1447
+ type: "touchStart",
1448
+ touchPoints: [{ x, y, id: 0 }],
1449
+ });
1450
+ // Dwell between start and end, like a real finger. An instant
1451
+ // touchStart→touchEnd starves RN Pressables whose `onPressIn`
1452
+ // mutates state (optimistic label flips, scale animations): React
1453
+ // re-renders mid-gesture and the press never completes. The dwell
1454
+ // lets that commit land before release; well under any long-press
1455
+ // threshold (RN default 500ms).
1456
+ await new Promise((r) => setTimeout(r, durationMs ?? TAP_DWELL_MS));
1457
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1458
+ type: "touchEnd",
1459
+ touchPoints: [],
1460
+ });
1461
+ },
1306
1462
  tapAt(x, y, opts) {
1307
1463
  // Recorded as a "click" (the event schema stays desktop-shaped; the
1308
- // mobile facade owns the author-facing "tap" vocabulary). Dispatches
1309
- // a real touch so RN-Web's responder system fires.
1310
- return instrumented("click", { x, y }, async () => {
1311
- await holder.view.cdp("Input.dispatchTouchEvent", {
1312
- type: "touchStart",
1313
- touchPoints: [{ x, y, id: 0 }],
1314
- });
1315
- // Dwell between start and end, like a real finger. An instant
1316
- // touchStart→touchEnd starves RN Pressables whose `onPressIn`
1317
- // mutates state (optimistic label flips, scale animations): React
1318
- // re-renders mid-gesture and the press never completes. The dwell
1319
- // lets that commit land before release; well under any long-press
1320
- // threshold (RN default 500ms).
1321
- await new Promise((r) => setTimeout(r, opts?.durationMs ?? TAP_DWELL_MS));
1322
- await holder.view.cdp("Input.dispatchTouchEvent", {
1323
- type: "touchEnd",
1324
- touchPoints: [],
1325
- });
1326
- });
1464
+ // mobile facade owns the author-facing "tap" vocabulary).
1465
+ return instrumented("click", { x, y }, () => backend.rawTap(x, y, opts?.durationMs));
1327
1466
  },
1328
1467
  swipeBy(x, y, dx, dy) {
1329
1468
  return instrumented("scroll", { dx, dy }, async () => {
1330
1469
  const steps = 8;
1331
- await holder.view.cdp("Input.dispatchTouchEvent", {
1470
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1332
1471
  type: "touchStart",
1333
1472
  touchPoints: [{ x, y, id: 0 }],
1334
1473
  });
1335
1474
  for (let i = 1; i <= steps; i++) {
1336
- await holder.view.cdp("Input.dispatchTouchEvent", {
1475
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1337
1476
  type: "touchMove",
1338
1477
  touchPoints: [{ x: x + (dx * i) / steps, y: y + (dy * i) / steps, id: 0 }],
1339
1478
  });
1340
1479
  }
1341
- await holder.view.cdp("Input.dispatchTouchEvent", {
1480
+ await holder.cdp.send("Input.dispatchTouchEvent", {
1342
1481
  type: "touchEnd",
1343
1482
  touchPoints: [],
1344
1483
  });
1345
1484
  });
1346
1485
  },
1347
1486
  probe<T = unknown>(expression: string): Promise<T> {
1348
- return holder.view.evaluate<T>(expression);
1487
+ return holder.page.evaluate(expression) as Promise<T>;
1488
+ },
1489
+ pageOp<T>(
1490
+ action: BrowserAction,
1491
+ fields: Partial<RecordableFields>,
1492
+ fn: (page: Page) => Promise<T>,
1493
+ ): Promise<T> {
1494
+ return instrumented(action, fields, () => fn(holder.page));
1349
1495
  },
1350
1496
  };
1351
1497
  return { backend, detach: endRecording };