@specific.dev/spectest 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/browser.ts CHANGED
@@ -16,7 +16,14 @@
16
16
  // Headless-Linux specifics live here: we force the chrome backend, add
17
17
  // `--no-sandbox` (Chrome refuses to run as root otherwise) and
18
18
  // `--disable-dev-shm-usage` (Firecracker's /dev/shm is tiny).
19
+ //
20
+ // Sessions are PERSISTENT by default: `ctx.browser()` always hands back the
21
+ // one shared desktop browser and `ctx.mobile(app)` the one session for that
22
+ // app, kept alive across tests so snapshots capture the live Chromium and
23
+ // dependsOn children resume exactly where the parent left off (signed-in
24
+ // SPA state included). See the "Persistent sessions" section below.
19
25
 
26
+ import { promises as dns } from "node:dns";
20
27
  import { readFileSync } from "node:fs";
21
28
  import path from "node:path";
22
29
  import { fileURLToPath } from "node:url";
@@ -160,6 +167,24 @@ export interface Browser {
160
167
  * test event log so the step list isn't a wall of minified code.
161
168
  */
162
169
  evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
170
+ /**
171
+ * Install a script that runs in every document loaded from now on, BEFORE
172
+ * any of the document's own scripts execute (CDP
173
+ * `Page.addScriptToEvaluateOnNewDocument` — the Playwright `addInitScript`
174
+ * equivalent). The deterministic way to plant shims and instrumentation
175
+ * (`fetch`/`XMLHttpRequest` wrappers, clock stubs, feature flags): unlike an
176
+ * `evaluate` racing the app bundle after a navigation, an init script is
177
+ * guaranteed to win.
178
+ *
179
+ * Does NOT run in the *current* document — call it before the `navigate`
180
+ * (or in-page `location.assign`) whose document needs it. Installed
181
+ * scripts persist for the browser session's lifetime, which for the
182
+ * persistent `ctx.browser()`/`ctx.mobile()` sessions means they ride
183
+ * snapshots into `dependsOn` children like the rest of the session state.
184
+ *
185
+ * `description` labels the step in the test event log.
186
+ */
187
+ addInitScript(description: string, source: string): Promise<void>;
163
188
  /**
164
189
  * Poll `expression` in the page until it returns a truthy value
165
190
  * (the returned value is what `waitFor` resolves with). Useful for
@@ -205,7 +230,15 @@ export interface Browser {
205
230
  reload(): Promise<void>;
206
231
  /** Capture a PNG/JPEG/WebP screenshot of the viewport. Returns raw bytes. */
207
232
  screenshot(options?: ScreenshotOptions): Promise<Uint8Array>;
208
- /** Close the underlying view. Idempotent. Drains any pending rrweb events. */
233
+ /**
234
+ * Destroy the underlying view. Idempotent; drains any pending rrweb
235
+ * events first. For the persistent session behind `ctx.browser()` /
236
+ * `ctx.mobile()` this is the escape hatch to a FRESH browser — the
237
+ * shared instance is discarded and the next call creates a new one.
238
+ * Don't call it for routine cleanup: the daemon detaches recording at
239
+ * test end automatically and deliberately keeps the browser alive so
240
+ * dependent tests inherit its state.
241
+ */
209
242
  close(): Promise<void>;
210
243
  }
211
244
 
