@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/package.json +3 -2
- package/src/browser.ts +372 -226
- package/src/mobile.ts +141 -135
- package/src/vendor/rrweb-plugin-console-record.umd.js +1 -0
- package/src/vendor/rrweb-record.min.js +5061 -5
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
|
|
15
|
-
//
|
|
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
|
|
86
|
-
* `expect(...)` on it nests under the read in the
|
|
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
|
|
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
|
-
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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.
|
|
167
|
-
|
|
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
|
-
//
|
|
181
|
-
//
|
|
182
|
-
async function
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
238
|
-
} catch {
|
|
239
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
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)) {
|