@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 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) {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.28.3",
3
+ "version": "0.30.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",