@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 +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 +42 -3
- package/src/index.ts +27 -0
- 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
|
@@ -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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|