@specific.dev/spectest 0.17.0 → 0.18.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/daemon.ts +17 -2
- package/src/mobile.ts +103 -129
- package/src/vendor/rrweb-plugin-console-record.umd.js +1 -0
- package/src/vendor/rrweb-record.min.js +5061 -5
package/src/daemon.ts
CHANGED
|
@@ -2967,6 +2967,13 @@ interface BrowserSessionRecord {
|
|
|
2967
2967
|
* (phone bezel). Drives the dashboard's chrome; the rrweb events are
|
|
2968
2968
|
* identical either way. */
|
|
2969
2969
|
frame?: "browser" | "mobile";
|
|
2970
|
+
/** Safe-area insets that were CDP-emulated during capture (mobile
|
|
2971
|
+
* sessions on a Chromium that supports the override). The dashboard
|
|
2972
|
+
* substitutes these for `env(safe-area-inset-*)` in the replayed CSS —
|
|
2973
|
+
* the replay iframe evaluates env() to 0, which would collapse the
|
|
2974
|
+
* app's own status-bar/home-indicator padding and shift every recorded
|
|
2975
|
+
* touch coordinate. Absent on desktop sessions and legacy captures. */
|
|
2976
|
+
safeAreaInsets?: { top: number; right: number; bottom: number; left: number };
|
|
2970
2977
|
steps: BrowserSessionStep[];
|
|
2971
2978
|
}
|
|
2972
2979
|
|
|
@@ -3810,6 +3817,9 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3810
3817
|
...(opts ?? {}),
|
|
3811
3818
|
recorder: session.recorder,
|
|
3812
3819
|
});
|
|
3820
|
+
if (browser.safeAreaInsets) {
|
|
3821
|
+
session.record.safeAreaInsets = browser.safeAreaInsets;
|
|
3822
|
+
}
|
|
3813
3823
|
// An attached session starts mid-page (no navigate event will fire) —
|
|
3814
3824
|
// stamp the inherited URL so the dashboard can still label the replay.
|
|
3815
3825
|
if (attached && session.record.initialUrl === undefined) {
|
|
@@ -3837,10 +3847,11 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3837
3847
|
if (existing) return existing;
|
|
3838
3848
|
const session = newBrowserSession(start, testCase.id, "mobile");
|
|
3839
3849
|
sessions.push(session);
|
|
3840
|
-
const { mobile, attached, detach } = await openPersistentMobile({
|
|
3850
|
+
const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
|
|
3841
3851
|
url: app.url,
|
|
3842
3852
|
recorder: session.recorder,
|
|
3843
3853
|
});
|
|
3854
|
+
if (safeAreaInsets) session.record.safeAreaInsets = safeAreaInsets;
|
|
3844
3855
|
if (attached && session.record.initialUrl === undefined) {
|
|
3845
3856
|
session.record.initialUrl = mobile.url;
|
|
3846
3857
|
}
|
|
@@ -4124,6 +4135,9 @@ async function evalCode(
|
|
|
4124
4135
|
...(opts ?? {}),
|
|
4125
4136
|
recorder: session.recorder,
|
|
4126
4137
|
});
|
|
4138
|
+
if (browser.safeAreaInsets) {
|
|
4139
|
+
session.record.safeAreaInsets = browser.safeAreaInsets;
|
|
4140
|
+
}
|
|
4127
4141
|
if (attached && session.record.initialUrl === undefined) {
|
|
4128
4142
|
session.record.initialUrl = browser.url;
|
|
4129
4143
|
}
|
|
@@ -4149,10 +4163,11 @@ async function evalCode(
|
|
|
4149
4163
|
if (existing) return existing;
|
|
4150
4164
|
const session = newBrowserSession(start, "eval", "mobile");
|
|
4151
4165
|
sessions.push(session);
|
|
4152
|
-
const { mobile, attached, detach } = await openPersistentMobile({
|
|
4166
|
+
const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
|
|
4153
4167
|
url: app.url,
|
|
4154
4168
|
recorder: session.recorder,
|
|
4155
4169
|
});
|
|
4170
|
+
if (safeAreaInsets) session.record.safeAreaInsets = safeAreaInsets;
|
|
4156
4171
|
if (attached && session.record.initialUrl === undefined) {
|
|
4157
4172
|
session.record.initialUrl = mobile.url;
|
|
4158
4173
|
}
|
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
|
|
@@ -82,80 +87,46 @@ export interface MobileLocator {
|
|
|
82
87
|
waitFor(opts?: { timeoutMs?: number }): Promise<void>;
|
|
83
88
|
/** Assert the element becomes visible within the timeout (throws otherwise). */
|
|
84
89
|
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
|
|
90
|
+
/** Whether the element is currently present and visible (no waiting).
|
|
91
|
+
* Wrapped so an `expect(...)` on it nests under the read in the
|
|
92
|
+
* timeline. */
|
|
87
93
|
isVisible(): Promise<Wrapped<boolean>>;
|
|
88
|
-
/** The element's
|
|
94
|
+
/** The element's text content, wrapped. Waits (default 5s) for the
|
|
95
|
+
* element to become visible; throws if it never does. */
|
|
89
96
|
textContent(): Promise<Wrapped<string | null>>;
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
function
|
|
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
|
-
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;
|
|
99
|
+
/** Map a locator descriptor onto a playwright Locator. The trailing
|
|
100
|
+
* `.filter({ visible: true }).first()` preserves the pre-Playwright
|
|
101
|
+
* selection rule: the first VISIBLE match wins, and a hidden duplicate
|
|
102
|
+
* (e.g. an offscreen menu item with the same label) neither gets picked
|
|
103
|
+
* nor trips playwright's strict-mode multi-match error. */
|
|
104
|
+
function pwLocator(page: Page, desc: LocatorDesc): Locator {
|
|
105
|
+
let base: Locator;
|
|
106
|
+
switch (desc.kind) {
|
|
107
|
+
case "css":
|
|
108
|
+
base = page.locator(desc.value);
|
|
109
|
+
break;
|
|
110
|
+
case "testid":
|
|
111
|
+
base = page.getByTestId(desc.value);
|
|
112
|
+
break;
|
|
113
|
+
case "label":
|
|
114
|
+
base = page.getByLabel(desc.value);
|
|
115
|
+
break;
|
|
116
|
+
case "role":
|
|
117
|
+
base = page.getByRole(
|
|
118
|
+
desc.value as Parameters<Page["getByRole"]>[0],
|
|
119
|
+
desc.name !== undefined ? { name: desc.name, exact: desc.nameExact } : {},
|
|
120
|
+
);
|
|
121
|
+
break;
|
|
122
|
+
default:
|
|
123
|
+
base = page.getByText(
|
|
124
|
+
desc.regex ? new RegExp(desc.regex.source, desc.regex.flags) : desc.value,
|
|
125
|
+
{ exact: desc.exact },
|
|
126
|
+
);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
return base.filter({ visible: true }).first();
|
|
159
130
|
}
|
|
160
131
|
|
|
161
132
|
/** Short human label for a descriptor, used in event descriptions. */
|
|
@@ -177,79 +148,80 @@ const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
|
|
|
177
148
|
function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
|
|
178
149
|
const label = descLabel(desc);
|
|
179
150
|
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
async function
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
151
|
+
// Wait for the element to be visible (playwright auto-wait), center it,
|
|
152
|
+
// and return its tap coordinates.
|
|
153
|
+
async function centerOf(page: Page, timeout: number): Promise<{ x: number; y: number }> {
|
|
154
|
+
const loc = pwLocator(page, desc);
|
|
155
|
+
await loc.waitFor({ state: "visible", timeout });
|
|
156
|
+
await loc.scrollIntoViewIfNeeded({ timeout });
|
|
157
|
+
const box = await loc.boundingBox();
|
|
158
|
+
if (!box) throw new Error(`mobile: ${label} vanished before it could be tapped`);
|
|
159
|
+
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function tap(opts?: { timeoutMs?: number; durationMs?: number }): Promise<void> {
|
|
163
|
+
await backend.pageOp("click", { selector: label }, async (page) => {
|
|
164
|
+
const { x, y } = await centerOf(page, opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
|
|
165
|
+
// Manual CDP touch (not playwright's tap()): RN Pressables need the
|
|
166
|
+
// press dwell — see rawTap in browser.ts.
|
|
167
|
+
await backend.rawTap(x, y, opts?.durationMs);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function waitForVisible(opts?: { timeoutMs?: number }): Promise<void> {
|
|
172
|
+
await backend.pageOp("waitFor", { description: `${label} visible` }, async (page) => {
|
|
173
|
+
await pwLocator(page, desc).waitFor({
|
|
174
|
+
state: "visible",
|
|
175
|
+
timeout: opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS,
|
|
176
|
+
});
|
|
177
|
+
});
|
|
194
178
|
}
|
|
195
179
|
|
|
196
180
|
return {
|
|
197
|
-
|
|
198
|
-
const { x, y } = await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
|
|
199
|
-
await backend.tapAt(x, y, { durationMs: opts?.durationMs });
|
|
200
|
-
},
|
|
181
|
+
tap,
|
|
201
182
|
async typeText(text, opts) {
|
|
202
|
-
|
|
203
|
-
|
|
183
|
+
// Two recorded events (click + type), same as a human: tap to focus,
|
|
184
|
+
// then insert the text.
|
|
185
|
+
await tap(opts);
|
|
204
186
|
await backend.type(text);
|
|
205
187
|
},
|
|
206
188
|
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
|
-
);
|
|
189
|
+
await backend.pageOp("evaluate", { description: `clear ${label}` }, async (page) => {
|
|
190
|
+
// playwright fill("") is the canonical React-safe clear (native
|
|
191
|
+
// value setter + input event, so RN-Web's controlled TextInput
|
|
192
|
+
// sees the change).
|
|
193
|
+
await pwLocator(page, desc).fill("", {
|
|
194
|
+
timeout: opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
223
197
|
},
|
|
224
198
|
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
|
-
);
|
|
199
|
+
await backend.pageOp("scrollTo", { selector: label }, async (page) => {
|
|
200
|
+
const loc = pwLocator(page, desc);
|
|
201
|
+
await loc.waitFor({ state: "visible", timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
202
|
+
await loc.scrollIntoViewIfNeeded({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
|
203
|
+
});
|
|
234
204
|
},
|
|
205
|
+
waitFor: waitForVisible,
|
|
235
206
|
async assertVisible(opts) {
|
|
236
207
|
try {
|
|
237
|
-
await
|
|
238
|
-
} catch {
|
|
239
|
-
|
|
208
|
+
await waitForVisible(opts);
|
|
209
|
+
} catch (e) {
|
|
210
|
+
const detail = e instanceof Error ? ` — ${e.message}` : "";
|
|
211
|
+
throw new Error(`expected ${label} to be visible${detail}`);
|
|
240
212
|
}
|
|
241
213
|
},
|
|
242
214
|
isVisible() {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
215
|
+
// pageOp("evaluate", …) provenance-wraps the value at runtime; the
|
|
216
|
+
// cast matches (same pattern as browser.ts's evaluate).
|
|
217
|
+
return backend.pageOp("evaluate", { description: `${label} visible?` }, (page) =>
|
|
218
|
+
pwLocator(page, desc).isVisible(),
|
|
219
|
+
) as unknown as Promise<Wrapped<boolean>>;
|
|
247
220
|
},
|
|
248
221
|
textContent() {
|
|
249
|
-
return backend.evaluate
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
);
|
|
222
|
+
return backend.pageOp("evaluate", { description: `${label} text` }, (page) =>
|
|
223
|
+
pwLocator(page, desc).textContent({ timeout: DEFAULT_ACTION_TIMEOUT_MS }),
|
|
224
|
+
) as Promise<Wrapped<string | null>>;
|
|
253
225
|
},
|
|
254
226
|
};
|
|
255
227
|
}
|
|
@@ -267,7 +239,9 @@ export interface Mobile {
|
|
|
267
239
|
readonly title: string;
|
|
268
240
|
/** Select by `testID` (RN-Web `data-testid`). The primary mobile selector. */
|
|
269
241
|
getByTestId(testId: string): MobileLocator;
|
|
270
|
-
/** Select by visible text (substring by default
|
|
242
|
+
/** Select by visible text (case-insensitive substring by default —
|
|
243
|
+
* playwright semantics; `exact: true` for a case-sensitive whole-string
|
|
244
|
+
* match, or pass a RegExp). */
|
|
271
245
|
getByText(text: string | RegExp, opts?: { exact?: boolean }): MobileLocator;
|
|
272
246
|
/** Select by ARIA role, optionally narrowed by accessible name. */
|
|
273
247
|
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)) {
|