@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/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 {
@@ -1478,6 +1478,88 @@ const HOP_BY_HOP_HEADERS = new Set([
1478
1478
  "host",
1479
1479
  ]);
1480
1480
 
1481
+ /**
1482
+ * Is this a CORS preflight? A preflight is the browser's own probe (never
1483
+ * app business logic): an `OPTIONS` carrying `Origin` +
1484
+ * `Access-Control-Request-Method`. Plain `OPTIONS` calls (no `ACRM`) are real
1485
+ * app requests and pass straight through to the upstream/fake.
1486
+ */
1487
+ function isCorsPreflight(req: Request): boolean {
1488
+ return (
1489
+ req.method === "OPTIONS" &&
1490
+ req.headers.has("origin") &&
1491
+ req.headers.has("access-control-request-method")
1492
+ );
1493
+ }
1494
+
1495
+ /**
1496
+ * Answer a CORS preflight at the ingress, permissively, reflecting exactly
1497
+ * what the browser asked for.
1498
+ *
1499
+ * Why this belongs in the platform, not the app: inside the hermetic sandbox
1500
+ * the app page's origin (e.g. `http://<svc>.internal:<port>`) and every host
1501
+ * it fetches through this ingress (`https://api.example.com`) are *always*
1502
+ * different origins, so any request with a non-safelisted header — which
1503
+ * includes `Authorization`, and crucially `Cache-Control` / `Pragma` — is
1504
+ * preflighted by the browser. If we forward the `OPTIONS` to the upstream, the
1505
+ * request succeeds or fails on whether *that* app happens to enumerate the
1506
+ * header in its `Access-Control-Allow-Headers`. Real apps list `Authorization`
1507
+ * but almost never `Cache-Control`/`Pragma`, so a client that sends those (many
1508
+ * HTTP libraries add `Cache-Control: no-cache` by default) fails the preflight
1509
+ * with an instant "Failed to fetch" — even though the identical request works
1510
+ * in production behind a permissive edge/gateway. Reflecting
1511
+ * `Access-Control-Request-Headers` verbatim makes the ingress transparent to
1512
+ * whatever header vocabulary the app under test uses.
1513
+ */
1514
+ function corsPreflightResponse(req: Request): Response {
1515
+ const origin = req.headers.get("origin") ?? "*";
1516
+ const reqHeaders = req.headers.get("access-control-request-headers");
1517
+ const reqMethod = req.headers.get("access-control-request-method");
1518
+ const headers = new Headers();
1519
+ headers.set("access-control-allow-origin", origin);
1520
+ // Echo the specific origin (not `*`) so credentialed requests are allowed;
1521
+ // `Allow-Origin: *` + `Allow-Credentials: true` is a spec violation browsers
1522
+ // reject.
1523
+ headers.set("access-control-allow-credentials", "true");
1524
+ headers.set(
1525
+ "access-control-allow-methods",
1526
+ reqMethod && reqMethod.length > 0
1527
+ ? reqMethod
1528
+ : "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
1529
+ );
1530
+ headers.set(
1531
+ "access-control-allow-headers",
1532
+ reqHeaders && reqHeaders.length > 0 ? reqHeaders : "*",
1533
+ );
1534
+ headers.set("access-control-max-age", "600");
1535
+ // The response varies by the reflected origin/headers — keep caches honest.
1536
+ headers.append("vary", "Origin");
1537
+ headers.append("vary", "Access-Control-Request-Headers");
1538
+ return new Response(null, { status: 204, headers });
1539
+ }
1540
+
1541
+ /**
1542
+ * Make sure the browser sees an `Access-Control-Allow-Origin` it accepts on the
1543
+ * *actual* cross-origin response. Only fills one in when the upstream/fake
1544
+ * didn't set its own, so an app that manages CORS itself keeps full control;
1545
+ * this just stops a missing header from turning an otherwise-fine 200 into a
1546
+ * "Failed to fetch". No-op for same-origin requests (no `Origin`).
1547
+ */
1548
+ function augmentCorsResponse(req: Request, res: Response): Response {
1549
+ const origin = req.headers.get("origin");
1550
+ if (!origin) return res;
1551
+ if (res.headers.has("access-control-allow-origin")) return res;
1552
+ try {
1553
+ res.headers.set("access-control-allow-origin", origin);
1554
+ res.headers.set("access-control-allow-credentials", "true");
1555
+ res.headers.append("vary", "Origin");
1556
+ } catch {
1557
+ // Some responses (e.g. a 101 upgrade stub) carry guarded/immutable
1558
+ // headers — leave those untouched.
1559
+ }
1560
+ return res;
1561
+ }
1562
+
1481
1563
  /**
1482
1564
  * Bring ingress servers up: bind one Bun.serve per unique HTTP port
1483
1565
  * (fakes' ports plus the always-on :80 for service proxies), plus a
@@ -1865,9 +1947,15 @@ async function dispatchIngress(
1865
1947
  { status: 404, headers: { "content-type": "text/plain" } },
1866
1948
  );
1867
1949
  }
1950
+ // Answer CORS preflights at the ingress (see corsPreflightResponse) so a
1951
+ // cross-origin browser request carrying any header — Authorization,
1952
+ // Cache-Control, Pragma, … — isn't rejected by whatever the upstream happens
1953
+ // to list in Access-Control-Allow-Headers.
1954
+ if (isCorsPreflight(req)) return corsPreflightResponse(req);
1868
1955
  if (route.kind === "fake") {
1869
1956
  try {
1870
- return await route.fake.def.handler(req, route.fake.state, FAKE_CTX);
1957
+ const res = await route.fake.def.handler(req, route.fake.state, FAKE_CTX);
1958
+ return augmentCorsResponse(req, res);
1871
1959
  } catch (err) {
1872
1960
  const e = err as Error;
1873
1961
  return new Response(
@@ -1876,7 +1964,15 @@ async function dispatchIngress(
1876
1964
  );
1877
1965
  }
1878
1966
  }
1879
- return proxyToService(req, server, route.service, route.port, listenerLabel, proto);
1967
+ const res = await proxyToService(
1968
+ req,
1969
+ server,
1970
+ route.service,
1971
+ route.port,
1972
+ listenerLabel,
1973
+ proto,
1974
+ );
1975
+ return augmentCorsResponse(req, res);
1880
1976
  }
1881
1977
 
1882
1978
  /**
@@ -2574,6 +2670,13 @@ async function bootstrap(): Promise<BootstrapTimings> {
2574
2670
  // browser.ts:213 (the long-standing intermittent NAME_NOT_RESOLVED) and
2575
2671
  // the clocksource-regression notes. Re-enabling requires fixing the
2576
2672
  // restored-renderer DNS state, not just re-adding the prewarm call.
2673
+ // NOTE: persistent sessions (ctx.browser/ctx.mobile keep one live view
2674
+ // across tests, so restored forks navigate on a pre-snapshot renderer
2675
+ // routinely) hit the same bug head-on; browser.ts handles it there by
2676
+ // rebuilding the view in the same Chrome and retrying the navigation
2677
+ // (`rebuildView`) — profile state survives, so auth carries over. That
2678
+ // recovery is scoped to inherited-navigation failures and does NOT make
2679
+ // the about:blank prewarm pool safe to re-enable.
2577
2680
 
2578
2681
  const result: BootstrapTimings = {
2579
2682
  totalMs: Date.now() - bootStart,
@@ -3645,22 +3748,49 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3645
3748
  const parentId = testCase.dependsOn?.id;
3646
3749
  const parent = parentId !== undefined ? TEST_DATA.get(parentId) : undefined;
3647
3750
 
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> }> = [];
3751
+ // Browser/mobile sessions are PERSISTENT: `ctx.browser()` acquires THE
3752
+ // shared desktop browser and `ctx.mobile(app)` the one session for that
3753
+ // app (browser.ts's module-scoped registry, which forks with the
3754
+ // snapshot like fake state). At test end we DETACH — final rrweb drain,
3755
+ // stop writing to this test's recorder — but deliberately keep the
3756
+ // Chromium alive so the post-test snapshot captures it and dependsOn
3757
+ // children resume the live page (cookies, localStorage, signed-in SPA
3758
+ // state) instead of re-navigating. Each test still gets its own session
3759
+ // record (attach re-arms rrweb with a fresh full snapshot, so replays
3760
+ // stay per-case self-contained); records flow back to the control plane
3761
+ // on RunResult.browserSessions and are archived to S3 as the case's
3762
+ // replay bundle. Within one test repeated ctx.browser()/ctx.mobile(app)
3763
+ // calls return the same handle (memoized below) so one test = one
3764
+ // session per device. An explicit `.close()` destroys the shared
3765
+ // instance — the memo is cleared so a later call starts fresh.
3766
+ const browserDetaches: Array<() => Promise<void>> = [];
3657
3767
  const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
3768
+ let sharedBrowser: Browser | null = null;
3769
+ const sharedMobiles = new Map<string, Mobile>();
3658
3770
  const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
3771
+ if (sharedBrowser) return sharedBrowser;
3659
3772
  const session = newBrowserSession(start, testCase.id);
3660
3773
  sessions.push(session);
3661
- const b = await openBrowser({ ...(opts ?? {}), recorder: session.recorder });
3662
- openBrowsers.push(b);
3663
- return b;
3774
+ const { browser, attached, detach } = await acquirePersistentBrowser({
3775
+ ...(opts ?? {}),
3776
+ recorder: session.recorder,
3777
+ });
3778
+ // An attached session starts mid-page (no navigate event will fire) —
3779
+ // stamp the inherited URL so the dashboard can still label the replay.
3780
+ if (attached && session.record.initialUrl === undefined) {
3781
+ session.record.initialUrl = browser.url;
3782
+ }
3783
+ browserDetaches.push(async () => {
3784
+ await detach();
3785
+ session.markClosed();
3786
+ });
3787
+ const innerClose = browser.close.bind(browser);
3788
+ browser.close = async () => {
3789
+ await innerClose();
3790
+ if (sharedBrowser === browser) sharedBrowser = null;
3791
+ };
3792
+ sharedBrowser = browser;
3793
+ return browser;
3664
3794
  };
3665
3795
  const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
3666
3796
  if (!isMobileApp(app)) {
@@ -3668,11 +3798,28 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3668
3798
  "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
3799
  );
3670
3800
  }
3801
+ const existing = sharedMobiles.get(app.url);
3802
+ if (existing) return existing;
3671
3803
  const session = newBrowserSession(start, testCase.id, "mobile");
3672
3804
  sessions.push(session);
3673
- const m = await openMobile({ url: app.url, recorder: session.recorder });
3674
- openBrowsers.push(m);
3675
- return m;
3805
+ const { mobile, attached, detach } = await openPersistentMobile({
3806
+ url: app.url,
3807
+ recorder: session.recorder,
3808
+ });
3809
+ if (attached && session.record.initialUrl === undefined) {
3810
+ session.record.initialUrl = mobile.url;
3811
+ }
3812
+ browserDetaches.push(async () => {
3813
+ await detach();
3814
+ session.markClosed();
3815
+ });
3816
+ const innerClose = mobile.close.bind(mobile);
3817
+ mobile.close = async () => {
3818
+ await innerClose();
3819
+ if (sharedMobiles.get(app.url) === mobile) sharedMobiles.delete(app.url);
3820
+ };
3821
+ sharedMobiles.set(app.url, mobile);
3822
+ return mobile;
3676
3823
  };
3677
3824
 
3678
3825
  // Build convenience handles (e.g. ctx.svc.db.client) from the loaded
@@ -3740,13 +3887,14 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3740
3887
  (process.stdout as any).write = origStdout;
3741
3888
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3742
3889
  (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) {
3890
+ // Detach every browser/mobile session: final rrweb drain (must be
3891
+ // awaited before collecting session records), then stop writing to
3892
+ // this test's recorder. The Chromium itself deliberately stays alive
3893
+ // — it's part of the state the post-test snapshot captures for
3894
+ // dependsOn children (see the acquire comment above).
3895
+ for (const detach of browserDetaches) {
3748
3896
  try {
3749
- await b.close();
3897
+ await detach();
3750
3898
  } catch {
3751
3899
  /* ignore */
3752
3900
  }
