@specific.dev/spectest 0.29.0 → 0.31.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/dist/browser.d.ts +34 -0
- package/dist/browser.js +39 -1
- package/dist/index.d.ts +24 -0
- package/dist/index.js +56 -1
- package/dist/locator.d.ts +21 -2
- package/dist/locator.js +92 -5
- package/dist/url-match.d.ts +11 -0
- package/dist/url-match.js +61 -0
- package/package.json +1 -1
package/dist/browser.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Wrapped } from "./inspect.js";
|
|
2
|
+
import type { UrlPattern } from "./url-match.js";
|
|
3
|
+
export type { UrlPattern } from "./url-match.js";
|
|
2
4
|
import type { GetByRoleOptions, GetByTextOptions, Locator } from "./locator.js";
|
|
3
5
|
import type { Page } from "playwright-core";
|
|
4
6
|
export interface BrowserOptions {
|
|
@@ -141,11 +143,43 @@ export interface Browser {
|
|
|
141
143
|
* keeps it (the one deviation from Playwright's bare `evaluate`).
|
|
142
144
|
*/
|
|
143
145
|
evaluate<T = unknown>(description: string, fn: string | ((arg?: unknown) => T | Promise<T>), arg?: unknown): Promise<Wrapped<T>>;
|
|
146
|
+
/**
|
|
147
|
+
* Wait until the page's URL matches `pattern` — Playwright's
|
|
148
|
+
* `page.waitForURL`. Resolves to the **matched URL** (provenance-wrapped,
|
|
149
|
+
* so `expect(...)` on it nests under this step), and returns immediately if
|
|
150
|
+
* the current URL already matches.
|
|
151
|
+
*
|
|
152
|
+
* This is the primitive for "the app redirected somewhere": an auth
|
|
153
|
+
* handoff, an OAuth callback, a post-login bounce. Reach for it instead of
|
|
154
|
+
* `waitForFunction("…", () => location.href.startsWith(…))` — that polls
|
|
155
|
+
* *inside* the page, so it races the navigation it's watching for (the
|
|
156
|
+
* `Execution context was destroyed` flake), misses any URL the browser only
|
|
157
|
+
* passes through between polls, and needs the destination to run JS at all.
|
|
158
|
+
* This one is driven by frame-navigation events, so it sees every commit.
|
|
159
|
+
*
|
|
160
|
+
* ```ts
|
|
161
|
+
* const url = await browser.waitForURL("https://api.workos.com/**");
|
|
162
|
+
* expect(url).toContain("code=");
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* `waitUntil` defaults to **`"commit"`**, not Playwright's `"load"`: the
|
|
166
|
+
* destination is usually somewhere the hermetic VM can't fully load, and
|
|
167
|
+
* waiting for its load event would time out on exactly the case this
|
|
168
|
+
* exists for. Pass `"load"`/`"domcontentloaded"` when you own the page and
|
|
169
|
+
* intend to keep driving it. Timeout defaults to 5 s.
|
|
170
|
+
*/
|
|
171
|
+
waitForURL(pattern: UrlPattern, options?: {
|
|
172
|
+
timeout?: number;
|
|
173
|
+
waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
|
|
174
|
+
}): Promise<Wrapped<string>>;
|
|
144
175
|
/**
|
|
145
176
|
* Poll `fn` in the page until it returns a truthy value (Playwright's
|
|
146
177
|
* `page.waitForFunction`), recorded as ONE step with the total wait + poll
|
|
147
178
|
* count. `fn` is a function (with optional `arg`) or a string expression.
|
|
148
179
|
* `description` labels the step. Defaults: 5 s timeout, 100 ms polling.
|
|
180
|
+
*
|
|
181
|
+
* For "wait until the page navigates somewhere" use {@link waitForURL} —
|
|
182
|
+
* an in-page poll is the wrong tool for watching a navigation.
|
|
149
183
|
*/
|
|
150
184
|
waitForFunction<T = unknown>(description: string, fn: string | ((arg?: unknown) => T), arg?: unknown, options?: {
|
|
151
185
|
timeout?: number;
|
package/dist/browser.js
CHANGED
|
@@ -34,7 +34,8 @@ import { fileURLToPath } from "node:url";
|
|
|
34
34
|
import { generateId } from "./ids.js";
|
|
35
35
|
import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
36
36
|
import { wrap } from "./inspect.js";
|
|
37
|
-
import {
|
|
37
|
+
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
38
|
+
import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
|
|
38
39
|
import { chromium } from "playwright-core";
|
|
39
40
|
/** Decoded byte count of a base64 string, without decoding it. */
|
|
40
41
|
function base64ByteLength(b64) {
|
|
@@ -1213,6 +1214,35 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1213
1214
|
return (await holder.page.evaluate(fn, arg));
|
|
1214
1215
|
}, { wrap: true });
|
|
1215
1216
|
},
|
|
1217
|
+
async waitForURL(pattern, options = {}) {
|
|
1218
|
+
const timeoutMs = options.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
1219
|
+
const label = describeUrlPattern(pattern);
|
|
1220
|
+
// `url` is stamped with the MATCHED url once we have it (the timeline
|
|
1221
|
+
// wants where we landed, not the pattern); `description` carries the
|
|
1222
|
+
// pattern so a timed-out step still says what it was waiting for.
|
|
1223
|
+
const fields = { description: label };
|
|
1224
|
+
return instrumented("waitForURL", fields, async () => {
|
|
1225
|
+
try {
|
|
1226
|
+
// Always a predicate, never playwright's own glob: `matchesUrl`
|
|
1227
|
+
// is then the single matching implementation shared with
|
|
1228
|
+
// `expect(browser).toHaveURL`.
|
|
1229
|
+
await holder.page.waitForURL((u) => matchesUrl(u.href, pattern), {
|
|
1230
|
+
timeout: timeoutMs,
|
|
1231
|
+
waitUntil: options.waitUntil ?? "commit",
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
catch (err) {
|
|
1235
|
+
// Playwright's timeout message is a wall of call-log; ours names
|
|
1236
|
+
// the pattern and where the page actually sat.
|
|
1237
|
+
if (err?.name !== "TimeoutError")
|
|
1238
|
+
throw err;
|
|
1239
|
+
throw new Error(`waitForURL ${label} timed out after ${timeoutMs}ms (current URL: ${holder.page.url()})`);
|
|
1240
|
+
}
|
|
1241
|
+
const matched = holder.page.url();
|
|
1242
|
+
fields.url = matched;
|
|
1243
|
+
return matched;
|
|
1244
|
+
}, { wrap: true });
|
|
1245
|
+
},
|
|
1216
1246
|
async waitForFunction(description, fn, arg, options = {}) {
|
|
1217
1247
|
const timeoutMs = options.timeout ?? 5_000;
|
|
1218
1248
|
const intervalMs = options.polling ?? 100;
|
|
@@ -1397,5 +1427,13 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1397
1427
|
return instrumented(action, fields, () => fn(holder.page), opts);
|
|
1398
1428
|
},
|
|
1399
1429
|
};
|
|
1430
|
+
// The seam `expect(browser).toHaveURL(...)` polls: a silent URL read plus
|
|
1431
|
+
// the single settled step its assertion nests under — the session twin of
|
|
1432
|
+
// the locator probe, and what brands this object as a browser session for
|
|
1433
|
+
// `expect`'s overload dispatch.
|
|
1434
|
+
attachBrowserProbe(backend, {
|
|
1435
|
+
url: () => holder.page.url(),
|
|
1436
|
+
settle: (action, waitedMs, error) => backend.recordSettled(action, { url: holder.page.url() }, waitedMs, error),
|
|
1437
|
+
});
|
|
1400
1438
|
return { backend, detach: endRecording };
|
|
1401
1439
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./br
|
|
|
9
9
|
import type { Browser, BrowserOptions } from "./browser.js";
|
|
10
10
|
export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, } from "./locator.js";
|
|
11
11
|
import type { Locator } from "./locator.js";
|
|
12
|
+
export type { UrlPattern } from "./url-match.js";
|
|
13
|
+
import type { UrlPattern } from "./url-match.js";
|
|
12
14
|
export type { Mobile, MobileApp } from "./mobile.js";
|
|
13
15
|
import type { Mobile, MobileApp } from "./mobile.js";
|
|
14
16
|
export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
|
|
@@ -1309,7 +1311,29 @@ export interface LocatorAssertion extends LocatorMatchers {
|
|
|
1309
1311
|
/** Negate every matcher (retries until the negated condition holds). */
|
|
1310
1312
|
not: LocatorMatchers;
|
|
1311
1313
|
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Auto-retrying assertions for a browser/mobile **session** — Playwright's
|
|
1316
|
+
* `expect(page)` matchers. Like the locator ones, they poll until the
|
|
1317
|
+
* condition holds or the deadline elapses (default 5 s) and record one
|
|
1318
|
+
* assertion under a settled browser step.
|
|
1319
|
+
*/
|
|
1320
|
+
export interface BrowserMatchers {
|
|
1321
|
+
/**
|
|
1322
|
+
* The page's URL matches `expected` — a glob string (`*` within a path
|
|
1323
|
+
* segment, `**` across `/`), a RegExp, or a predicate over the parsed URL.
|
|
1324
|
+
* The settling twin of {@link Browser.waitForURL}: use `waitForURL` when
|
|
1325
|
+
* you want the matched URL back, this when you only mean to assert.
|
|
1326
|
+
*/
|
|
1327
|
+
toHaveURL(expected: UrlPattern, opts?: {
|
|
1328
|
+
timeout?: number;
|
|
1329
|
+
}): Promise<void>;
|
|
1330
|
+
}
|
|
1331
|
+
export interface BrowserAssertion extends BrowserMatchers {
|
|
1332
|
+
/** Negate every matcher (retries until the negated condition holds). */
|
|
1333
|
+
not: BrowserMatchers;
|
|
1334
|
+
}
|
|
1312
1335
|
export declare function expect(actual: Locator, message?: string): LocatorAssertion;
|
|
1336
|
+
export declare function expect(actual: Browser, message?: string): BrowserAssertion;
|
|
1313
1337
|
export declare function expect(actual: Provenanced, message?: string): Expectation;
|
|
1314
1338
|
/**
|
|
1315
1339
|
* Assert on a value with **no provenance** — a computed number, a raw
|
package/dist/index.js
CHANGED
|
@@ -25,7 +25,8 @@ export { field } from "./inspect.js";
|
|
|
25
25
|
export { SQL } from "./sql.js";
|
|
26
26
|
export { RedisClient } from "./redis.js";
|
|
27
27
|
export { S3Client } from "./s3.js";
|
|
28
|
-
import { isLocator, getLocatorProbe, DEFAULT_ACTION_TIMEOUT_MS } from "./locator.js";
|
|
28
|
+
import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, } from "./locator.js";
|
|
29
|
+
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
29
30
|
// Low-level ingress primitives + the framework lowering that the friendly
|
|
30
31
|
// `tls` / `hostnames` fields and `defineFake(...)` are built on. See
|
|
31
32
|
// `ingress.ts`.
|
|
@@ -458,6 +459,8 @@ export class ExpectationError extends Error {
|
|
|
458
459
|
export function expect(actual, message) {
|
|
459
460
|
if (isLocator(actual))
|
|
460
461
|
return buildLocatorAssertion(actual, message);
|
|
462
|
+
if (isBrowserSession(actual))
|
|
463
|
+
return buildBrowserAssertion(actual, message);
|
|
461
464
|
return expectValue(actual, message);
|
|
462
465
|
}
|
|
463
466
|
function expectValue(actual, message) {
|
|
@@ -593,6 +596,58 @@ function buildLocatorMatchers(loc, negated, message) {
|
|
|
593
596
|
}, () => `expected ${probe.label}${not} to be checked`),
|
|
594
597
|
};
|
|
595
598
|
}
|
|
599
|
+
// ── expect(browser): auto-retrying session matchers ───────────────────────
|
|
600
|
+
function buildBrowserAssertion(session, message) {
|
|
601
|
+
return Object.assign(buildBrowserMatchers(session, false, message), {
|
|
602
|
+
not: buildBrowserMatchers(session, true, message),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
function buildBrowserMatchers(session, negated, message) {
|
|
606
|
+
const probe = getBrowserProbe(session);
|
|
607
|
+
const not = negated ? " not" : "";
|
|
608
|
+
return {
|
|
609
|
+
toHaveURL: async (expected, opts) => {
|
|
610
|
+
const started = Date.now();
|
|
611
|
+
const deadline = started + (opts?.timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
|
|
612
|
+
// `probe.url()` is a synchronous unrecorded read, so the poll costs
|
|
613
|
+
// nothing and emits nothing — one settled step at the end, exactly like
|
|
614
|
+
// the locator matchers.
|
|
615
|
+
const label = describeUrlPattern(expected);
|
|
616
|
+
for (;;) {
|
|
617
|
+
const actual = probe.url();
|
|
618
|
+
if (matchesUrl(actual, expected) !== negated) {
|
|
619
|
+
const sourceSeq = await probe.settle("toHaveURL", Date.now() - started);
|
|
620
|
+
recordAssertion({
|
|
621
|
+
matcher: "toHaveURL",
|
|
622
|
+
negated,
|
|
623
|
+
passed: true,
|
|
624
|
+
actual: safeSerialize(actual),
|
|
625
|
+
expected: label,
|
|
626
|
+
message,
|
|
627
|
+
sourceSeq,
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (Date.now() >= deadline) {
|
|
632
|
+
const msg = `expected page URL${not} to match ${label}, got ${fmt(actual)}`;
|
|
633
|
+
const sourceSeq = await probe.settle("toHaveURL", Date.now() - started, msg);
|
|
634
|
+
recordAssertion({
|
|
635
|
+
matcher: "toHaveURL",
|
|
636
|
+
negated,
|
|
637
|
+
passed: false,
|
|
638
|
+
actual: safeSerialize(actual),
|
|
639
|
+
expected: label,
|
|
640
|
+
error: msg,
|
|
641
|
+
message,
|
|
642
|
+
sourceSeq,
|
|
643
|
+
});
|
|
644
|
+
throw new ExpectationError(msg);
|
|
645
|
+
}
|
|
646
|
+
await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
}
|
|
596
651
|
/**
|
|
597
652
|
* Assert on a value with **no provenance** — a computed number, a raw
|
|
598
653
|
* WebSocket frame, anything that didn't flow from a recorded op. `message` is
|
package/dist/locator.d.ts
CHANGED
|
@@ -141,6 +141,24 @@ export interface LocatorProbe {
|
|
|
141
141
|
* `undefined` when nothing is recording. */
|
|
142
142
|
settle(action: string, waitedMs: number, error?: string): Promise<number | undefined>;
|
|
143
143
|
}
|
|
144
|
+
/** Silent (non-recorded) reads an `expect(browser)` matcher polls — the
|
|
145
|
+
* session twin of {@link LocatorProbe}. Lives here, next to it, so `index.ts`
|
|
146
|
+
* can reach both seams without importing `browser.ts` (and with it
|
|
147
|
+
* playwright) at runtime. */
|
|
148
|
+
export interface BrowserProbe {
|
|
149
|
+
/** Current main-frame URL. Synchronous, unrecorded, no rrweb drain. */
|
|
150
|
+
url(): string;
|
|
151
|
+
/** After the silent poll settles, emit the single settled browser step the
|
|
152
|
+
* matcher's assertion nests under, and return its seq. Same contract as
|
|
153
|
+
* {@link LocatorProbe.settle}. */
|
|
154
|
+
settle(action: string, waitedMs: number, error?: string): Promise<number | undefined>;
|
|
155
|
+
}
|
|
156
|
+
/** Brand a browser/mobile session with its {@link BrowserProbe}. */
|
|
157
|
+
export declare function attachBrowserProbe<T extends object>(session: T, probe: BrowserProbe): T;
|
|
158
|
+
/** True if `x` is a spectest browser/mobile session (i.e. probe-branded). */
|
|
159
|
+
export declare function isBrowserSession(x: unknown): boolean;
|
|
160
|
+
/** The probe branded onto a browser/mobile session by `buildBackend`. */
|
|
161
|
+
export declare function getBrowserProbe(session: unknown): BrowserProbe;
|
|
144
162
|
/** The silent-read probe for a locator — the seam `expect(locator)` matchers
|
|
145
163
|
* poll (in index.ts) without pulling playwright types or the backend into
|
|
146
164
|
* that module. */
|
|
@@ -150,11 +168,12 @@ export declare function getLocatorProbe(loc: Locator): LocatorProbe;
|
|
|
150
168
|
export interface ActionStrategy {
|
|
151
169
|
/** Whether this session supports touch. Desktop → `tap()` throws. */
|
|
152
170
|
readonly touch: boolean;
|
|
153
|
-
/** Perform a touch tap on the resolved element (mobile only).
|
|
171
|
+
/** Perform a touch tap on the resolved element (mobile only). `rec` is the
|
|
172
|
+
* gesture's own step, still open — see {@link stampActionPoint}. */
|
|
154
173
|
tap(backend: LocatorBackend, loc: PWLocator, opts?: {
|
|
155
174
|
timeout?: number;
|
|
156
175
|
duration?: number;
|
|
157
|
-
}): Promise<void>;
|
|
176
|
+
}, rec?: Partial<RecordableFields>): Promise<void>;
|
|
158
177
|
}
|
|
159
178
|
/** Desktop: no touchscreen. `tap()` is a mobile gesture — steer authors to
|
|
160
179
|
* `click()`. */
|
package/dist/locator.js
CHANGED
|
@@ -164,6 +164,20 @@ export function chainLabel(chain) {
|
|
|
164
164
|
return out;
|
|
165
165
|
}
|
|
166
166
|
const PROBE = Symbol.for("spectest.locatorProbe");
|
|
167
|
+
const BROWSER_PROBE = Symbol.for("spectest.browserProbe");
|
|
168
|
+
/** Brand a browser/mobile session with its {@link BrowserProbe}. */
|
|
169
|
+
export function attachBrowserProbe(session, probe) {
|
|
170
|
+
session[BROWSER_PROBE] = probe;
|
|
171
|
+
return session;
|
|
172
|
+
}
|
|
173
|
+
/** True if `x` is a spectest browser/mobile session (i.e. probe-branded). */
|
|
174
|
+
export function isBrowserSession(x) {
|
|
175
|
+
return typeof x === "object" && x !== null && BROWSER_PROBE in x;
|
|
176
|
+
}
|
|
177
|
+
/** The probe branded onto a browser/mobile session by `buildBackend`. */
|
|
178
|
+
export function getBrowserProbe(session) {
|
|
179
|
+
return session[BROWSER_PROBE];
|
|
180
|
+
}
|
|
167
181
|
/** The silent-read probe for a locator — the seam `expect(locator)` matchers
|
|
168
182
|
* poll (in index.ts) without pulling playwright types or the backend into
|
|
169
183
|
* that module. */
|
|
@@ -182,15 +196,74 @@ export const desktopStrategy = {
|
|
|
182
196
|
* the press dwell RN Pressables need (playwright's `tap()` has no dwell). */
|
|
183
197
|
export const mobileStrategy = {
|
|
184
198
|
touch: true,
|
|
185
|
-
async tap(backend, loc, opts) {
|
|
199
|
+
async tap(backend, loc, opts, rec) {
|
|
186
200
|
await loc.waitFor({ state: "visible", timeout: opts?.timeout });
|
|
187
201
|
await loc.scrollIntoViewIfNeeded({ timeout: opts?.timeout });
|
|
188
202
|
const box = await loc.boundingBox();
|
|
189
203
|
if (!box)
|
|
190
204
|
throw new Error("tap: element vanished before it could be tapped");
|
|
191
|
-
|
|
205
|
+
const x = box.x + box.width / 2;
|
|
206
|
+
const y = box.y + box.height / 2;
|
|
207
|
+
// Exact, no probe needed: this IS the point the touch is dispatched at,
|
|
208
|
+
// measured after the scroll. Same field the replay reads — see
|
|
209
|
+
// stampActionPoint.
|
|
210
|
+
if (rec) {
|
|
211
|
+
rec.x = Math.round(x);
|
|
212
|
+
rec.y = Math.round(y);
|
|
213
|
+
}
|
|
214
|
+
await backend.rawTap(x, y, opts?.duration);
|
|
192
215
|
},
|
|
193
216
|
};
|
|
217
|
+
/** How long the click-point probe waits for the element. Short on purpose:
|
|
218
|
+
* either it is already on screen and the answer is one round trip away, or
|
|
219
|
+
* the action's own actionability wait — with the author's timeout and a real
|
|
220
|
+
* error message — is the one that should report it. */
|
|
221
|
+
const POINT_PROBE_MS = 250;
|
|
222
|
+
/** Stamp the viewport point an action is about to act on onto its (still
|
|
223
|
+
* open) step, as the `x`/`y` a raw `browser.clickAt` records.
|
|
224
|
+
*
|
|
225
|
+
* The replay cursor is normally positioned from rrweb's own recorded mouse
|
|
226
|
+
* events — but a click that navigates loses them. The page-side event buffer
|
|
227
|
+
* lives on `window`, so everything the click emitted dies with the document
|
|
228
|
+
* before the post-op drain can collect it, and the step arrives carrying only
|
|
229
|
+
* the destination page's load. The step's own coordinates are then the sole
|
|
230
|
+
* record of where the click landed; without them the viewer has nothing to
|
|
231
|
+
* put the cursor on.
|
|
232
|
+
*
|
|
233
|
+
* Best-effort in every direction. A probe failure (not attached yet, strict
|
|
234
|
+
* violation, frame detached) leaves the step unstamped and lets the action
|
|
235
|
+
* itself report the problem. We only stamp a centre that is inside the
|
|
236
|
+
* viewport of the top-level document, because that is exactly the case where
|
|
237
|
+
* playwright clicks the element where it currently sits — one that is
|
|
238
|
+
* off-screen (or in an iframe, whose rect is frame-relative) gets scrolled
|
|
239
|
+
* into view first, which moves the point we measured. */
|
|
240
|
+
async function stampActionPoint(loc, rec, position) {
|
|
241
|
+
try {
|
|
242
|
+
const pt = await loc.evaluate((el, pos) => {
|
|
243
|
+
if (window.top !== window)
|
|
244
|
+
return null;
|
|
245
|
+
const r = el.getBoundingClientRect();
|
|
246
|
+
if (!r.width || !r.height)
|
|
247
|
+
return null;
|
|
248
|
+
// Playwright clicks the element's centre unless the caller named a
|
|
249
|
+
// point — which it takes relative to the PADDING box, so an element
|
|
250
|
+
// with a border (a plain `<button>` has 2px of it) sits that far off
|
|
251
|
+
// the rect we measured.
|
|
252
|
+
const cs = pos ? getComputedStyle(el) : null;
|
|
253
|
+
const x = cs ? r.left + parseFloat(cs.borderLeftWidth) + pos.x : r.left + r.width / 2;
|
|
254
|
+
const y = cs ? r.top + parseFloat(cs.borderTopWidth) + pos.y : r.top + r.height / 2;
|
|
255
|
+
const onScreen = x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight;
|
|
256
|
+
return onScreen ? { x, y } : null;
|
|
257
|
+
}, position, { timeout: POINT_PROBE_MS });
|
|
258
|
+
if (pt) {
|
|
259
|
+
rec.x = Math.round(pt.x);
|
|
260
|
+
rec.y = Math.round(pt.y);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
/* Element not ready / gone / strict violation — the action reports it. */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
194
267
|
// ────────────────────────────────────────────────────────────────────────
|
|
195
268
|
// Factory
|
|
196
269
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -202,6 +275,14 @@ export function makeLocator(backend, strategy, chain) {
|
|
|
202
275
|
const extend = (step) => makeLocator(backend, strategy, { steps: [...chain.steps, step] });
|
|
203
276
|
// One recorded event, result NOT wrapped (void/action).
|
|
204
277
|
const act = (action, fields, fn) => backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain), page));
|
|
278
|
+
// One recorded event whose fields the action itself finishes filling in:
|
|
279
|
+
// `rec` is the very object the recorder spreads once `fn` resolves (the
|
|
280
|
+
// same mutate-in-flight seam `screenshot`'s artifact id rides), so the
|
|
281
|
+
// click family can stamp the point it acted on. See stampActionPoint.
|
|
282
|
+
const actAt = (action, fn) => {
|
|
283
|
+
const rec = { selector: label };
|
|
284
|
+
return backend.pageOp(action, rec, (page) => fn(lower(page, chain), rec));
|
|
285
|
+
};
|
|
205
286
|
// One recorded event, result provenance-wrapped for expect().
|
|
206
287
|
const read = (action, fn, fields = {}) => backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain)), {
|
|
207
288
|
wrap: true,
|
|
@@ -236,9 +317,15 @@ export function makeLocator(backend, strategy, chain) {
|
|
|
236
317
|
last: () => extend({ m: "last", args: [] }),
|
|
237
318
|
nth: (i) => extend({ m: "nth", args: [i] }),
|
|
238
319
|
// actions
|
|
239
|
-
click: (opts) =>
|
|
240
|
-
|
|
241
|
-
|
|
320
|
+
click: (opts) => actAt("click", async (l, rec) => {
|
|
321
|
+
await stampActionPoint(l, rec, opts?.position);
|
|
322
|
+
return l.click(opts);
|
|
323
|
+
}),
|
|
324
|
+
dblclick: (opts) => actAt("dblclick", async (l, rec) => {
|
|
325
|
+
await stampActionPoint(l, rec, opts?.position);
|
|
326
|
+
return l.dblclick(opts);
|
|
327
|
+
}),
|
|
328
|
+
tap: (opts) => actAt("tap", (l, rec) => strategy.tap(backend, l, opts, rec)),
|
|
242
329
|
fill: (value, opts) => {
|
|
243
330
|
const t = truncateUtf8(value);
|
|
244
331
|
return act("fill", { text: t.value, textTruncated: t.truncated }, (l) => l.fill(value, { timeout: opts?.timeout }));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a URL is matched. A **glob string** (`*` stays inside one path segment,
|
|
3
|
+
* `**` crosses `/`; anything else is literal, so a plain URL is an exact
|
|
4
|
+
* match), a **RegExp** (tested against the full URL, unanchored), or a
|
|
5
|
+
* **predicate** over the parsed {@link URL}.
|
|
6
|
+
*/
|
|
7
|
+
export type UrlPattern = string | RegExp | ((url: URL) => boolean);
|
|
8
|
+
/** Whether `current` (a full URL string) matches `pattern`. */
|
|
9
|
+
export declare function matchesUrl(current: string, pattern: UrlPattern): boolean;
|
|
10
|
+
/** Human display of a pattern, for timeline labels and failure messages. */
|
|
11
|
+
export declare function describeUrlPattern(pattern: UrlPattern): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// URL pattern matching — shared by `browser.waitForURL(...)` and
|
|
2
|
+
// `expect(browser).toHaveURL(...)`.
|
|
3
|
+
//
|
|
4
|
+
// Both take the same pattern forms as Playwright (glob string | RegExp |
|
|
5
|
+
// predicate over a `URL`), and both lower to `matchesUrl` here so the two
|
|
6
|
+
// can never disagree: `waitForURL` hands playwright a predicate that calls
|
|
7
|
+
// this rather than letting playwright apply its own glob dialect, and the
|
|
8
|
+
// matcher polls it directly. Deliberately dependency-free so `index.ts` (the
|
|
9
|
+
// module a project imports on the host, where playwright isn't installed) can
|
|
10
|
+
// use it without pulling in `browser.ts`.
|
|
11
|
+
/** Whether `current` (a full URL string) matches `pattern`. */
|
|
12
|
+
export function matchesUrl(current, pattern) {
|
|
13
|
+
if (typeof pattern === "function") {
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = new URL(current);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// `about:blank`, a data: URL, or a page that never committed — nothing
|
|
20
|
+
// for a predicate to inspect, so it can't match.
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return Boolean(pattern(parsed));
|
|
24
|
+
}
|
|
25
|
+
if (pattern instanceof RegExp)
|
|
26
|
+
return pattern.test(current);
|
|
27
|
+
return globToRegExp(pattern).test(current);
|
|
28
|
+
}
|
|
29
|
+
/** Human display of a pattern, for timeline labels and failure messages. */
|
|
30
|
+
export function describeUrlPattern(pattern) {
|
|
31
|
+
if (typeof pattern === "function")
|
|
32
|
+
return "a URL predicate";
|
|
33
|
+
if (pattern instanceof RegExp)
|
|
34
|
+
return String(pattern);
|
|
35
|
+
return JSON.stringify(pattern);
|
|
36
|
+
}
|
|
37
|
+
// Compiled globs are cached: a matcher polls the same pattern every 50ms.
|
|
38
|
+
const GLOB_CACHE = new Map();
|
|
39
|
+
function globToRegExp(glob) {
|
|
40
|
+
const hit = GLOB_CACHE.get(glob);
|
|
41
|
+
if (hit)
|
|
42
|
+
return hit;
|
|
43
|
+
let out = "";
|
|
44
|
+
for (let i = 0; i < glob.length; i++) {
|
|
45
|
+
const c = glob[i];
|
|
46
|
+
if (c === "*") {
|
|
47
|
+
if (glob[i + 1] === "*") {
|
|
48
|
+
out += ".*";
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
out += "[^/]*";
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
57
|
+
}
|
|
58
|
+
const re = new RegExp(`^${out}$`);
|
|
59
|
+
GLOB_CACHE.set(glob, re);
|
|
60
|
+
return re;
|
|
61
|
+
}
|