@specific.dev/spectest 0.5.0 → 0.7.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.5.0",
3
+ "version": "0.7.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
@@ -73,10 +73,19 @@ interface BunWebViewCtor {
73
73
  declare const Bun: { WebView: BunWebViewCtor };
74
74
 
75
75
  export interface BrowserOptions {
76
- /** Viewport width in pixels. Default 1280. */
76
+ /** Viewport width in pixels. Default 1280. Ignored when `frame: "mobile"`
77
+ * (the device preset's viewport wins). */
77
78
  width?: number;
78
- /** Viewport height in pixels. Default 720. */
79
+ /** Viewport height in pixels. Default 720. Ignored when `frame: "mobile"`. */
79
80
  height?: number;
81
+ /**
82
+ * Which device frame this session represents. `"browser"` (default) is a
83
+ * desktop viewport rendered as a browser window in the replay; `"mobile"`
84
+ * emulates a phone (viewport + DPR + mobile UA + touch via CDP) and the
85
+ * replay wraps the capture in a phone bezel. The mobile device is fixed
86
+ * (latest iPhone) and not caller-configurable — see `ctx.mobile`.
87
+ */
88
+ frame?: "browser" | "mobile";
80
89
  /** Initial URL to navigate to before the constructor returns. */
81
90
  url?: string;
82
91
  /**
@@ -200,6 +209,27 @@ export interface Browser {
200
209
  close(): Promise<void>;
201
210
  }
202
211
 
212
+ /**
213
+ * A {@link Browser} with the lower-level touch primitives the mobile
214
+ * (`ctx.mobile`) facade is built on. Not exposed to test authors directly —
215
+ * `sdk/src/mobile.ts` wraps it in the ergonomic locator/gesture API. The
216
+ * touch ops dispatch real `Input.dispatchTouchEvent` sequences (so RN-Web's
217
+ * responder system sees genuine touches) and ride the same recorder + rrweb
218
+ * drain as the desktop verbs.
219
+ */
220
+ export interface MobileBackend extends Browser {
221
+ /** Touch-tap at viewport CSS coordinates (touchStart→touchEnd). */
222
+ tapAt(x: number, y: number): Promise<void>;
223
+ /** Touch-drag from (x,y) by (dx,dy) over a short move sequence. */
224
+ swipeBy(x: number, y: number, dx: number, dy: number): Promise<void>;
225
+ /**
226
+ * Evaluate a JS expression in the page WITHOUT recording an event or
227
+ * draining rrweb — the locator layer's internal element resolver. rrweb
228
+ * keeps buffering page-side; the next recorded op drains it.
229
+ */
230
+ probe<T = unknown>(expression: string): Promise<T>;
231
+ }
232
+
203
233
  // Default extra flags for headless Chromium inside a Firecracker microVM.
204
234
  // --no-sandbox: Chrome refuses to launch as root otherwise (no user
205
235
  // namespaces in the guest).
@@ -222,6 +252,65 @@ const CHROME_ARGV = [
222
252
  "--dns-over-https-mode=off",
223
253
  ];
224
254
 
255
+ // ────────────────────────────────────────────────────────────────────────
256
+ // Device emulation (mobile frame)
257
+ // ────────────────────────────────────────────────────────────────────────
258
+
259
+ /** A device descriptor in the shape of Playwright's `devices[...]` entries
260
+ * — the subset we feed to CDP. Not caller-configurable today; a single
261
+ * fixed preset (latest iPhone) backs every `ctx.mobile(...)` session. */
262
+ interface DevicePreset {
263
+ name: string;
264
+ viewport: { width: number; height: number };
265
+ deviceScaleFactor: number;
266
+ isMobile: boolean;
267
+ hasTouch: boolean;
268
+ userAgent: string;
269
+ }
270
+
271
+ /** The fixed mobile device. Logical resolution + DPR of a current iPhone;
272
+ * the UA mirrors what Playwright emits for iOS so RN-Web's mobile branches
273
+ * fire. We run Chromium under the hood, so the Safari UA is a deliberate
274
+ * emulation lie (same as every device-emulation tool). */
275
+ const LATEST_IPHONE: DevicePreset = {
276
+ name: "iPhone 15 Pro",
277
+ viewport: { width: 393, height: 852 },
278
+ deviceScaleFactor: 3,
279
+ isMobile: true,
280
+ hasTouch: true,
281
+ userAgent:
282
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " +
283
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
284
+ };
285
+
286
+ /** Apply CDP device emulation to a freshly-created view. The overrides are
287
+ * CDP-session-global, so they persist across the app navigation that
288
+ * follows (we set them on the about:blank bootstrap page). Best-effort:
289
+ * a CDP failure degrades to a plain desktop view rather than aborting. */
290
+ async function applyDeviceEmulation(
291
+ view: BunWebViewInstance,
292
+ d: DevicePreset,
293
+ ): Promise<void> {
294
+ try {
295
+ await view.cdp("Emulation.setDeviceMetricsOverride", {
296
+ width: d.viewport.width,
297
+ height: d.viewport.height,
298
+ deviceScaleFactor: d.deviceScaleFactor,
299
+ mobile: d.isMobile,
300
+ screenWidth: d.viewport.width,
301
+ screenHeight: d.viewport.height,
302
+ });
303
+ await view.cdp("Emulation.setUserAgentOverride", { userAgent: d.userAgent });
304
+ await view.cdp("Emulation.setTouchEmulationEnabled", {
305
+ enabled: d.hasTouch,
306
+ maxTouchPoints: 5,
307
+ });
308
+ } catch (err) {
309
+ // eslint-disable-next-line no-console
310
+ console.warn("[spectest] device emulation failed; using desktop view:", err);
311
+ }
312
+ }
313
+
225
314
  // ────────────────────────────────────────────────────────────────────────
226
315
  // rrweb bootstrap
227
316
  // ────────────────────────────────────────────────────────────────────────
@@ -572,12 +661,32 @@ export async function prewarmViewPool(n = 1): Promise<void> {
572
661
  * the pre-opened pool when the caller uses the default viewport.
573
662
  */
574
663
  export async function openBrowser(opts: BrowserOptions = {}): Promise<Browser> {
575
- const wantW = opts.width ?? 1280;
576
- const wantH = opts.height ?? 720;
664
+ return openMobileBackend(opts);
665
+ }
666
+
667
+ /**
668
+ * Like {@link openBrowser} but returns the {@link MobileBackend} superset
669
+ * (touch + probe). When `opts.frame === "mobile"` the view is created at the
670
+ * 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.
675
+ */
676
+ export async function openMobileBackend(
677
+ opts: BrowserOptions = {},
678
+ ): Promise<MobileBackend> {
679
+ const device = opts.frame === "mobile" ? LATEST_IPHONE : null;
680
+ const wantW = device ? device.viewport.width : opts.width ?? 1280;
681
+ const wantH = device ? device.viewport.height : opts.height ?? 720;
682
+ // Mobile views are never pooled — the pool holds only default-desktop
683
+ // views, and a mobile view needs its emulation applied fresh anyway.
577
684
  const pooled =
578
- wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
685
+ !device && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
579
686
  const { view, recordingInstalled } = pooled ?? (await createView(wantW, wantH));
580
687
 
688
+ if (device) await applyDeviceEmulation(view, device);
689
+
581
690
  const browser = wrapView(view, opts.recorder ?? null, recordingInstalled);
582
691
 
583
692
  // We deliberately don't forward `opts.url` to the constructor — going
@@ -593,7 +702,7 @@ function wrapView(
593
702
  view: BunWebViewInstance,
594
703
  recorder: BrowserSessionRecorder | null,
595
704
  recordingInstalled: boolean,
596
- ): Browser {
705
+ ): MobileBackend {
597
706
  let closed = false;
598
707
  const sessionStart = Date.now();
599
708
  let stepSeq = 0;
@@ -803,6 +912,44 @@ function wrapView(
803
912
  /* already closed by the runtime */
804
913
  }
805
914
  },
915
+ // ── Mobile-only primitives ──────────────────────────────────────────
916
+ tapAt(x, y) {
917
+ // Recorded as a "click" (the event schema stays desktop-shaped; the
918
+ // mobile facade owns the author-facing "tap" vocabulary). Dispatches
919
+ // a real touch so RN-Web's responder system fires.
920
+ return instrumented("click", { x, y }, async () => {
921
+ await view.cdp("Input.dispatchTouchEvent", {
922
+ type: "touchStart",
923
+ touchPoints: [{ x, y, id: 0 }],
924
+ });
925
+ await view.cdp("Input.dispatchTouchEvent", {
926
+ type: "touchEnd",
927
+ touchPoints: [],
928
+ });
929
+ });
930
+ },
931
+ swipeBy(x, y, dx, dy) {
932
+ return instrumented("scroll", { dx, dy }, async () => {
933
+ const steps = 8;
934
+ await view.cdp("Input.dispatchTouchEvent", {
935
+ type: "touchStart",
936
+ touchPoints: [{ x, y, id: 0 }],
937
+ });
938
+ for (let i = 1; i <= steps; i++) {
939
+ await view.cdp("Input.dispatchTouchEvent", {
940
+ type: "touchMove",
941
+ touchPoints: [{ x: x + (dx * i) / steps, y: y + (dy * i) / steps, id: 0 }],
942
+ });
943
+ }
944
+ await view.cdp("Input.dispatchTouchEvent", {
945
+ type: "touchEnd",
946
+ touchPoints: [],
947
+ });
948
+ });
949
+ },
950
+ probe<T = unknown>(expression: string): Promise<T> {
951
+ return view.evaluate<T>(expression);
952
+ },
806
953
  };
807
954
  }
808
955
 
@@ -0,0 +1,138 @@
1
+ // `expo()` — a ready-to-use service for an Expo / React Native app rendered
2
+ // to the web, built for `ctx.mobile(...)`.
3
+ //
4
+ // Serving model: STATIC EXPORT. The image runs `expo export -p web` at build
5
+ // time (producing `dist/`) and the container just serves those static files
6
+ // with a tiny Node server. No Metro at runtime — so the ready-check is fast
7
+ // and deterministic and the bundle rides the image-layer cache, instead of
8
+ // paying a non-deterministic first-request bundle on every boot.
9
+ //
10
+ // The service's `helpers` factory returns a `MobileApp` handle (the resolved
11
+ // in-VM URL), so a test drives it with `ctx.mobile(ctx.svc.app)` — no URL, no
12
+ // `navigate`.
13
+
14
+ import type { ServiceDefinition } from "../index.js";
15
+ import { mobileApp } from "../mobile.js";
16
+ import type { MobileApp } from "../mobile.js";
17
+
18
+ export interface ExpoOptions {
19
+ /** Port the static server listens on. Default `8081`. */
20
+ port?: number;
21
+ /**
22
+ * App directory inside the project, relative to the build context root.
23
+ * Default `"."` (the project root is the Expo app). Set this when the
24
+ * Expo app lives in a subdirectory of a larger repo.
25
+ */
26
+ appDir?: string;
27
+ /** Node base image tag. Default `"20-bookworm-slim"`. */
28
+ nodeVersion?: string;
29
+ /** Output dir `expo export` writes to (relative to the app dir). Default `"dist"`. */
30
+ outputDir?: string;
31
+ /** Extra environment variables for the static server container. */
32
+ env?: Record<string, string>;
33
+ }
34
+
35
+ /** The handle `expo()` exposes on `ctx.svc.<name>` — a {@link MobileApp}. */
36
+ export type ExpoHelpers = MobileApp;
37
+
38
+ // A dependency-free static file server with SPA fallback. Bind-mounted into
39
+ // the container (so it isn't baked into the image build) and run as the
40
+ // container command. Serves `dist/`, falling back to index.html for client
41
+ // routes. Embedded as a file rather than `npx serve` so the container needs
42
+ // no extra install at runtime.
43
+ const STATIC_SERVER = String.raw`import { createServer } from "node:http";
44
+ import { stat, readFile } from "node:fs/promises";
45
+ import { join, extname, normalize, resolve } from "node:path";
46
+
47
+ const root = resolve(process.argv[2] || "dist");
48
+ const port = Number(process.argv[3] || 8081);
49
+ const TYPES = {
50
+ ".html": "text/html; charset=utf-8",
51
+ ".js": "text/javascript; charset=utf-8",
52
+ ".mjs": "text/javascript; charset=utf-8",
53
+ ".css": "text/css; charset=utf-8",
54
+ ".json": "application/json; charset=utf-8",
55
+ ".map": "application/json; charset=utf-8",
56
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
57
+ ".gif": "image/gif", ".svg": "image/svg+xml", ".ico": "image/x-icon",
58
+ ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2",
59
+ ".ttf": "font/ttf", ".otf": "font/otf", ".wasm": "application/wasm",
60
+ };
61
+
62
+ async function send(res, fp) {
63
+ const body = await readFile(fp);
64
+ res.writeHead(200, { "content-type": TYPES[extname(fp)] || "application/octet-stream" });
65
+ res.end(body);
66
+ }
67
+
68
+ createServer(async (req, res) => {
69
+ try {
70
+ let p = decodeURIComponent((req.url || "/").split("?")[0]);
71
+ if (p.endsWith("/")) p += "index.html";
72
+ let fp = normalize(join(root, p));
73
+ if (!fp.startsWith(root)) { res.writeHead(403); return res.end("forbidden"); }
74
+ try {
75
+ const s = await stat(fp);
76
+ if (s.isDirectory()) fp = join(fp, "index.html");
77
+ await send(res, fp);
78
+ } catch {
79
+ // SPA fallback: unknown path → index.html.
80
+ await send(res, join(root, "index.html"));
81
+ }
82
+ } catch (e) {
83
+ res.writeHead(500);
84
+ res.end(String(e));
85
+ }
86
+ }).listen(port, "0.0.0.0", () => console.log("[expo-static] serving " + root + " on :" + port));
87
+ `;
88
+
89
+ /**
90
+ * A ready-to-use Expo (web) service. Drop into `environment.services` and
91
+ * drive it with `ctx.mobile`:
92
+ *
93
+ * ```ts
94
+ * services: { app: expo() }
95
+ * // in a test:
96
+ * const m = await ctx.mobile(ctx.svc.app);
97
+ * await m.getByText("Sign in").tap();
98
+ * ```
99
+ *
100
+ * The web build is produced once at image-build time; the container serves
101
+ * the static bundle. `ctx.svc.<key>` is the app's `MobileApp` handle.
102
+ */
103
+ export function expo(opts: ExpoOptions = {}) {
104
+ const port = opts.port ?? 8081;
105
+ const appDir = opts.appDir ?? ".";
106
+ const nodeVersion = opts.nodeVersion ?? "20-bookworm-slim";
107
+ const outputDir = opts.outputDir ?? "dist";
108
+
109
+ // CI=1 keeps `expo export` non-interactive. Static export resolves the web
110
+ // bundle deterministically into `outputDir`.
111
+ const dockerfile = `FROM node:${nodeVersion}
112
+ WORKDIR /app
113
+ ENV CI=1
114
+ COPY ${appDir}/ ./
115
+ RUN npm install
116
+ RUN npx expo export --platform web --output-dir ${outputDir}
117
+ `;
118
+
119
+ return {
120
+ image: { type: "dockerfile", content: dockerfile },
121
+ files: [
122
+ { path: "/spectest-expo-serve.mjs", content: STATIC_SERVER },
123
+ ],
124
+ command: `node /spectest-expo-serve.mjs /app/${outputDir} ${port}`,
125
+ env: { ...(opts.env ?? {}) },
126
+ ports: [port],
127
+ readyCheck: { type: "http" as const, port, path: "/", timeoutSecs: 60 },
128
+ helpers: ({ name }: { name: string }): ExpoHelpers => {
129
+ // Use the multi-label `<name>.internal` alias (the daemon registers it
130
+ // for every service) rather than the bare single-label `<name>`:
131
+ // headless Chromium mishandles a single-label http navigation (it tries
132
+ // TLS → net::ERR_SSL_PROTOCOL_ERROR), while a dotted host navigates as
133
+ // plain HTTP. Node-side `fetch` is unaffected, but the mobile session
134
+ // drives a real browser, so the URL must be browser-navigable.
135
+ return mobileApp(`http://${name}.internal:${port}`);
136
+ },
137
+ } satisfies ServiceDefinition<ExpoHelpers>;
138
+ }
@@ -18,6 +18,11 @@ export {
18
18
  type K3sHelpers,
19
19
  type K3sClient,
20
20
  } from "./k3s.js";
21
+ export {
22
+ expo,
23
+ type ExpoOptions,
24
+ type ExpoHelpers,
25
+ } from "./expo.js";
21
26
  export {
22
27
  replayFake,
23
28
  type ReplayFakeOptions,
package/src/daemon.ts CHANGED
@@ -24,9 +24,19 @@ import net from "node:net";
24
24
  import path from "node:path";
25
25
  import { pathToFileURL } from "node:url";
26
26
 
27
- import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard } from "./index.js";
27
+ import {
28
+ assert,
29
+ expect,
30
+ expectRaw,
31
+ lowerIngress,
32
+ dnsName as makeDnsDecl,
33
+ isWildcard,
34
+ proxy as makeProxyDecl,
35
+ } from "./index.js";
28
36
  import type { DnsTarget, LoweredIngress } from "./index.js";
29
37
  import { openBrowser } from "./browser.js";
38
+ import { openMobile, isMobileApp } from "./mobile.js";
39
+ import type { Mobile, MobileApp } from "./mobile.js";
30
40
  import { openTerminal } from "./terminal.js";
31
41
  import {
32
42
  recordEnv,
@@ -1220,6 +1230,24 @@ const INGRESS_HTTP_SERVERS = new Map<number, any>();
1220
1230
  /** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
1221
1231
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1222
1232
  const INGRESS_HTTPS_SERVERS = new Map<number, any>();
1233
+ /**
1234
+ * Live per-port route tables, keyed by listen port (80 / 443 / fake ports).
1235
+ * Each listener's `fetch` closure captures *this* Map object, so adding an
1236
+ * entry takes effect immediately with no rebind — that's what lets a runtime
1237
+ * `tls` (a `ctx.startService({ tls })`) bind a new ingress route after boot.
1238
+ * Held at module scope so it's part of the live daemon process and forks
1239
+ * with the snapshot, exactly like fake state / the names REGISTRY. Rebuilt on
1240
+ * /load (cleared by {@link stopIngressServers}).
1241
+ */
1242
+ const INGRESS_ROUTES_BY_PORT = new Map<number, Map<string, Route>>();
1243
+ /**
1244
+ * The :443 SNI cert table: serverName → leaf. Unlike the route table, Bun's
1245
+ * TLS config is fixed at `Bun.serve` time (reload won't add an SNI entry), so
1246
+ * minting a cert for a *new* hostname requires rebinding the :443 listener
1247
+ * (cheap, ~1ms — see {@link rebindHttpsListener}). A hostname already covered
1248
+ * by an existing exact or wildcard cert needs no rebind, just a route entry.
1249
+ */
1250
+ const HTTPS_CERT_BY_HOST = new Map<string, { cert: string; key: string }>();
1223
1251
 
1224
1252
  /**
1225
1253
  * Tear down listener servers between /load calls so the new project's
@@ -1244,6 +1272,8 @@ function stopIngressServers(): void {
1244
1272
  }
1245
1273
  }
1246
1274
  INGRESS_HTTPS_SERVERS.clear();
1275
+ INGRESS_ROUTES_BY_PORT.clear();
1276
+ HTTPS_CERT_BY_HOST.clear();
1247
1277
  }
1248
1278
 
1249
1279
  function buildIngress(project: Project): void {
@@ -1439,12 +1469,11 @@ async function startIngress(): Promise<void> {
1439
1469
  // before binding so the HTTPS listener has certs ready and a startup
1440
1470
  // failure aborts /bootstrap cleanly.
1441
1471
  const caPresent = existsSync(CA_PATH) && existsSync(CA_KEY_PATH);
1442
- const certByHost = new Map<string, { cert: string; key: string }>();
1443
1472
  if (caPresent) {
1444
1473
  for (const group of LOWERED.certificates) {
1445
1474
  if (group.hostnames.length === 0) continue;
1446
1475
  const leaf = await generateHostCert(group.hostnames[0], group.hostnames);
1447
- for (const h of group.hostnames) certByHost.set(h, leaf);
1476
+ for (const h of group.hostnames) HTTPS_CERT_BY_HOST.set(h, leaf);
1448
1477
  }
1449
1478
  } else if (LOWERED.certificates.length > 0) {
1450
1479
  // eslint-disable-next-line no-console
@@ -1453,73 +1482,49 @@ async function startIngress(): Promise<void> {
1453
1482
  );
1454
1483
  }
1455
1484
 
1456
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1457
- const Bun = (globalThis as any).Bun;
1458
- if (!Bun?.serve) {
1459
- throw new Error(
1460
- "ingress requires Bun.serve; the daemon must run under Bun (it does in-VM)",
1461
- );
1462
- }
1463
-
1464
- // Resolve every ingress hostname to its handler. Fakes run an in-daemon
1465
- // handler; proxies reverse-proxy to a service:port. This single table
1466
- // drives both the HTTP and HTTPS listeners.
1467
- const routeByHost = new Map<string, Route>();
1468
- for (const fake of FAKES.values()) {
1469
- for (const h of fake.hostnames) routeByHost.set(h, { kind: "fake", fake });
1470
- }
1471
- for (const p of LOWERED.proxies) {
1472
- routeByHost.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
1473
- }
1485
+ const Bun = requireBun();
1474
1486
 
1475
- // ── HTTP listeners: group routes by port, dispatch per-request by Host.
1476
- // Proxies bind :80 (HTTPS, if any, is on :443); fakes use their
1477
- // declared port. Skip :443 in the HTTP map — HTTPS wins.
1478
- const httpRoutesByPort = new Map<number, Map<string, Route>>();
1487
+ // Resolve every ingress hostname to its handler into the live per-port
1488
+ // route tables (module scope, so runtime `tls` can extend them later).
1489
+ // Fakes run an in-daemon handler on their declared port; proxies
1490
+ // reverse-proxy to a service:port and bind :80 (HTTPS, if any, is :443).
1479
1491
  const ensurePort = (port: number): Map<string, Route> => {
1480
- const m = httpRoutesByPort.get(port) ?? new Map<string, Route>();
1481
- httpRoutesByPort.set(port, m);
1492
+ const m = INGRESS_ROUTES_BY_PORT.get(port) ?? new Map<string, Route>();
1493
+ INGRESS_ROUTES_BY_PORT.set(port, m);
1482
1494
  return m;
1483
1495
  };
1484
1496
  for (const fake of FAKES.values()) {
1485
1497
  if (fake.port === INGRESS_HTTPS_PORT) continue;
1486
1498
  const routes = ensurePort(fake.port);
1487
- for (const h of fake.hostnames) routes.set(h, routeByHost.get(h)!);
1499
+ for (const h of fake.hostnames) routes.set(h, { kind: "fake", fake });
1488
1500
  }
1489
1501
  if (LOWERED.proxies.length > 0) {
1490
1502
  const routes = ensurePort(INGRESS_HTTP_PORT);
1491
- for (const p of LOWERED.proxies) routes.set(p.hostname, routeByHost.get(p.hostname)!);
1503
+ for (const p of LOWERED.proxies) {
1504
+ routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
1505
+ }
1492
1506
  }
1493
- for (const [port, byHost] of httpRoutesByPort) {
1494
- const label = `port ${port}`;
1495
- INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, label));
1496
- const hosts = [...byHost.keys()].join(", ");
1497
- // eslint-disable-next-line no-console
1498
- console.log(`[ingress] http :${port} for ${hosts}`);
1507
+ // The :443 route table mirrors every certificated hostname's handler.
1508
+ if (HTTPS_CERT_BY_HOST.size > 0) {
1509
+ const httpsRoutes = ensurePort(INGRESS_HTTPS_PORT);
1510
+ for (const h of HTTPS_CERT_BY_HOST.keys()) {
1511
+ for (const fake of FAKES.values()) {
1512
+ if (fake.hostnames.includes(h)) httpsRoutes.set(h, { kind: "fake", fake });
1513
+ }
1514
+ const proxy = LOWERED.proxies.find((p) => p.hostname === h);
1515
+ if (proxy) httpsRoutes.set(h, { kind: "proxy", service: proxy.service, port: proxy.port });
1516
+ }
1499
1517
  }
1500
1518
 
1501
- // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
1502
- if (certByHost.size > 0) {
1503
- const tlsEntries: Array<{ cert: string; key: string; serverName: string }> = [];
1504
- const byHostHttps = new Map<string, Route>();
1505
- for (const [h, leaf] of certByHost) {
1506
- tlsEntries.push({ cert: leaf.cert, key: leaf.key, serverName: h });
1507
- const route = routeByHost.get(h);
1508
- if (route) byHostHttps.set(h, route);
1509
- }
1510
- const label = `https :${INGRESS_HTTPS_PORT}`;
1511
- const server = bindIngressServer(
1512
- Bun,
1513
- INGRESS_HTTPS_PORT,
1514
- byHostHttps,
1515
- label,
1516
- tlsEntries,
1517
- );
1518
- INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
1519
- const hosts = [...byHostHttps.keys()].join(", ");
1519
+ // ── HTTP listeners (one per non-443 port).
1520
+ for (const [port, byHost] of INGRESS_ROUTES_BY_PORT) {
1521
+ if (port === INGRESS_HTTPS_PORT) continue;
1522
+ INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, `port ${port}`));
1520
1523
  // eslint-disable-next-line no-console
1521
- console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${hosts}`);
1524
+ console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
1522
1525
  }
1526
+ // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
1527
+ if (HTTPS_CERT_BY_HOST.size > 0) rebindHttpsListener(Bun);
1523
1528
 
1524
1529
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
1525
1530
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
@@ -1528,6 +1533,148 @@ async function startIngress(): Promise<void> {
1528
1533
  await seedNamesRegistry({ servicesUp: false });
1529
1534
  }
1530
1535
 
1536
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1537
+ function requireBun(): any {
1538
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1539
+ const Bun = (globalThis as any).Bun;
1540
+ if (!Bun?.serve) {
1541
+ throw new Error(
1542
+ "ingress requires Bun.serve; the daemon must run under Bun (it does in-VM)",
1543
+ );
1544
+ }
1545
+ return Bun;
1546
+ }
1547
+
1548
+ /** Flatten {@link HTTPS_CERT_BY_HOST} into Bun's TLS-entry SNI array. */
1549
+ function tlsEntriesFromCerts(): Array<{ cert: string; key: string; serverName: string }> {
1550
+ return [...HTTPS_CERT_BY_HOST].map(([serverName, leaf]) => ({
1551
+ cert: leaf.cert,
1552
+ key: leaf.key,
1553
+ serverName,
1554
+ }));
1555
+ }
1556
+
1557
+ /**
1558
+ * (Re)bind the :443 listener from the current cert table + route map. Bun's
1559
+ * TLS config is immutable per `Bun.serve`, so adding an SNI cert means
1560
+ * stopping the old listener and serving a fresh one — cheap (~1ms) and the
1561
+ * window is sub-millisecond. The route Map is the persistent module object,
1562
+ * so the new listener closes over the same table (later route additions need
1563
+ * no rebind). No-ops to a plain rebind when only routes changed.
1564
+ */
1565
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1566
+ function rebindHttpsListener(Bun: any): void {
1567
+ const routes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT) ?? new Map<string, Route>();
1568
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTPS_PORT, routes);
1569
+ const old = INGRESS_HTTPS_SERVERS.get(INGRESS_HTTPS_PORT);
1570
+ if (old) {
1571
+ try {
1572
+ old.stop(true);
1573
+ } catch (err) {
1574
+ // eslint-disable-next-line no-console
1575
+ console.warn("[ingress] failed to stop https listener for rebind:", err);
1576
+ }
1577
+ }
1578
+ const server = bindIngressServer(
1579
+ Bun,
1580
+ INGRESS_HTTPS_PORT,
1581
+ routes,
1582
+ `https :${INGRESS_HTTPS_PORT}`,
1583
+ tlsEntriesFromCerts(),
1584
+ );
1585
+ INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
1586
+ // eslint-disable-next-line no-console
1587
+ console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
1588
+ }
1589
+
1590
+ /** True if an exact or wildcard cert already covers `hostname` for SNI. */
1591
+ function certCovers(hostname: string): boolean {
1592
+ if (HTTPS_CERT_BY_HOST.has(hostname)) return true;
1593
+ for (const serverName of HTTPS_CERT_BY_HOST.keys()) {
1594
+ if (isWildcard(serverName) && hostname.endsWith(wildcardSuffix(serverName))) {
1595
+ return true;
1596
+ }
1597
+ }
1598
+ return false;
1599
+ }
1600
+
1601
+ /**
1602
+ * Bind a runtime ingress route for one `tls: [{ hostname, port }]` entry on a
1603
+ * {@link RuntimeServiceSpec} — the runtime twin of a boot service's `tls`.
1604
+ * Mints a leaf cert (unless one already covers the hostname), stands up a
1605
+ * TLS-terminating reverse proxy at `https://<hostname>/` → `service:port`,
1606
+ * also serves plain `http://<hostname>/`, and points the hostname at the
1607
+ * daemon gateway in the resolver registry. Idempotent per hostname.
1608
+ *
1609
+ * Everything it mutates (the live route tables, the :443 cert table, the
1610
+ * names REGISTRY) is daemon-process state, so the binding forks with the
1611
+ * per-test snapshot exactly like fake state — a `dependsOn` child inherits
1612
+ * it, siblings forked from an earlier snapshot never see it.
1613
+ */
1614
+ async function bindRuntimeTls(hostname: string, service: string, port: number): Promise<void> {
1615
+ // Reuse the boot primitives for validation + lowercasing; throws on a
1616
+ // malformed hostname / upstream just like a boot `tls` would at load.
1617
+ const decl = makeProxyDecl(hostname, { service, port });
1618
+ const host = decl.hostname;
1619
+ if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
1620
+ throw new Error(
1621
+ `runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`,
1622
+ );
1623
+ }
1624
+ const Bun = requireBun();
1625
+ const route: Route = { kind: "proxy", service, port };
1626
+
1627
+ // Plain HTTP on :80 (parity with boot `tls`, which serves both schemes).
1628
+ let httpRoutes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT);
1629
+ if (!httpRoutes) {
1630
+ httpRoutes = new Map<string, Route>();
1631
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTP_PORT, httpRoutes);
1632
+ }
1633
+ httpRoutes.set(host, route);
1634
+ if (!INGRESS_HTTP_SERVERS.has(INGRESS_HTTP_PORT)) {
1635
+ INGRESS_HTTP_SERVERS.set(
1636
+ INGRESS_HTTP_PORT,
1637
+ bindIngressServer(Bun, INGRESS_HTTP_PORT, httpRoutes, `port ${INGRESS_HTTP_PORT}`),
1638
+ );
1639
+ }
1640
+
1641
+ // HTTPS on :443. A new cert forces a listener rebind; an already-covered
1642
+ // hostname (exact dup or a boot wildcard) just needs the route entry.
1643
+ const needCert = !certCovers(host);
1644
+ if (needCert) {
1645
+ HTTPS_CERT_BY_HOST.set(host, await generateHostCert(host, [host]));
1646
+ }
1647
+ const httpsRoutes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT) ?? new Map<string, Route>();
1648
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTPS_PORT, httpsRoutes);
1649
+ httpsRoutes.set(host, route);
1650
+ if (needCert || !INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT)) {
1651
+ rebindHttpsListener(Bun);
1652
+ }
1653
+
1654
+ // Resolve the hostname to the daemon gateway (where :443/:80 listen).
1655
+ const gw = await bridgeGatewayIp();
1656
+ REGISTRY.hosts[host] = gw;
1657
+ await writeRegistry();
1658
+ // eslint-disable-next-line no-console
1659
+ console.log(`[ingress] runtime https ${host} -> ${service}:${port}`);
1660
+ }
1661
+
1662
+ /**
1663
+ * Undo {@link bindRuntimeTls} for one hostname when its runtime service is
1664
+ * stopped: drop the route (so it 404s) and the registry entry. The cert is
1665
+ * left in the SNI table — harmless without a route, and removing it would
1666
+ * mean an avoidable :443 rebind.
1667
+ */
1668
+ async function unbindRuntimeTls(hostname: string): Promise<void> {
1669
+ const host = hostname.toLowerCase();
1670
+ INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT)?.delete(host);
1671
+ INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT)?.delete(host);
1672
+ if (host in REGISTRY.hosts) {
1673
+ delete REGISTRY.hosts[host];
1674
+ await writeRegistry();
1675
+ }
1676
+ }
1677
+
1531
1678
  /**
1532
1679
  * Spin up one Bun.serve listener bound to (port, optional TLS) that
1533
1680
  * dispatches every request to the matching Route by Host header.
@@ -2018,6 +2165,11 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
2018
2165
  await runContainer(svc, tag, flags, aliases);
2019
2166
  await waitForReady(svc);
2020
2167
  const ip = (await serviceContainerIp(svc.name)) ?? "";
2168
+ // `tls` is the runtime twin of a boot service's: stand up a
2169
+ // TLS-terminating reverse proxy at https://<hostname>/ → this container.
2170
+ for (const entry of svc.tls ?? []) {
2171
+ await bindRuntimeTls(entry.hostname, svc.name, entry.port);
2172
+ }
2021
2173
  RUNTIME_SERVICES.set(svc.name, svc);
2022
2174
  recordEnv({
2023
2175
  op: "startService",
@@ -2044,8 +2196,10 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
2044
2196
  async function stopRuntimeService(name: string): Promise<void> {
2045
2197
  const t0 = Date.now();
2046
2198
  const resv = reserveEvent();
2199
+ const svc = RUNTIME_SERVICES.get(name);
2047
2200
  await docker(["rm", "-f", name], 30_000);
2048
2201
  RUNTIME_SERVICES.delete(name);
2202
+ for (const entry of svc?.tls ?? []) await unbindRuntimeTls(entry.hostname);
2049
2203
  recordEnv({ op: "stopService", service: name, durationMs: Date.now() - t0 }, resv);
2050
2204
  }
2051
2205
 
@@ -2521,6 +2675,10 @@ interface BrowserSessionRecord {
2521
2675
  openedAtMs: number;
2522
2676
  closedAtMs?: number;
2523
2677
  initialUrl?: string;
2678
+ /** Replay frame: `"browser"` (desktop window, default) or `"mobile"`
2679
+ * (phone bezel). Drives the dashboard's chrome; the rrweb events are
2680
+ * identical either way. */
2681
+ frame?: "browser" | "mobile";
2524
2682
  steps: BrowserSessionStep[];
