@specific.dev/spectest 0.4.55 → 0.6.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.4.55",
3
+ "version": "0.6.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
@@ -27,6 +27,8 @@ import { pathToFileURL } from "node:url";
27
27
  import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard } from "./index.js";
28
28
  import type { DnsTarget, LoweredIngress } from "./index.js";
29
29
  import { openBrowser } from "./browser.js";
30
+ import { openMobile, isMobileApp } from "./mobile.js";
31
+ import type { Mobile, MobileApp } from "./mobile.js";
30
32
  import { openTerminal } from "./terminal.js";
31
33
  import {
32
34
  recordEnv,
@@ -2521,6 +2523,10 @@ interface BrowserSessionRecord {
2521
2523
  openedAtMs: number;
2522
2524
  closedAtMs?: number;
2523
2525
  initialUrl?: string;
2526
+ /** Replay frame: `"browser"` (desktop window, default) or `"mobile"`
2527
+ * (phone bezel). Drives the dashboard's chrome; the rrweb events are
2528
+ * identical either way. */
2529
+ frame?: "browser" | "mobile";
2524
2530
  steps: BrowserSessionStep[];
2525
2531
  }
2526
2532
 
@@ -2546,7 +2552,11 @@ function newSessionId(idScope: string): string {
2546
2552
  * The returned `recorder` is what `openBrowser` writes into; the
2547
2553
  * returned `record` is the in-flight session object the daemon owns.
2548
2554
  */
2549
- function newBrowserSession(testStart: number, idScope: string): {
2555
+ function newBrowserSession(
2556
+ testStart: number,
2557
+ idScope: string,
2558
+ frame: "browser" | "mobile" = "browser",
2559
+ ): {
2550
2560
  recorder: BrowserSessionRecorder;
2551
2561
  record: BrowserSessionRecord;
2552
2562
  markClosed(): void;
@@ -2554,6 +2564,7 @@ function newBrowserSession(testStart: number, idScope: string): {
2554
2564
  const record: BrowserSessionRecord = {
2555
2565
  sessionId: newSessionId(idScope),
2556
2566
  openedAtMs: Date.now() - testStart,
2567
+ frame,
2557
2568
  steps: [],
2558
2569
  };
2559
2570
  let closed = false;
@@ -3252,7 +3263,9 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3252
3263
  // and chew memory across forks. Each Browser also gets a session
3253
3264
  // recorder; the records flow back to the control plane as part of
3254
3265
  // RunResult.browserSessions and are persisted to SQLite.
3255
- const openBrowsers: Browser[] = [];
3266
+ // Tracks both Browser and Mobile handles for cleanup — both expose an
3267
+ // async close() that does the final rrweb drain before teardown.
3268
+ const openBrowsers: Array<{ close(): Promise<void> }> = [];
3256
3269
  const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
3257
3270
  const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
3258
3271
  const session = newBrowserSession(start, testCase.id);
@@ -3261,6 +3274,18 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3261
3274
  openBrowsers.push(b);
3262
3275
  return b;
3263
3276
  };
3277
+ const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
3278
+ if (!isMobileApp(app)) {
3279
+ throw new Error(
3280
+ "ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().",
3281
+ );
3282
+ }
3283
+ const session = newBrowserSession(start, testCase.id, "mobile");
3284
+ sessions.push(session);
3285
+ const m = await openMobile({ url: app.url, recorder: session.recorder });
3286
+ openBrowsers.push(m);
3287
+ return m;
3288
+ };
3264
3289
 
3265
3290
  // Build convenience handles (e.g. ctx.svc.db.client) from the loaded
3266
3291
  // project. Done before installing the timeout so a slow client factory
@@ -3281,6 +3306,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3281
3306
  terminal: recordedTerminal as unknown as TestContext<unknown>["terminal"],
3282
3307
  openTerminal: recordedOpenTerminal,
3283
3308
  browser: trackedOpenBrowser,
3309
+ mobile: trackedOpenMobile,
3284
3310
  testName: testCase.name,
3285
3311
  parent,
3286
3312
  svc,
@@ -3509,7 +3535,7 @@ async function evalCode(
3509
3535
  // wrapped type is honest at runtime). Restored in the `finally` below.
3510
3536
  const restoreFetch = installFetchWrapper();
3511
3537
 
3512
- const openBrowsers: Browser[] = [];
3538
+ const openBrowsers: Array<{ close(): Promise<void> }> = [];
3513
3539
  const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
3514
3540
  const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
3515
3541
  const session = newBrowserSession(start, "eval");
@@ -3518,6 +3544,18 @@ async function evalCode(
3518
3544
  openBrowsers.push(b);
3519
3545
  return b;
3520
3546
  };
3547
+ const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
3548
+ if (!isMobileApp(app)) {
3549
+ throw new Error(
3550
+ "ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().",
3551
+ );
3552
+ }
3553
+ const session = newBrowserSession(start, "eval", "mobile");
3554
+ sessions.push(session);
3555
+ const m = await openMobile({ url: app.url, recorder: session.recorder });
3556
+ openBrowsers.push(m);
3557
+ return m;
3558
+ };
3521
3559
 
3522
3560
  // Terminal sessions — same shape as runOne, but eval has no active
3523
3561
  // recorder so we don't emit inline events; the asciicast frames
@@ -3580,6 +3618,7 @@ async function evalCode(
3580
3618
  terminal: evalTerminal as unknown as TestContext<undefined>["terminal"],
3581
3619
  openTerminal: evalOpenTerminal,
3582
3620
  browser: trackedOpenBrowser,
3621
+ mobile: trackedOpenMobile,
3583
3622
  testName: "eval",
3584
3623
  parent: undefined,
3585
3624
  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";
@@ -602,6 +609,26 @@ export interface TestContext<
602
609
  * release earlier if you're opening many.
603
610
  */
604
611
  browser(opts?: BrowserOptions): Promise<Browser>;
612
+ /**
613
+ * Open a phone-emulated session for a mobile app and return a {@link Mobile}
614
+ * handle already pointed at it — no `navigate`. Pass the app handle a
615
+ * mobile-app component exposes on `ctx.svc`, e.g. a service declared with
616
+ * `expo()`:
617
+ *
618
+ * ```ts
619
+ * services: { app: expo() }
620
+ * // in a test:
621
+ * const m = await ctx.mobile(ctx.svc.app);
622
+ * await m.getByTestId("email").typeText("a@b.com");
623
+ * await m.getByText("Sign in").tap();
624
+ * await m.getByText(/Welcome/).assertVisible();
625
+ * ```
626
+ *
627
+ * The session emulates the latest iPhone (viewport + DPR + mobile UA +
628
+ * touch) and the dashboard replays it inside a phone bezel. Auto-closed
629
+ * when the test finishes.
630
+ */
631
+ mobile(app: MobileApp): Promise<Mobile>;
605
632
  /** The test's display name. */
606
633
  readonly testName: string;
607
634
  /**
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
+ }