@specific.dev/spectest 0.14.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.14.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 {
@@ -0,0 +1,387 @@
1
+ // `email()` — a real SMTP server as a spectest service. The app under test
2
+ // points its SMTP transport at `<key>:1025` and sends mail exactly as it
3
+ // would in production; every message is captured instead of delivered.
4
+ // Tests read the mailbox through typed helpers on `ctx.svc.<key>`
5
+ // (`lastEmail`, `emails`, `clear`), each recorded as an `email` event on
6
+ // the test timeline — single-message ops embed the full captured message
7
+ // (HTML body included) so the dashboard renders the actual email the test
8
+ // asserted against, and returns come back inspect-wrapped so
9
+ // `expect(mail.subject)` links under the step. To wait for a message to
10
+ // *arrive*, use the standard `ctx.poll` with `lastEmail` as the predicate
11
+ // (it returns `undefined` while the mailbox is empty); the winning
12
+ // iteration's `email` event survives poll truncation, so the message still
13
+ // renders nested under the wait step.
14
+ //
15
+ // Captured mail lives in the server's process memory, so it snapshots and
16
+ // forks with the rest of the environment: a `dependsOn` child inherits the
17
+ // parent's mailbox, sibling forks never see each other's messages — the
18
+ // same isolation contract as fake state.
19
+
20
+ import type { ServiceDefinition } from "../index.js";
21
+ import {
22
+ pauseRecording,
23
+ recordEmail,
24
+ reserveEvent,
25
+ resumeRecording,
26
+ truncateUtf8,
27
+ type EmailEventMessage,
28
+ type EmailEventSummary,
29
+ } from "../recorder.js";
30
+ import { wrap } from "../inspect.js";
31
+ import type { Wrapped } from "../inspect.js";
32
+
33
+ const DEFAULT_IMAGE = "axllent/mailpit:v1.30";
34
+ const SMTP_PORT = 1025;
35
+ const API_PORT = 8025;
36
+
37
+ export interface EmailOptions {
38
+ /** TCP port the SMTP listener binds. Default `1025`. */
39
+ smtpPort?: number;
40
+ /** Override the underlying mail-server image. */
41
+ image?: string;
42
+ /** Extra environment variables forwarded to the container. */
43
+ env?: Record<string, string>;
44
+ }
45
+
46
+ export interface EmailAttachment {
47
+ filename: string;
48
+ contentType: string;
49
+ /** Decoded size in bytes. */
50
+ size: number;
51
+ }
52
+
53
+ /** A fully captured email, as returned by `lastEmail`. */
54
+ export interface EmailMessage {
55
+ id: string;
56
+ /** Sender address. */
57
+ from: string;
58
+ /** Recipient addresses. */
59
+ to: string[];
60
+ cc: string[];
61
+ bcc: string[];
62
+ subject: string;
63
+ /** Message date, ISO-formatted. */
64
+ date: string;
65
+ /** Plain-text body ("" when the mail had none). */
66
+ text: string;
67
+ /** HTML body ("" when the mail had none). */
68
+ html: string;
69
+ attachments: EmailAttachment[];
70
+ }
71
+
72
+ /** A mailbox-listing row, as returned by `emails()`. */
73
+ export interface EmailSummary {
74
+ id: string;
75
+ from: string;
76
+ to: string[];
77
+ subject: string;
78
+ /** Plain-text preview of the body. */
79
+ snippet: string;
80
+ date: string;
81
+ /** Attachment count. */
82
+ attachments: number;
83
+ }
84
+
85
+ /** Filter for `emails()` listings. All given fields must match. */
86
+ export interface EmailMatch {
87
+ /** A recipient address, compared case-insensitively. */
88
+ to?: string;
89
+ /** The sender address, compared case-insensitively. */
90
+ from?: string;
91
+ /** Subject substring (string) or pattern (RegExp). */
92
+ subject?: string | RegExp;
93
+ }
94
+
95
+ /** Helpers an `email(...)` service exposes on `ctx.svc.<name>`. */
96
+ export interface EmailHelpers {
97
+ /**
98
+ * The newest captured message, in full (or `undefined` while the mailbox
99
+ * is empty) — which makes it the natural `ctx.poll` predicate for waiting
100
+ * on delivery; assert on its fields once it returns:
101
+ *
102
+ * ```ts
103
+ * const mail = await ctx.poll("welcome email", () => ctx.svc.email.lastEmail());
104
+ * expect(mail.to).toContain("alice@example.com");
105
+ * ```
106
+ *
107
+ * To wait for a *specific* message when several are in flight, check
108
+ * fields inside the predicate:
109
+ *
110
+ * ```ts
111
+ * const mail = await ctx.poll("reset email", async () => {
112
+ * const m = await ctx.svc.email.lastEmail();
113
+ * return m && /reset/i.test(m.unwrap().subject) ? m : undefined;
114
+ * });
115
+ * ```
116
+ */
117
+ lastEmail(): Promise<Wrapped<EmailMessage> | undefined>;
118
+ /** All captured messages matching `match`, newest first. */
119
+ emails(match?: EmailMatch): Promise<Wrapped<EmailSummary[]>>;
120
+ /** Delete every captured message. */
121
+ clear(): Promise<void>;
122
+ }
123
+
124
+ /**
125
+ * A capture-everything SMTP server. Drop into `environment.services`:
126
+ *
127
+ * ```ts
128
+ * services: {
129
+ * email: email(),
130
+ * app: {
131
+ * ...,
132
+ * env: { SMTP_HOST: "email", SMTP_PORT: "1025" },
133
+ * },
134
+ * }
135
+ * ```
136
+ *
137
+ * The app sends real SMTP (any or no credentials are accepted, no TLS
138
+ * required); tests assert on what arrived, using the standard `ctx.poll`
139
+ * to wait for delivery:
140
+ *
141
+ * ```ts
142
+ * const mail = await ctx.poll("welcome email", () => ctx.svc.email.lastEmail());
143
+ * expect(mail.to).toContain("alice@example.com");
144
+ * expect(mail.subject).toBe("Welcome!");
145
+ * expect(mail.html).toContain("Alice");
146
+ * ```
147
+ */
148
+ export function email(opts: EmailOptions = {}) {
149
+ const smtpPort = opts.smtpPort ?? SMTP_PORT;
150
+ const reference = opts.image ?? DEFAULT_IMAGE;
151
+ // `satisfies` (not a return-type annotation) so the helpers factory's
152
+ // literal return type flows through to `ctx.svc.<name>` — see the note
153
+ // in postgres.ts.
154
+ return {
155
+ image: { type: "registry" as const, reference },
156
+ env: {
157
+ // Accept whatever AUTH the app offers (including none, over
158
+ // plaintext), so an app configured with production-style SMTP
159
+ // credentials runs unchanged against the capture server.
160
+ MP_SMTP_AUTH_ACCEPT_ANY: "1",
161
+ MP_SMTP_AUTH_ALLOW_INSECURE: "1",
162
+ ...(smtpPort !== SMTP_PORT
163
+ ? { MP_SMTP_BIND_ADDR: `0.0.0.0:${smtpPort}` }
164
+ : {}),
165
+ ...(opts.env ?? {}),
166
+ },
167
+ ports: [smtpPort, API_PORT],
168
+ readyCheck: {
169
+ type: "http" as const,
170
+ port: API_PORT,
171
+ path: "/livez",
172
+ timeoutSecs: 60,
173
+ },
174
+ helpers: ({ name }: { name: string }): EmailHelpers =>
175
+ buildHelpers(name, `http://${name}:${API_PORT}`),
176
+ } satisfies ServiceDefinition<EmailHelpers>;
177
+ }
178
+
179
+ function buildHelpers(service: string, base: string): EmailHelpers {
180
+ return {
181
+ async lastEmail() {
182
+ return instrumented(service, "lastEmail", undefined, async () => {
183
+ const [hit] = await listAll(base); // newest first
184
+ if (!hit) return { value: undefined, count: 0 };
185
+ const value = await getMessage(base, hit.id);
186
+ return { value, message: toEventMessage(value) };
187
+ });
188
+ },
189
+
190
+ emails(match) {
191
+ return instrumented(service, "emails", describeMatch(match), async () => {
192
+ const value = (await listAll(base)).filter((s) => matches(s, match));
193
+ return {
194
+ value,
195
+ count: value.length,
196
+ messages: value.slice(0, EVENT_LIST_CAP).map(toEventSummary),
197
+ };
198
+ });
199
+ },
200
+
201
+ async clear() {
202
+ await instrumented(service, "clear", undefined, async () => {
203
+ await api(base, "/api/v1/messages", { method: "DELETE" });
204
+ return { value: undefined };
205
+ });
206
+ },
207
+ };
208
+ }
209
+
210
+ /** Newest-first mailbox listing rows returned by `emails()` are capped at
211
+ * this many entries on the recorded event (the return value itself is
212
+ * never truncated). */
213
+ const EVENT_LIST_CAP = 50;
214
+
215
+ /** Run one helper op: reserve a timeline slot up front, record an `email`
216
+ * event when the op settles, and hand the value back inspect-wrapped
217
+ * against that event so assertions on it nest under the step. */
218
+ async function instrumented<T>(
219
+ service: string,
220
+ op: string,
221
+ query: string | undefined,
222
+ body: () => Promise<{
223
+ value: T;
224
+ message?: EmailEventMessage;
225
+ messages?: EmailEventSummary[];
226
+ count?: number;
227
+ }>,
228
+ ): Promise<Wrapped<T>> {
229
+ const started = Date.now();
230
+ const resv = reserveEvent();
231
+ try {
232
+ const { value, message, messages, count } = await body();
233
+ const seq = recordEmail(
234
+ { service, op, query, message, messages, count, durationMs: Date.now() - started },
235
+ resv,
236
+ );
237
+ return wrap(value, seq) as Wrapped<T>;
238
+ } catch (err) {
239
+ const e = err as Error;
240
+ recordEmail(
241
+ {
242
+ service,
243
+ op,
244
+ query,
245
+ durationMs: Date.now() - started,
246
+ error: e?.message ?? String(err),
247
+ },
248
+ resv,
249
+ );
250
+ throw err;
251
+ }
252
+ }
253
+
254
+ /** Query the mail server's HTTP API. Recording is paused around the fetch
255
+ * so these internal polls don't land as `http` events on the timeline —
256
+ * the helper records one consolidated `email` event instead. */
257
+ async function api(base: string, path: string, init?: RequestInit): Promise<unknown> {
258
+ pauseRecording();
259
+ try {
260
+ const res = await fetch(`${base}${path}`, init);
261
+ if (!res.ok) {
262
+ throw new Error(`email server API ${path} failed: HTTP ${res.status}`);
263
+ }
264
+ const text = await res.text();
265
+ if (text === "") return undefined;
266
+ try {
267
+ return JSON.parse(text);
268
+ } catch {
269
+ // Mutating endpoints reply with a plain-text acknowledgement.
270
+ return text;
271
+ }
272
+ } finally {
273
+ resumeRecording();
274
+ }
275
+ }
276
+
277
+ interface RawAddress {
278
+ Name?: string;
279
+ Address?: string;
280
+ }
281
+
282
+ function addresses(v: unknown): string[] {
283
+ if (!Array.isArray(v)) return [];
284
+ return v.map((a) => (a as RawAddress)?.Address ?? "").filter((a) => a !== "");
285
+ }
286
+
287
+ async function listAll(base: string): Promise<EmailSummary[]> {
288
+ const data = (await api(base, "/api/v1/messages?limit=500")) as {
289
+ messages?: unknown[];
290
+ };
291
+ return (data?.messages ?? []).map((raw) => {
292
+ const m = raw as Record<string, unknown>;
293
+ return {
294
+ id: (m.ID as string) ?? "",
295
+ from: (m.From as RawAddress)?.Address ?? "",
296
+ to: addresses(m.To),
297
+ subject: (m.Subject as string) ?? "",
298
+ snippet: (m.Snippet as string) ?? "",
299
+ date: (m.Created as string) ?? "",
300
+ attachments: (m.Attachments as number) ?? 0,
301
+ };
302
+ });
303
+ }
304
+
305
+ async function getMessage(base: string, id: string): Promise<EmailMessage> {
306
+ const m = (await api(base, `/api/v1/message/${encodeURIComponent(id)}`)) as Record<
307
+ string,
308
+ unknown
309
+ >;
310
+ return {
311
+ id: (m.ID as string) ?? "",
312
+ from: (m.From as RawAddress)?.Address ?? "",
313
+ to: addresses(m.To),
314
+ cc: addresses(m.Cc),
315
+ bcc: addresses(m.Bcc),
316
+ subject: (m.Subject as string) ?? "",
317
+ date: (m.Date as string) ?? "",
318
+ text: (m.Text as string) ?? "",
319
+ html: (m.HTML as string) ?? "",
320
+ attachments: (Array.isArray(m.Attachments) ? m.Attachments : []).map((raw) => {
321
+ const a = raw as Record<string, unknown>;
322
+ return {
323
+ filename: (a.FileName as string) ?? "",
324
+ contentType: (a.ContentType as string) ?? "",
325
+ size: (a.Size as number) ?? 0,
326
+ };
327
+ }),
328
+ };
329
+ }
330
+
331
+ function matches(s: EmailSummary, match?: EmailMatch): boolean {
332
+ if (!match) return true;
333
+ if (match.to !== undefined) {
334
+ const want = match.to.toLowerCase();
335
+ if (!s.to.some((a) => a.toLowerCase() === want)) return false;
336
+ }
337
+ if (match.from !== undefined && s.from.toLowerCase() !== match.from.toLowerCase()) {
338
+ return false;
339
+ }
340
+ if (match.subject !== undefined) {
341
+ if (typeof match.subject === "string") {
342
+ if (!s.subject.includes(match.subject)) return false;
343
+ } else if (!match.subject.test(s.subject)) {
344
+ return false;
345
+ }
346
+ }
347
+ return true;
348
+ }
349
+
350
+ function describeMatch(match?: EmailMatch): string | undefined {
351
+ if (!match) return undefined;
352
+ const parts: string[] = [];
353
+ if (match.to !== undefined) parts.push(`to ${match.to}`);
354
+ if (match.from !== undefined) parts.push(`from ${match.from}`);
355
+ if (match.subject !== undefined) parts.push(`subject ${String(match.subject)}`);
356
+ return parts.length > 0 ? parts.join(", ") : undefined;
357
+ }
358
+
359
+ function toEventSummary(s: EmailSummary): EmailEventSummary {
360
+ return {
361
+ from: s.from,
362
+ to: s.to,
363
+ subject: s.subject,
364
+ snippet: s.snippet,
365
+ date: s.date,
366
+ };
367
+ }
368
+
369
+ function toEventMessage(m: EmailMessage): EmailEventMessage {
370
+ const html = truncateUtf8(m.html);
371
+ const text = truncateUtf8(m.text);
372
+ return {
373
+ from: m.from,
374
+ to: m.to,
375
+ ...(m.cc.length > 0 ? { cc: m.cc } : {}),
376
+ ...(m.bcc.length > 0 ? { bcc: m.bcc } : {}),
377
+ subject: m.subject,
378
+ date: m.date,
379
+ ...(m.html !== ""
380
+ ? { html: html.value, ...(html.truncated ? { htmlTruncated: true } : {}) }
381
+ : {}),
382
+ ...(m.text !== ""
383
+ ? { text: text.value, ...(text.truncated ? { textTruncated: true } : {}) }
384
+ : {}),
385
+ ...(m.attachments.length > 0 ? { attachments: m.attachments } : {}),
386
+ };
387
+ }
@@ -37,6 +37,15 @@ export {
37
37
  type SupabaseHelpers,
38
38
  type SupabaseStack,
39
39
  } from "./supabase.js";
40
+ export {
41
+ email,
42
+ type EmailOptions,
43
+ type EmailHelpers,
44
+ type EmailMessage,
45
+ type EmailSummary,
46
+ type EmailMatch,
47
+ type EmailAttachment,
48
+ } from "./email.js";
40
49
  export {
41
50
  replayFake,
42
51
  type ReplayFakeOptions,
package/src/daemon.ts CHANGED
@@ -34,8 +34,8 @@ import {
34
34
  proxy as makeProxyDecl,
35
35
  } from "./index.js";
36
36
  import type { DnsTarget, LoweredIngress } from "./index.js";
37
- import { openBrowser } from "./browser.js";
38
- import { openMobile, isMobileApp } from "./mobile.js";
37
+ import { acquirePersistentBrowser } from "./browser.js";
38
+ import { isMobileApp, openPersistentMobile } from "./mobile.js";
39
39
  import type { Mobile, MobileApp } from "./mobile.js";
40
40
  import { openTerminal } from "./terminal.js";
41
41
  import {
@@ -2574,6 +2574,13 @@ async function bootstrap(): Promise<BootstrapTimings> {
2574
2574
  // browser.ts:213 (the long-standing intermittent NAME_NOT_RESOLVED) and
2575
2575
  // the clocksource-regression notes. Re-enabling requires fixing the
2576
2576
  // restored-renderer DNS state, not just re-adding the prewarm call.
2577
+ // NOTE: persistent sessions (ctx.browser/ctx.mobile keep one live view
2578
+ // across tests, so restored forks navigate on a pre-snapshot renderer
2579
+ // routinely) hit the same bug head-on; browser.ts handles it there by
2580
+ // rebuilding the view in the same Chrome and retrying the navigation
2581
+ // (`rebuildView`) — profile state survives, so auth carries over. That
2582
+ // recovery is scoped to inherited-navigation failures and does NOT make
2583
+ // the about:blank prewarm pool safe to re-enable.
2577
2584
 
2578
2585
  const result: BootstrapTimings = {
2579
2586
  totalMs: Date.now() - bootStart,
@@ -3645,22 +3652,49 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3645
3652
  const parentId = testCase.dependsOn?.id;
3646
3653
  const parent = parentId !== undefined ? TEST_DATA.get(parentId) : undefined;
3647
3654
 
3648
- // Track every Browser opened during this test so we can close them in
3649
- // `finally` — leaked Chromium subprocesses would survive the snapshot
3650
- // and chew memory across forks. Each Browser also gets a session
3651
- // recorder; the records flow back to the control plane as part of
3652
- // RunResult.browserSessions and are archived to S3 as the case's
3653
- // replay bundle.
3654
- // Tracks both Browser and Mobile handles for cleanup — both expose an
3655
- // async close() that does the final rrweb drain before teardown.
3656
- const openBrowsers: Array<{ close(): Promise<void> }> = [];
3655
+ // Browser/mobile sessions are PERSISTENT: `ctx.browser()` acquires THE
3656
+ // shared desktop browser and `ctx.mobile(app)` the one session for that
3657
+ // app (browser.ts's module-scoped registry, which forks with the
3658
+ // snapshot like fake state). At test end we DETACH — final rrweb drain,
3659
+ // stop writing to this test's recorder — but deliberately keep the
3660
+ // Chromium alive so the post-test snapshot captures it and dependsOn
3661
+ // children resume the live page (cookies, localStorage, signed-in SPA
3662
+ // state) instead of re-navigating. Each test still gets its own session
3663
+ // record (attach re-arms rrweb with a fresh full snapshot, so replays
3664
+ // stay per-case self-contained); records flow back to the control plane
3665
+ // on RunResult.browserSessions and are archived to S3 as the case's
3666
+ // replay bundle. Within one test repeated ctx.browser()/ctx.mobile(app)
3667
+ // calls return the same handle (memoized below) so one test = one
3668
+ // session per device. An explicit `.close()` destroys the shared
3669
+ // instance — the memo is cleared so a later call starts fresh.
3670
+ const browserDetaches: Array<() => Promise<void>> = [];
3657
3671
  const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
3672
+ let sharedBrowser: Browser | null = null;
3673
+ const sharedMobiles = new Map<string, Mobile>();
3658
3674
  const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
3675
+ if (sharedBrowser) return sharedBrowser;
3659
3676
  const session = newBrowserSession(start, testCase.id);
3660
3677
  sessions.push(session);
3661
- const b = await openBrowser({ ...(opts ?? {}), recorder: session.recorder });
3662
- openBrowsers.push(b);
3663
- return b;
3678
+ const { browser, attached, detach } = await acquirePersistentBrowser({
3679
+ ...(opts ?? {}),
3680
+ recorder: session.recorder,
3681
+ });
3682
+ // An attached session starts mid-page (no navigate event will fire) —
3683
+ // stamp the inherited URL so the dashboard can still label the replay.
3684
+ if (attached && session.record.initialUrl === undefined) {
3685
+ session.record.initialUrl = browser.url;
3686
+ }
3687
+ browserDetaches.push(async () => {
3688
+ await detach();
3689
+ session.markClosed();
3690
+ });
3691
+ const innerClose = browser.close.bind(browser);
3692
+ browser.close = async () => {
3693
+ await innerClose();
3694
+ if (sharedBrowser === browser) sharedBrowser = null;
3695
+ };
3696
+ sharedBrowser = browser;
3697
+ return browser;
3664
3698
  };
3665
3699
  const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
3666
3700
  if (!isMobileApp(app)) {
@@ -3668,11 +3702,28 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3668
3702
  "ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().",
3669
3703
  );
3670
3704
  }
3705
+ const existing = sharedMobiles.get(app.url);
3706
+ if (existing) return existing;
3671
3707
  const session = newBrowserSession(start, testCase.id, "mobile");
3672
3708
  sessions.push(session);
3673
- const m = await openMobile({ url: app.url, recorder: session.recorder });
3674
- openBrowsers.push(m);
3675
- return m;
3709
+ const { mobile, attached, detach } = await openPersistentMobile({
3710
+ url: app.url,
3711
+ recorder: session.recorder,
3712
+ });
3713
+ if (attached && session.record.initialUrl === undefined) {
3714
+ session.record.initialUrl = mobile.url;
3715
+ }
3716
+ browserDetaches.push(async () => {
3717
+ await detach();
3718
+ session.markClosed();
3719
+ });
3720
+ const innerClose = mobile.close.bind(mobile);
3721
+ mobile.close = async () => {
3722
+ await innerClose();
3723
+ if (sharedMobiles.get(app.url) === mobile) sharedMobiles.delete(app.url);
3724
+ };
3725
+ sharedMobiles.set(app.url, mobile);
3726
+ return mobile;
3676
3727
  };
3677
3728
 
3678
3729
  // Build convenience handles (e.g. ctx.svc.db.client) from the loaded
@@ -3740,13 +3791,14 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3740
3791
  (process.stdout as any).write = origStdout;
3741
3792
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3742
3793
  (process.stderr as any).write = origStderr;
3743
- // Best-effort browser cleanup. `close()` does a final rrweb drain
3744
- // before tearing the view down, so we must `await` it before
3745
- // collecting session records. Leaked Chromium subprocesses would
3746
- // survive the snapshot and chew memory across forks.
3747
- for (const b of openBrowsers) {
3794
+ // Detach every browser/mobile session: final rrweb drain (must be
3795
+ // awaited before collecting session records), then stop writing to
3796
+ // this test's recorder. The Chromium itself deliberately stays alive
3797
+ // — it's part of the state the post-test snapshot captures for
3798
+ // dependsOn children (see the acquire comment above).
3799
+ for (const detach of browserDetaches) {
3748
3800
  try {
3749
- await b.close();
3801
+ await detach();
3750
3802
  } catch {
3751
3803
  /* ignore */
3752
3804
  }
@@ -3926,14 +3978,35 @@ async function evalCode(
3926
3978
  // wrapped type is honest at runtime). Restored in the `finally` below.
3927
3979
  const restoreFetch = installFetchWrapper();
3928
3980
 
3929
- const openBrowsers: Array<{ close(): Promise<void> }> = [];
3981
+ // Same persistent acquire/detach as a test run (see runOne): the browser
3982
+ // survives the eval, so successive `spectest env eval` calls continue one
3983
+ // live session — and a snapshot taken afterwards carries it.
3984
+ const browserDetaches: Array<() => Promise<void>> = [];
3930
3985
  const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
3986
+ let sharedBrowser: Browser | null = null;
3987
+ const sharedMobiles = new Map<string, Mobile>();
3931
3988
  const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
3989
+ if (sharedBrowser) return sharedBrowser;
3932
3990
  const session = newBrowserSession(start, "eval");
3933
3991
  sessions.push(session);
3934
- const b = await openBrowser({ ...(opts ?? {}), recorder: session.recorder });
3935
- openBrowsers.push(b);
3936
- return b;
3992
+ const { browser, attached, detach } = await acquirePersistentBrowser({
3993
+ ...(opts ?? {}),
3994
+ recorder: session.recorder,
3995
+ });
3996
+ if (attached && session.record.initialUrl === undefined) {
3997
+ session.record.initialUrl = browser.url;
3998
+ }
3999
+ browserDetaches.push(async () => {
4000
+ await detach();
4001
+ session.markClosed();
4002
+ });
4003
+ const innerClose = browser.close.bind(browser);
4004
+ browser.close = async () => {
4005
+ await innerClose();
4006
+ if (sharedBrowser === browser) sharedBrowser = null;
4007
+ };
4008
+ sharedBrowser = browser;
4009
+ return browser;
3937
4010
  };
3938
4011
  const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
3939
4012
  if (!isMobileApp(app)) {
@@ -3941,11 +4014,28 @@ async function evalCode(
3941
4014
  "ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().",
3942
4015
  );
3943
4016
  }
4017
+ const existing = sharedMobiles.get(app.url);
4018
+ if (existing) return existing;
3944
4019
  const session = newBrowserSession(start, "eval", "mobile");
3945
4020
  sessions.push(session);
3946
- const m = await openMobile({ url: app.url, recorder: session.recorder });
3947
- openBrowsers.push(m);
3948
- return m;
4021
+ const { mobile, attached, detach } = await openPersistentMobile({
4022
+ url: app.url,
4023
+ recorder: session.recorder,
4024
+ });
4025
+ if (attached && session.record.initialUrl === undefined) {
4026
+ session.record.initialUrl = mobile.url;
4027
+ }
4028
+ browserDetaches.push(async () => {
4029
+ await detach();
4030
+ session.markClosed();
4031
+ });
4032
+ const innerClose = mobile.close.bind(mobile);
4033
+ mobile.close = async () => {
4034
+ await innerClose();
4035
+ if (sharedMobiles.get(app.url) === mobile) sharedMobiles.delete(app.url);
4036
+ };
4037
+ sharedMobiles.set(app.url, mobile);
4038
+ return mobile;
3949
4039
  };
3950
4040
 
3951
4041
  // Terminal sessions — same shape as runOne, but eval has no active
@@ -4058,9 +4148,11 @@ async function evalCode(
4058
4148
  (process.stdout as any).write = origStdout;
4059
4149
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
4060
4150
  (process.stderr as any).write = origStderr;
4061
- for (const b of openBrowsers) {
4151
+ // Detach (final rrweb drain) — the browser itself stays alive; see
4152
+ // the acquire comment above.
4153
+ for (const detach of browserDetaches) {
4062
4154
  try {
4063
- await b.close();
4155
+ await detach();
4064
4156
  } catch {
4065
4157
  /* ignore */
4066
4158
  }
package/src/index.ts CHANGED
@@ -1061,9 +1061,20 @@ export interface TestContext<
1061
1061
  */
1062
1062
  openTerminal(service: string, opts?: TerminalOpts): Promise<Terminal>;
1063
1063
  /**
1064
- * Open a headless browser. Backed by Chromium-over-CDP inside the VM.
1065
- * Every view is auto-closed when the test finishes; call `.close()` to
1066
- * release earlier if you're opening many.
1064
+ * Open the headless browser. Backed by Chromium-over-CDP inside the VM.
1065
+ *
1066
+ * There is ONE persistent browser per environment: every `ctx.browser()`
1067
+ * call returns it, and it stays alive across tests — the browser is part
1068
+ * of the state a test's snapshot captures, so a `dependsOn` child resumes
1069
+ * the exact live page its parent left (cookies, localStorage, signed-in
1070
+ * SPA state). Sign in once in a parent test; every descendant is already
1071
+ * signed in. Sibling tests fork from the same parent snapshot, so they
1072
+ * can't see each other's browsing. A test with no browser-using ancestor
1073
+ * gets a fresh browser on first call (first call's options win).
1074
+ *
1075
+ * `.close()` destroys the shared instance — the next `ctx.browser()`
1076
+ * starts fresh. Don't call it for routine cleanup; recording is detached
1077
+ * automatically at test end.
1067
1078
  */
1068
1079
  browser(opts?: BrowserOptions): Promise<Browser>;
1069
1080
  /**
@@ -1082,8 +1093,13 @@ export interface TestContext<
1082
1093
  * ```
1083
1094
  *
1084
1095
  * The session emulates the latest iPhone (viewport + DPR + mobile UA +
1085
- * touch) and the dashboard replays it inside a phone bezel. Auto-closed
1086
- * when the test finishes.
1096
+ * touch) and the dashboard replays it inside a phone bezel.
1097
+ *
1098
+ * Sessions are persistent, one per app: like `ctx.browser()`, the live
1099
+ * session is captured in the test's snapshot, so a `dependsOn` child
1100
+ * picks up the app exactly where the parent left it (already signed in,
1101
+ * mid-flow) instead of reloading it. `.close()` discards the session;
1102
+ * the next `ctx.mobile(app)` opens the app fresh.
1087
1103
  */
1088
1104
  mobile(app: MobileApp): Promise<Mobile>;
1089
1105
  /** The test's display name. */
package/src/mobile.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // `accessibilityLabel` to `aria-label`, so `getByTestId`/`getByLabel` map to
15
15
  // plain DOM attribute selectors with no shimming.
16
16
 
17
- import { openMobileBackend } from "./browser.js";
17
+ import { acquirePersistentMobileBackend, openMobileBackend } from "./browser.js";
18
18
  import type {
19
19
  BrowserSessionRecorder,
20
20
  MobileBackend,
@@ -362,9 +362,9 @@ function wrapMobile(backend: MobileBackend): Mobile {
362
362
  }
363
363
 
364
364
  /**
365
- * Open a phone-emulated session pointed at `url`. The daemon calls this from
366
- * `ctx.mobile(app)` with a per-session rrweb recorder; the resulting record
367
- * carries `frame: "mobile"` so the dashboard renders a phone bezel.
365
+ * Open an EPHEMERAL phone-emulated session pointed at `url` (`close()`
366
+ * destroys it). Library callers only — the daemon's `ctx.mobile(app)` goes
367
+ * through {@link openPersistentMobile} so sessions survive across tests.
368
368
  */
369
369
  export async function openMobile(opts: {
370
370
  url?: string;
@@ -377,3 +377,22 @@ export async function openMobile(opts: {
377
377
  });
378
378
  return wrapMobile(backend);
379
379
  }
380
+
381
+ /**
382
+ * Acquire the persistent phone-emulated session for an app (one per app
383
+ * URL, created on first use — see the "Persistent sessions" section in
384
+ * browser.ts). The daemon calls this from `ctx.mobile(app)` with a
385
+ * per-test rrweb recorder; the resulting record carries `frame: "mobile"`
386
+ * so the dashboard renders a phone bezel. `detach` is the test-end hook;
387
+ * `mobile.close()` destroys the session for real.
388
+ */
389
+ export async function openPersistentMobile(opts: {
390
+ url: string;
391
+ recorder: BrowserSessionRecorder | null;
392
+ }): Promise<{ mobile: Mobile; attached: boolean; detach(): Promise<void> }> {
393
+ const { browser, attached, detach } = await acquirePersistentMobileBackend(
394
+ opts.url,
395
+ opts.recorder,
396
+ );
397
+ return { mobile: wrapMobile(browser), attached, detach };
398
+ }
package/src/recorder.ts CHANGED
@@ -25,7 +25,8 @@ export type TestEvent =
25
25
  | TerminalStepEvent
26
26
  | WaitEvent
27
27
  | FakeEvent
28
- | EnvEvent;
28
+ | EnvEvent
29
+ | EmailEvent;
29
30
 
30
31
  interface BaseEvent {
31
32
  /** Order of *start* within the test. Reserved when an op begins (see
@@ -275,6 +276,69 @@ export interface FakeEvent extends BaseEvent {
275
276
  error?: string;
276
277
  }
277
278
 
279
+ /**
280
+ * One captured email, as embedded on an {@link EmailEvent}. The HTML and
281
+ * text bodies ride along (truncated to the output cap) so the dashboard
282
+ * can render the actual email a test asserted against.
283
+ */
284
+ export interface EmailEventMessage {
285
+ /** Sender address. */
286
+ from?: string;
287
+ /** Recipient addresses. */
288
+ to?: string[];
289
+ cc?: string[];
290
+ bcc?: string[];
291
+ subject?: string;
292
+ /** RFC date of the message, ISO-formatted. */
293
+ date?: string;
294
+ /** HTML body (truncated to the output cap). */
295
+ html?: string;
296
+ htmlTruncated?: boolean;
297
+ /** Plain-text body (truncated to the output cap). */
298
+ text?: string;
299
+ textTruncated?: boolean;
300
+ attachments?: { filename: string; contentType: string; size: number }[];
301
+ }
302
+
303
+ /** One row of a mailbox listing embedded on an {@link EmailEvent}. */
304
+ export interface EmailEventSummary {
305
+ from?: string;
306
+ to?: string[];
307
+ subject?: string;
308
+ /** Plain-text preview of the body. */
309
+ snippet?: string;
310
+ date?: string;
311
+ }
312
+
313
+ /**
314
+ * One call to an email service's helpers (`ctx.svc.<name>.lastEmail(...)`
315
+ * etc.). Single-message ops embed the full captured message — including its
316
+ * HTML body — so timelines can render the email itself; listing ops embed
317
+ * compact summaries. The return value is `wrap()`ped against this event's
318
+ * seq, so `expect(...)` on it nests under this step (same mechanism as
319
+ * http/db/fake). Inside a `ctx.poll` predicate the event ride-alongs with
320
+ * the poll's iteration events: failed iterations get truncated, the winning
321
+ * one survives as a child of the `wait` event.
322
+ */
323
+ export interface EmailEvent extends BaseEvent {
324
+ kind: "email";
325
+ /** Service key of the mail server (`ctx.svc.<service>`). */
326
+ service: string;
327
+ /** Helper called, e.g. `"lastEmail"`. */
328
+ op: string;
329
+ /** Human-readable match criteria, e.g. `to alice@example.com`. */
330
+ query?: string;
331
+ /** Number of matching messages (listing ops / mailbox size on error). */
332
+ count?: number;
333
+ /** The captured message (single-message ops). */
334
+ message?: EmailEventMessage;
335
+ /** Message summaries (listing ops). */
336
+ messages?: EmailEventSummary[];
337
+ durationMs: number;
338
+ /** Set if the op threw (e.g. the mail server's query API failed). */
339
+ error?: string;
340
+ }
341
+
278
342
  /**
279
343
  * A runtime mutation of the environment — `ctx.startService` /
280
344
  * `ctx.stopService` / `ctx.dnsName`, whether called from a test or from a
@@ -663,6 +727,13 @@ export function recordEnv(
663
727
  return active() ? current!.push({ kind: "env", ...ev }, reservation) : undefined;
664
728
  }
665
729
 
730
+ export function recordEmail(
731
+ ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">,
732
+ reservation?: EventReservation,
733
+ ): number | undefined {
734
+ return active() ? current!.push({ kind: "email", ...ev }, reservation) : undefined;
735
+ }
736
+
666
737
  /**
667
738
  * Recorded once per `ctx.poll(...)` call. Stands in for the suppressed
668
739
  * intermediate iterations and gives downstream tagged values