@specific.dev/spectest 0.66.0 → 0.67.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.js CHANGED
@@ -34,6 +34,7 @@ import { fileURLToPath } from "node:url";
34
34
  import { generateId } from "./ids.js";
35
35
  import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
36
36
  import { wrap } from "./inspect.js";
37
+ import { capturePageStructure } from "./page-snapshot.js";
37
38
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
38
39
  import { attachBrowserCoverage, browserCoverageActive, harvestBrowserCoverage, } from "./browser-coverage.js";
39
40
  import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
@@ -1122,6 +1123,37 @@ function buildBackend(holder, recorder, buildOpts) {
1122
1123
  // when rrweb's load-deferred full snapshot is most likely to be
1123
1124
  // missing from the chunk we're about to drain (see `drainExpr`).
1124
1125
  let lastDrainUrl = null;
1126
+ // Failed steps that captured their page. Bounded per session per case: a
1127
+ // test that catches its own locator errors in a loop must not pay for a
1128
+ // capture on each one, and three pages is already more than a reader will
1129
+ // open. `ctx.poll` needs no bookkeeping here — it truncates the events of
1130
+ // every superseded attempt, and each capture rides its own event.
1131
+ let pageCaptures = 0;
1132
+ const PAGE_CAPTURE_LIMIT = 3;
1133
+ /** The page behind a failed step, for its event (see page-snapshot.ts).
1134
+ * Best-effort and quiet: this runs while an error is already on its way
1135
+ * up, and must never become the failure the author sees. */
1136
+ async function failedPageStructure(action, error) {
1137
+ // No recorder means nothing is recording this op (an eval, a library
1138
+ // caller), so there is no event for the capture to ride out on.
1139
+ if (!recorder || recordingEnded || viewClosed)
1140
+ return undefined;
1141
+ if (pageCaptures >= PAGE_CAPTURE_LIMIT)
1142
+ return undefined;
1143
+ pageCaptures += 1;
1144
+ try {
1145
+ return await capturePageStructure(holder.page, {
1146
+ session: recorder.sessionName ?? "",
1147
+ // A desktop view has no device preset; a phone-emulated one does.
1148
+ kind: holder.device ? "mobile" : "browser",
1149
+ action,
1150
+ error,
1151
+ });
1152
+ }
1153
+ catch {
1154
+ return undefined;
1155
+ }
1156
+ }
1125
1157
  /** Session provenance stamped on every recorded op: which replay player
1126
1158
  * the step belongs to, which named browser it acted on, and where in
1127
1159
  * the player to seek (`endT`, in rrweb's clock). */
@@ -1203,12 +1235,17 @@ function buildBackend(holder, recorder, buildOpts) {
1203
1235
  catch (err) {
1204
1236
  const e = err;
1205
1237
  const endT = Date.now();
1238
+ const error = e?.message ?? String(err);
1239
+ // Captured after the clock stops, so the diagnostic is not billed to
1240
+ // the step, and before the event is recorded, so it rides it.
1241
+ const pageStructure = await failedPageStructure(action, error);
1206
1242
  recordBrowser({
1207
1243
  action,
1208
1244
  ...fields,
1209
1245
  ...sessionFields(endT),
1210
1246
  durationMs: endT - t,
1211
- error: e?.message ?? String(err),
1247
+ error,
1248
+ ...(pageStructure ? { pageStructure } : {}),
1212
1249
  }, resv);
1213
1250
  // Still try to drain — the failure itself may have produced
1214
1251
  // useful rrweb events (mutations from a half-loaded page, etc.).
@@ -1536,12 +1573,16 @@ function buildBackend(holder, recorder, buildOpts) {
1536
1573
  // where the matcher started polling, since that's what `tOffsetMs` means
1537
1574
  // everywhere else (the ops that reserve up front stamp their start).
1538
1575
  const endT = Date.now();
1576
+ // A matcher that ran out of budget is the other half of a failed
1577
+ // browser step, and the page it settled on is the same evidence.
1578
+ const pageStructure = error ? await failedPageStructure(action, error) : undefined;
1539
1579
  const seq = recordBrowser({
1540
1580
  action,
1541
1581
  ...fields,
1542
1582
  ...sessionFields(endT),
1543
1583
  durationMs: waitedMs,
1544
1584
  ...(error ? { error } : {}),
1585
+ ...(pageStructure ? { pageStructure } : {}),
1545
1586
  }, reserveBackdated(waitedMs));
1546
1587
  // Drain the rrweb the page buffered while the matcher waited into this
1547
1588
  // step's chunk, so `settledTarget` has bounds to seek into.
@@ -33,20 +33,6 @@ export interface SupabaseOptions {
33
33
  storage?: boolean;
34
34
  /** Include Realtime (`<name>-realtime`). Default `true`. */
35
35
  realtime?: boolean;
36
- /**
37
- * Edge functions. `isolation` picks how they run on the edge runtime:
38
- * `"shared"` (default) loads every function into **one** isolate, so a
39
- * shared module graph (`_shared/`, npm packages) is loaded once rather than
40
- * once per function — measured on a 51-function project as 230 MB against
41
- * 3.6 GB. Hosted Supabase runs one isolate per function, and the one thing
42
- * that differs is module-level state: a singleton in `_shared` is one
43
- * object for all functions here, one per function there. `"per-function"`
44
- * runs it the hosted way. Either way the runtime, the request each
45
- * function sees and the per-function `verify_jwt` are the same.
46
- */
47
- functions?: {
48
- isolation?: "shared" | "per-function";
49
- };
50
36
  /**
51
37
  * Also serve the gateway over **HTTPS** at `https://<hostname>` via the
52
38
  * daemon's CA-trusted TLS reverse proxy (in addition to the plain
@@ -698,9 +698,6 @@ async function validJwt(token: string): Promise<boolean> {
698
698
  }
699
699
  }
700
700
 
701
- /** How the functions run: every function in one isolate (the default), or
702
- * one isolate per function as hosted Supabase does — see sharedWorker. */
703
- const ISOLATION = Deno.env.get("FUNCTIONS_ISOLATION") === "per-function" ? "per-function" : "shared";
704
701
  /** Where the generated entry of the shared isolate lives — outside the repo
705
702
  * mount, which is read-only and the user's checkout, and NOT under /tmp:
706
703
  * the runtime gives its workers an in-memory /tmp, so a file the main
@@ -780,7 +777,8 @@ async function stdServerUrls(dir: string, out: Set<string>): Promise<void> {
780
777
  // what differs from hosted Supabase is that module-level state is one
781
778
  // object for every function rather than one per function.
782
779
  //
783
- // It stands in only where it can be exact. A function with its own
780
+ // There is deliberately no opt-out: it stands in wherever it can be exact,
781
+ // and where it cannot the per-function isolates are used. A function with its own
784
782
  // deno.json / import map (per-function resolution the one config cannot
785
783
  // express), a functions/deno.jsonc (comments; not merged) or a config that
786
784
  // names an importMap keep the per-function isolates, and so does a shared
@@ -929,7 +927,6 @@ async function writeShared(names: string[]): Promise<string | null> {
929
927
  /** The shared isolate (pooled by the runtime under SHARED_DIR), or null when
930
928
  * the functions run one isolate each — decided once, with the reason logged. */
931
929
  async function sharedWorker(): Promise<any | null> {
932
- if (ISOLATION !== "shared") return null;
933
930
  if (!sharedSpec) {
934
931
  sharedSpec = (async () => {
935
932
  const names = await functionNames();
@@ -1729,9 +1726,6 @@ export function supabase(opts = {}) {
1729
1726
  JWT_SECRET: jwtSecret,
1730
1727
  // Where the router looks for `<name>/`, inside the repo mount.
1731
1728
  FUNCTIONS_DIR: fnServePath,
1732
- // One isolate for all functions (default) or one each — see the
1733
- // `functions` option and the router's shared-isolate notes.
1734
- FUNCTIONS_ISOLATION: opts.functions?.isolation ?? "shared",
1735
1729
  // The default a function inherits when the config says nothing:
1736
1730
  // reject an unauthenticated call, as hosted Supabase does.
1737
1731
  VERIFY_JWT: "true",
package/dist/daemon.js CHANGED
@@ -42,7 +42,7 @@ import { pollUntilReady } from "./harness/ready-poll.js";
42
42
  import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
43
43
  import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
44
44
  import { encodeRegistry } from "./harness/names-registry.js";
45
- import { InterceptRegistry, runChain, } from "./harness/intercept.js";
45
+ import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
46
46
  import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
47
47
  import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, wildcardSuffix, } from "./harness/hostmatch.js";
48
48
  import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, certEntries, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
@@ -2301,35 +2301,61 @@ function ingressClaimsHostname(hostname) {
2301
2301
  }
2302
2302
  return false;
2303
2303
  }
2304
- /** Record one request an interceptor saw, nested under its `intercept` step. */
2304
+ /** Record one request an interceptor saw, nested under its `intercept` step.
2305
+ *
2306
+ * A forced 503 is the step doing exactly what its description says, so it is
2307
+ * **not** marked failed — reddening the outcome the test asked for trains the
2308
+ * reader to ignore the colour. Only a handler that threw is marked, because
2309
+ * that 500 is a bug in the middleware rather than the outage it stands for. */
2305
2310
  function recordInterceptedRequest(it, rec, durationMs) {
2306
2311
  const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
2307
2312
  if (parentSeq === undefined || !isRecording())
2308
2313
  return;
2309
- const by = rec.answeredBy === "handler"
2310
- ? "answered by the interceptor"
2311
- : rec.answeredBy === "modified"
2312
- ? "upstream answer replaced by the interceptor"
2313
- : "passed through to the upstream";
2314
+ const by = rec.threw
2315
+ ? "the interceptor threw — 500 from spectest, not from your handler"
2316
+ : rec.answeredBy === "handler"
2317
+ ? "answered by the interceptor"
2318
+ : rec.answeredBy === "modified"
2319
+ ? "upstream answer replaced by the interceptor"
2320
+ : "passed through to the upstream";
2314
2321
  recordStep({
2315
2322
  kind: "intercept-request",
2316
2323
  parentSeq,
2317
2324
  title: `${rec.method} ${rec.path} → ${rec.status}`,
2318
- status: rec.status >= 500 && rec.answeredBy !== "upstream" ? "failed" : "passed",
2325
+ status: rec.threw ? "failed" : "passed",
2326
+ // Method, path and status are already the title; the one thing the row
2327
+ // cannot say for itself is who produced that status.
2319
2328
  blocks: [
2320
- {
2321
- type: "kv",
2322
- rows: [
2323
- { label: "Host", value: it.hostname },
2324
- { label: "Request", value: `${rec.method} ${rec.path}` },
2325
- { label: "Status", value: String(rec.status) },
2326
- { label: "Answered", value: by },
2327
- ],
2328
- },
2329
+ { type: "kv", rows: [{ label: "Answered", value: by, error: rec.threw === true }] },
2329
2330
  ],
2330
2331
  durationMs,
2331
2332
  });
2332
2333
  }
2334
+ /** The handler's own source, which is what the interceptor actually does.
2335
+ *
2336
+ * Free and impossible to let rot: Bun runs the project's TypeScript
2337
+ * directly, so `toString()` is the text the author wrote. It is the step's
2338
+ * body, under the description — a mechanism the reader can check against
2339
+ * the intent the description claims. A handler long enough to be a wall of
2340
+ * code folds into a disclosure instead of pushing the requests off screen.
2341
+ */
2342
+ function handlerBlocks(handler) {
2343
+ let src;
2344
+ try {
2345
+ src = String(handler);
2346
+ }
2347
+ catch {
2348
+ return [];
2349
+ }
2350
+ const code = { type: "code", lang: "ts", label: "Handler", code: src };
2351
+ return src.split("\n").length > HANDLER_FOLD_LINES
2352
+ ? [{ type: "details", summary: "Handler", blocks: [code] }]
2353
+ : [code];
2354
+ }
2355
+ /** Past this many lines the handler source is folded away. Chosen so the
2356
+ * shapes this feature is for — a one-line forced status, a short
2357
+ * fail-twice-then-pass — always show, and only a real program hides. */
2358
+ const HANDLER_FOLD_LINES = 15;
2333
2359
  /**
2334
2360
  * Put middleware in front of a hostname the ingress serves — the
2335
2361
  * implementation behind `ctx.intercept`.
@@ -2338,15 +2364,19 @@ function recordInterceptedRequest(it, rec, durationMs) {
2338
2364
  * the daemon (DNS does not point here), so the interceptor could only be
2339
2365
  * silent — and silence is the failure mode this whole layer is designed
2340
2366
  * against. The message names the two ways to get a route.
2367
+ *
2368
+ * The `description` is required because it is the step's whole title in the
2369
+ * timeline and in the CLI's failure detail: it is what tells a reader why
2370
+ * the UI under test went to its error state. The handler source says how;
2371
+ * only the author can say what it means.
2341
2372
  */
2342
- function registerInterceptor(hostname, pathOrHandler, maybeHandler) {
2343
- const path = typeof pathOrHandler === "string" ? pathOrHandler : undefined;
2344
- const handler = typeof pathOrHandler === "function" ? pathOrHandler : maybeHandler;
2345
- if (typeof hostname !== "string" || hostname.length === 0) {
2346
- throw new Error("ctx.intercept: a hostname is required");
2347
- }
2348
- const host = hostname.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
2349
- if (!handler) {
2373
+ function registerInterceptor(target, description, handler) {
2374
+ const { hostname: host, path } = parseTarget(target);
2375
+ if (typeof description !== "string" || description.trim() === "") {
2376
+ throw new Error(`ctx.intercept(${JSON.stringify(target)}): a description is required — what the ` +
2377
+ `environment now does, as the timeline reads it, e.g. "the sync endpoint returns 503".`);
2378
+ }
2379
+ if (typeof handler !== "function") {
2350
2380
  throw new Error("ctx.intercept: a handler (req, next) => Response is required");
2351
2381
  }
2352
2382
  if (!ingressClaimsHostname(host)) {
@@ -2357,19 +2387,41 @@ function registerInterceptor(hostname, pathOrHandler, maybeHandler) {
2357
2387
  }
2358
2388
  const resv = reserveEvent();
2359
2389
  const it = INTERCEPTORS.register(host, path, handler);
2390
+ const where = `${host}${it.path === "/" ? "" : it.path}`;
2360
2391
  const seq = recordStep({
2361
2392
  kind: "intercept",
2362
- title: `intercept ${host}${it.path === "/" ? "" : it.path}`,
2393
+ // The description alone. The pill already says INTERCEPT, and the
2394
+ // target is in the panel — a row that repeats both spends its width
2395
+ // on what the reader can already see and none of it on the one thing
2396
+ // only the author knows.
2397
+ title: description.trim(),
2363
2398
  blocks: [
2399
+ ...handlerBlocks(handler),
2364
2400
  {
2365
- type: "kv",
2366
- rows: [
2367
- { label: "Host", value: host },
2368
- { label: "Path", value: it.path === "/" ? "/ (every path)" : `${it.path} and below` },
2401
+ // The target IS the summary line, so the closed disclosure still
2402
+ // says where the interceptor sits — a generic "Target" label
2403
+ // would spend the one visible line saying nothing.
2404
+ type: "details",
2405
+ summary: where,
2406
+ blocks: [
2407
+ {
2408
+ type: "kv",
2409
+ rows: [
2410
+ {
2411
+ label: "Matches",
2412
+ value: it.path === "/" ? "every path on this host" : `${it.path} and below`,
2413
+ },
2414
+ {
2415
+ label: "Until",
2416
+ value: it.scope === undefined ? "remove()" : "the end of this test",
2417
+ },
2418
+ ],
2419
+ },
2369
2420
  ],
2370
2421
  },
2371
2422
  ],
2372
- durationMs: 0,
2423
+ // No duration: this is a marker for the moment the interceptor went
2424
+ // up, and "(0 ms)" reads as a step that did nothing.
2373
2425
  }, resv);
2374
2426
  if (seq !== undefined)
2375
2427
  INTERCEPT_STEP_SEQ.set(it.id, seq);
@@ -48,6 +48,12 @@ export interface InterceptedRequest {
48
48
  * upstream via `next()` untouched (`upstream`), or the upstream's answer
49
49
  * replaced by the interceptor after `next()` (`modified`). */
50
50
  answeredBy: "handler" | "upstream" | "modified";
51
+ /** The handler threw (or returned something that is not a `Response`), so
52
+ * the 500 below is a bug in the test's own middleware rather than the
53
+ * outage it was asked to produce. The distinction cannot be recovered
54
+ * from the status: a deliberate 500 and a crashed handler are the same
55
+ * number, and only this tells the timeline which one to mark as wrong. */
56
+ threw?: boolean;
51
57
  }
52
58
  export interface Interceptor {
53
59
  id: number;
@@ -61,6 +67,22 @@ export interface Interceptor {
61
67
  calls: number;
62
68
  requests: InterceptedRequest[];
63
69
  }
70
+ /**
71
+ * Split an intercept target — `app.test`, `app.test/api/sync`,
72
+ * `*.example.com/v1` — into the hostname and the mount path.
73
+ *
74
+ * The path rides the target rather than a second argument because that is
75
+ * what it is: part of what the interceptor claims, not a separate knob. It
76
+ * also keeps the signature down to one string, so the description that
77
+ * follows can never be mistaken for a path.
78
+ *
79
+ * A scheme is tolerated (`https://app.test/api`) since that is how the same
80
+ * address is written everywhere else.
81
+ */
82
+ export declare function parseTarget(target: string): {
83
+ hostname: string;
84
+ path: string;
85
+ };
64
86
  /** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
65
87
  * never `/apix`. `/` matches everything. Query strings do not take part. */
66
88
  export declare function pathMounts(mount: string, pathname: string): boolean;
@@ -33,6 +33,34 @@
33
33
  * plain `Request`/`Response` objects.
34
34
  */
35
35
  import { isWildcard, wildcardSuffix } from "./hostmatch";
36
+ /**
37
+ * Split an intercept target — `app.test`, `app.test/api/sync`,
38
+ * `*.example.com/v1` — into the hostname and the mount path.
39
+ *
40
+ * The path rides the target rather than a second argument because that is
41
+ * what it is: part of what the interceptor claims, not a separate knob. It
42
+ * also keeps the signature down to one string, so the description that
43
+ * follows can never be mistaken for a path.
44
+ *
45
+ * A scheme is tolerated (`https://app.test/api`) since that is how the same
46
+ * address is written everywhere else.
47
+ */
48
+ export function parseTarget(target) {
49
+ if (typeof target !== "string" || target.trim() === "") {
50
+ throw new Error("intercept: a target hostname is required, e.g. \"app.test\" or \"app.test/api\"");
51
+ }
52
+ const bare = target.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
53
+ const slash = bare.indexOf("/");
54
+ const hostname = slash === -1 ? bare : bare.slice(0, slash);
55
+ const rest = slash === -1 ? undefined : bare.slice(slash);
56
+ if (hostname === "") {
57
+ throw new Error(`intercept: target ${JSON.stringify(target)} names no hostname — write it as "app.test/api", not "/api"`);
58
+ }
59
+ if (hostname.includes("?") || hostname.includes("#")) {
60
+ throw new Error(`intercept: target ${JSON.stringify(target)} cannot carry a query or fragment`);
61
+ }
62
+ return { hostname, path: normalizeMount(rest) };
63
+ }
36
64
  /** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
37
65
  * never `/apix`. `/` matches everything. Query strings do not take part. */
38
66
  export function pathMounts(mount, pathname) {
@@ -159,6 +187,7 @@ export async function runChain(chain, req, upstream, observe) {
159
187
  res = new Response(`spectest-daemon: interceptor for ${it.hostname} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
160
188
  record.answeredBy = "handler";
161
189
  record.status = 500;
190
+ record.threw = true;
162
191
  it.calls++;
163
192
  it.requests.push(record);
164
193
  observe?.(it, record, Date.now() - started);
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export { McpHttpError, McpRpcError, McpAuthDeniedError, type Mcp, type McpOption
14
14
  import type { Mcp, McpOptions } from "./mcp.js";
15
15
  export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, FilePayload, InputFiles, } from "./locator.js";
16
16
  import type { Locator } from "./locator.js";
17
+ import { type TextMatchOptions } from "./text-match.js";
17
18
  export type { UrlPattern } from "./url-match.js";
18
19
  import type { UrlPattern } from "./url-match.js";
19
20
  export type { Mobile, MobileApp } from "./mobile.js";
@@ -34,7 +35,8 @@ export type { InterceptHandler, InterceptNext, InterceptedRequest };
34
35
  */
35
36
  export interface Interception {
36
37
  hostname: string;
37
- /** The normalised mount path (`"/"` when none was given). */
38
+ /** The normalised mount path parsed off the target (`"/"` when the
39
+ * target was a bare hostname). */
38
40
  path: string;
39
41
  /** How many requests the interceptor has seen. Provenance-wrapped, so an
40
42
  * `expect(outage.calls)` nests under the intercept step; `.unwrap()` for
@@ -381,12 +383,23 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
381
383
  * make your **own** backend misbehave for one test: force a 500 from an
382
384
  * API route, add latency, fail twice then pass, or just count calls.
383
385
  *
386
+ * `target` is the hostname, optionally with a mount path:
387
+ * `"app.test"` claims every request to that host, `"app.test/api/sync"`
388
+ * claims that path and everything below it (like `app.use(path, fn)`).
389
+ *
390
+ * `description` is what the environment now **does**, in the present
391
+ * tense — it is the step's whole title in the timeline and in the CLI's
392
+ * failure detail, so it is what tells a reader why the page under test
393
+ * went to its error state. Write the effect, not the act of intercepting
394
+ * (the step is already labelled INTERCEPT) and not the test's goal:
395
+ * `"the sync endpoint returns 503"`, not `"intercept sync"`, `"mock the
396
+ * API"` or `"test error handling"`. The handler's own source is shown
397
+ * under it, so the description carries the intent, never the mechanism.
398
+ *
384
399
  * Hono/Koa-style middleware: `(req, next)` where `next()` returns the real upstream's
385
400
  * response (a proxied service, or a fake). Return a `Response` to answer
386
401
  * yourself, `next()` to pass through, or change what `next()` returned.
387
- * An optional mount `path` limits it to that path and everything below
388
- * (`"/functions/v1/sync"`), like `app.use(path, fn)`. Interceptors run in
389
- * registration order.
402
+ * Interceptors run in registration order.
390
403
  *
391
404
  * Only traffic that reaches the daemon can be intercepted: the browser,
392
405
  * `ctx.fetch`, and any container that calls the hostname — so the service
@@ -400,16 +413,18 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
400
413
  * `remove()`. Every request it sees is recorded under the intercept step.
401
414
  *
402
415
  * ```ts
403
- * const outage = ctx.intercept("api.test", "/functions/v1/sync", () =>
404
- * new Response("boom", { status: 500 }));
416
+ * const outage = ctx.intercept(
417
+ * "api.test/functions/v1/sync",
418
+ * "the sync function returns 500",
419
+ * () => new Response("boom", { status: 500 }),
420
+ * );
405
421
  * await page.getByRole("button", { name: "Sync" }).click();
406
422
  * await expect(page.getByText("Retry")).toBeVisible();
407
423
  * expect(outage.calls).toBe(1);
408
424
  * outage.remove(); // the retry now reaches the real function
409
425
  * ```
410
426
  */
411
- intercept(hostname: string, handler: InterceptHandler): Interception;
412
- intercept(hostname: string, path: string, handler: InterceptHandler): Interception;
427
+ intercept(target: string, description: string, handler: InterceptHandler): Interception;
413
428
  /**
414
429
  * Mint a leaf certificate from the in-VM root CA and return the PEMs.
415
430
  *
@@ -1672,6 +1687,14 @@ interface Matchers {
1672
1687
  export interface Expectation extends Matchers {
1673
1688
  not: Matchers;
1674
1689
  }
1690
+ /** Options for the text matchers — playwright's set, same names, same
1691
+ * meanings. */
1692
+ export interface TextMatcherOptions extends TextMatchOptions {
1693
+ timeout?: number;
1694
+ /** Read `innerText` (what the page renders — hidden elements dropped,
1695
+ * `text-transform` applied) instead of `textContent`. */
1696
+ useInnerText?: boolean;
1697
+ }
1675
1698
  /**
1676
1699
  * Auto-retrying web-first assertions for a {@link Locator} — Playwright's
1677
1700
  * `expect(locator)` matchers. Each polls the element until it passes or a
@@ -1687,14 +1710,27 @@ export interface LocatorMatchers {
1687
1710
  toBeHidden(opts?: {
1688
1711
  timeout?: number;
1689
1712
  }): Promise<void>;
1690
- /** The element's (trimmed) text equals `expected` (or matches a RegExp). */
1691
- toHaveText(expected: string | RegExp, opts?: {
1692
- timeout?: number;
1693
- }): Promise<void>;
1694
- /** The element's text contains `expected`. */
1695
- toContainText(expected: string, opts?: {
1696
- timeout?: number;
1697
- }): Promise<void>;
1713
+ /**
1714
+ * The element's text equals `expected`, or matches it when it is a RegExp.
1715
+ *
1716
+ * **Whitespace is normalized on both sides**, exactly as playwright does
1717
+ * it: the text is trimmed and every run of whitespace becomes one space.
1718
+ * So a typed space matches the no-break space (U+00A0) that
1719
+ * `Intl.NumberFormat` puts between thousands, and a value wrapped across
1720
+ * two lines in the markup matches the one-line string you wrote.
1721
+ *
1722
+ * Pass an **array** to assert over every element the locator matches, in
1723
+ * order; the counts must then agree.
1724
+ */
1725
+ toHaveText(expected: string | RegExp | Array<string | RegExp>, opts?: TextMatcherOptions): Promise<void>;
1726
+ /**
1727
+ * The element's text contains `expected` — a substring, or a RegExp tested
1728
+ * against the text. Whitespace is normalized as in {@link toHaveText}.
1729
+ *
1730
+ * An **array** asserts that the matched elements contain these texts in
1731
+ * order; unlike `toHaveText` extra elements between them are allowed.
1732
+ */
1733
+ toContainText(expected: string | RegExp | Array<string | RegExp>, opts?: TextMatcherOptions): Promise<void>;
1698
1734
  /** The input's value equals `expected` (or matches a RegExp). */
1699
1735
  toHaveValue(expected: string | RegExp, opts?: {
1700
1736
  timeout?: number;