2525
2683
  }
2526
2684
 
@@ -2546,7 +2704,11 @@ function newSessionId(idScope: string): string {
2546
2704
  * The returned `recorder` is what `openBrowser` writes into; the
2547
2705
  * returned `record` is the in-flight session object the daemon owns.
2548
2706
  */
2549
- function newBrowserSession(testStart: number, idScope: string): {
2707
+ function newBrowserSession(
2708
+ testStart: number,
2709
+ idScope: string,
2710
+ frame: "browser" | "mobile" = "browser",
2711
+ ): {
2550
2712
  recorder: BrowserSessionRecorder;
2551
2713
  record: BrowserSessionRecord;
2552
2714
  markClosed(): void;
@@ -2554,6 +2716,7 @@ function newBrowserSession(testStart: number, idScope: string): {
2554
2716
  const record: BrowserSessionRecord = {
2555
2717
  sessionId: newSessionId(idScope),
2556
2718
  openedAtMs: Date.now() - testStart,
2719
+ frame,
2557
2720
  steps: [],
2558
2721
  };
2559
2722
  let closed = false;
@@ -3252,7 +3415,9 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3252
3415
  // and chew memory across forks. Each Browser also gets a session
3253
3416
  // recorder; the records flow back to the control plane as part of
3254
3417
  // RunResult.browserSessions and are persisted to SQLite.
3255
- const openBrowsers: Browser[] = [];
3418
+ // Tracks both Browser and Mobile handles for cleanup — both expose an
3419
+ // async close() that does the final rrweb drain before teardown.
3420
+ const openBrowsers: Array<{ close(): Promise<void> }> = [];
3256
3421
  const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
3257
3422
  const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
3258
3423
  const session = newBrowserSession(start, testCase.id);
@@ -3261,6 +3426,18 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3261
3426
  openBrowsers.push(b);
3262
3427
  return b;
3263
3428
  };
3429
+ const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
3430
+ if (!isMobileApp(app)) {
3431
+ throw new Error(
3432
+ "ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().",
3433
+ );
3434
+ }
3435
+ const session = newBrowserSession(start, testCase.id, "mobile");
3436
+ sessions.push(session);
3437
+ const m = await openMobile({ url: app.url, recorder: session.recorder });
3438
+ openBrowsers.push(m);
3439
+ return m;
3440
+ };
3264
3441
 
3265
3442
  // Build convenience handles (e.g. ctx.svc.db.client) from the loaded
3266
3443
  // project. Done before installing the timeout so a slow client factory
@@ -3281,6 +3458,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3281
3458
  terminal: recordedTerminal as unknown as TestContext<unknown>["terminal"],
3282
3459
  openTerminal: recordedOpenTerminal,
3283
3460
  browser: trackedOpenBrowser,
3461
+ mobile: trackedOpenMobile,
3284
3462
  testName: testCase.name,
3285
3463
  parent,
3286
3464
  svc,
@@ -3509,7 +3687,7 @@ async function evalCode(
3509
3687
  // wrapped type is honest at runtime). Restored in the `finally` below.
3510
3688
  const restoreFetch = installFetchWrapper();
3511
3689
 
3512
- const openBrowsers: Browser[] = [];
3690
+ const openBrowsers: Array<{ close(): Promise<void> }> = [];
3513
3691
  const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
3514
3692
  const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
3515
3693
  const session = newBrowserSession(start, "eval");
@@ -3518,6 +3696,18 @@ async function evalCode(
3518
3696
  openBrowsers.push(b);
3519
3697
  return b;
3520
3698
  };
3699
+ const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
3700
+ if (!isMobileApp(app)) {
3701
+ throw new Error(
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().",
3703
+ );
3704
+ }
3705
+ const session = newBrowserSession(start, "eval", "mobile");
3706
+ sessions.push(session);
3707
+ const m = await openMobile({ url: app.url, recorder: session.recorder });
3708
+ openBrowsers.push(m);
3709
+ return m;
3710
+ };
3521
3711
 
3522
3712
  // Terminal sessions — same shape as runOne, but eval has no active
3523
3713
  // recorder so we don't emit inline events; the asciicast frames
@@ -3580,6 +3770,7 @@ async function evalCode(
3580
3770
  terminal: evalTerminal as unknown as TestContext<undefined>["terminal"],
3581
3771
  openTerminal: evalOpenTerminal,
3582
3772
  browser: trackedOpenBrowser,
3773
+ mobile: trackedOpenMobile,
3583
3774
  testName: "eval",
3584
3775
  parent: undefined,
3585
3776
  svc,
package/src/index.ts CHANGED
@@ -42,6 +42,13 @@ export type {
42
42
 
43
43
  import type { Browser, BrowserOptions } from "./browser.js";
44
44
 
45
+ // Mobile (Expo / React Native Web) surface: a phone-emulated session driven
46
+ // with locators + touch gestures, replayed inside a phone bezel. Opened with
47
+ // `ctx.mobile(ctx.svc.app)` where the app is registered via the `expo()`
48
+ // component.
49
+ export type { Mobile, MobileLocator, MobileApp } from "./mobile.js";
50
+ import type { Mobile, MobileApp } from "./mobile.js";
51
+
45
52
  export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
46
53
 
47
54
  import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
@@ -341,22 +348,28 @@ export type ReadyCheck =
341
348
  /**
342
349
  * Spec for a service started at runtime via {@link TestContext.startService}
343
350
  * (or the `ctx` handed to a fake). It's a normal {@link ServiceConfig} plus a
344
- * required `name`, minus the two fields that only make sense at boot:
345
- *
346
- * - `tls` — runtime services are reached *directly* by their own IP on
347
- * `spectest-net` (like a real machine on a network), not through the
348
- * daemon's HTTP ingress, so there's no proxy/cert to configure. Map a
349
- * friendly DNS name onto one with {@link TestContext.dnsName}.
350
- * - `dependsOn` — there is no boot DAG at runtime; the caller orders
351
- * `startService` calls itself with `await`.
351
+ * required `name`, minus only `dependsOn` (there is no boot DAG at runtime;
352
+ * the caller orders `startService` calls itself with `await`).
352
353
  *
353
354
  * The container joins `spectest-net` with its own IP and is resolvable by
354
355
  * `name` (single-label, via the resolver / docker embedded DNS) and by any
355
356
  * `hostnames` (extra `--network-alias`es). Like everything else in the VM it
356
357
  * is captured by the per-test post-state snapshot, so a `dependsOn` child
357
358
  * inherits the live container while siblings never see it.
359
+ *
360
+ * `tls: [{ hostname, port }]` works exactly as it does for a boot service:
361
+ * the daemon mints a leaf cert from the in-VM root CA and stands up a
362
+ * TLS-terminating reverse proxy at `https://<hostname>/` (and plain
363
+ * `http://<hostname>/`) → the container's `port`, binding it onto the live
364
+ * `:443`/`:80` ingress listeners the moment the container is ready. The
365
+ * hostname resolves to the daemon gateway for tests, `ctx.browser()`, and
366
+ * peer containers. Because the route lives in daemon memory (and the
367
+ * resolver registry file), it forks with the per-test snapshot like fake
368
+ * state, and is torn down when the service is stopped. This lets a runtime
369
+ * provider mint a CA-trusted HTTPS endpoint on demand — e.g. a per-DB proxy
370
+ * the Neon serverless driver reaches at its *default* `https://<host>/sql`.
358
371
  */
359
- export interface RuntimeServiceSpec extends Omit<ServiceConfig, "tls" | "dependsOn"> {
372
+ export interface RuntimeServiceSpec extends Omit<ServiceConfig, "dependsOn"> {
360
373
  /**
361
374
  * Container name and primary DNS name on `spectest-net`. Must be unique
362
375
  * within the current fork — generate a fresh one per provisioned instance
@@ -602,6 +615,26 @@ export interface TestContext<
602
615
  * release earlier if you're opening many.
603
616
  */
604
617
  browser(opts?: BrowserOptions): Promise<Browser>;
618
+ /**
619
+ * Open a phone-emulated session for a mobile app and return a {@link Mobile}
620
+ * handle already pointed at it — no `navigate`. Pass the app handle a
621
+ * mobile-app component exposes on `ctx.svc`, e.g. a service declared with
622
+ * `expo()`:
623
+ *
624
+ * ```ts
625
+ * services: { app: expo() }
626
+ * // in a test:
627
+ * const m = await ctx.mobile(ctx.svc.app);
628
+ * await m.getByTestId("email").typeText("a@b.com");
629
+ * await m.getByText("Sign in").tap();
630
+ * await m.getByText(/Welcome/).assertVisible();
631
+ * ```
632
+ *
633
+ * The session emulates the latest iPhone (viewport + DPR + mobile UA +
634
+ * touch) and the dashboard replays it inside a phone bezel. Auto-closed
635
+ * when the test finishes.
636
+ */
637
+ mobile(app: MobileApp): Promise<Mobile>;
605
638
  /** The test's display name. */
606
639
  readonly testName: string;
607
640
  /**
package/src/inspect.ts CHANGED
@@ -44,8 +44,20 @@
44
44
  // `Carrier<T>` honest at runtime everywhere, instead of silently collapsing to
45
45
  // a raw value wherever recording happened to be off.
46
46
 
47
- export const OP_TAG = Symbol("spectest.opTag");
48
- export const UNWRAP = Symbol("spectest.unwrap");
47
+ // Registered in the GLOBAL symbol registry (`Symbol.for`), not module-private
48
+ // (`Symbol`), so a value tagged by ONE copy of this module unwraps correctly in
49
+ // ANOTHER. This matters whenever two copies of the SDK load in the same runtime:
50
+ // the in-VM daemon runs the baked SDK at /opt/spectest/sdk and mints the
51
+ // provenance carriers (ctx.fetch / ctx.browser / db results), while a user's
52
+ // test file resolving `@specific.dev/spectest` to a registry-pinned copy gets
53
+ // its `expect`. With module-private symbols the two `UNWRAP`s differed, so
54
+ // `expect`'s auto-unwrap silently no-op'd and wrapped assertions failed
55
+ // nonsensically (`expect("Hello Spec").toContain("Hello")` -> false). The
56
+ // control plane also repoints the dep at the baked copy so normally only one
57
+ // copy loads; this is defense-in-depth for the cases where it can't (and only
58
+ // takes full effect once both copies ship this `Symbol.for`).
59
+ export const OP_TAG = Symbol.for("spectest.opTag");
60
+ export const UNWRAP = Symbol.for("spectest.unwrap");
49
61
 
50
62
  // The escape hatch from a wrapped value to its raw form is the `.unwrap()`
51
63
  // method that lives on the `Carrier` / `WrappedObject` / `WrappedArray` /
package/src/mobile.ts ADDED
@@ -0,0 +1,379 @@
1
+ // Mobile app handle for tests — drives an Expo/React-Native-Web app rendered
2
+ // in a phone-emulated headless Chromium and recorded as an rrweb session that
3
+ // the dashboard replays inside a phone bezel.
4
+ //
5
+ // `ctx.mobile(ctx.svc.app)` opens one of these already pointed at the app, so
6
+ // there's no `navigate`. The surface is mobile-native (tap/typeText/swipe,
7
+ // select-then-act locators that lean on testID) rather than the desktop
8
+ // `Browser` verbs — see DESIGN. Under the hood it's a thin facade over a
9
+ // `MobileBackend` (browser.ts): the heavy lifting (CDP device emulation, the
10
+ // recorder, rrweb capture/drain) is shared with the desktop browser; this
11
+ // file only adds the locator/gesture ergonomics.
12
+ //
13
+ // React Native Web renders `testID="x"` to `data-testid="x"` and
14
+ // `accessibilityLabel` to `aria-label`, so `getByTestId`/`getByLabel` map to
15
+ // plain DOM attribute selectors with no shimming.
16
+
17
+ import { openMobileBackend } from "./browser.js";
18
+ import type {
19
+ BrowserSessionRecorder,
20
+ MobileBackend,
21
+ ScreenshotOptions,
22
+ } from "./browser.js";
23
+ import type { Wrapped } from "./inspect.js";
24
+
25
+ /** Branded handle a mobile-app component (e.g. `expo()`) exposes on
26
+ * `ctx.svc.<name>`. The brand is a `Symbol.for` key so `JSON.stringify`
27
+ * drops it (wire-invisible) while `ctx.mobile(...)` can still type-check
28
+ * against it. */
29
+ export const MOBILE_APP: unique symbol = Symbol.for("spectest.mobileApp");
30
+
31
+ /** What `ctx.mobile(...)` accepts — produced by a mobile-app component's
32
+ * `helpers` factory. Carries the in-VM URL the session auto-navigates to. */
33
+ export interface MobileApp {
34
+ readonly [MOBILE_APP]: true;
35
+ /** Resolved in-VM URL of the app's web build (e.g. `http://app.internal:8081`). */
36
+ readonly url: string;
37
+ }
38
+
39
+ /** True if `x` is a {@link MobileApp} handle. */
40
+ export function isMobileApp(x: unknown): x is MobileApp {
41
+ return (
42
+ typeof x === "object" &&
43
+ x !== null &&
44
+ (x as Record<symbol, unknown>)[MOBILE_APP] === true &&
45
+ typeof (x as { url?: unknown }).url === "string"
46
+ );
47
+ }
48
+
49
+ /** Build a {@link MobileApp} handle from a resolved URL. */
50
+ export function mobileApp(url: string): MobileApp {
51
+ return { [MOBILE_APP]: true, url };
52
+ }
53
+
54
+ // ────────────────────────────────────────────────────────────────────────
55
+ // Locators
56
+ // ────────────────────────────────────────────────────────────────────────
57
+
58
+ interface LocatorDesc {
59
+ kind: "testid" | "text" | "role" | "label" | "css";
60
+ value: string;
61
+ exact?: boolean;
62
+ regex?: { source: string; flags: string };
63
+ name?: string;
64
+ nameExact?: boolean;
65
+ }
66
+
67
+ /** A lazy reference to a single element, resolved (with auto-wait) at the
68
+ * moment an action runs. Mirrors the select-then-act idiom shared by
69
+ * Playwright, Detox, and RN Testing Library. */
70
+ export interface MobileLocator {
71
+ /** Wait for the element to be visible, then touch-tap its center. */
72
+ tap(): Promise<void>;
73
+ /** Tap to focus, then type `text` via real key events. */
74
+ typeText(text: string): Promise<void>;
75
+ /** Clear a text input's current value (RN-Web controlled input safe). */
76
+ clearText(): Promise<void>;
77
+ /** Scroll the element to the center of the viewport. */
78
+ scrollIntoView(): Promise<void>;
79
+ /** Wait until the element is attached and visible (throws on timeout). */
80
+ waitFor(opts?: { timeoutMs?: number }): Promise<void>;
81
+ /** Assert the element becomes visible within the timeout (throws otherwise). */
82
+ assertVisible(opts?: { timeoutMs?: number }): Promise<void>;
83
+ /** Whether the element is currently present and visible. Wrapped so an
84
+ * `expect(...)` on it nests under the read in the timeline. */
85
+ isVisible(): Promise<Wrapped<boolean>>;
86
+ /** The element's trimmed text content (or `null` if absent), wrapped. */
87
+ textContent(): Promise<Wrapped<string | null>>;
88
+ }
89
+
90
+ /** Build the in-page resolver expression for a descriptor. Returns the
91
+ * matched element's center coords + visibility + text, or `{found:false}`.
92
+ * `scroll` centers the element first (so a tap can reach an offscreen
93
+ * target). Pure string ops — no regex literals — so it survives the
94
+ * template-literal escaping. */
95
+ function resolveExpr(desc: LocatorDesc, scroll: boolean): string {
96
+ return `(function(){
97
+ var desc = ${JSON.stringify(desc)};
98
+ var doScroll = ${scroll ? "true" : "false"};
99
+ function visible(el){
100
+ if(!el) return false;
101
+ var cs = window.getComputedStyle(el);
102
+ if(cs.display==='none'||cs.visibility==='hidden') return false;
103
+ if(parseFloat(cs.opacity||'1')===0) return false;
104
+ var r = el.getBoundingClientRect();
105
+ return r.width>0 && r.height>0;
106
+ }
107
+ function txt(el){ return (el.textContent||'').split(/\\s+/).join(' ').trim(); }
108
+ function esc(v){ return String(v).split('"').join('\\\\"'); }
109
+ function matchesText(el){
110
+ var t = txt(el);
111
+ if(desc.regex){ try{ return new RegExp(desc.regex.source, desc.regex.flags).test(t); }catch(e){ return false; } }
112
+ if(desc.exact) return t === desc.value;
113
+ return t.indexOf(desc.value) >= 0;
114
+ }
115
+ function collect(){
116
+ if(desc.kind==='css') return [].slice.call(document.querySelectorAll(desc.value));
117
+ if(desc.kind==='testid') return [].slice.call(document.querySelectorAll('[data-testid="'+esc(desc.value)+'"]'));
118
+ if(desc.kind==='label') return [].slice.call(document.querySelectorAll('[aria-label="'+esc(desc.value)+'"]'));
119
+ if(desc.kind==='role'){
120
+ var implicit = { button:'button,[type=button],[type=submit]', link:'a[href]', heading:'h1,h2,h3,h4,h5,h6', textbox:'input,textarea', img:'img', list:'ul,ol', listitem:'li', checkbox:'[type=checkbox]' };
121
+ var sel = '[role="'+esc(desc.value)+'"]';
122
+ if(implicit[desc.value]) sel += ','+implicit[desc.value];
123
+ var cands = [].slice.call(document.querySelectorAll(sel));
124
+ if(desc.name){
125
+ cands = cands.filter(function(el){
126
+ var n = el.getAttribute('aria-label') || txt(el);
127
+ return desc.nameExact ? n===desc.name : n.indexOf(desc.name)>=0;
128
+ });
129
+ }
130
+ return cands;
131
+ }
132
+ if(desc.kind==='text'){
133
+ var all = [].slice.call(document.querySelectorAll('body *'));
134
+ var hits = all.filter(matchesText);
135
+ // Keep only the innermost matches (drop ancestors of another hit).
136
+ return hits.filter(function(el){ return !hits.some(function(o){ return o!==el && el.contains(o); }); });
137
+ }
138
+ return [];
139
+ }
140
+ var els = collect();
141
+ var el = null;
142
+ for(var i=0;i<els.length;i++){ if(visible(els[i])){ el = els[i]; break; } }
143
+ if(!el) el = els[0] || null;
144
+ if(!el) return { found:false };
145
+ if(doScroll){ try{ el.scrollIntoView({block:'center', inline:'center'}); }catch(e){} }
146
+ var r = el.getBoundingClientRect();
147
+ return { found:true, visible: visible(el), x: r.left + r.width/2, y: r.top + r.height/2, text: txt(el) };
148
+ })()`;
149
+ }
150
+
151
+ interface ResolveResult {
152
+ found: boolean;
153
+ visible?: boolean;
154
+ x?: number;
155
+ y?: number;
156
+ text?: string | null;
157
+ }
158
+
159
+ /** Short human label for a descriptor, used in event descriptions. */
160
+ function descLabel(desc: LocatorDesc): string {
161
+ if (desc.kind === "text") {
162
+ return desc.regex ? `text /${desc.regex.source}/` : `text ${JSON.stringify(desc.value)}`;
163
+ }
164
+ if (desc.kind === "role") {
165
+ return desc.name ? `role ${desc.value} ${JSON.stringify(desc.name)}` : `role ${desc.value}`;
166
+ }
167
+ return `${desc.kind} ${JSON.stringify(desc.value)}`;
168
+ }
169
+
170
+ function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
171
+ const label = descLabel(desc);
172
+
173
+ // Poll the unrecorded resolver until the element is visible. Returns the
174
+ // tap coordinates. Throws a clear error on timeout.
175
+ async function waitCoords(timeoutMs: number): Promise<{ x: number; y: number }> {
176
+ const deadline = Date.now() + timeoutMs;
177
+ for (;;) {
178
+ const r = await backend.probe<ResolveResult>(resolveExpr(desc, true));
179
+ if (r.found && r.visible && typeof r.x === "number" && typeof r.y === "number") {
180
+ return { x: r.x, y: r.y };
181
+ }
182
+ if (Date.now() >= deadline) {
183
+ throw new Error(`mobile: ${label} not visible after ${timeoutMs}ms`);
184
+ }
185
+ await new Promise((res) => setTimeout(res, 100));
186
+ }
187
+ }
188
+
189
+ return {
190
+ async tap() {
191
+ const { x, y } = await waitCoords(5_000);
192
+ await backend.tapAt(x, y);
193
+ },
194
+ async typeText(text) {
195
+ const { x, y } = await waitCoords(5_000);
196
+ await backend.tapAt(x, y);
197
+ await backend.type(text);
198
+ },
199
+ async clearText() {
200
+ await waitCoords(5_000);
201
+ // Native value-setter + input event so RN-Web's controlled TextInput
202
+ // sees the change (a plain `.value = ""` is swallowed by React).
203
+ await backend.evaluate(
204
+ `clear ${label}`,
205
+ `(function(){
206
+ var r = ${resolveExpr(desc, false)};
207
+ var el = document.activeElement;
208
+ if(!el || !('value' in el)) return false;
209
+ var proto = el.tagName==='TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
210
+ var setter = Object.getOwnPropertyDescriptor(proto,'value').set;
211
+ setter.call(el, '');
212
+ el.dispatchEvent(new Event('input', { bubbles: true }));
213
+ return true;
214
+ })()`,
215
+ );
216
+ },
217
+ async scrollIntoView() {
218
+ // Routed through the recorded evaluate so the scroll lands in the replay.
219
+ await backend.evaluate(`scrollIntoView ${label}`, resolveExpr(desc, true));
220
+ },
221
+ async waitFor(opts) {
222
+ await backend.waitFor(
223
+ `${label} visible`,
224
+ `(function(){ var r = ${resolveExpr(desc, false)}; return (r.found && r.visible) ? r : null; })()`,
225
+ { timeoutMs: opts?.timeoutMs ?? 5_000 },
226
+ );
227
+ },
228
+ async assertVisible(opts) {
229
+ try {
230
+ await this.waitFor(opts);
231
+ } catch {
232
+ throw new Error(`expected ${label} to be visible`);
233
+ }
234
+ },
235
+ isVisible() {
236
+ return backend.evaluate<boolean>(
237
+ `${label} visible?`,
238
+ `(function(){ var r = ${resolveExpr(desc, false)}; return !!(r.found && r.visible); })()`,
239
+ );
240
+ },
241
+ textContent() {
242
+ return backend.evaluate<string | null>(
243
+ `${label} text`,
244
+ `(function(){ var r = ${resolveExpr(desc, false)}; return r.found ? r.text : null; })()`,
245
+ );
246
+ },
247
+ };
248
+ }
249
+
250
+ // ────────────────────────────────────────────────────────────────────────
251
+ // Mobile session
252
+ // ────────────────────────────────────────────────────────────────────────
253
+
254
+ /** A phone-emulated app session. Opened via `ctx.mobile(app)` already on the
255
+ * app, so there is no `navigate`; interactions are mobile-native. */
256
+ export interface Mobile {
257
+ /** Current page URL. */
258
+ readonly url: string;
259
+ /** Current page `<title>`. */
260
+ readonly title: string;
261
+ /** Select by `testID` (RN-Web `data-testid`). The primary mobile selector. */
262
+ getByTestId(testId: string): MobileLocator;
263
+ /** Select by visible text (substring by default; pass a RegExp for patterns). */
264
+ getByText(text: string | RegExp, opts?: { exact?: boolean }): MobileLocator;
265
+ /** Select by ARIA role, optionally narrowed by accessible name. */
266
+ getByRole(role: string, opts?: { name?: string; exact?: boolean }): MobileLocator;
267
+ /** Select by `accessibilityLabel` (RN-Web `aria-label`). */
268
+ getByLabel(text: string): MobileLocator;
269
+ /** Escape hatch: select by a raw CSS selector. */
270
+ locator(css: string): MobileLocator;
271
+ /** Swipe the screen in a direction (a touch drag from the center). */
272
+ swipe(direction: "up" | "down" | "left" | "right", opts?: { distance?: number }): Promise<void>;
273
+ /** Wheel-scroll the viewport by a pixel delta. */
274
+ scroll(dx: number, dy: number): Promise<void>;
275
+ /** Press a named key (`"Enter"`, `"Backspace"`, …) on the focused element. */
276
+ pressKey(key: string): Promise<void>;
277
+ /** Navigate back in history (the device back gesture). */
278
+ back(): Promise<void>;
279
+ /** Low-level escape hatch: evaluate JS in the page (recorded, wrapped). */
280
+ evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
281
+ /** Low-level escape hatch: poll a JS expression until truthy (recorded). */
282
+ waitFor<T = unknown>(
283
+ description: string,
284
+ expression: string,
285
+ options?: { timeoutMs?: number; intervalMs?: number },
286
+ ): Promise<Wrapped<T>>;
287
+ /** Capture a screenshot of the viewport. */
288
+ screenshot(options?: ScreenshotOptions): Promise<Uint8Array>;
289
+ /** Close the session. Idempotent; drains pending rrweb events. */
290
+ close(): Promise<void>;
291
+ }
292
+
293
+ function wrapMobile(backend: MobileBackend): Mobile {
294
+ return {
295
+ get url() {
296
+ return backend.url;
297
+ },
298
+ get title() {
299
+ return backend.title;
300
+ },
301
+ getByTestId(testId) {
302
+ return makeLocator(backend, { kind: "testid", value: testId });
303
+ },
304
+ getByText(text, opts) {
305
+ if (text instanceof RegExp) {
306
+ return makeLocator(backend, {
307
+ kind: "text",
308
+ value: text.source,
309
+ regex: { source: text.source, flags: text.flags },
310
+ });
311
+ }
312
+ return makeLocator(backend, { kind: "text", value: text, exact: opts?.exact });
313
+ },
314
+ getByRole(role, opts) {
315
+ return makeLocator(backend, {
316
+ kind: "role",
317
+ value: role,
318
+ name: opts?.name,
319
+ nameExact: opts?.exact,
320
+ });
321
+ },
322
+ getByLabel(text) {
323
+ return makeLocator(backend, { kind: "label", value: text });
324
+ },
325
+ locator(css) {
326
+ return makeLocator(backend, { kind: "css", value: css });
327
+ },
328
+ async swipe(direction, opts) {
329
+ const vp = await backend.probe<{ w: number; h: number }>(
330
+ "({ w: window.innerWidth, h: window.innerHeight })",
331
+ );
332
+ const cx = vp.w / 2;
333
+ const cy = vp.h / 2;
334
+ const horiz = direction === "left" || direction === "right";
335
+ const dist = opts?.distance ?? Math.round((horiz ? vp.w : vp.h) * 0.5);
336
+ const dx = direction === "left" ? -dist : direction === "right" ? dist : 0;
337
+ const dy = direction === "up" ? -dist : direction === "down" ? dist : 0;
338
+ await backend.swipeBy(cx, cy, dx, dy);
339
+ },
340
+ scroll(dx, dy) {
341
+ return backend.scroll(dx, dy);
342
+ },
343
+ pressKey(key) {
344
+ return backend.press(key);
345
+ },
346
+ back() {
347
+ return backend.back();
348
+ },
349
+ evaluate(description, script) {
350
+ return backend.evaluate(description, script);
351
+ },
352
+ waitFor(description, expression, options) {
353
+ return backend.waitFor(description, expression, options);
354
+ },
355
+ screenshot(options) {
356
+ return backend.screenshot(options);
357
+ },
358
+ close() {
359
+ return backend.close();
360
+ },
361
+ };
362
+ }
363
+
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.
368
+ */
369
+ export async function openMobile(opts: {
370
+ url?: string;
371
+ recorder: BrowserSessionRecorder | null;
372
+ }): Promise<Mobile> {
373
+ const backend = await openMobileBackend({
374
+ frame: "mobile",
375
+ url: opts.url,
376
+ recorder: opts.recorder,
377
+ });
378
+ return wrapMobile(backend);
379
+ }