@specific.dev/spectest 0.122.0 → 0.123.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/locator.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { LocatorAssertionRequest } from "./playwright-assertions.js";
1
2
  import type { Page, Locator as PWLocator } from "playwright-core";
2
3
  import type { RecordableFields } from "./browser.js";
3
4
  import type { Wrapped } from "./inspect.js";
@@ -140,7 +141,7 @@ export declare function chainLabel(chain: Chain): string;
140
141
  /** The slice of the browser backend a locator needs. `pageOp` records one
141
142
  * browser event and (when `wrap`) provenance-wraps the result so a later
142
143
  * `expect(...)` nests under the step. `silentRead` runs a lowered read with
143
- * NO recording/drain — the poll path for `expect(locator)` matchers, which
144
+ * NO recording/drain — the assertion path for `expect(locator)` matchers, which
144
145
  * must not emit one browser event per retry. */
145
146
  export interface LocatorBackend {
146
147
  pageOp<T>(action: string, fields: Partial<RecordableFields>, fn: (page: Page) => Promise<T>, opts?: {
@@ -154,30 +155,18 @@ export interface LocatorBackend {
154
155
  * See {@link "./browser".MobileBackend.recordSettled}. */
155
156
  recordSettled(action: string, fields: Partial<RecordableFields>, waitedMs: number, error?: string): Promise<number | undefined>;
156
157
  }
157
- /** Silent (non-recorded) reads a locator exposes for `expect(...)` matchers to
158
- * poll. See {@link getLocatorProbe}. */
158
+ /** Native assertions and recording for a lazy Spectest locator. */
159
159
  export interface LocatorProbe {
160
- /** Human label of the locator (for failure messages). */
161
160
  readonly label: string;
162
- isVisible(): Promise<boolean>;
163
- textContent(timeout?: number): Promise<string | null>;
164
- innerText(timeout?: number): Promise<string>;
165
- /** Every match's text — the array forms of `toHaveText`/`toContainText`,
166
- * which are about a list of elements and so must not go through the
167
- * single-element (strict) reads above. */
168
- allTextContents(): Promise<string[]>;
169
- allInnerTexts(): Promise<string[]>;
170
- inputValue(timeout?: number): Promise<string>;
171
- count(): Promise<number>;
172
- /** The "did you mean…" block for a locator that matched nothing, or `""`.
173
- * See locator-hints.ts. */
161
+ /** Resolve the current page once per assertion; Playwright re-resolves the
162
+ * selector on that page throughout the assertion, including navigation. */
163
+ assert(request: LocatorAssertionRequest): Promise<void>;
164
+ /** Supplemental diagnostics, never part of deciding the assertion result. */
174
165
  nearMiss(): Promise<string>;
175
- isEnabled(timeout?: number): Promise<boolean>;
176
- isChecked(timeout?: number): Promise<boolean>;
177
- /** After the silent poll settles, emit the single settled browser step this
166
+ /** After the native assertion settles, emit the single settled browser step this
178
167
  * locator's `expect(...)` matcher assertion nests under, and return its seq
179
168
  * (provenance + replay seek). `action` is the matcher name, `waitedMs` the
180
- * poll time, `error` marks the step failed on a timed-out matcher. Returns
169
+ * assertion time, `error` marks the step failed on a failed matcher. Returns
181
170
  * `undefined` when nothing is recording.
182
171
  *
183
172
  * `opts.reveal` additionally stamps the element's rrweb node id for the
@@ -189,14 +178,13 @@ export interface LocatorProbe {
189
178
  reveal?: boolean;
190
179
  }): Promise<number | undefined>;
191
180
  }
192
- /** Silent (non-recorded) reads an `expect(browser)` matcher polls — the
193
- * session twin of {@link LocatorProbe}. Lives here, next to it, so `index.ts`
194
- * can reach both seams without importing `browser.ts` (and with it
195
- * playwright) at runtime. */
181
+ /** Native page assertions, kept behind a seam so index.ts does not load
182
+ * Playwright when a project only imports configuration or value assertions. */
196
183
  export interface BrowserProbe {
197
- /** Current main-frame URL. Synchronous, unrecorded, no rrweb drain. */
198
- url(): string;
199
- /** After the silent poll settles, emit the single settled browser step the
184
+ assertURL(negated: boolean, expected: import("./url-match.js").UrlPattern, opts?: {
185
+ timeout?: number;
186
+ }): Promise<void>;
187
+ /** After the native assertion settles, emit the single settled browser step the
200
188
  * matcher's assertion nests under, and return its seq. Same contract as
201
189
  * {@link LocatorProbe.settle}. */
202
190
  settle(action: string, waitedMs: number, error?: string): Promise<number | undefined>;
@@ -207,9 +195,7 @@ export declare function attachBrowserProbe<T extends object>(session: T, probe:
207
195
  export declare function isBrowserSession(x: unknown): boolean;
208
196
  /** The probe branded onto a browser/mobile session by `buildBackend`. */
209
197
  export declare function getBrowserProbe(session: unknown): BrowserProbe;
210
- /** The silent-read probe for a locator — the seam `expect(locator)` matchers
211
- * poll (in index.ts) without pulling playwright types or the backend into
212
- * that module. */
198
+ /** The assertion/recording seam used by index.ts without loading Playwright. */
213
199
  export declare function getLocatorProbe(loc: Locator): LocatorProbe;
214
200
  /** Per-session flavor of the `tap()` gesture. Desktop and mobile differ only
215
201
  * here; every other locator method is platform-agnostic. */
@@ -233,9 +219,9 @@ export declare const mobileStrategy: ActionStrategy;
233
219
  * A lazy reference to element(s), resolved with auto-waiting at the moment an
234
220
  * action runs. Mirrors playwright-core's `Locator`: STRICT by default — a
235
221
  * locator resolving to multiple elements throws a strict-mode violation at
236
- * action time rather than silently acting on one. Narrow with `exact: true`,
237
- * a testid, `filter(...)`, or the explicit `first()`/`last()`/`nth(i)`
238
- * opt-outs.
222
+ * action time rather than silently acting on one. Scope to the intended
223
+ * container, an exact role/name, or a stable test ID. Use positional selection
224
+ * only when order is part of what the test verifies.
239
225
  */
240
226
  export interface Locator {
241
227
  /** Descendant CSS query, scoped to this locator's subtree. */
@@ -251,13 +237,15 @@ export interface Locator {
251
237
  filter(opts: FilterOptions): Locator;
252
238
  /** Intersect with another locator (both must match the same element). */
253
239
  and(other: Locator): Locator;
254
- /** Union with another locator (either may match). */
240
+ /** Union with another locator (either may match). If both match, a
241
+ * single-element operation still fails strictness. */
255
242
  or(other: Locator): Locator;
256
- /** Explicit strict-mode opt-out: the first resolved element. */
243
+ /** First resolved element. Use only when position is the intended contract;
244
+ * does not establish identity or relate this match to another locator. */
257
245
  first(): Locator;
258
- /** Explicit strict-mode opt-out: the last resolved element. */
246
+ /** Last resolved element. Prefer a unique identity unless testing order. */
259
247
  last(): Locator;
260
- /** Explicit strict-mode opt-out: the i-th resolved element (0-based). */
248
+ /** i-th resolved element (0-based). Prefer a unique identity unless testing order. */
261
249
  nth(index: number): Locator;
262
250
  click(opts?: ClickOptions): Promise<void>;
263
251
  dblclick(opts?: ClickOptions): Promise<void>;
@@ -312,11 +300,15 @@ export interface Locator {
312
300
  blur(opts?: TimeoutOption): Promise<void>;
313
301
  scrollIntoViewIfNeeded(opts?: TimeoutOption): Promise<void>;
314
302
  dragTo(target: Locator, opts?: TimeoutOption): Promise<void>;
303
+ /** One observed value; use `await expect(locator).toHaveText(...)` to
304
+ * retry until expected text appears. Wrapping preserves provenance, not retries. */
315
305
  textContent(opts?: TimeoutOption): Promise<Wrapped<string | null>>;
316
306
  innerText(opts?: TimeoutOption): Promise<Wrapped<string>>;
317
307
  innerHTML(opts?: TimeoutOption): Promise<Wrapped<string>>;
318
308
  inputValue(opts?: TimeoutOption): Promise<Wrapped<string>>;
319
309
  getAttribute(name: string, opts?: TimeoutOption): Promise<Wrapped<string | null>>;
310
+ /** Immediate visibility observation. Use `await expect(locator).toBeVisible()`
311
+ * for readiness; use `.unwrap()` if branching on this observation. */
320
312
  isVisible(): Promise<Wrapped<boolean>>;
321
313
  isHidden(): Promise<Wrapped<boolean>>;
322
314
  isEnabled(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
@@ -324,8 +316,9 @@ export interface Locator {
324
316
  isChecked(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
325
317
  isEditable(opts?: TimeoutOption): Promise<Wrapped<boolean>>;
326
318
  count(): Promise<Wrapped<number>>;
327
- /** Resolve to one locator per current match (handle-free — each is pinned
328
- * with `nth(i)`). One recorded event. */
319
+ /** One locator per current match, using `nth(i)` (positions, not pinned
320
+ * identities). Does not wait for a list to finish loading; assert its ready
321
+ * state first, and prefer stable IDs if items can move during iteration. */
329
322
  all(): Promise<Locator[]>;
330
323
  allTextContents(): Promise<Wrapped<string[]>>;
331
324
  allInnerTexts(): Promise<Wrapped<string[]>>;
package/dist/locator.js CHANGED
@@ -244,9 +244,7 @@ export function isBrowserSession(x) {
244
244
  export function getBrowserProbe(session) {
245
245
  return session[BROWSER_PROBE];
246
246
  }
247
- /** The silent-read probe for a locator — the seam `expect(locator)` matchers
248
- * poll (in index.ts) without pulling playwright types or the backend into
249
- * that module. */
247
+ /** The assertion/recording seam used by index.ts without loading Playwright. */
250
248
  export function getLocatorProbe(loc) {
251
249
  return loc[PROBE];
252
250
  }
@@ -476,23 +474,23 @@ export function makeLocator(backend, strategy, chain) {
476
474
  const rec = { selector: label, target, ...fields };
477
475
  return backend.pageOp(action, rec, (page) => readable(page, () => withTarget(page, action, rec, fn)), { wrap: true });
478
476
  };
479
- // Silent, non-recorded reads for expect(locator) matchers to poll.
477
+ // Native assertion and recording seam for expect(locator).
480
478
  const probe = {
481
479
  label,
482
- isVisible: () => backend.silentRead((page) => lower(page, chain).isVisible()),
483
- textContent: (timeout) => backend.silentRead((page) => lower(page, chain).textContent({ timeout })),
484
- innerText: (timeout) => backend.silentRead((page) => lower(page, chain).innerText({ timeout })),
485
- allTextContents: () => backend.silentRead((page) => lower(page, chain).allTextContents()),
486
- allInnerTexts: () => backend.silentRead((page) => lower(page, chain).allInnerTexts()),
487
- inputValue: (timeout) => backend.silentRead((page) => lower(page, chain).inputValue({ timeout })),
488
- count: () => backend.silentRead((page) => lower(page, chain).count()),
480
+ assert: async (request) => {
481
+ const { assertLocator } = await import("./playwright-assertions.js");
482
+ return backend.silentRead((page) => assertLocator(lower(page, chain), request));
483
+ },
489
484
  nearMiss: () => backend.silentRead((page) => hintBlock(page)),
490
- isEnabled: (timeout) => backend.silentRead((page) => lower(page, chain).isEnabled({ timeout })),
491
- isChecked: (timeout) => backend.silentRead((page) => lower(page, chain).isChecked({ timeout })),
492
485
  settle: async (action, waitedMs, error, opts) => {
493
486
  const fields = { selector: label, target };
494
487
  if (opts && opts.reveal) {
495
- await backend.silentRead((page) => stampRevealTarget(lower(page, chain), fields));
488
+ try {
489
+ await backend.silentRead((page) => stampRevealTarget(lower(page, chain), fields));
490
+ }
491
+ catch {
492
+ // A page closing after evaluation must not replace the assertion result.
493
+ }
496
494
  }
497
495
  return backend.recordSettled(action, fields, waitedMs, error);
498
496
  },
@@ -0,0 +1,11 @@
1
+ import type { Locator, Page } from "playwright-core";
2
+ import type { BrowserMatchers, LocatorMatchers } from "./index.js";
3
+ export type LocatorAssertionRequest = {
4
+ [K in keyof LocatorMatchers]: {
5
+ matcher: K;
6
+ args: Parameters<LocatorMatchers[K]>;
7
+ negated: boolean;
8
+ };
9
+ }[keyof LocatorMatchers];
10
+ export declare function assertLocator(locator: Locator, request: LocatorAssertionRequest): Promise<void>;
11
+ export declare function assertURL(page: Page, negated: boolean, ...[expected, options]: Parameters<BrowserMatchers["toHaveURL"]>): Promise<void>;
@@ -0,0 +1,32 @@
1
+ // Loaded only when a browser assertion runs. Use the public matcher API;
2
+ // Playwright owns selector resolution, retries, deadlines and negation.
3
+ import { expect } from "@playwright/test";
4
+ import { DEFAULT_ACTION_TIMEOUT_MS } from "./locator.js";
5
+ import { globToRegExp } from "./url-match.js";
6
+ const browserExpect = expect.configure({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
7
+ export async function assertLocator(locator, request) {
8
+ const positive = browserExpect(locator);
9
+ const assertion = request.negated ? positive.not : positive;
10
+ switch (request.matcher) {
11
+ case "toBeVisible": return assertion.toBeVisible(...request.args);
12
+ case "toBeHidden": return assertion.toBeHidden(...request.args);
13
+ case "toHaveText": return assertion.toHaveText(...request.args);
14
+ case "toContainText": return assertion.toContainText(...request.args);
15
+ case "toHaveValue": return assertion.toHaveValue(...request.args);
16
+ case "toHaveCount": return assertion.toHaveCount(...request.args);
17
+ case "toBeEnabled": return assertion.toBeEnabled(...request.args);
18
+ case "toBeDisabled": return assertion.toBeDisabled(...request.args);
19
+ case "toBeChecked": return assertion.toBeChecked(...request.args);
20
+ default: {
21
+ const unsupported = request;
22
+ throw new Error(`Unsupported locator assertion: ${String(unsupported)}`);
23
+ }
24
+ }
25
+ }
26
+ export async function assertURL(page, negated, ...[expected, options]) {
27
+ const positive = browserExpect(page);
28
+ // Spectest has always accepted URL globs. Convert just that syntax; use
29
+ // native URL assertions for evaluation (including predicates and .not).
30
+ const pattern = typeof expected === "string" ? globToRegExp(expected) : expected;
31
+ await (negated ? positive.not : positive).toHaveURL(pattern, options);
32
+ }
@@ -7,16 +7,9 @@
7
7
  */
8
8
  export declare function normalizeWhiteSpace(text: string): string;
9
9
  export interface TextMatchOptions {
10
- /** Compare without regard to letter case. On a RegExp this adds the `i`
11
- * flag, as playwright's own `ignoreCase` does. */
10
+ /** Override case sensitivity, including a RegExp's `i` flag. */
12
11
  ignoreCase?: boolean;
13
12
  }
14
- /** The element's text equals `expected`, with whitespace normalized on both
15
- * sides. A RegExp is tested against the normalized text. */
16
- export declare function matchesText(actual: string, expected: string | RegExp, opts?: TextMatchOptions): boolean;
17
- /** The element's text contains `expected`, with whitespace normalized on both
18
- * sides. A RegExp is tested against the normalized text. */
19
- export declare function containsText(actual: string, expected: string | RegExp, opts?: TextMatchOptions): boolean;
20
13
  /** `" "` for an invisible character, the character itself otherwise.
21
14
  * Applied to an ALREADY `JSON.stringify`-ed string, so the result stays a
22
15
  * valid JSON string literal. */
@@ -31,9 +24,3 @@ export declare function escapeInvisible(s: string): string;
31
24
  * `"contains"` for the substring ones.
32
25
  */
33
26
  export declare function textDifferenceNote(actual: unknown, expected: unknown, mode: "equal" | "contains"): string;
34
- /** Every element's text equals the item at its position, and there are
35
- * exactly as many elements as items. */
36
- export declare function matchesTextArray(actual: string[], expected: (string | RegExp)[], opts?: TextMatchOptions): boolean;
37
- /** The elements' texts contain every item, in order. Extra elements between
38
- * the matches are allowed. */
39
- export declare function containsTextArray(actual: string[], expected: (string | RegExp)[], opts?: TextMatchOptions): boolean;
@@ -1,25 +1,4 @@
1
- // Text comparison for the web-first matchers, and readable notes for the
2
- // failures that invisible characters cause.
3
- //
4
- // Two rules, and they are deliberately different:
5
- //
6
- // * **Matching is Playwright's.** `expect(locator).toHaveText(...)` and
7
- // `toContainText(...)` normalize whitespace on BOTH sides exactly as
8
- // playwright's own text engine does (`normalizeWhiteSpace` in
9
- // playwright-core). Without this the SDK disagreed with itself: the same
10
- // string matched through `getByText("15 000 kr")` (playwright's engine,
11
- // which normalizes) and failed through `toContainText("15 000 kr")`
12
- // (ours, which did a raw `includes`). The case that reported it is
13
- // Swedish number formatting — `Intl.NumberFormat("sv-SE")` groups
14
- // thousands with U+00A0 NO-BREAK SPACE, and JavaScript's `\s` covers
15
- // U+00A0, so normalization makes a typed space match it.
16
- //
17
- // * **Diagnostics are wider.** A value the author compares themselves
18
- // (`expect(body).toContain(...)`, `toHaveValue`) is still exact, as it
19
- // must be — so when such a comparison fails on characters that print the
20
- // same, the message has to say so. `textDifferenceNote` reports the
21
- // invisible or look-alike characters that are the whole difference, and
22
- // `escapeInvisible` makes the two printed strings differ on screen.
1
+ // Text diagnostics only. Browser comparisons are delegated to Playwright.
23
2
  /** Characters that are invisible on screen (or print as an ordinary space)
24
3
  * and their Unicode names, for a message a reader can act on. */
25
4
  const INVISIBLE_NAMES = {
@@ -82,30 +61,6 @@ const LOOKALIKES = {
82
61
  export function normalizeWhiteSpace(text) {
83
62
  return text.replace(/[\u200b\u00ad]/g, "").trim().replace(/\s+/g, " ");
84
63
  }
85
- function caseFold(s, opts) {
86
- return opts?.ignoreCase ? s.toLowerCase() : s;
87
- }
88
- function withIgnoreCase(re, opts) {
89
- if (!opts?.ignoreCase || re.flags.includes("i"))
90
- return re;
91
- return new RegExp(re.source, `${re.flags}i`);
92
- }
93
- /** The element's text equals `expected`, with whitespace normalized on both
94
- * sides. A RegExp is tested against the normalized text. */
95
- export function matchesText(actual, expected, opts) {
96
- const a = normalizeWhiteSpace(actual);
97
- if (expected instanceof RegExp)
98
- return withIgnoreCase(expected, opts).test(a);
99
- return caseFold(a, opts) === caseFold(normalizeWhiteSpace(expected), opts);
100
- }
101
- /** The element's text contains `expected`, with whitespace normalized on both
102
- * sides. A RegExp is tested against the normalized text. */
103
- export function containsText(actual, expected, opts) {
104
- const a = normalizeWhiteSpace(actual);
105
- if (expected instanceof RegExp)
106
- return withIgnoreCase(expected, opts).test(a);
107
- return caseFold(a, opts).includes(caseFold(normalizeWhiteSpace(expected), opts));
108
- }
109
64
  // ── Diagnostics ───────────────────────────────────────────────────────────
110
65
  /** `" "` for an invisible character, the character itself otherwise.
111
66
  * Applied to an ALREADY `JSON.stringify`-ed string, so the result stays a
@@ -210,30 +165,3 @@ export function textDifferenceNote(actual, expected, mode) {
210
165
  }
211
166
  return "";
212
167
  }
213
- // ── Array forms (`toHaveText([...])` / `toContainText([...])`) ────────────
214
- //
215
- // Playwright's own rule, from its injected script: the expected items are
216
- // matched against the received texts **greedily, in order**, and the lengths
217
- // must agree for `toHaveText` but not for `toContainText`. So
218
- // `toContainText(["Two", "Four"])` passes against a four-row list, while
219
- // `toHaveText(["Two", "Four"])` does not.
220
- function matchesInOrder(actual, expected, item) {
221
- let want = 0;
222
- for (let i = 0; i < actual.length && want < expected.length; i++) {
223
- if (item(actual[i], expected[want]))
224
- want++;
225
- }
226
- return want === expected.length;
227
- }
228
- /** Every element's text equals the item at its position, and there are
229
- * exactly as many elements as items. */
230
- export function matchesTextArray(actual, expected, opts) {
231
- if (actual.length !== expected.length)
232
- return false;
233
- return matchesInOrder(actual, expected, (a, e) => matchesText(a, e, opts));
234
- }
235
- /** The elements' texts contain every item, in order. Extra elements between
236
- * the matches are allowed. */
237
- export function containsTextArray(actual, expected, opts) {
238
- return matchesInOrder(actual, expected, (a, e) => containsText(a, e, opts));
239
- }
@@ -9,3 +9,4 @@ export type UrlPattern = string | RegExp | ((url: URL) => boolean);
9
9
  export declare function matchesUrl(current: string, pattern: UrlPattern): boolean;
10
10
  /** Human display of a pattern, for timeline labels and failure messages. */
11
11
  export declare function describeUrlPattern(pattern: UrlPattern): string;
12
+ export declare function globToRegExp(glob: string): RegExp;
package/dist/url-match.js CHANGED
@@ -1,13 +1,9 @@
1
1
  // URL pattern matching — shared by `browser.waitForURL(...)` and
2
2
  // `expect(browser).toHaveURL(...)`.
3
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`.
4
+ // Both accept glob strings, RegExp, or predicates over a URL. waitForURL
5
+ // uses matchesUrl; the assertion adapter passes regexes/predicates directly
6
+ // to Playwright and compiles string globs with the same globToRegExp helper.
11
7
  /** Whether `current` (a full URL string) matches `pattern`. */
12
8
  export function matchesUrl(current, pattern) {
13
9
  if (typeof pattern === "function") {
@@ -34,9 +30,9 @@ export function describeUrlPattern(pattern) {
34
30
  return String(pattern);
35
31
  return JSON.stringify(pattern);
36
32
  }
37
- // Compiled globs are cached: a matcher polls the same pattern every 50ms.
33
+ // Reuse compiled globs across waits and assertions.
38
34
  const GLOB_CACHE = new Map();
39
- function globToRegExp(glob) {
35
+ export function globToRegExp(glob) {
40
36
  const hit = GLOB_CACHE.get(glob);
41
37
  if (hit)
42
38
  return hit;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.122.0",
3
+ "version": "0.123.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,10 +54,11 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@kubernetes/client-node": "^1.0.0",
57
+ "@playwright/test": "1.61.1",
57
58
  "@xterm/headless": "^5.5.0",
58
59
  "acorn": "8.18.0",
59
60
  "dns-packet": "^5.6.1",
60
- "playwright-core": "^1.61.1",
61
+ "playwright-core": "1.61.1",
61
62
  "ws": "^8.20.1"
62
63
  },
63
64
  "devDependencies": {
package/src/browser.ts CHANGED
@@ -2082,9 +2082,8 @@ function buildBackend(
2082
2082
  fields,
2083
2083
  async () => {
2084
2084
  try {
2085
- // Always a predicate, never playwright's own glob: `matchesUrl`
2086
- // is then the single matching implementation shared with
2087
- // `expect(browser).toHaveURL`.
2085
+ // Preserve Spectest's glob dialect. URL assertions use the same
2086
+ // glob compiler through their native matcher adapter.
2088
2087
  await holder.page.waitForURL((u: URL) => matchesUrl(u.href, pattern), {
2089
2088
  timeout: timeoutMs,
2090
2089
  waitUntil: options.waitUntil ?? "commit",
@@ -2310,12 +2309,15 @@ function buildBackend(
2310
2309
  return instrumented(action, fields, () => fn(holder.page), opts);
2311
2310
  },
2312
2311
  };
2313
- // The seam `expect(browser).toHaveURL(...)` polls: a silent URL read plus
2312
+ // The seam `expect(browser).toHaveURL(...)` uses: a native assertion plus
2314
2313
  // the single settled step its assertion nests under — the session twin of
2315
2314
  // the locator probe, and what brands this object as a browser session for
2316
2315
  // `expect`'s overload dispatch.
2317
2316
  attachBrowserProbe(backend, {
2318
- url: () => holder.page.url(),
2317
+ assertURL: async (negated, expected, opts) => {
2318
+ const { assertURL } = await import("./playwright-assertions.js");
2319
+ await assertURL(holder.page, negated, expected, opts);
2320
+ },
2319
2321
  settle: (action, waitedMs, error) =>
2320
2322
  backend.recordSettled(action, { url: holder.page.url() }, waitedMs, error),
2321
2323
  });