@specific.dev/spectest 0.13.0 → 0.15.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.13.0",
3
+ "version": "0.15.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";
@@ -205,7 +212,15 @@ export interface Browser {
205
212
  reload(): Promise<void>;
206
213
  /** Capture a PNG/JPEG/WebP screenshot of the viewport. Returns raw bytes. */
207
214
  screenshot(options?: ScreenshotOptions): Promise<Uint8Array>;
208
- /** Close the underlying view. Idempotent. Drains any pending rrweb events. */
215
+ /**
216
+ * Destroy the underlying view. Idempotent; drains any pending rrweb
217
+ * events first. For the persistent session behind `ctx.browser()` /
218
+ * `ctx.mobile()` this is the escape hatch to a FRESH browser — the
219
+ * shared instance is discarded and the next call creates a new one.
220
+ * Don't call it for routine cleanup: the daemon detaches recording at
221
+ * test end automatically and deliberately keeps the browser alive so
222
+ * dependent tests inherit its state.
223
+ */
209
224
  close(): Promise<void>;
210
225
  }
211
226
 
@@ -603,6 +618,22 @@ interface PooledView {
603
618
  recordingInstalled: boolean;
604
619
  }
605
620
 
621
+ /**
622
+ * The unit a Browser/Mobile handle drives. `view` is deliberately mutable:
623
+ * the DNS-recovery path (see `navigate` in {@link buildBackend}) replaces a
624
+ * broken restored renderer with a freshly-spawned view in the same Chrome,
625
+ * and every wrapper reads through the holder so the swap is transparent.
626
+ * `device`/`width`/`height` are kept so a rebuilt view comes back with the
627
+ * same viewport and emulation.
628
+ */
629
+ interface ViewHolder {
630
+ view: BunWebViewInstance;
631
+ recordingInstalled: boolean;
632
+ device: DevicePreset | null;
633
+ width: number;
634
+ height: number;
635
+ }
636
+
606
637
  // Pre-opened view pool. Renderer spawn is the expensive part of
607
638
  // `ctx.browser()` — ~1.8s for the first view in a fresh Chrome and
608
639
  // (measured 2026-06-05) a constant ~1.2-1.5s per view in a Chrome that
