@specific.dev/spectest 0.28.3 → 0.30.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 +120 -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 +14 -0
- 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) {
|
|
@@ -550,6 +551,87 @@ const RRWEB_BOOTSTRAP = `
|
|
|
550
551
|
}, true);
|
|
551
552
|
} catch (e) { log("init FAIL " + (e && e.message)); }
|
|
552
553
|
})();
|
|
554
|
+
// ── Record form resets as synthetic Input events ───────────────────
|
|
555
|
+
// rrweb learns that a control's value changed through exactly two
|
|
556
|
+
// channels: the \`input\`/\`change\` events, and setters it hooks on the
|
|
557
|
+
// HTMLInput/TextArea/SelectElement prototypes. A **form reset** uses
|
|
558
|
+
// neither — the browser clears the controls internally — so the
|
|
559
|
+
// recording keeps the last typed value forever while the real page
|
|
560
|
+
// shows an empty field. Measured, not assumed: \`el.value = x\` IS
|
|
561
|
+
// captured (even through React's own per-node \`value\` accessor, since
|
|
562
|
+
// that delegates to the prototype descriptor we hooked first), and
|
|
563
|
+
// \`form.reset()\` is NOT. The shape is everywhere in chat/prompt UIs —
|
|
564
|
+
// shadcn/ai-elements' PromptInput resets the form on submit, as does
|
|
565
|
+
// React 19's automatic \`<form action>\` reset — where it reads as
|
|
566
|
+
// "clicking send didn't clear the box".
|
|
567
|
+
//
|
|
568
|
+
// The \`reset\` event is the hook: it fires (cancelable) BEFORE the
|
|
569
|
+
// controls are reset, for both \`form.reset()\` and a user-clicked
|
|
570
|
+
// \`type="reset"\` button. Snapshot the values there, diff on the next
|
|
571
|
+
// task, and push an Input event per control that actually moved —
|
|
572
|
+
// byte-identical in shape to what rrweb emits for a real input, so
|
|
573
|
+
// the replayer applies it the same way. A prevented or no-op reset
|
|
574
|
+
// changes nothing and therefore emits nothing. We never touch the
|
|
575
|
+
// page's DOM to provoke a recording (\`el.value = el.value\` would
|
|
576
|
+
// work, but it sets the control's dirty-value flag and would change
|
|
577
|
+
// how the app's own later attribute writes render).
|
|
578
|
+
(function () {
|
|
579
|
+
if (window.__spectestFormReset) return;
|
|
580
|
+
window.__spectestFormReset = true;
|
|
581
|
+
var INPUT_TAGS = ["INPUT", "TEXTAREA", "SELECT"];
|
|
582
|
+
function snap(form) {
|
|
583
|
+
var out = [], els;
|
|
584
|
+
try { els = form.elements; } catch (e) { return out; }
|
|
585
|
+
if (!els) return out;
|
|
586
|
+
for (var i = 0; i < els.length; i++) {
|
|
587
|
+
var el = els[i];
|
|
588
|
+
if (!el || !el.tagName) continue;
|
|
589
|
+
if (INPUT_TAGS.indexOf(String(el.tagName).toUpperCase()) < 0) continue;
|
|
590
|
+
out.push({ el: el, value: el.value, checked: !!el.checked });
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
function emitChanged(before) {
|
|
595
|
+
var mirror = rec && rec.mirror;
|
|
596
|
+
if (!mirror || typeof mirror.getId !== "function") return;
|
|
597
|
+
for (var i = 0; i < before.length; i++) {
|
|
598
|
+
var el = before[i].el;
|
|
599
|
+
if (el.value === before[i].value && !!el.checked === before[i].checked) continue;
|
|
600
|
+
if (el.classList && el.classList.contains("rr-ignore")) continue;
|
|
601
|
+
var id = mirror.getId(el);
|
|
602
|
+
if (!(id > 0)) continue;
|
|
603
|
+
var type = el.type ? String(el.type).toLowerCase() : "";
|
|
604
|
+
var text = String(el.value);
|
|
605
|
+
// rrweb masks password inputs by default (maskInputOptions
|
|
606
|
+
// \`{ password: true }\`, default maskInputFn = same-length '*').
|
|
607
|
+
if (type === "password") {
|
|
608
|
+
var masked = "";
|
|
609
|
+
for (var k = 0; k < text.length; k++) masked += "*";
|
|
610
|
+
text = masked;
|
|
611
|
+
}
|
|
612
|
+
window.__spectestRrwebEvents.push({
|
|
613
|
+
type: 3,
|
|
614
|
+
data: {
|
|
615
|
+
source: 5,
|
|
616
|
+
text: text,
|
|
617
|
+
isChecked: (type === "radio" || type === "checkbox") ? !!el.checked : false,
|
|
618
|
+
id: id,
|
|
619
|
+
},
|
|
620
|
+
timestamp: Date.now(),
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
document.addEventListener("reset", function (ev) {
|
|
626
|
+
var form = ev.target;
|
|
627
|
+
if (!form || !form.tagName || String(form.tagName).toUpperCase() !== "FORM") return;
|
|
628
|
+
var before = snap(form);
|
|
629
|
+
if (!before.length) return;
|
|
630
|
+
// The controls are reset after dispatch returns — diff then.
|
|
631
|
+
setTimeout(function () { emitChanged(before); }, 0);
|
|
632
|
+
}, true);
|
|
633
|
+
} catch (e) { /* recording continues without reset capture. */ }
|
|
634
|
+
})();
|
|
553
635
|
})();
|
|
554
636
|
`;
|
|
555
637
|
// The `;` between parts is load-bearing: a vendored bundle that ends
|
|
@@ -1132,6 +1214,35 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1132
1214
|
return (await holder.page.evaluate(fn, arg));
|
|
1133
1215
|
}, { wrap: true });
|
|
1134
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
|
+
},
|
|
1135
1246
|
async waitForFunction(description, fn, arg, options = {}) {
|
|
1136
1247
|
const timeoutMs = options.timeout ?? 5_000;
|
|
1137
1248
|
const intervalMs = options.polling ?? 100;
|
|
@@ -1316,5 +1427,13 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1316
1427
|
return instrumented(action, fields, () => fn(holder.page), opts);
|
|
1317
1428
|
},
|
|
1318
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
|
+
});
|
|
1319
1438
|
return { backend, detach: endRecording };
|
|
1320
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. */
|
|
@@ -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
|
+
}
|