@@ -3926,14 +4074,35 @@ async function evalCode(
3926
4074
  // wrapped type is honest at runtime). Restored in the `finally` below.
3927
4075
  const restoreFetch = installFetchWrapper();
3928
4076
 
3929
- const openBrowsers: Array<{ close(): Promise<void> }> = [];
4077
+ // Same persistent acquire/detach as a test run (see runOne): the browser
4078
+ // survives the eval, so successive `spectest env eval` calls continue one
4079
+ // live session — and a snapshot taken afterwards carries it.
4080
+ const browserDetaches: Array<() => Promise<void>> = [];
3930
4081
  const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
4082
+ let sharedBrowser: Browser | null = null;
4083
+ const sharedMobiles = new Map<string, Mobile>();
3931
4084
  const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
4085
+ if (sharedBrowser) return sharedBrowser;
3932
4086
  const session = newBrowserSession(start, "eval");
3933
4087
  sessions.push(session);
3934
- const b = await openBrowser({ ...(opts ?? {}), recorder: session.recorder });
3935
- openBrowsers.push(b);
3936
- return b;
4088
+ const { browser, attached, detach } = await acquirePersistentBrowser({
4089
+ ...(opts ?? {}),
4090
+ recorder: session.recorder,
4091
+ });
4092
+ if (attached && session.record.initialUrl === undefined) {
4093
+ session.record.initialUrl = browser.url;
4094
+ }
4095
+ browserDetaches.push(async () => {
4096
+ await detach();
4097
+ session.markClosed();
4098
+ });
4099
+ const innerClose = browser.close.bind(browser);
4100
+ browser.close = async () => {
4101
+ await innerClose();
4102
+ if (sharedBrowser === browser) sharedBrowser = null;
4103
+ };
4104
+ sharedBrowser = browser;
4105
+ return browser;
3937
4106
  };