@@ -659,6 +690,11 @@ export async function prewarmViewPool(n = 1): Promise<void> {
659
690
  * Open a browser view. Always uses the Chrome backend in the daemon
660
691
  * (Firecracker guest is Linux; WKWebView isn't available). Serves from
661
692
  * the pre-opened pool when the caller uses the default viewport.
693
+ *
694
+ * This is the EPHEMERAL path — `close()` destroys the view. The daemon's
695
+ * `ctx.browser()`/`ctx.mobile()` go through {@link acquirePersistentBrowser}
696
+ * / {@link acquirePersistentMobileBackend} instead, which keep one view
697
+ * alive across tests so it rides snapshots/forks.
662
698
  */
663
699
  export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
664
700
  return openMobileBackend(opts);
@@ -668,10 +704,9 @@ export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
668
704
  * Like {@link openBrowser} but returns the {@link MobileBackend} superset
669
705
  * (touch + probe). When `opts.frame === "mobile"` the view is created at the
670
706
  * 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.
707
+ * device emulation applied before the first navigation. Desktop callers go
708
+ * through {@link openBrowser} and get the narrower {@link Browser} view of
709
+ * the same object.
675
710
  */
676
711
  export async function openMobileBackend(
677
712
  opts: BrowserOptions = {},
@@ -683,27 +718,227 @@ export async function openMobileBackend(
683
718
  // views, and a mobile view needs its emulation applied fresh anyway.
684
719
  const pooled =
685
720
  !device && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
686
- const { view, recordingInstalled } = pooled ?? (await createView(wantW, wantH));
721
+ const base = pooled ?? (await createView(wantW, wantH));
722
+ const holder: ViewHolder = {
723
+ view: base.view,
724
+ recordingInstalled: base.recordingInstalled,
725
+ device,
726
+ width: wantW,
727
+ height: wantH,
728
+ };
687
729
 
688
- if (device) await applyDeviceEmulation(view, device);
730
+ if (device) await applyDeviceEmulation(holder.view, device);
689
731
 
690
- const browser = wrapView(view, opts.recorder ?? null, recordingInstalled);
732
+ const { backend } = buildBackend(holder, opts.recorder ?? null, {
733
+ persistent: false,
734
+ });
691
735
 
692
736
  // We deliberately don't forward `opts.url` to the constructor — going
693
737
  // through our own `navigate()` keeps the recorder log uniform (one
694
738
  // event per navigation, with timing) and drains rrweb after the load.
695
739
  if (opts.url !== undefined) {
696
- await browser.navigate(opts.url);
740
+ await backend.navigate(opts.url);
697
741
  }
698
- return browser;
742
+ return backend;
699
743
  }
700
744
 
701
- function wrapView(
702
- view: BunWebViewInstance,
745
+ // ────────────────────────────────────────────────────────────────────────
746
+ // Persistent sessions (the default behind ctx.browser / ctx.mobile)
747
+ // ────────────────────────────────────────────────────────────────────────
748
+
749
+ // One long-lived desktop browser plus one mobile session per app URL.
750
+ // Module state lives in daemon memory, so it forks with the snapshot the
751
+ // same way fake `state` and TEST_DATA do: a test's browser — its live
752
+ // page, cookies, localStorage, in-memory SPA state — is captured in the
753
+ // post-test snapshot and inherited by `dependsOn` children, while sibling
754
+ // forks never see each other's sessions. That's what lets a child test
755
+ // continue where its parent left off (e.g. already signed in) instead of
756
+ // re-navigating and re-authenticating.
757
+ let SHARED_BROWSER: ViewHolder | null = null;
758
+ const SHARED_MOBILE = new Map<string, ViewHolder>();
759
+
760
+ /**
761
+ * What acquiring a persistent session returns. `detach` is the test-end
762
+ * hook (final rrweb drain, stop writing to this test's recorder, keep the
763
+ * view alive); `browser.close()` is the author-facing escape hatch that
764
+ * actually destroys the view (the next `ctx.browser()` starts fresh).
765
+ */
766
+ export interface PersistentBrowser {
767
+ browser: MobileBackend;
768
+ /** True when this call attached to a view inherited from an earlier
769
+ * test (possibly across a snapshot fork) rather than creating one. */
770
+ attached: boolean;
771
+ /** Final rrweb drain + detach from the current recorder. The view stays
772
+ * alive so the post-test snapshot captures it. Idempotent. */
773
+ detach(): Promise<void>;
774
+ }
775
+
776
+ // Runs in the page when a persistent view is attached to a new test's
777
+ // recorder: drop whatever rrweb buffered since the previous detach (idle
778
+ // mutations; the previous test's session already drained everything it
779
+ // owns) and emit a fresh Meta + FullSnapshot so the new session's replay
780
+ // is self-contained from its first event.
781
+ const ATTACH_RESET_EXPR = `(function () {
782
+ window.__spectestRrwebEvents = [];
783
+ try {
784
+ if (typeof rrwebRecord === "function" &&
785
+ typeof rrwebRecord.takeFullSnapshot === "function") {
786
+ rrwebRecord.takeFullSnapshot();
787
+ }
788
+ } catch (e) { /* recording not active on this document */ }
789
+ return true;
790
+ })()`;
791
+
792
+ async function newHolder(
793
+ width: number,
794
+ height: number,
795
+ device: DevicePreset | null,
796
+ ): Promise<ViewHolder> {
797
+ const { view, recordingInstalled } = await createView(width, height);
798
+ const holder: ViewHolder = { view, recordingInstalled, device, width, height };
799
+ if (device) await applyDeviceEmulation(view, device);
800
+ return holder;
801
+ }
802
+
803
+ async function attachReset(holder: ViewHolder): Promise<void> {
804
+ if (!holder.recordingInstalled) return;
805
+ try {
806
+ await holder.view.evaluate(ATTACH_RESET_EXPR);
807
+ } catch {
808
+ // Page mid-navigation or renderer unhappy — the first drain forces a
809
+ // full snapshot when one is missing (drainExpr), so replay still works.
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Acquire THE persistent desktop browser (creating it on first use). There
815
+ * is deliberately a single one — `ctx.browser()` always returns it — so a
816
+ * test DAG shares one browsing session along each branch. The first call's
817
+ * options win; later calls attach to the existing view as-is.
818
+ */
819
+ export async function acquirePersistentBrowser(
820
+ opts: BrowserOptions = {},
821
+ ): Promise<PersistentBrowser> {
822
+ const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
823
+ let attached = true;
824
+ if (!SHARED_BROWSER) {
825
+ attached = false;
826
+ SHARED_BROWSER = await newHolder(
827
+ device ? device.viewport.width : opts.width ?? 1280,
828
+ device ? device.viewport.height : opts.height ?? 720,
829
+ device,
830
+ );
831
+ } else {
832
+ await attachReset(SHARED_BROWSER);
833
+ }
834
+ const holder = SHARED_BROWSER;
835
+ const { backend, detach } = buildBackend(holder, opts.recorder ?? null, {
836
+ persistent: true,
837
+ onDestroy: () => {
838
+ if (SHARED_BROWSER === holder) SHARED_BROWSER = null;
839
+ },
840
+ });
841
+ if (!attached && opts.url !== undefined) await backend.navigate(opts.url);
842
+ return { browser: backend, attached, detach };
843
+ }
844
+
845
+ /**
846
+ * Acquire the persistent mobile session for an app URL (one per app). A
847
+ * fresh session navigates to the app; an attach continues on the live page.
848
+ */
849
+ export async function acquirePersistentMobileBackend(
850
+ url: string,
851
+ recorder: BrowserSessionRecorder | null,
852
+ ): Promise<PersistentBrowser> {
853
+ const existing = SHARED_MOBILE.get(url);
854
+ const holder =
855
+ existing ??
856
+ (await newHolder(
857
+ LATEST_IPHONE.viewport.width,
858
+ LATEST_IPHONE.viewport.height,
859
+ LATEST_IPHONE,
860
+ ));
861
+ if (existing) {
862
+ await attachReset(holder);
863
+ } else {
864
+ SHARED_MOBILE.set(url, holder);
865
+ }
866
+ const { backend, detach } = buildBackend(holder, recorder, {
867
+ persistent: true,
868
+ onDestroy: () => {
869
+ if (SHARED_MOBILE.get(url) === holder) SHARED_MOBILE.delete(url);
870
+ },
871
+ });
872
+ if (!existing) await backend.navigate(url);
873
+ return { browser: backend, attached: existing !== undefined, detach };
874
+ }
875
+
876
+ /** True when a navigation failed on Chromium name resolution. */
877
+ function isNameNotResolved(err: unknown): boolean {
878
+ return String((err as Error)?.message ?? err).includes("ERR_NAME_NOT_RESOLVED");
879
+ }
880
+
881
+ /**
882
+ * Whether the daemon's own resolver can look the URL's host up. Chromium
883
+ * runs with AsyncDns disabled (see CHROME_ARGV) so it uses the same
884
+ * getaddrinfo path — a host the daemon resolves but Chrome can't means
885
+ * the RENDERER is broken, not the name.
886
+ */
887
+ async function daemonResolves(url: string): Promise<boolean> {
888
+ try {
889
+ await dns.lookup(new URL(url).hostname);
890
+ return true;
891
+ } catch {
892
+ return false;
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Replace a persistent holder's view with a freshly-spawned one in the same
898
+ * Chrome. Profile state — cookies, localStorage, the in-VM CA trust — is
899
+ * per-Chrome, so it survives; only renderer-held page state is lost, and
900
+ * this path only runs when that renderer already can't navigate.
901
+ *
902
+ * Known trigger: a renderer created before a snapshot fails its first
903
+ * post-restore navigation with `net::ERR_NAME_NOT_RESOLVED` even though a
904
+ * fresh view in the SAME restored Chrome resolves fine (root cause never
905
+ * found — see the disabled-prewarm note at the end of /bootstrap in
906
+ * daemon.ts). Persistent sessions walk into exactly that scenario whenever
907
+ * a child test navigates, so the recovery lives here: rebuild the view,
908
+ * retry once.
909
+ */
910
+ async function rebuildView(holder: ViewHolder): Promise<void> {
911
+ try {
912
+ holder.view.close();
913
+ } catch {
914
+ /* view may already be gone */
915
+ }
916
+ const fresh = await createView(holder.width, holder.height);
917
+ holder.view = fresh.view;
918
+ holder.recordingInstalled = fresh.recordingInstalled;
919
+ if (holder.device) await applyDeviceEmulation(holder.view, holder.device);
920
+ }
921
+
922
+ interface BackendBuildOptions {
923
+ /** Persistent views get the DNS-recovery navigate and a close() that
924
+ * clears them out of the shared registry; ephemeral views just close. */
925
+ persistent: boolean;
926
+ /** Called when close() destroys the underlying view — the acquire
927
+ * functions use it to drop the holder from the shared registry. */
928
+ onDestroy?: () => void;
929
+ }
930
+
931
+ function buildBackend(
932
+ holder: ViewHolder,
703
933
  recorder: BrowserSessionRecorder | null,
704
- recordingInstalled: boolean,
705
- ): MobileBackend {
706
- let closed = false;
934
+ buildOpts: BackendBuildOptions,
935
+ ): { backend: MobileBackend; detach(): Promise<void> } {
936
+ // All page access goes through `holder.view` — never capture the view in
937
+ // a local — because the DNS-recovery rebuild swaps it mid-wrapper.
938
+ // `recordingEnded` stops this wrapper's recorder writes (test end);
939
+ // `viewClosed` tracks actual destruction (author called close()).
940
+ let recordingEnded = false;
941
+ let viewClosed = false;
707
942
  const sessionStart = Date.now();
708
943
  let stepSeq = 0;
709
944
  // URL observed at the previous drain. A change means the main frame
@@ -713,11 +948,11 @@ function wrapView(
713
948
  let lastDrainUrl: string | null = null;
714
949
 
715
950
  async function drain(action: BrowserAction | "close"): Promise<void> {
716
- if (!recordingInstalled || !recorder || closed) return;
951
+ if (!holder.recordingInstalled || !recorder || recordingEnded) return;
717
952
  try {
718
- const urlChanged = view.url !== lastDrainUrl;
719
- const events = await view.evaluate<unknown[]>(drainExpr(urlChanged));
720
- lastDrainUrl = view.url;
953
+ const urlChanged = holder.view.url !== lastDrainUrl;
954
+ const events = await holder.view.evaluate<unknown[]>(drainExpr(urlChanged));
955
+ lastDrainUrl = holder.view.url;
721
956
  if (Array.isArray(events) && events.length > 0) {
722
957
  recorder.recordStep({
723
958
  stepSeq: stepSeq++,
@@ -783,16 +1018,37 @@ function wrapView(
783
1018
  }
784
1019
  }
785
1020
 
786
- return {
1021
+ async function endRecording(): Promise<void> {
1022
+ if (recordingEnded) return;
1023
+ // Final drain before we stop writing to this recorder.
1024
+ await drain("close");
1025
+ recordingEnded = true;
1026
+ }
1027
+
1028
+ const backend: MobileBackend = {
787
1029
  get url() {
788
- return view.url;
1030
+ return holder.view.url;
789
1031
  },
790
1032
  get title() {
791
- return view.title;
1033
+ return holder.view.title;
792
1034
  },
793
1035
  navigate(url) {
794
1036
  recorder?.noteNavigation?.(url);
795
- return instrumented("navigate", { url }, () => view.navigate(url));
1037
+ return instrumented("navigate", { url }, async () => {
1038
+ try {
1039
+ await holder.view.navigate(url);
1040
+ } catch (err) {
1041
+ // Restored-renderer DNS bug (see `rebuildView`): only when the
1042
+ // view is persistent (so it may have lived through a snapshot
1043
+ // restore) and the daemon itself CAN resolve the host — a name
1044
+ // that's genuinely unknown must fail without discarding the live
1045
+ // page state a rebuild would cost.
1046
+ if (!buildOpts.persistent || !isNameNotResolved(err)) throw err;
1047
+ if (!(await daemonResolves(url))) throw err;
1048
+ await rebuildView(holder);
1049
+ await holder.view.navigate(url);
1050
+ }
1051
+ });
796
1052
  },
797
1053
  async evaluate<T = unknown>(
798
1054
  description: string,
@@ -809,7 +1065,7 @@ function wrapView(
809
1065
  scriptTruncated: truncatedScript.truncated,
810
1066
  },
811
1067
  async () => {
812
- const v = await view.evaluate<T>(script);
1068
+ const v = await holder.view.evaluate<T>(script);
813
1069
  return v;
814
1070
  },
815
1071
  ) as Promise<Wrapped<T>>;
@@ -842,7 +1098,7 @@ function wrapView(
842
1098
  fields.attempts = (fields.attempts ?? 0) + 1;
843
1099
  let v: unknown;
844
1100
  try {
845
- v = await view.evaluate<unknown>(expression);
1101
+ v = await holder.view.evaluate<unknown>(expression);
846
1102
  } catch (err) {
847
1103
  if (Date.now() >= deadline) throw err;
848
1104
  await new Promise((r) => setTimeout(r, intervalMs));
@@ -859,41 +1115,41 @@ function wrapView(
859
1115
  }) as Promise<Wrapped<T>>;
860
1116
  },
861
1117
  click(selector) {
862
- return instrumented("click", { selector }, () => view.click(selector));
1118
+ return instrumented("click", { selector }, () => holder.view.click(selector));
863
1119
  },
864
1120
  clickAt(x, y) {
865
- return instrumented("click", { x, y }, () => view.click(x, y));
1121
+ return instrumented("click", { x, y }, () => holder.view.click(x, y));
866
1122
  },
867
1123
  type(text) {
868
1124
  const t = truncateUtf8(text);
869
1125
  return instrumented(
870
1126
  "type",
871
1127
  { text: t.value, textTruncated: t.truncated },
872
- () => view.type(text),
1128
+ () => holder.view.type(text),
873
1129
  );
874
1130
  },
875
1131
  press(key) {
876
- return instrumented("press", { key }, () => view.press(key));
1132
+ return instrumented("press", { key }, () => holder.view.press(key));
877
1133
  },
878
1134
  scroll(dx, dy) {
879
- return instrumented("scroll", { dx, dy }, () => view.scroll(dx, dy));
1135
+ return instrumented("scroll", { dx, dy }, () => holder.view.scroll(dx, dy));
880
1136
  },
881
1137
  scrollTo(selector) {
882
- return instrumented("scrollTo", { selector }, () => view.scrollTo(selector));
1138
+ return instrumented("scrollTo", { selector }, () => holder.view.scrollTo(selector));
883
1139
  },
884
1140
  back() {
885
- return instrumented("back", {}, () => view.back());
1141
+ return instrumented("back", {}, () => holder.view.back());
886
1142
  },
887
1143
  forward() {
888
- return instrumented("forward", {}, () => view.forward());
1144
+ return instrumented("forward", {}, () => holder.view.forward());
889
1145
  },
890
1146
  reload() {
891
- return instrumented("reload", {}, () => view.reload());
1147
+ return instrumented("reload", {}, () => holder.view.reload());
892
1148
  },
893
1149
  async screenshot(options) {
894
1150
  const format = options?.format ?? "png";
895
1151
  return instrumented("screenshot", { format }, async () => {
896
- const buf = await view.screenshot({
1152
+ const buf = await holder.view.screenshot({
897
1153
  encoding: "buffer",
898
1154
  format,
899
1155
  quality: options?.quality,
@@ -902,12 +1158,18 @@ function wrapView(
902
1158
  });
903
1159
  },
904
1160
  async close() {
905
- if (closed) return;
906
- // Final drain before the page evaporates.
907
- await drain("close");
908
- closed = true;
1161
+ // Ends this wrapper's recording AND destroys the view. For a
1162
+ // persistent view this is the author-facing escape hatch to a fresh
1163
+ // browser: `onDestroy` drops the holder from the shared registry, so
1164
+ // the next `ctx.browser()`/`ctx.mobile()` creates a new one. The
1165
+ // routine test-end path is `detach` (recording stops, view lives on
1166
+ // into the post-test snapshot).
1167
+ await endRecording();
1168
+ if (viewClosed) return;
1169
+ viewClosed = true;
1170
+ buildOpts.onDestroy?.();
909
1171
  try {
910
- view.close();
1172
+ holder.view.close();
911
1173
  } catch {
912
1174
  /* already closed by the runtime */
913
1175
  }
@@ -918,11 +1180,11 @@ function wrapView(
918
1180
  // mobile facade owns the author-facing "tap" vocabulary). Dispatches
919
1181
  // a real touch so RN-Web's responder system fires.
920
1182
  return instrumented("click", { x, y }, async () => {
921
- await view.cdp("Input.dispatchTouchEvent", {
1183
+ await holder.view.cdp("Input.dispatchTouchEvent", {
922
1184
  type: "touchStart",
923
1185
  touchPoints: [{ x, y, id: 0 }],
924
1186
  });
925
- await view.cdp("Input.dispatchTouchEvent", {
1187
+ await holder.view.cdp("Input.dispatchTouchEvent", {
926
1188
  type: "touchEnd",
927
1189
  touchPoints: [],
928
1190
  });
@@ -931,26 +1193,27 @@ function wrapView(
931
1193
  swipeBy(x, y, dx, dy) {
932
1194
  return instrumented("scroll", { dx, dy }, async () => {
933
1195
  const steps = 8;
934
- await view.cdp("Input.dispatchTouchEvent", {
1196
+ await holder.view.cdp("Input.dispatchTouchEvent", {
935
1197
  type: "touchStart",
936
1198
  touchPoints: [{ x, y, id: 0 }],
937
1199
  });
938
1200
  for (let i = 1; i <= steps; i++) {
939
- await view.cdp("Input.dispatchTouchEvent", {
1201
+ await holder.view.cdp("Input.dispatchTouchEvent", {
940
1202
  type: "touchMove",
941
1203
  touchPoints: [{ x: x + (dx * i) / steps, y: y + (dy * i) / steps, id: 0 }],
942
1204
  });
943
1205
  }
944
- await view.cdp("Input.dispatchTouchEvent", {
1206
+ await holder.view.cdp("Input.dispatchTouchEvent", {
945
1207
  type: "touchEnd",
946
1208
  touchPoints: [],
947
1209
  });
948
1210
  });
949
1211
  },
950
1212
  probe<T = unknown>(expression: string): Promise<T> {
951
- return view.evaluate<T>(expression);
1213
+ return holder.view.evaluate<T>(expression);
952
1214
  },
953
1215
  };
1216
+ return { backend, detach: endRecording };
954
1217
  }
955
1218
 
956
1219
  interface RecordableFields {