@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/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, 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.
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
- // React Native Web renders `testID="x"` to `data-testid="x"` and
14
- // `accessibilityLabel` to `aria-label`, so `getByTestId`/`getByLabel` map
15
- // straight onto playwright's built-in locators (its `testIdAttribute`
16
- // default is `data-testid`). Locator resolution, waiting, and actionability
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
- /** A phone-emulated app session. Opened via `ctx.mobile(app)` already on the
265
- * app, so there is no `navigate`; interactions are mobile-native. */
266
- export interface Mobile {
267
- /** Current page URL. */
268
- readonly url: string;
269
- /** Current page `<title>`. */
270
- readonly title: string;
271
- /** Select by `testID` (RN-Web `data-testid`). The primary mobile selector. */
272
- getByTestId(testId: string): MobileLocator;
273
- /** Select by visible text (case-insensitive substring by default —
274
- * playwright semantics; `exact: true` for a case-sensitive whole-string
275
- * match, or pass a RegExp). */
276
- getByText(text: string | RegExp, opts?: { exact?: boolean }): MobileLocator;
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
- return wrapMobile(backend);
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
- * URL, created on first use — see the "Persistent sessions" section in
422
- * browser.ts). The daemon calls this from `ctx.mobile(app)` with a
423
- * per-test rrweb recorder; the resulting record carries `frame: "mobile"`
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: wrapMobile(browser),
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
- export type BrowserAction =
372
- | "navigate"
373
- | "evaluate"
374
- | "addInitScript"
375
- | "waitFor"
376
- | "click"
377
- | "type"
378
- | "press"
379
- | "scroll"
380
- | "scrollTo"
381
- | "back"
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";