3938
4107
  const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
3939
4108
  if (!isMobileApp(app)) {
@@ -3941,11 +4110,28 @@ async function evalCode(
3941
4110
  "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
4111
  );
3943
4112
  }
4113
+ const existing = sharedMobiles.get(app.url);
4114
+ if (existing) return existing;
3944
4115
  const session = newBrowserSession(start, "eval", "mobile");
3945
4116
  sessions.push(session);
3946
- const m = await openMobile({ url: app.url, recorder: session.recorder });
3947
- openBrowsers.push(m);
3948
- return m;
4117
+ const { mobile, attached, detach } = await openPersistentMobile({
4118
+ url: app.url,
4119
+ recorder: session.recorder,
4120
+ });
4121
+ if (attached && session.record.initialUrl === undefined) {
4122
+ session.record.initialUrl = mobile.url;
4123
+ }
4124
+ browserDetaches.push(async () => {
4125
+ await detach();
4126
+ session.markClosed();
4127
+ });
4128
+ const innerClose = mobile.close.bind(mobile);
4129
+ mobile.close = async () => {
4130
+ await innerClose();
4131
+ if (sharedMobiles.get(app.url) === mobile) sharedMobiles.delete(app.url);
4132
+ };
4133
+ sharedMobiles.set(app.url, mobile);
4134
+ return mobile;
3949
4135
  };
