@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 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 { DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
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
- await backend.rawTap(box.x + box.width / 2, box.y + box.height / 2, opts?.duration);
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) => act("click", {}, (l) => l.click(opts)),
240
- dblclick: (opts) => act("dblclick", {}, (l) => l.dblclick(opts)),
241
- tap: (opts) => act("tap", {}, (l) => strategy.tap(backend, l, opts)),
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",