@specific.dev/spectest 0.21.0 → 0.22.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 +301 -214
- package/src/daemon.ts +4 -4
- package/src/index.ts +241 -9
- package/src/locator.ts +576 -0
- package/src/mobile.ts +34 -359
- package/src/recorder.ts +11 -14
package/src/mobile.ts
CHANGED
|
@@ -2,30 +2,27 @@
|
|
|
2
2
|
// in a phone-emulated headless Chromium and recorded as an rrweb session that
|
|
3
3
|
// the dashboard replays inside a phone bezel.
|
|
4
4
|
//
|
|
5
|
-
// `ctx.mobile(ctx.svc.app)` opens one of these already pointed at the app
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// `
|
|
9
|
-
// `
|
|
10
|
-
//
|
|
11
|
-
//
|
|
5
|
+
// `ctx.mobile(ctx.svc.app)` opens one of these already pointed at the app. The
|
|
6
|
+
// surface is the SAME Playwright-native one as `ctx.browser()` (the shared
|
|
7
|
+
// `Browser` session + `Locator`s from browser.ts/locator.ts) plus two mobile
|
|
8
|
+
// extensions: a `touchscreen` and `swipe`. There is no mobile-specific locator
|
|
9
|
+
// type — `getByTestId`/`getByRole`/`getByText`/… return the same `Locator`,
|
|
10
|
+
// and on a mobile session a locator's `tap()` dispatches a real CDP touch with
|
|
11
|
+
// the press dwell RN Pressables need (the `mobileStrategy` in locator.ts;
|
|
12
|
+
// desktop `tap()` throws and steers you to `click()`).
|
|
12
13
|
//
|
|
13
|
-
//
|
|
14
|
-
// `
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// are playwright's; this file adds the descriptor plumbing, the recorder
|
|
18
|
-
// events, and the CDP touch dispatch (playwright's `tap()` has no press
|
|
19
|
-
// dwell, which RN Pressables need — see `rawTap` in browser.ts).
|
|
14
|
+
// This file is now just the `MobileApp` handle (`ctx.mobile` accepts it) and
|
|
15
|
+
// the `Mobile` public view of the shared backend. React Native Web renders
|
|
16
|
+
// `testID="x"` to `data-testid="x"` and `accessibilityLabel` to `aria-label`,
|
|
17
|
+
// so `getByTestId`/`getByLabel` map straight onto Playwright's built-ins.
|
|
20
18
|
|
|
21
19
|
import { acquirePersistentMobileBackend, openMobileBackend } from "./browser.js";
|
|
22
20
|
import type {
|
|
21
|
+
Browser,
|
|
23
22
|
BrowserSessionRecorder,
|
|
24
|
-
MobileBackend,
|
|
25
23
|
SafeAreaInsets,
|
|
24
|
+
Touchscreen,
|
|
26
25
|
} from "./browser.js";
|
|
27
|
-
import type { Locator, Page } from "playwright-core";
|
|
28
|
-
import type { Wrapped } from "./inspect.js";
|
|
29
26
|
|
|
30
27
|
/** Branded handle a mobile-app component (e.g. `expo()`) exposes on
|
|
31
28
|
* `ctx.svc.<name>`. The brand is a `Symbol.for` key so `JSON.stringify`
|
|
@@ -56,347 +53,25 @@ export function mobileApp(url: string): MobileApp {
|
|
|
56
53
|
return { [MOBILE_APP]: true, url };
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
60
|
-
// Locators
|
|
61
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
interface LocatorDesc {
|
|
64
|
-
kind: "testid" | "text" | "role" | "label" | "css";
|
|
65
|
-
value: string;
|
|
66
|
-
exact?: boolean;
|
|
67
|
-
regex?: { source: string; flags: string };
|
|
68
|
-
name?: string;
|
|
69
|
-
nameExact?: boolean;
|
|
70
|
-
/** Narrowing applied after resolution (`.first()`/`.last()`/`.nth(i)`) —
|
|
71
|
-
* the explicit escape hatch from strict mode. */
|
|
72
|
-
nth?: number | "first" | "last";
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** A lazy reference to a single element, resolved (with auto-wait) at the
|
|
76
|
-
* moment an action runs. Mirrors the select-then-act idiom shared by
|
|
77
|
-
* Playwright, Detox, and RN Testing Library — including Playwright's
|
|
78
|
-
* STRICT mode: a locator matching multiple elements throws an
|
|
79
|
-
* element-listing error rather than acting on one of them. Narrow with
|
|
80
|
-
* `exact: true`, a testID, or {@link first}/{@link nth}. */
|
|
81
|
-
export interface MobileLocator {
|
|
82
|
-
/** Wait for the element to be visible (default 5s, `timeoutMs` overrides),
|
|
83
|
-
* then touch-tap its center. `durationMs` overrides the touch dwell. */
|
|
84
|
-
tap(opts?: { timeoutMs?: number; durationMs?: number }): Promise<void>;
|
|
85
|
-
/** Tap to focus, then type `text` via real key events. */
|
|
86
|
-
typeText(text: string, opts?: { timeoutMs?: number }): Promise<void>;
|
|
87
|
-
/** Clear a text input's current value (RN-Web controlled input safe). */
|
|
88
|
-
clearText(opts?: { timeoutMs?: number }): Promise<void>;
|
|
89
|
-
/** Scroll the element to the center of the viewport. */
|
|
90
|
-
scrollIntoView(): Promise<void>;
|
|
91
|
-
/** Wait until the element is attached and visible (throws on timeout). */
|
|
92
|
-
waitFor(opts?: { timeoutMs?: number }): Promise<void>;
|
|
93
|
-
/** Assert the element becomes visible within the timeout (throws otherwise). */
|
|
94
|
-
assertVisible(opts?: { timeoutMs?: number }): Promise<void>;
|
|
95
|
-
/** Whether the element is currently present and visible (no waiting).
|
|
96
|
-
* Wrapped so an `expect(...)` on it nests under the read in the
|
|
97
|
-
* timeline. */
|
|
98
|
-
isVisible(): Promise<Wrapped<boolean>>;
|
|
99
|
-
/** The element's text content, wrapped. Waits (default 5s) for the
|
|
100
|
-
* element to become visible; throws if it never does. */
|
|
101
|
-
textContent(): Promise<Wrapped<string | null>>;
|
|
102
|
-
/** Narrow to the first resolved element — the explicit opt-out from
|
|
103
|
-
* strict mode when multiple matches are intentional. */
|
|
104
|
-
first(): MobileLocator;
|
|
105
|
-
/** Narrow to the last resolved element. */
|
|
106
|
-
last(): MobileLocator;
|
|
107
|
-
/** Narrow to the i-th resolved element (0-based). */
|
|
108
|
-
nth(index: number): MobileLocator;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Map a locator descriptor onto a playwright Locator — STRICT, exactly
|
|
112
|
-
* like stock Playwright: a locator that resolves to multiple elements
|
|
113
|
-
* throws an element-listing "strict mode violation" at action time
|
|
114
|
-
* instead of silently picking one. (An earlier `.filter({visible})
|
|
115
|
-
* .first()` auto-pick deterministically tapped the WRONG element when
|
|
116
|
-
* case-insensitive substring matching made two leaves qualify —
|
|
117
|
-
* `getByText("Men")` also matches "Wo**men**". Loud beats lucky.)
|
|
118
|
-
* Disambiguate with `exact: true`, a testID, or `.first()`/`.nth(i)`. */
|
|
119
|
-
function pwLocator(page: Page, desc: LocatorDesc): Locator {
|
|
120
|
-
let base: Locator;
|
|
121
|
-
switch (desc.kind) {
|
|
122
|
-
case "css":
|
|
123
|
-
base = page.locator(desc.value);
|
|
124
|
-
break;
|
|
125
|
-
case "testid":
|
|
126
|
-
base = page.getByTestId(desc.value);
|
|
127
|
-
break;
|
|
128
|
-
case "label":
|
|
129
|
-
base = page.getByLabel(desc.value);
|
|
130
|
-
break;
|
|
131
|
-
case "role":
|
|
132
|
-
base = page.getByRole(
|
|
133
|
-
desc.value as Parameters<Page["getByRole"]>[0],
|
|
134
|
-
desc.name !== undefined ? { name: desc.name, exact: desc.nameExact } : {},
|
|
135
|
-
);
|
|
136
|
-
break;
|
|
137
|
-
default:
|
|
138
|
-
base = page.getByText(
|
|
139
|
-
desc.regex ? new RegExp(desc.regex.source, desc.regex.flags) : desc.value,
|
|
140
|
-
{ exact: desc.exact },
|
|
141
|
-
);
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
if (desc.nth === "first") return base.first();
|
|
145
|
-
if (desc.nth === "last") return base.last();
|
|
146
|
-
if (typeof desc.nth === "number") return base.nth(desc.nth);
|
|
147
|
-
return base;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Short human label for a descriptor, used in event descriptions. */
|
|
151
|
-
function descLabel(desc: LocatorDesc): string {
|
|
152
|
-
let base: string;
|
|
153
|
-
if (desc.kind === "text") {
|
|
154
|
-
base = desc.regex ? `text /${desc.regex.source}/` : `text ${JSON.stringify(desc.value)}`;
|
|
155
|
-
} else if (desc.kind === "role") {
|
|
156
|
-
base = desc.name ? `role ${desc.value} ${JSON.stringify(desc.name)}` : `role ${desc.value}`;
|
|
157
|
-
} else {
|
|
158
|
-
base = `${desc.kind} ${JSON.stringify(desc.value)}`;
|
|
159
|
-
}
|
|
160
|
-
if (desc.nth === "first" || desc.nth === "last") return `${base} .${desc.nth}()`;
|
|
161
|
-
if (typeof desc.nth === "number") return `${base} .nth(${desc.nth})`;
|
|
162
|
-
return base;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Default wait for a locator action's target to become visible. Override
|
|
166
|
-
* per action with `{ timeoutMs }` (animations that move elements mid-flight
|
|
167
|
-
* often need more than the default). */
|
|
168
|
-
const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
|
|
169
|
-
|
|
170
|
-
function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
|
|
171
|
-
const label = descLabel(desc);
|
|
172
|
-
|
|
173
|
-
// Wait for the element to be visible (playwright auto-wait), center it,
|
|
174
|
-
// and return its tap coordinates.
|
|
175
|
-
async function centerOf(page: Page, timeout: number): Promise<{ x: number; y: number }> {
|
|
176
|
-
const loc = pwLocator(page, desc);
|
|
177
|
-
await loc.waitFor({ state: "visible", timeout });
|
|
178
|
-
await loc.scrollIntoViewIfNeeded({ timeout });
|
|
179
|
-
const box = await loc.boundingBox();
|
|
180
|
-
if (!box) throw new Error(`mobile: ${label} vanished before it could be tapped`);
|
|
181
|
-
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
async function tap(opts?: { timeoutMs?: number; durationMs?: number }): Promise<void> {
|
|
185
|
-
await backend.pageOp("click", { selector: label }, async (page) => {
|
|
186
|
-
const { x, y } = await centerOf(page, opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
|
|
187
|
-
// Manual CDP touch (not playwright's tap()): RN Pressables need the
|
|
188
|
-
// press dwell — see rawTap in browser.ts.
|
|
189
|
-
await backend.rawTap(x, y, opts?.durationMs);
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
async function waitForVisible(opts?: { timeoutMs?: number }): Promise<void> {
|
|
194
|
-
await backend.pageOp("waitFor", { description: `${label} visible` }, async (page) => {
|
|
195
|
-
await pwLocator(page, desc).waitFor({
|
|
196
|
-
state: "visible",
|
|
197
|
-
timeout: opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS,
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
tap,
|
|
204
|
-
async typeText(text, opts) {
|
|
205
|
-
// Two recorded events (click + type), same as a human: tap to focus,
|
|
206
|
-
// then insert the text.
|
|
207
|
-
await tap(opts);
|
|
208
|
-
await backend.type(text);
|
|
209
|
-
},
|
|
210
|
-
async clearText(opts) {
|
|
211
|
-
await backend.pageOp("evaluate", { description: `clear ${label}` }, async (page) => {
|
|
212
|
-
// playwright fill("") is the canonical React-safe clear (native
|
|
213
|
-
// value setter + input event, so RN-Web's controlled TextInput
|
|
214
|
-
// sees the change).
|
|
215
|
-
await pwLocator(page, desc).fill("", {
|
|
216
|
-
timeout: opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS,
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
},
|
|
220
|
-
async scrollIntoView() {
|
|
221
|
-
await backend.pageOp("scrollTo", { selector: label }, async (page) => {
|
|
222
|
-
const loc = pwLocator(page, desc);
|
|
223
|
-
await loc.waitFor({ state: "visible", timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
224
|
-
await loc.scrollIntoViewIfNeeded({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
225
|
-
});
|
|
226
|
-
},
|
|
227
|
-
waitFor: waitForVisible,
|
|
228
|
-
async assertVisible(opts) {
|
|
229
|
-
try {
|
|
230
|
-
await waitForVisible(opts);
|
|
231
|
-
} catch (e) {
|
|
232
|
-
const detail = e instanceof Error ? ` — ${e.message}` : "";
|
|
233
|
-
throw new Error(`expected ${label} to be visible${detail}`);
|
|
234
|
-
}
|
|
235
|
-
},
|
|
236
|
-
isVisible() {
|
|
237
|
-
// pageOp("evaluate", …) provenance-wraps the value at runtime; the
|
|
238
|
-
// cast matches (same pattern as browser.ts's evaluate).
|
|
239
|
-
return backend.pageOp("evaluate", { description: `${label} visible?` }, (page) =>
|
|
240
|
-
pwLocator(page, desc).isVisible(),
|
|
241
|
-
) as unknown as Promise<Wrapped<boolean>>;
|
|
242
|
-
},
|
|
243
|
-
textContent() {
|
|
244
|
-
return backend.pageOp("evaluate", { description: `${label} text` }, (page) =>
|
|
245
|
-
pwLocator(page, desc).textContent({ timeout: DEFAULT_ACTION_TIMEOUT_MS }),
|
|
246
|
-
) as Promise<Wrapped<string | null>>;
|
|
247
|
-
},
|
|
248
|
-
first() {
|
|
249
|
-
return makeLocator(backend, { ...desc, nth: "first" });
|
|
250
|
-
},
|
|
251
|
-
last() {
|
|
252
|
-
return makeLocator(backend, { ...desc, nth: "last" });
|
|
253
|
-
},
|
|
254
|
-
nth(index: number) {
|
|
255
|
-
return makeLocator(backend, { ...desc, nth: index });
|
|
256
|
-
},
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
56
|
// ────────────────────────────────────────────────────────────────────────
|
|
261
57
|
// Mobile session
|
|
262
58
|
// ────────────────────────────────────────────────────────────────────────
|
|
263
59
|
|
|
264
|
-
/**
|
|
265
|
-
*
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
/** Select by ARIA role, optionally narrowed by accessible name. */
|
|
278
|
-
getByRole(role: string, opts?: { name?: string; exact?: boolean }): MobileLocator;
|
|
279
|
-
/** Select by `accessibilityLabel` (RN-Web `aria-label`). */
|
|
280
|
-
getByLabel(text: string): MobileLocator;
|
|
281
|
-
/** Escape hatch: select by a raw CSS selector. */
|
|
282
|
-
locator(css: string): MobileLocator;
|
|
283
|
-
/**
|
|
284
|
-
* Touch-tap at viewport CSS coordinates — the escape hatch for targets no
|
|
285
|
-
* locator can select (unlabeled icon-only buttons, canvas hit areas). Same
|
|
286
|
-
* real-touch dispatch and dwell as a locator `tap()`. Prefer locators when
|
|
287
|
-
* the element has a testID/label; coordinates break on layout changes.
|
|
288
|
-
*/
|
|
289
|
-
tapAt(x: number, y: number, opts?: { durationMs?: number }): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* A phone-emulated app session. The full {@link Browser} surface (locators,
|
|
62
|
+
* `keyboard`/`mouse`, `evaluate`, …) plus a `touchscreen` and `swipe`. Opened
|
|
63
|
+
* via `ctx.mobile(app)` already on the app.
|
|
64
|
+
*
|
|
65
|
+
* The device is fixed (latest iPhone: viewport, DPR, mobile UA, touch). A
|
|
66
|
+
* locator's `tap()` on this session uses a real CDP touch with the RN press
|
|
67
|
+
* dwell; `getByTestId` targets `testID`, `getByLabel` targets
|
|
68
|
+
* `accessibilityLabel`.
|
|
69
|
+
*/
|
|
70
|
+
export interface Mobile extends Browser {
|
|
71
|
+
/** Coordinate touch taps (mobile escape hatch — prefer locator `tap()`). */
|
|
72
|
+
readonly touchscreen: Touchscreen;
|
|
290
73
|
/** Swipe the screen in a direction (a touch drag from the center). */
|
|
291
74
|
swipe(direction: "up" | "down" | "left" | "right", opts?: { distance?: number }): Promise<void>;
|
|
292
|
-
/** Wheel-scroll the viewport by a pixel delta. */
|
|
293
|
-
scroll(dx: number, dy: number): Promise<void>;
|
|
294
|
-
/** Press a named key (`"Enter"`, `"Backspace"`, …) on the focused element. */
|
|
295
|
-
pressKey(key: string): Promise<void>;
|
|
296
|
-
/** Navigate back in history (the device back gesture). */
|
|
297
|
-
back(): Promise<void>;
|
|
298
|
-
/** Low-level escape hatch: evaluate JS in the page (recorded, wrapped). */
|
|
299
|
-
evaluate<T = unknown>(description: string, script: string): Promise<Wrapped<T>>;
|
|
300
|
-
/**
|
|
301
|
-
* Install a script that runs before every *subsequent* document's own
|
|
302
|
-
* scripts (the Playwright `addInitScript` equivalent) — the deterministic
|
|
303
|
-
* way to plant shims/instrumentation that must win the race against the
|
|
304
|
-
* app bundle. Takes effect on the next navigation (e.g. a
|
|
305
|
-
* `location.assign` deep link), not the current document.
|
|
306
|
-
*/
|
|
307
|
-
addInitScript(description: string, source: string): Promise<void>;
|
|
308
|
-
/** Low-level escape hatch: poll a JS expression until truthy (recorded). */
|
|
309
|
-
waitFor<T = unknown>(
|
|
310
|
-
description: string,
|
|
311
|
-
expression: string,
|
|
312
|
-
options?: { timeoutMs?: number; intervalMs?: number },
|
|
313
|
-
): Promise<Wrapped<T>>;
|
|
314
|
-
/**
|
|
315
|
-
* Capture a PNG screenshot of the viewport and upload it as a
|
|
316
|
-
* downloadable artifact. Resolves to the artifact's `art_…` id
|
|
317
|
-
* (`spectest artifact download <id>`). Eval-only for now — throws with a
|
|
318
|
-
* clear message during test runs.
|
|
319
|
-
*/
|
|
320
|
-
screenshot(): Promise<string>;
|
|
321
|
-
/** Close the session. Idempotent; drains pending rrweb events. */
|
|
322
|
-
close(): Promise<void>;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function wrapMobile(backend: MobileBackend): Mobile {
|
|
326
|
-
return {
|
|
327
|
-
get url() {
|
|
328
|
-
return backend.url;
|
|
329
|
-
},
|
|
330
|
-
get title() {
|
|
331
|
-
return backend.title;
|
|
332
|
-
},
|
|
333
|
-
getByTestId(testId) {
|
|
334
|
-
return makeLocator(backend, { kind: "testid", value: testId });
|
|
335
|
-
},
|
|
336
|
-
getByText(text, opts) {
|
|
337
|
-
if (text instanceof RegExp) {
|
|
338
|
-
return makeLocator(backend, {
|
|
339
|
-
kind: "text",
|
|
340
|
-
value: text.source,
|
|
341
|
-
regex: { source: text.source, flags: text.flags },
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
return makeLocator(backend, { kind: "text", value: text, exact: opts?.exact });
|
|
345
|
-
},
|
|
346
|
-
getByRole(role, opts) {
|
|
347
|
-
return makeLocator(backend, {
|
|
348
|
-
kind: "role",
|
|
349
|
-
value: role,
|
|
350
|
-
name: opts?.name,
|
|
351
|
-
nameExact: opts?.exact,
|
|
352
|
-
});
|
|
353
|
-
},
|
|
354
|
-
getByLabel(text) {
|
|
355
|
-
return makeLocator(backend, { kind: "label", value: text });
|
|
356
|
-
},
|
|
357
|
-
locator(css) {
|
|
358
|
-
return makeLocator(backend, { kind: "css", value: css });
|
|
359
|
-
},
|
|
360
|
-
tapAt(x, y, opts) {
|
|
361
|
-
return backend.tapAt(x, y, opts);
|
|
362
|
-
},
|
|
363
|
-
async swipe(direction, opts) {
|
|
364
|
-
const vp = await backend.probe<{ w: number; h: number }>(
|
|
365
|
-
"({ w: window.innerWidth, h: window.innerHeight })",
|
|
366
|
-
);
|
|
367
|
-
const cx = vp.w / 2;
|
|
368
|
-
const cy = vp.h / 2;
|
|
369
|
-
const horiz = direction === "left" || direction === "right";
|
|
370
|
-
const dist = opts?.distance ?? Math.round((horiz ? vp.w : vp.h) * 0.5);
|
|
371
|
-
const dx = direction === "left" ? -dist : direction === "right" ? dist : 0;
|
|
372
|
-
const dy = direction === "up" ? -dist : direction === "down" ? dist : 0;
|
|
373
|
-
await backend.swipeBy(cx, cy, dx, dy);
|
|
374
|
-
},
|
|
375
|
-
scroll(dx, dy) {
|
|
376
|
-
return backend.scroll(dx, dy);
|
|
377
|
-
},
|
|
378
|
-
pressKey(key) {
|
|
379
|
-
return backend.press(key);
|
|
380
|
-
},
|
|
381
|
-
back() {
|
|
382
|
-
return backend.back();
|
|
383
|
-
},
|
|
384
|
-
evaluate(description, script) {
|
|
385
|
-
return backend.evaluate(description, script);
|
|
386
|
-
},
|
|
387
|
-
addInitScript(description, source) {
|
|
388
|
-
return backend.addInitScript(description, source);
|
|
389
|
-
},
|
|
390
|
-
waitFor(description, expression, options) {
|
|
391
|
-
return backend.waitFor(description, expression, options);
|
|
392
|
-
},
|
|
393
|
-
screenshot() {
|
|
394
|
-
return backend.screenshot();
|
|
395
|
-
},
|
|
396
|
-
close() {
|
|
397
|
-
return backend.close();
|
|
398
|
-
},
|
|
399
|
-
};
|
|
400
75
|
}
|
|
401
76
|
|
|
402
77
|
/**
|
|
@@ -413,15 +88,15 @@ export async function openMobile(opts: {
|
|
|
413
88
|
url: opts.url,
|
|
414
89
|
recorder: opts.recorder,
|
|
415
90
|
});
|
|
416
|
-
|
|
91
|
+
// MobileBackend is a superset of Mobile (adds the low-level primitives).
|
|
92
|
+
return backend;
|
|
417
93
|
}
|
|
418
94
|
|
|
419
95
|
/**
|
|
420
|
-
* Acquire the persistent phone-emulated session for an app (one per app
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
* so the dashboard renders a phone bezel. `detach` is the test-end hook;
|
|
96
|
+
* Acquire the persistent phone-emulated session for an app (one per app URL,
|
|
97
|
+
* created on first use). The daemon calls this from `ctx.mobile(app)` with a
|
|
98
|
+
* per-test rrweb recorder; the resulting record carries `frame: "mobile"` so
|
|
99
|
+
* the dashboard renders a phone bezel. `detach` is the test-end hook;
|
|
425
100
|
* `mobile.close()` destroys the session for real.
|
|
426
101
|
*/
|
|
427
102
|
export async function openPersistentMobile(opts: {
|
|
@@ -440,7 +115,7 @@ export async function openPersistentMobile(opts: {
|
|
|
440
115
|
opts.recorder,
|
|
441
116
|
);
|
|
442
117
|
return {
|
|
443
|
-
mobile:
|
|
118
|
+
mobile: browser,
|
|
444
119
|
attached,
|
|
445
120
|
detach,
|
|
446
121
|
safeAreaInsets: browser.safeAreaInsets,
|
package/src/recorder.ts
CHANGED
|
@@ -368,20 +368,17 @@ export interface EnvEvent extends BaseEvent {
|
|
|
368
368
|
error?: string;
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
| "forward"
|
|
383
|
-
| "reload"
|
|
384
|
-
| "screenshot";
|
|
371
|
+
// A browser step's action label. Since the Playwright-native surface exposes
|
|
372
|
+
// the full locator method vocabulary (click/fill/press/getAttribute/…) plus
|
|
373
|
+
// the session verbs (goto/goBack/evaluate/waitForFunction/screenshot/…), this
|
|
374
|
+
// is an open string rather than a closed union — the value is the Playwright
|
|
375
|
+
// method name verbatim. Both renderers (control-plane web + CLI) treat it as
|
|
376
|
+
// an opaque string with a generic `{action} <target>` fallback, so new method
|
|
377
|
+
// names need no Rust change. Representative values: "goto", "goBack",
|
|
378
|
+
// "goForward", "reload", "evaluate", "addInitScript", "waitForFunction",
|
|
379
|
+
// "click", "dblclick", "tap", "fill", "clear", "press", "type", "scroll",
|
|
380
|
+
// "check", "hover", "textContent", "inputValue", "count", "screenshot".
|
|
381
|
+
export type BrowserAction = string;
|
|
385
382
|
|
|
386
383
|
export interface BrowserEvent extends BaseEvent {
|
|
387
384
|
kind: "browser";
|