3950
4136
 
3951
4137
  // Terminal sessions — same shape as runOne, but eval has no active
@@ -4058,9 +4244,11 @@ async function evalCode(
4058
4244
  (process.stdout as any).write = origStdout;
4059
4245
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
4060
4246
  (process.stderr as any).write = origStderr;
4061
- for (const b of openBrowsers) {
4247
+ // Detach (final rrweb drain) — the browser itself stays alive; see
4248
+ // the acquire comment above.
4249
+ for (const detach of browserDetaches) {
4062
4250
  try {
4063
- await b.close();
4251
+ await detach();
4064
4252
  } catch {
4065
4253
  /* ignore */
4066
4254
  }
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,10 +14,11 @@
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,
21
+ SafeAreaInsets,
21
22
  ScreenshotOptions,
22
23
  } from "./browser.js";
23
24
  import type { Wrapped } from "./inspect.js";
@@ -278,6 +279,14 @@ export interface Mobile {
278
279
  back(): Promise<void>;
279
280
  /** Low-level escape hatch: evaluate JS in the page (recorded, wrapped). */
280
281
  evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
282
+ /**
283
+ * Install a script that runs before every *subsequent* document's own
284
+ * scripts (the Playwright `addInitScript` equivalent) — the deterministic
285
+ * way to plant shims/instrumentation that must win the race against the
286
+ * app bundle. Takes effect on the next navigation (e.g. a
287
+ * `location.assign` deep link), not the current document.
288
+ */
289
+ addInitScript(description: string, source: string): Promise<void>;
281
290
  /** Low-level escape hatch: poll a JS expression until truthy (recorded). */
282
291
  waitFor<T = unknown>(
283
292
  description: string,
@@ -349,6 +358,9 @@ function wrapMobile(backend: MobileBackend): Mobile {
349
358
  evaluate(description, script) {
350
359
  return backend.evaluate(description, script);
351
360
  },
361
+ addInitScript(description, source) {
362
+ return backend.addInitScript(description, source);
363
+ },
352
364
  waitFor(description, expression, options) {
353
365
  return backend.waitFor(description, expression, options);
354
366
  },
@@ -362,9 +374,9 @@ function wrapMobile(backend: MobileBackend): Mobile {
362
374
  }
363
375
 
364
376
  /**
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.
377
+ * Open an EPHEMERAL phone-emulated session pointed at `url` (`close()`
378
+ * destroys it). Library callers only — the daemon's `ctx.mobile(app)` goes
379
+ * through {@link openPersistentMobile} so sessions survive across tests.
368
380
  */
369
381
  export async function openMobile(opts: {
370
382
  url?: string;
@@ -377,3 +389,34 @@ export async function openMobile(opts: {
377
389
  });
378
390
  return wrapMobile(backend);
379
391
  }
392
+
393
+ /**
394
+ * Acquire the persistent phone-emulated session for an app (one per app
395
+ * URL, created on first use — see the "Persistent sessions" section in
396
+ * browser.ts). The daemon calls this from `ctx.mobile(app)` with a
397
+ * per-test rrweb recorder; the resulting record carries `frame: "mobile"`
398
+ * so the dashboard renders a phone bezel. `detach` is the test-end hook;
399
+ * `mobile.close()` destroys the session for real.
400
+ */
401
+ export async function openPersistentMobile(opts: {
402
+ url: string;
403
+ recorder: BrowserSessionRecorder | null;
404
+ }): Promise<{
405
+ mobile: Mobile;
406
+ attached: boolean;
407
+ detach(): Promise<void>;
408
+ /** Safe-area insets emulated on the view (`null` when the CDP override
409
+ * is unavailable) — the daemon stamps them onto the session record. */
410
+ safeAreaInsets: SafeAreaInsets | null;
411
+ }> {
412
+ const { browser, attached, detach } = await acquirePersistentMobileBackend(
413
+ opts.url,
414
+ opts.recorder,
415
+ );
416
+ return {
417
+ mobile: wrapMobile(browser),
418
+ attached,
419
+ detach,
420
+ safeAreaInsets: browser.safeAreaInsets,
421
+ };
422
+ }
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
@@ -303,6 +367,7 @@ export interface EnvEvent extends BaseEvent {
303
367
  export type BrowserAction =
304
368
  | "navigate"
305
369
  | "evaluate"
370
+ | "addInitScript"
306
371
  | "waitFor"
307
372
  | "click"
308
373
  | "type"
@@ -663,6 +728,13 @@ export function recordEnv(
663
728
  return active() ? current!.push({ kind: "env", ...ev }, reservation) : undefined;
664
729
  }
665
730
 
731
+ export function recordEmail(
732
+ ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">,
733
+ reservation?: EventReservation,
734
+ ): number | undefined {
735
+ return active() ? current!.push({ kind: "email", ...ev }, reservation) : undefined;
736
+ }
737
+
666
738
  /**
667
739
  * Recorded once per `ctx.poll(...)` call. Stands in for the suppressed
668
740
  * intermediate iterations and gives downstream tagged values