@@ -218,6 +251,10 @@ export interface Browser {
218
251
  * drain as the desktop verbs.
219
252
  */
220
253
  export interface MobileBackend extends Browser {
254
+ /** Safe-area insets emulated on this view (`null` on desktop views or
255
+ * when the CDP override is unavailable). The daemon stamps these onto
256
+ * the session record for the dashboard's replay. */
257
+ readonly safeAreaInsets: SafeAreaInsets | null;
221
258
  /** Touch-tap at viewport CSS coordinates (touchStart→touchEnd). */
222
259
  tapAt(x: number, y: number): Promise<void>;
223
260
  /** Touch-drag from (x,y) by (dx,dy) over a short move sequence. */
@@ -266,6 +303,15 @@ interface DevicePreset {
266
303
  isMobile: boolean;
267
304
  hasTouch: boolean;
268
305
  userAgent: string;
306
+ safeAreaInsets: SafeAreaInsets;
307
+ }
308
+
309
+ /** iOS safe-area insets (CSS `env(safe-area-inset-*)`), in CSS px. */
310
+ export interface SafeAreaInsets {
311
+ top: number;
312
+ right: number;
313
+ bottom: number;
314
+ left: number;
269
315
  }
270
316
 
271
317
  /** The fixed mobile device. Logical resolution + DPR of a current iPhone;
@@ -281,16 +327,28 @@ const LATEST_IPHONE: DevicePreset = {
281
327
  userAgent:
282
328
  "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " +
283
329
  "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
330
+ // Portrait safe area of the 393×852 iPhones (14 Pro through 16): 59pt
331
+ // status-bar/Dynamic-Island clearance on top, 34pt home-indicator strip
332
+ // at the bottom. Emulated via CDP so the app's `env(safe-area-inset-*)`
333
+ // padding fires exactly like on the real device.
334
+ safeAreaInsets: { top: 59, right: 0, bottom: 34, left: 0 },
284
335
  };
285
336
 
286
337
  /** Apply CDP device emulation to a freshly-created view. The overrides are
287
338
  * CDP-session-global, so they persist across the app navigation that
288
339
  * follows (we set them on the about:blank bootstrap page). Best-effort:
289
- * a CDP failure degrades to a plain desktop view rather than aborting. */
340
+ * a CDP failure degrades to a plain desktop view rather than aborting.
341
+ *
342
+ * Returns the safe-area insets that actually took effect (`null` when the
343
+ * override failed — Chromium < 135 lacks the CDP method). The caller
344
+ * stamps them onto the session record so the dashboard can substitute the
345
+ * same values for `env(safe-area-inset-*)` in the replayed CSS; stamping
346
+ * only what was really applied keeps capture layout and replay layout in
347
+ * lockstep (recorded touch coordinates would misalign otherwise). */
290
348
  async function applyDeviceEmulation(
291
349
  view: BunWebViewInstance,
292
350
  d: DevicePreset,
293
- ): Promise<void> {
351
+ ): Promise<SafeAreaInsets | null> {
294
352
  try {
295
353
  await view.cdp("Emulation.setDeviceMetricsOverride", {
296
354
  width: d.viewport.width,
@@ -308,6 +366,17 @@ async function applyDeviceEmulation(
308
366
  } catch (err) {
309
367
  // eslint-disable-next-line no-console
310
368
  console.warn("[spectest] device emulation failed; using desktop view:", err);
369
+ return null;
370
+ }
371
+ try {
372
+ await view.cdp("Emulation.setSafeAreaInsetsOverride", {
373
+ insets: { ...d.safeAreaInsets },
374
+ });
375
+ return d.safeAreaInsets;
376
+ } catch (err) {
377
+ // eslint-disable-next-line no-console
378
+ console.warn("[spectest] safe-area inset emulation unavailable:", err);
379
+ return null;
311
380
  }
312
381
  }
313
382
 
@@ -603,6 +672,29 @@ interface PooledView {
603
672
  recordingInstalled: boolean;
604
673
  }
605
674
 
675
+ /**
676
+ * The unit a Browser/Mobile handle drives. `view` is deliberately mutable:
677
+ * the DNS-recovery path (see `navigate` in {@link buildBackend}) replaces a
678
+ * broken restored renderer with a freshly-spawned view in the same Chrome,
679
+ * and every wrapper reads through the holder so the swap is transparent.
680
+ * `device`/`width`/`height` are kept so a rebuilt view comes back with the
681
+ * same viewport and emulation.
682
+ */
683
+ interface ViewHolder {
684
+ view: BunWebViewInstance;
685
+ recordingInstalled: boolean;
686
+ device: DevicePreset | null;
687
+ width: number;
688
+ height: number;
689
+ /** Safe-area insets actually applied to the view (`null` for desktop
690
+ * views or when the CDP override is unavailable). Stamped onto the
691
+ * session record so the replay can mirror them. */
692
+ safeAreaInsets: SafeAreaInsets | null;
693
+ /** User scripts installed via `addInitScript`, kept so the DNS-recovery
694
+ * rebuild can re-install them on the replacement view. */
695
+ initScripts: string[];
696
+ }
697
+
606
698
  // Pre-opened view pool. Renderer spawn is the expensive part of
607
699
  // `ctx.browser()` — ~1.8s for the first view in a fresh Chrome and
608
700
  // (measured 2026-06-05) a constant ~1.2-1.5s per view in a Chrome that
@@ -659,6 +751,11 @@ export async function prewarmViewPool(n = 1): Promise<void> {
659
751
  * Open a browser view. Always uses the Chrome backend in the daemon
660
752
  * (Firecracker guest is Linux; WKWebView isn't available). Serves from
661
753
  * the pre-opened pool when the caller uses the default viewport.
754
+ *
755
+ * This is the EPHEMERAL path — `close()` destroys the view. The daemon's
756
+ * `ctx.browser()`/`ctx.mobile()` go through {@link acquirePersistentBrowser}
757
+ * / {@link acquirePersistentMobileBackend} instead, which keep one view
758
+ * alive across tests so it rides snapshots/forks.
662
759
  */
663
760
  export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
664
761
  return openMobileBackend(opts);
@@ -668,10 +765,9 @@ export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
668
765
  * Like {@link openBrowser} but returns the {@link MobileBackend} superset
669
766
  * (touch + probe). When `opts.frame === "mobile"` the view is created at the
670
767
  * fixed device viewport, bypasses the (desktop-sized) pool, and has CDP
671
- * device emulation applied before the first navigation. The mobile facade
672
- * (`sdk/src/mobile.ts`) calls this; desktop callers go through
673
- * {@link openBrowser} and get the narrower {@link Browser} view of the same
674
- * object.
768
+ * device emulation applied before the first navigation. Desktop callers go
769
+ * through {@link openBrowser} and get the narrower {@link Browser} view of
770
+ * the same object.
675
771
  */
676
772
  export async function openMobileBackend(
677
773
  opts: BrowserOptions = {},
@@ -683,27 +779,242 @@ export async function openMobileBackend(
683
779
  // views, and a mobile view needs its emulation applied fresh anyway.
684
780
  const pooled =
685
781
  !device && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
686
- const { view, recordingInstalled } = pooled ?? (await createView(wantW, wantH));
782
+ const base = pooled ?? (await createView(wantW, wantH));
783
+ const holder: ViewHolder = {
784
+ view: base.view,
785
+ recordingInstalled: base.recordingInstalled,
786
+ device,
787
+ width: wantW,
788
+ height: wantH,
789
+ safeAreaInsets: null,
790
+ initScripts: [],
791
+ };
687
792
 
688
- if (device) await applyDeviceEmulation(view, device);
793
+ if (device) holder.safeAreaInsets = await applyDeviceEmulation(holder.view, device);
689
794
 
690
- const browser = wrapView(view, opts.recorder ?? null, recordingInstalled);
795
+ const { backend } = buildBackend(holder, opts.recorder ?? null, {
796
+ persistent: false,
797
+ });
691
798
 
692
799
  // We deliberately don't forward `opts.url` to the constructor — going
693
800
  // through our own `navigate()` keeps the recorder log uniform (one
694
801
  // event per navigation, with timing) and drains rrweb after the load.
695
802
  if (opts.url !== undefined) {
696
- await browser.navigate(opts.url);
803
+ await backend.navigate(opts.url);
697
804
  }
698
- return browser;
805
+ return backend;
699
806
  }
700
807
 
701
- function wrapView(
702
- view: BunWebViewInstance,
808
+ // ────────────────────────────────────────────────────────────────────────
809
+ // Persistent sessions (the default behind ctx.browser / ctx.mobile)
810
+ // ────────────────────────────────────────────────────────────────────────
811
+
812
+ // One long-lived desktop browser plus one mobile session per app URL.
813
+ // Module state lives in daemon memory, so it forks with the snapshot the
814
+ // same way fake `state` and TEST_DATA do: a test's browser — its live
815
+ // page, cookies, localStorage, in-memory SPA state — is captured in the
816
+ // post-test snapshot and inherited by `dependsOn` children, while sibling
817
+ // forks never see each other's sessions. That's what lets a child test
818
+ // continue where its parent left off (e.g. already signed in) instead of
819
+ // re-navigating and re-authenticating.
820
+ let SHARED_BROWSER: ViewHolder | null = null;
821
+ const SHARED_MOBILE = new Map<string, ViewHolder>();
822
+
823
+ /**
824
+ * What acquiring a persistent session returns. `detach` is the test-end
825
+ * hook (final rrweb drain, stop writing to this test's recorder, keep the
826
+ * view alive); `browser.close()` is the author-facing escape hatch that
827
+ * actually destroys the view (the next `ctx.browser()` starts fresh).
828
+ */
829
+ export interface PersistentBrowser {
830
+ browser: MobileBackend;
831
+ /** True when this call attached to a view inherited from an earlier
832
+ * test (possibly across a snapshot fork) rather than creating one. */
833
+ attached: boolean;
834
+ /** Final rrweb drain + detach from the current recorder. The view stays
835
+ * alive so the post-test snapshot captures it. Idempotent. */
836
+ detach(): Promise<void>;
837
+ }
838
+
839
+ // Runs in the page when a persistent view is attached to a new test's
840
+ // recorder: drop whatever rrweb buffered since the previous detach (idle
841
+ // mutations; the previous test's session already drained everything it
842
+ // owns) and emit a fresh Meta + FullSnapshot so the new session's replay
843
+ // is self-contained from its first event.
844
+ const ATTACH_RESET_EXPR = `(function () {
845
+ window.__spectestRrwebEvents = [];
846
+ try {
847
+ if (typeof rrwebRecord === "function" &&
848
+ typeof rrwebRecord.takeFullSnapshot === "function") {
849
+ rrwebRecord.takeFullSnapshot();
850
+ }
851
+ } catch (e) { /* recording not active on this document */ }
852
+ return true;
853
+ })()`;
854
+
855
+ async function newHolder(
856
+ width: number,
857
+ height: number,
858
+ device: DevicePreset | null,
859
+ ): Promise<ViewHolder> {
860
+ const { view, recordingInstalled } = await createView(width, height);
861
+ const holder: ViewHolder = {
862
+ view,
863
+ recordingInstalled,
864
+ device,
865
+ width,
866
+ height,
867
+ safeAreaInsets: null,
868
+ initScripts: [],
869
+ };
870
+ if (device) holder.safeAreaInsets = await applyDeviceEmulation(view, device);
871
+ return holder;
872
+ }
873
+
874
+ async function attachReset(holder: ViewHolder): Promise<void> {
875
+ if (!holder.recordingInstalled) return;
876
+ try {
877
+ await holder.view.evaluate(ATTACH_RESET_EXPR);
878
+ } catch {
879
+ // Page mid-navigation or renderer unhappy — the first drain forces a
880
+ // full snapshot when one is missing (drainExpr), so replay still works.
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Acquire THE persistent desktop browser (creating it on first use). There
886
+ * is deliberately a single one — `ctx.browser()` always returns it — so a
887
+ * test DAG shares one browsing session along each branch. The first call's
888
+ * options win; later calls attach to the existing view as-is.
889
+ */
890
+ export async function acquirePersistentBrowser(
891
+ opts: BrowserOptions = {},
892
+ ): Promise<PersistentBrowser> {
893
+ const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
894
+ let attached = true;
895
+ if (!SHARED_BROWSER) {
896
+ attached = false;
897
+ SHARED_BROWSER = await newHolder(
898
+ device ? device.viewport.width : opts.width ?? 1280,
899
+ device ? device.viewport.height : opts.height ?? 720,
900
+ device,
901
+ );
902
+ } else {
903
+ await attachReset(SHARED_BROWSER);
904
+ }
905
+ const holder = SHARED_BROWSER;
906
+ const { backend, detach } = buildBackend(holder, opts.recorder ?? null, {
907
+ persistent: true,
908
+ onDestroy: () => {
909
+ if (SHARED_BROWSER === holder) SHARED_BROWSER = null;
910
+ },
911
+ });
912
+ if (!attached && opts.url !== undefined) await backend.navigate(opts.url);
913
+ return { browser: backend, attached, detach };
914
+ }
915
+
916
+ /**
917
+ * Acquire the persistent mobile session for an app URL (one per app). A
918
+ * fresh session navigates to the app; an attach continues on the live page.
919
+ */
920
+ export async function acquirePersistentMobileBackend(
921
+ url: string,
922
+ recorder: BrowserSessionRecorder | null,
923
+ ): Promise<PersistentBrowser> {
924
+ const existing = SHARED_MOBILE.get(url);
925
+ const holder =
926
+ existing ??
927
+ (await newHolder(
928
+ LATEST_IPHONE.viewport.width,
929
+ LATEST_IPHONE.viewport.height,
930
+ LATEST_IPHONE,
931
+ ));
932
+ if (existing) {
933
+ await attachReset(holder);
934
+ } else {
935
+ SHARED_MOBILE.set(url, holder);
936
+ }
937
+ const { backend, detach } = buildBackend(holder, recorder, {
938
+ persistent: true,
939
+ onDestroy: () => {
940
+ if (SHARED_MOBILE.get(url) === holder) SHARED_MOBILE.delete(url);
941
+ },
942
+ });
943
+ if (!existing) await backend.navigate(url);
944
+ return { browser: backend, attached: existing !== undefined, detach };
945
+ }
946
+
947
+ /** True when a navigation failed on Chromium name resolution. */
948
+ function isNameNotResolved(err: unknown): boolean {
949
+ return String((err as Error)?.message ?? err).includes("ERR_NAME_NOT_RESOLVED");
950
+ }
951
+
952
+ /**
953
+ * Whether the daemon's own resolver can look the URL's host up. Chromium
954
+ * runs with AsyncDns disabled (see CHROME_ARGV) so it uses the same
955
+ * getaddrinfo path — a host the daemon resolves but Chrome can't means
956
+ * the RENDERER is broken, not the name.
957
+ */
958
+ async function daemonResolves(url: string): Promise<boolean> {
959
+ try {
960
+ await dns.lookup(new URL(url).hostname);
961
+ return true;
962
+ } catch {
963
+ return false;
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Replace a persistent holder's view with a freshly-spawned one in the same
969
+ * Chrome. Profile state — cookies, localStorage, the in-VM CA trust — is
970
+ * per-Chrome, so it survives; only renderer-held page state is lost, and
971
+ * this path only runs when that renderer already can't navigate.
972
+ *
973
+ * Known trigger: a renderer created before a snapshot fails its first
974
+ * post-restore navigation with `net::ERR_NAME_NOT_RESOLVED` even though a
975
+ * fresh view in the SAME restored Chrome resolves fine (root cause never
976
+ * found — see the disabled-prewarm note at the end of /bootstrap in
977
+ * daemon.ts). Persistent sessions walk into exactly that scenario whenever
978
+ * a child test navigates, so the recovery lives here: rebuild the view,
979
+ * retry once.
980
+ */
981
+ async function rebuildView(holder: ViewHolder): Promise<void> {
982
+ try {
983
+ holder.view.close();
984
+ } catch {
985
+ /* view may already be gone */
986
+ }
987
+ const fresh = await createView(holder.width, holder.height);
988
+ holder.view = fresh.view;
989
+ holder.recordingInstalled = fresh.recordingInstalled;
990
+ if (holder.device) {
991
+ holder.safeAreaInsets = await applyDeviceEmulation(holder.view, holder.device);
992
+ }
993
+ for (const source of holder.initScripts) {
994
+ await holder.view.cdp("Page.addScriptToEvaluateOnNewDocument", { source });
995
+ }
996
+ }
997
+
998
+ interface BackendBuildOptions {
999
+ /** Persistent views get the DNS-recovery navigate and a close() that
1000
+ * clears them out of the shared registry; ephemeral views just close. */
1001
+ persistent: boolean;
1002
+ /** Called when close() destroys the underlying view — the acquire
1003
+ * functions use it to drop the holder from the shared registry. */
1004
+ onDestroy?: () => void;
1005
+ }
1006
+
1007
+ function buildBackend(
1008
+ holder: ViewHolder,
703
1009
  recorder: BrowserSessionRecorder | null,
704
- recordingInstalled: boolean,
705
- ): MobileBackend {
706
- let closed = false;
1010
+ buildOpts: BackendBuildOptions,
1011
+ ): { backend: MobileBackend; detach(): Promise<void> } {
1012
+ // All page access goes through `holder.view` — never capture the view in
1013
+ // a local — because the DNS-recovery rebuild swaps it mid-wrapper.
1014
+ // `recordingEnded` stops this wrapper's recorder writes (test end);
1015
+ // `viewClosed` tracks actual destruction (author called close()).
1016
+ let recordingEnded = false;
1017
+ let viewClosed = false;
707
1018
  const sessionStart = Date.now();
708
1019
  let stepSeq = 0;
709
1020
  // URL observed at the previous drain. A change means the main frame
@@ -713,11 +1024,11 @@ function wrapView(
713
1024
  let lastDrainUrl: string | null = null;
714
1025
 
715
1026
  async function drain(action: BrowserAction | "close"): Promise<void> {
716
- if (!recordingInstalled || !recorder || closed) return;
1027
+ if (!holder.recordingInstalled || !recorder || recordingEnded) return;
717
1028
  try {
718
- const urlChanged = view.url !== lastDrainUrl;
719
- const events = await view.evaluate<unknown[]>(drainExpr(urlChanged));
720
- lastDrainUrl = view.url;
1029
+ const urlChanged = holder.view.url !== lastDrainUrl;
1030
+ const events = await holder.view.evaluate<unknown[]>(drainExpr(urlChanged));
1031
+ lastDrainUrl = holder.view.url;
721
1032
  if (Array.isArray(events) && events.length > 0) {
722
1033
  recorder.recordStep({
723
1034
  stepSeq: stepSeq++,
@@ -783,16 +1094,40 @@ function wrapView(
783
1094
  }
784
1095
  }
785
1096
 
786
- return {
1097
+ async function endRecording(): Promise<void> {
1098
+ if (recordingEnded) return;
1099
+ // Final drain before we stop writing to this recorder.
1100
+ await drain("close");
1101
+ recordingEnded = true;
1102
+ }
1103
+
1104
+ const backend: MobileBackend = {
787
1105
  get url() {
788
- return view.url;
1106
+ return holder.view.url;
789
1107
  },
790
1108
  get title() {
791
- return view.title;
1109
+ return holder.view.title;
1110
+ },
1111
+ get safeAreaInsets() {
1112
+ return holder.safeAreaInsets;
792
1113
  },
793
1114
  navigate(url) {
794
1115
  recorder?.noteNavigation?.(url);
795
- return instrumented("navigate", { url }, () => view.navigate(url));
1116
+ return instrumented("navigate", { url }, async () => {
1117
+ try {
1118
+ await holder.view.navigate(url);
1119
+ } catch (err) {
1120
+ // Restored-renderer DNS bug (see `rebuildView`): only when the
1121
+ // view is persistent (so it may have lived through a snapshot
1122
+ // restore) and the daemon itself CAN resolve the host — a name
1123
+ // that's genuinely unknown must fail without discarding the live
1124
+ // page state a rebuild would cost.
1125
+ if (!buildOpts.persistent || !isNameNotResolved(err)) throw err;
1126
+ if (!(await daemonResolves(url))) throw err;
1127
+ await rebuildView(holder);
1128
+ await holder.view.navigate(url);
1129
+ }
1130
+ });
796
1131
  },
797
1132
  async evaluate<T = unknown>(
798
1133
  description: string,
@@ -809,11 +1144,29 @@ function wrapView(
809
1144
  scriptTruncated: truncatedScript.truncated,
810
1145
  },
811
1146
  async () => {
812
- const v = await view.evaluate<T>(script);
1147
+ const v = await holder.view.evaluate<T>(script);
813
1148
  return v;
814
1149
  },
815
1150
  ) as Promise<Wrapped<T>>;
816
1151
  },
1152
+ addInitScript(description: string, source: string): Promise<void> {
1153
+ const truncated = truncateUtf8(source);
1154
+ return instrumented(
1155
+ "addInitScript",
1156
+ {
1157
+ description,
1158
+ script: truncated.value,
1159
+ scriptTruncated: truncated.truncated,
1160
+ },
1161
+ async () => {
1162
+ await holder.view.cdp("Page.addScriptToEvaluateOnNewDocument", {
1163
+ source,
1164
+ });
1165
+ // Remember it so a DNS-recovery view rebuild re-installs it.
1166
+ holder.initScripts.push(source);
1167
+ },
1168
+ );
1169
+ },
817
1170
  async waitFor<T = unknown>(
818
1171
  description: string,
819
1172
  expression: string,
@@ -842,7 +1195,7 @@ function wrapView(
842
1195
  fields.attempts = (fields.attempts ?? 0) + 1;
843
1196
  let v: unknown;
844
1197
  try {
845
- v = await view.evaluate<unknown>(expression);
1198
+ v = await holder.view.evaluate<unknown>(expression);
846
1199
  } catch (err) {
847
1200
  if (Date.now() >= deadline) throw err;
848
1201
  await new Promise((r) => setTimeout(r, intervalMs));
@@ -859,41 +1212,41 @@ function wrapView(
859
1212
  }) as Promise<Wrapped<T>>;
860
1213
  },
861
1214
  click(selector) {
862
- return instrumented("click", { selector }, () => view.click(selector));
1215
+ return instrumented("click", { selector }, () => holder.view.click(selector));
863
1216
  },
864
1217
  clickAt(x, y) {
865
- return instrumented("click", { x, y }, () => view.click(x, y));
1218
+ return instrumented("click", { x, y }, () => holder.view.click(x, y));
866
1219
  },
867
1220
  type(text) {
868
1221
  const t = truncateUtf8(text);
869
1222
  return instrumented(
870
1223
  "type",
871
1224
  { text: t.value, textTruncated: t.truncated },
872
- () => view.type(text),
1225
+ () => holder.view.type(text),
873
1226
  );
874
1227
  },
875
1228
  press(key) {
876
- return instrumented("press", { key }, () => view.press(key));
1229
+ return instrumented("press", { key }, () => holder.view.press(key));
877
1230
  },
878
1231
  scroll(dx, dy) {
879
- return instrumented("scroll", { dx, dy }, () => view.scroll(dx, dy));
1232
+ return instrumented("scroll", { dx, dy }, () => holder.view.scroll(dx, dy));
880
1233
  },
881
1234
  scrollTo(selector) {
882
- return instrumented("scrollTo", { selector }, () => view.scrollTo(selector));
1235
+ return instrumented("scrollTo", { selector }, () => holder.view.scrollTo(selector));
883
1236
  },
884
1237
  back() {
885
- return instrumented("back", {}, () => view.back());
1238
+ return instrumented("back", {}, () => holder.view.back());
886
1239
  },
887
1240
  forward() {
888
- return instrumented("forward", {}, () => view.forward());
1241
+ return instrumented("forward", {}, () => holder.view.forward());
889
1242
  },
890
1243
  reload() {
891
- return instrumented("reload", {}, () => view.reload());
1244
+ return instrumented("reload", {}, () => holder.view.reload());
892
1245
  },
893
1246
  async screenshot(options) {
894
1247
  const format = options?.format ?? "png";
895
1248
  return instrumented("screenshot", { format }, async () => {
896
- const buf = await view.screenshot({
1249
+ const buf = await holder.view.screenshot({
897
1250
  encoding: "buffer",
898
1251
  format,
899
1252
  quality: options?.quality,
@@ -902,12 +1255,18 @@ function wrapView(
902
1255
  });
903
1256
  },
904
1257
  async close() {
905
- if (closed) return;
906
- // Final drain before the page evaporates.
907
- await drain("close");
908
- closed = true;
1258
+ // Ends this wrapper's recording AND destroys the view. For a
1259
+ // persistent view this is the author-facing escape hatch to a fresh
1260
+ // browser: `onDestroy` drops the holder from the shared registry, so
1261
+ // the next `ctx.browser()`/`ctx.mobile()` creates a new one. The
1262
+ // routine test-end path is `detach` (recording stops, view lives on
1263
+ // into the post-test snapshot).
1264
+ await endRecording();
1265
+ if (viewClosed) return;
1266
+ viewClosed = true;
1267
+ buildOpts.onDestroy?.();
909
1268
  try {
910
- view.close();
1269
+ holder.view.close();
911
1270
  } catch {
912
1271
  /* already closed by the runtime */
913
1272
  }
@@ -918,11 +1277,11 @@ function wrapView(
918
1277
  // mobile facade owns the author-facing "tap" vocabulary). Dispatches
919
1278
  // a real touch so RN-Web's responder system fires.
920
1279
  return instrumented("click", { x, y }, async () => {
921
- await view.cdp("Input.dispatchTouchEvent", {
1280
+ await holder.view.cdp("Input.dispatchTouchEvent", {
922
1281
  type: "touchStart",
923
1282
  touchPoints: [{ x, y, id: 0 }],
924
1283
  });
925
- await view.cdp("Input.dispatchTouchEvent", {
1284
+ await holder.view.cdp("Input.dispatchTouchEvent", {
926
1285
  type: "touchEnd",
927
1286
  touchPoints: [],
928
1287
  });
@@ -931,26 +1290,27 @@ function wrapView(
931
1290
  swipeBy(x, y, dx, dy) {
932
1291
  return instrumented("scroll", { dx, dy }, async () => {
933
1292
  const steps = 8;
934
- await view.cdp("Input.dispatchTouchEvent", {
1293
+ await holder.view.cdp("Input.dispatchTouchEvent", {
935
1294
  type: "touchStart",
936
1295
  touchPoints: [{ x, y, id: 0 }],
937
1296
  });
938
1297
  for (let i = 1; i <= steps; i++) {
939
- await view.cdp("Input.dispatchTouchEvent", {
1298
+ await holder.view.cdp("Input.dispatchTouchEvent", {
940
1299
  type: "touchMove",
941
1300
  touchPoints: [{ x: x + (dx * i) / steps, y: y + (dy * i) / steps, id: 0 }],
942
1301
  });
943
1302
  }
944
- await view.cdp("Input.dispatchTouchEvent", {
1303
+ await holder.view.cdp("Input.dispatchTouchEvent", {
945
1304
  type: "touchEnd",
946
1305
  touchPoints: [],
947
1306
  });
948
1307
  });
949
1308
  },
950
1309
  probe<T = unknown>(expression: string): Promise<T> {
951
- return view.evaluate<T>(expression);
1310
+ return holder.view.evaluate<T>(expression);
952
1311
  },
953
1312
  };
1313
+ return { backend, detach: endRecording };
954
1314
  }
955
1315
 
956
1316
  interface RecordableFields {