@specific.dev/spectest 0.17.1 → 0.19.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
@@ -11,8 +11,12 @@
11
11
  // file only adds the locator/gesture ergonomics.
12
12
  //
13
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.
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).
16
20
 
17
21
  import { acquirePersistentMobileBackend, openMobileBackend } from "./browser.js";
18
22
  import type {
@@ -21,6 +25,7 @@ import type {
21
25
  SafeAreaInsets,
22
26
  ScreenshotOptions,
23
27
  } from "./browser.js";
28
+ import type { Locator, Page } from "playwright-core";
24
29
  import type { Wrapped } from "./inspect.js";
25
30
 
26
31
  /** Branded handle a mobile-app component (e.g. `expo()`) exposes on
@@ -63,11 +68,17 @@ interface LocatorDesc {
63
68
  regex?: { source: string; flags: string };
64
69
  name?: string;
65
70
  nameExact?: boolean;
71
+ /** Narrowing applied after resolution (`.first()`/`.last()`/`.nth(i)`) —
72
+ * the explicit escape hatch from strict mode. */
73
+ nth?: number | "first" | "last";
66
74
  }
67
75
 
68
76
  /** A lazy reference to a single element, resolved (with auto-wait) at the
69
77
  * moment an action runs. Mirrors the select-then-act idiom shared by
70
- * Playwright, Detox, and RN Testing Library. */
78
+ * Playwright, Detox, and RN Testing Library — including Playwright's
79
+ * STRICT mode: a locator matching multiple elements throws an
80
+ * element-listing error rather than acting on one of them. Narrow with
81
+ * `exact: true`, a testID, or {@link first}/{@link nth}. */
71
82
  export interface MobileLocator {
72
83
  /** Wait for the element to be visible (default 5s, `timeoutMs` overrides),
73
84
  * then touch-tap its center. `durationMs` overrides the touch dwell. */
@@ -82,91 +93,74 @@ export interface MobileLocator {
82
93
  waitFor(opts?: { timeoutMs?: number }): Promise<void>;
83
94
  /** Assert the element becomes visible within the timeout (throws otherwise). */
84
95
  assertVisible(opts?: { timeoutMs?: number }): Promise<void>;
85
- /** Whether the element is currently present and visible. Wrapped so an
86
- * `expect(...)` on it nests under the read in the timeline. */
96
+ /** Whether the element is currently present and visible (no waiting).
97
+ * Wrapped so an `expect(...)` on it nests under the read in the
98
+ * timeline. */
87
99
  isVisible(): Promise<Wrapped<boolean>>;
88
- /** The element's trimmed text content (or `null` if absent), wrapped. */
100
+ /** The element's text content, wrapped. Waits (default 5s) for the
101
+ * element to become visible; throws if it never does. */
89
102
  textContent(): Promise<Wrapped<string | null>>;
103
+ /** Narrow to the first resolved element — the explicit opt-out from
104
+ * strict mode when multiple matches are intentional. */
105
+ first(): MobileLocator;
106
+ /** Narrow to the last resolved element. */
107
+ last(): MobileLocator;
108
+ /** Narrow to the i-th resolved element (0-based). */
109
+ nth(index: number): MobileLocator;
90
110
  }
91
111
 
92
- /** Build the in-page resolver expression for a descriptor. Returns the
93
- * matched element's center coords + visibility + text, or `{found:false}`.
94
- * `scroll` centers the element first (so a tap can reach an offscreen
95
- * target). Pure string ops — no regex literals — so it survives the
96
- * template-literal escaping. */
97
- function resolveExpr(desc: LocatorDesc, scroll: boolean): string {
98
- return `(function(){
99
- var desc = ${JSON.stringify(desc)};
100
- var doScroll = ${scroll ? "true" : "false"};
101
- function visible(el){
102
- if(!el) return false;
103
- var cs = window.getComputedStyle(el);
104
- if(cs.display==='none'||cs.visibility==='hidden') return false;
105
- if(parseFloat(cs.opacity||'1')===0) return false;
106
- var r = el.getBoundingClientRect();
107
- return r.width>0 && r.height>0;
108
- }
109
- function txt(el){ return (el.textContent||'').split(/\\s+/).join(' ').trim(); }
110
- function esc(v){ return String(v).split('"').join('\\\\"'); }
111
- function matchesText(el){
112
- var t = txt(el);
113
- if(desc.regex){ try{ return new RegExp(desc.regex.source, desc.regex.flags).test(t); }catch(e){ return false; } }
114
- if(desc.exact) return t === desc.value;
115
- return t.indexOf(desc.value) >= 0;
116
- }
117
- function collect(){
118
- if(desc.kind==='css') return [].slice.call(document.querySelectorAll(desc.value));
119
- if(desc.kind==='testid') return [].slice.call(document.querySelectorAll('[data-testid="'+esc(desc.value)+'"]'));
120
- if(desc.kind==='label') return [].slice.call(document.querySelectorAll('[aria-label="'+esc(desc.value)+'"]'));
121
- if(desc.kind==='role'){
122
- 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]' };
123
- var sel = '[role="'+esc(desc.value)+'"]';
124
- if(implicit[desc.value]) sel += ','+implicit[desc.value];
125
- var cands = [].slice.call(document.querySelectorAll(sel));
126
- if(desc.name){
127
- cands = cands.filter(function(el){
128
- var n = el.getAttribute('aria-label') || txt(el);
129
- return desc.nameExact ? n===desc.name : n.indexOf(desc.name)>=0;
130
- });
131
- }
132
- return cands;
133
- }
134
- if(desc.kind==='text'){
135
- var all = [].slice.call(document.querySelectorAll('body *'));
136
- var hits = all.filter(matchesText);
137
- // Keep only the innermost matches (drop ancestors of another hit).
138
- return hits.filter(function(el){ return !hits.some(function(o){ return o!==el && el.contains(o); }); });
139
- }
140
- return [];
141
- }
142
- var els = collect();
143
- var el = null;
144
- for(var i=0;i<els.length;i++){ if(visible(els[i])){ el = els[i]; break; } }
145
- if(!el) el = els[0] || null;
146
- if(!el) return { found:false };
147
- if(doScroll){ try{ el.scrollIntoView({block:'center', inline:'center'}); }catch(e){} }
148
- var r = el.getBoundingClientRect();
149
- return { found:true, visible: visible(el), x: r.left + r.width/2, y: r.top + r.height/2, text: txt(el) };
150
- })()`;
151
- }
152
-
153
- interface ResolveResult {
154
- found: boolean;
155
- visible?: boolean;
156
- x?: number;
157
- y?: number;
158
- text?: string | null;
112
+ /** Map a locator descriptor onto a playwright Locator — STRICT, exactly
113
+ * like stock Playwright: a locator that resolves to multiple elements
114
+ * throws an element-listing "strict mode violation" at action time
115
+ * instead of silently picking one. (An earlier `.filter({visible})
116
+ * .first()` auto-pick deterministically tapped the WRONG element when
117
+ * case-insensitive substring matching made two leaves qualify —
118
+ * `getByText("Men")` also matches "Wo**men**". Loud beats lucky.)
119
+ * Disambiguate with `exact: true`, a testID, or `.first()`/`.nth(i)`. */
120
+ function pwLocator(page: Page, desc: LocatorDesc): Locator {
121
+ let base: Locator;
122
+ switch (desc.kind) {
123
+ case "css":
124
+ base = page.locator(desc.value);
125
+ break;
126
+ case "testid":
127
+ base = page.getByTestId(desc.value);
128
+ break;
129
+ case "label":
130
+ base = page.getByLabel(desc.value);
131
+ break;
132
+ case "role":
133
+ base = page.getByRole(
134
+ desc.value as Parameters<Page["getByRole"]>[0],
135
+ desc.name !== undefined ? { name: desc.name, exact: desc.nameExact } : {},
136
+ );
137
+ break;
138
+ default:
139
+ base = page.getByText(
140
+ desc.regex ? new RegExp(desc.regex.source, desc.regex.flags) : desc.value,
141
+ { exact: desc.exact },
142
+ );
143
+ break;
144
+ }
145
+ if (desc.nth === "first") return base.first();
146
+ if (desc.nth === "last") return base.last();
147
+ if (typeof desc.nth === "number") return base.nth(desc.nth);
148
+ return base;
159
149
  }
160
150
 
161
151
  /** Short human label for a descriptor, used in event descriptions. */
162
152
  function descLabel(desc: LocatorDesc): string {
153
+ let base: string;
163
154
  if (desc.kind === "text") {
164
- return desc.regex ? `text /${desc.regex.source}/` : `text ${JSON.stringify(desc.value)}`;
155
+ base = desc.regex ? `text /${desc.regex.source}/` : `text ${JSON.stringify(desc.value)}`;
156
+ } else if (desc.kind === "role") {
157
+ base = desc.name ? `role ${desc.value} ${JSON.stringify(desc.name)}` : `role ${desc.value}`;
158
+ } else {
159
+ base = `${desc.kind} ${JSON.stringify(desc.value)}`;
165
160
  }
166
- if (desc.kind === "role") {
167
- return desc.name ? `role ${desc.value} ${JSON.stringify(desc.name)}` : `role ${desc.value}`;
168
- }
169
- return `${desc.kind} ${JSON.stringify(desc.value)}`;
161
+ if (desc.nth === "first" || desc.nth === "last") return `${base} .${desc.nth}()`;
162
+ if (typeof desc.nth === "number") return `${base} .nth(${desc.nth})`;
163
+ return base;
170
164
  }
171
165
 
172
166
  /** Default wait for a locator action's target to become visible. Override
@@ -177,79 +171,89 @@ const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
177
171
  function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
178
172
  const label = descLabel(desc);
179
173
 
180
- // Poll the unrecorded resolver until the element is visible. Returns the
181
- // tap coordinates. Throws a clear error on timeout.
182
- async function waitCoords(timeoutMs: number): Promise<{ x: number; y: number }> {
183
- const deadline = Date.now() + timeoutMs;
184
- for (;;) {
185
- const r = await backend.probe<ResolveResult>(resolveExpr(desc, true));
186
- if (r.found && r.visible && typeof r.x === "number" && typeof r.y === "number") {
187
- return { x: r.x, y: r.y };
188
- }
189
- if (Date.now() >= deadline) {
190
- throw new Error(`mobile: ${label} not visible after ${timeoutMs}ms`);
191
- }
192
- await new Promise((res) => setTimeout(res, 100));
193
- }
174
+ // Wait for the element to be visible (playwright auto-wait), center it,
175
+ // and return its tap coordinates.
176
+ async function centerOf(page: Page, timeout: number): Promise<{ x: number; y: number }> {
177
+ const loc = pwLocator(page, desc);
178
+ await loc.waitFor({ state: "visible", timeout });
179
+ await loc.scrollIntoViewIfNeeded({ timeout });
180
+ const box = await loc.boundingBox();
181
+ if (!box) throw new Error(`mobile: ${label} vanished before it could be tapped`);
182
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
183
+ }
184
+
185
+ async function tap(opts?: { timeoutMs?: number; durationMs?: number }): Promise<void> {
186
+ await backend.pageOp("click", { selector: label }, async (page) => {
187
+ const { x, y } = await centerOf(page, opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
188
+ // Manual CDP touch (not playwright's tap()): RN Pressables need the
189
+ // press dwell — see rawTap in browser.ts.
190
+ await backend.rawTap(x, y, opts?.durationMs);
191
+ });
192
+ }
193
+
194
+ async function waitForVisible(opts?: { timeoutMs?: number }): Promise<void> {
195
+ await backend.pageOp("waitFor", { description: `${label} visible` }, async (page) => {
196
+ await pwLocator(page, desc).waitFor({
197
+ state: "visible",
198
+ timeout: opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS,
199
+ });
200
+ });
194
201
  }
195
202
 
196
203
  return {
197
- async tap(opts) {
198
- const { x, y } = await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
199
- await backend.tapAt(x, y, { durationMs: opts?.durationMs });
200
- },
204
+ tap,
201
205
  async typeText(text, opts) {
202
- const { x, y } = await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
203
- await backend.tapAt(x, y);
206
+ // Two recorded events (click + type), same as a human: tap to focus,
207
+ // then insert the text.
208
+ await tap(opts);
204
209
  await backend.type(text);
205
210
  },
206
211
  async clearText(opts) {
207
- await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
208
- // Native value-setter + input event so RN-Web's controlled TextInput
209
- // sees the change (a plain `.value = ""` is swallowed by React).
210
- await backend.evaluate(
211
- `clear ${label}`,
212
- `(function(){
213
- var r = ${resolveExpr(desc, false)};
214
- var el = document.activeElement;
215
- if(!el || !('value' in el)) return false;
216
- var proto = el.tagName==='TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
217
- var setter = Object.getOwnPropertyDescriptor(proto,'value').set;
218
- setter.call(el, '');
219
- el.dispatchEvent(new Event('input', { bubbles: true }));
220
- return true;
221
- })()`,
222
- );
212
+ await backend.pageOp("evaluate", { description: `clear ${label}` }, async (page) => {
213
+ // playwright fill("") is the canonical React-safe clear (native
214
+ // value setter + input event, so RN-Web's controlled TextInput
215
+ // sees the change).
216
+ await pwLocator(page, desc).fill("", {
217
+ timeout: opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS,
218
+ });
219
+ });
223
220
  },
224
221
  async scrollIntoView() {
225
- // Routed through the recorded evaluate so the scroll lands in the replay.
226
- await backend.evaluate(`scrollIntoView ${label}`, resolveExpr(desc, true));
227
- },
228
- async waitFor(opts) {
229
- await backend.waitFor(
230
- `${label} visible`,
231
- `(function(){ var r = ${resolveExpr(desc, false)}; return (r.found && r.visible) ? r : null; })()`,
232
- { timeoutMs: opts?.timeoutMs ?? 5_000 },
233
- );
222
+ await backend.pageOp("scrollTo", { selector: label }, async (page) => {
223
+ const loc = pwLocator(page, desc);
224
+ await loc.waitFor({ state: "visible", timeout: DEFAULT_ACTION_TIMEOUT_MS });
225
+ await loc.scrollIntoViewIfNeeded({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
226
+ });
234
227
  },
228
+ waitFor: waitForVisible,
235
229
  async assertVisible(opts) {
236
230
  try {
237
- await this.waitFor(opts);
238
- } catch {
239
- throw new Error(`expected ${label} to be visible`);
231
+ await waitForVisible(opts);
232
+ } catch (e) {
233
+ const detail = e instanceof Error ? ` — ${e.message}` : "";
234
+ throw new Error(`expected ${label} to be visible${detail}`);
240
235
  }
241
236
  },
242
237
  isVisible() {
243
- return backend.evaluate<boolean>(
244
- `${label} visible?`,
245
- `(function(){ var r = ${resolveExpr(desc, false)}; return !!(r.found && r.visible); })()`,
246
- );
238
+ // pageOp("evaluate", …) provenance-wraps the value at runtime; the
239
+ // cast matches (same pattern as browser.ts's evaluate).
240
+ return backend.pageOp("evaluate", { description: `${label} visible?` }, (page) =>
241
+ pwLocator(page, desc).isVisible(),
242
+ ) as unknown as Promise<Wrapped<boolean>>;
247
243
  },
248
244
  textContent() {
249
- return backend.evaluate<string | null>(
250
- `${label} text`,
251
- `(function(){ var r = ${resolveExpr(desc, false)}; return r.found ? r.text : null; })()`,
252
- );
245
+ return backend.pageOp("evaluate", { description: `${label} text` }, (page) =>
246
+ pwLocator(page, desc).textContent({ timeout: DEFAULT_ACTION_TIMEOUT_MS }),
247
+ ) as Promise<Wrapped<string | null>>;
248
+ },
249
+ first() {
250
+ return makeLocator(backend, { ...desc, nth: "first" });
251
+ },
252
+ last() {
253
+ return makeLocator(backend, { ...desc, nth: "last" });
254
+ },
255
+ nth(index: number) {
256
+ return makeLocator(backend, { ...desc, nth: index });
253
257
  },
254
258
  };
255
259
  }
@@ -267,7 +271,9 @@ export interface Mobile {
267
271
  readonly title: string;
268
272
  /** Select by `testID` (RN-Web `data-testid`). The primary mobile selector. */
269
273
  getByTestId(testId: string): MobileLocator;
270
- /** Select by visible text (substring by default; pass a RegExp for patterns). */
274
+ /** Select by visible text (case-insensitive substring by default —
275
+ * playwright semantics; `exact: true` for a case-sensitive whole-string
276
+ * match, or pass a RegExp). */
271
277
  getByText(text: string | RegExp, opts?: { exact?: boolean }): MobileLocator;
272
278
  /** Select by ARIA role, optionally narrowed by accessible name. */
273
279
  getByRole(role: string, opts?: { name?: string; exact?: boolean }): MobileLocator;
@@ -16,6 +16,7 @@ var __defProp = Object.defineProperty;
16
16
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
17
17
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
18
18
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
19
+ if (!/* @__PURE__ */ /[1-9][0-9]{12}/.test(Date.now().toString())) ;
19
20
  function patch(source, name, replacement) {
20
21
  try {
21
22
  if (!(name in source)) {