@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 +1 -1
- package/src/browser.ts +153 -6
- package/src/components/expo.ts +138 -0
- package/src/components/index.ts +5 -0
- package/src/daemon.ts +249 -58
- package/src/index.ts +42 -9
- package/src/inspect.ts +14 -2
- package/src/mobile.ts +379 -0
package/package.json
CHANGED
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
|
-
|
|
576
|
-
|
|
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
|
-
):
|
|
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
|
+
}
|
package/src/components/index.ts
CHANGED
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 {
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
|
|
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 =
|
|
1481
|
-
|
|
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,
|
|
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)
|
|
1503
|
+
for (const p of LOWERED.proxies) {
|
|
1504
|
+
routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
|
|
1505
|
+
}
|
|
1492
1506
|
}
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1498
|
-
|
|
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
|
-
// ──
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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]
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
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, "
|
|
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
|
-
|
|
48
|
-
|
|
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
|
+
}
|