@specific.dev/spectest 0.62.0 → 0.64.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.
@@ -698,12 +698,60 @@ async function validJwt(token: string): Promise<boolean> {
698
698
  }
699
699
  }
700
700
 
701
+ /** One isolate per function, reused across requests (the runtime pools by
702
+ * servicePath). The idle timeout is a day, not upstream's five minutes: a
703
+ * test environment is a snapshot lineage whose forks inherit these
704
+ * isolates, and an isolate reaped for idleness is rebooted by the next
705
+ * test that calls the function — a boot's worth of dirtied memory in that
706
+ * test's own snapshot diff. */
707
+ function workerFor(name: string) {
708
+ return EdgeRuntime.userWorkers.create({
709
+ servicePath: FUNCTIONS_DIR + "/" + name,
710
+ memoryLimitMb: 256,
711
+ workerTimeoutMs: 24 * 60 * 60 * 1000,
712
+ noModuleCache: false,
713
+ importMapPath: null,
714
+ envVars: Object.entries(Deno.env.toObject()),
715
+ });
716
+ }
717
+
718
+ /** Boot every function's isolate now, without calling it. Creating the
719
+ * worker loads the module graph and runs the module's top level — for a
720
+ * function that is its \`Deno.serve(handler)\` registration, no business
721
+ * logic. Called once at environment setup, before the warm snapshot, so
722
+ * the isolates are shared, clean pages of the snapshot rather than a boot
723
+ * in every test that first touches a function. */
724
+ async function warmAll(): Promise<{ warmed: string[]; failed: Record<string, string> }> {
725
+ const names: string[] = [];
726
+ for await (const entry of Deno.readDir(FUNCTIONS_DIR)) {
727
+ if (!entry.isDirectory || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
728
+ try {
729
+ await Deno.stat(FUNCTIONS_DIR + "/" + entry.name + "/index.ts");
730
+ names.push(entry.name);
731
+ } catch {
732
+ // not a function directory
733
+ }
734
+ }
735
+ // All at once: a project can have dozens of functions, and each boot is
736
+ // mostly module loading — the isolates come up concurrently.
737
+ const results = await Promise.allSettled(names.map((name) => workerFor(name)));
738
+ const warmed: string[] = [];
739
+ const failed: Record<string, string> = {};
740
+ results.forEach((r, i) => {
741
+ if (r.status === "fulfilled") warmed.push(names[i]);
742
+ else failed[names[i]] = String(r.reason);
743
+ });
744
+ return { warmed: warmed.sort(), failed };
745
+ }
746
+
701
747
  Deno.serve(async (req: Request) => {
702
748
  const { pathname } = new URL(req.url);
703
749
 
704
750
  // Ready probe — answered before any auth check, so bring-up never needs a
705
751
  // token, and never treated as a function name.
706
752
  if (pathname === "/_spectest/health") return new Response("ok");
753
+ // Setup-time warm-up (see warmAll); never treated as a function name.
754
+ if (pathname === "/_spectest/warm") return json(await warmAll());
707
755
 
708
756
  const name = pathname.split("/")[1] ?? "";
709
757
  if (name === "") return json({ msg: "missing function name in request" }, 400);
@@ -718,14 +766,7 @@ Deno.serve(async (req: Request) => {
718
766
 
719
767
  const servicePath = FUNCTIONS_DIR + "/" + name;
720
768
  try {
721
- const worker = await EdgeRuntime.userWorkers.create({
722
- servicePath,
723
- memoryLimitMb: 256,
724
- workerTimeoutMs: 5 * 60 * 1000,
725
- noModuleCache: false,
726
- importMapPath: null,
727
- envVars: Object.entries(Deno.env.toObject()),
728
- });
769
+ const worker = await workerFor(name);
729
770
  return await worker.fetch(req);
730
771
  } catch (e) {
731
772
  // A missing directory lands here too, so say which function was asked
@@ -1416,7 +1457,7 @@ export function supabase(opts = {}) {
1416
1457
  }
1417
1458
  // ── functions (edge runtime) ────────────────────────────────────
1418
1459
  if (withFunctions) {
1419
- parts.functions = {
1460
+ const functionsDef = {
1420
1461
  // Built rather than pulled, so the functions' remote imports are
1421
1462
  // fetched by the host builder and cached into the image — see
1422
1463
  // `functionsDockerfile`.
@@ -1472,7 +1513,27 @@ export function supabase(opts = {}) {
1472
1513
  path: "/_spectest/health",
1473
1514
  timeoutSecs: 120,
1474
1515
  },
1516
+ // Boot every function's isolate before the warm snapshot is
1517
+ // taken (see warmAll in the router). Measured on a real project
1518
+ // (Harmony, 2026-08-28): a test that first touched a few
1519
+ // functions dirtied ~1 GB booting them — memory that then had to
1520
+ // be written into that test's snapshot diff and paid for by every
1521
+ // fork of it. Warm, the isolates are clean shared pages of the
1522
+ // snapshot and a test's diff is what its requests mutate.
1523
+ setup: async () => {
1524
+ const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
1525
+ if (!res.ok) {
1526
+ console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
1527
+ return;
1528
+ }
1529
+ const { warmed, failed } = (await res.json());
1530
+ console.log(`supabase(): warmed ${warmed.length} edge function${warmed.length === 1 ? "" : "s"}${warmed.length ? ` (${warmed.join(", ")})` : ""}.`);
1531
+ for (const [name, err] of Object.entries(failed)) {
1532
+ console.warn(`supabase(): edge function "${name}" failed to boot at warm-up — it will be retried on first call: ${err}`);
1533
+ }
1534
+ },
1475
1535
  };
1536
+ parts.functions = functionsDef;
1476
1537
  }
1477
1538
  // ── kong (gateway / primary) — no explicit dependsOn: the group
1478
1539
  // expansion makes the primary depend on every member ──────────────
package/dist/daemon.js CHANGED
@@ -42,6 +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
46
  import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
46
47
  import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, wildcardSuffix, } from "./harness/hostmatch.js";
47
48
  import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, certEntries, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
@@ -54,7 +55,7 @@ import { openTerminal } from "./terminal.js";
54
55
  import { openMcp } from "./mcp.js";
55
56
  import { setRawFetch } from "./harness/raw-fetch.js";
56
57
  import { readAnnotation } from "./annotate.js";
57
- import { pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
58
+ import { isRecording, pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
58
59
  import { deepUnwrap, readRaw, wrap, wrapResponse } from "./inspect.js";
59
60
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
60
61
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
@@ -1993,17 +1994,27 @@ server, byHost, listenerLabel, proto) {
1993
1994
  // to list in Access-Control-Allow-Headers.
1994
1995
  if (isCorsPreflight(req))
1995
1996
  return corsPreflightResponse(req);
1996
- if (route.kind === "fake") {
1997
- try {
1998
- const res = await route.fake.def.handler(req, route.fake.state, FAKE_CTX);
1999
- return augmentCorsResponse(req, res);
2000
- }
2001
- catch (err) {
2002
- const e = err;
2003
- return new Response(`spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
1997
+ // The real answer — a fake handled in-process, or the proxied container.
1998
+ const upstream = async (current) => {
1999
+ if (route.kind === "fake") {
2000
+ try {
2001
+ return await route.fake.def.handler(current, route.fake.state, FAKE_CTX);
2002
+ }
2003
+ catch (err) {
2004
+ const e = err;
2005
+ return new Response(`spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
2006
+ }
2004
2007
  }
2005
- }
2006
- const res = await proxyToService(req, server, route.service, route.port, listenerLabel, proto);
2008
+ return proxyToService(current, server, route.service, route.port, listenerLabel, proto);
2009
+ };
2010
+ // `ctx.intercept` middleware runs first, in registration order, and reaches
2011
+ // the upstream through `next()`. The CORS headers go on *after* the chain,
2012
+ // so a forced 500 reaches the browser as a 500 and not as a CORS error —
2013
+ // the trap `page.route`-style interception falls into.
2014
+ const chain = INTERCEPTORS.chainFor(host, new URL(req.url).pathname);
2015
+ const res = chain.length === 0
2016
+ ? await upstream(req)
2017
+ : await runChain(chain, req, upstream, recordInterceptedRequest);
2007
2018
  return augmentCorsResponse(req, res);
2008
2019
  }
2009
2020
  /**
@@ -2263,6 +2274,120 @@ async function seedNamesRegistry(opts) {
2263
2274
  }
2264
2275
  await writeRegistry();
2265
2276
  }
2277
+ /**
2278
+ * The live interceptors (`ctx.intercept`). Module memory, so they fork with
2279
+ * the environment like the route tables; a test's own are removed when its
2280
+ * case ends (`runOne` opens and closes the scope), so a `dependsOn` child
2281
+ * never inherits a parent's forced outage.
2282
+ */
2283
+ const INTERCEPTORS = new InterceptRegistry();
2284
+ /** The recorder seq of the `intercept` step each interceptor was registered
2285
+ * under, so every request it sees can nest below that step. */
2286
+ const INTERCEPT_STEP_SEQ = new Map();
2287
+ /** What every port's route table together claims — the hostnames a request
2288
+ * can reach the ingress under at all. */
2289
+ function ingressClaimsHostname(hostname) {
2290
+ for (const byHost of INGRESS.routesByPort.values()) {
2291
+ if (matchRoute(byHost, hostname))
2292
+ return true;
2293
+ if (isWildcard(hostname)) {
2294
+ // A wildcard interceptor is fine when any route sits under it.
2295
+ const suffix = wildcardSuffix(hostname);
2296
+ for (const key of byHost.keys()) {
2297
+ if (key === hostname || key.endsWith(suffix))
2298
+ return true;
2299
+ }
2300
+ }
2301
+ }
2302
+ return false;
2303
+ }
2304
+ /** Record one request an interceptor saw, nested under its `intercept` step. */
2305
+ function recordInterceptedRequest(it, rec, durationMs) {
2306
+ const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
2307
+ if (parentSeq === undefined || !isRecording())
2308
+ 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
+ recordStep({
2315
+ kind: "intercept-request",
2316
+ parentSeq,
2317
+ title: `${rec.method} ${rec.path} → ${rec.status}`,
2318
+ status: rec.status >= 500 && rec.answeredBy !== "upstream" ? "failed" : "passed",
2319
+ 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
+ ],
2330
+ durationMs,
2331
+ });
2332
+ }
2333
+ /**
2334
+ * Put middleware in front of a hostname the ingress serves — the
2335
+ * implementation behind `ctx.intercept`.
2336
+ *
2337
+ * Refuses a hostname no route claims: a request for it would never reach
2338
+ * the daemon (DNS does not point here), so the interceptor could only be
2339
+ * silent — and silence is the failure mode this whole layer is designed
2340
+ * against. The message names the two ways to get a route.
2341
+ */
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) {
2350
+ throw new Error("ctx.intercept: a handler (req, next) => Response is required");
2351
+ }
2352
+ if (!ingressClaimsHostname(host)) {
2353
+ throw new Error(`ctx.intercept(${JSON.stringify(host)}): no request can reach the ingress under that hostname. ` +
2354
+ `Only traffic that passes through the daemon can be intercepted: give the service a \`tls\`/\`hostnames\` ` +
2355
+ `entry (or \`supabase({ hostname })\`), or target a fake's hostname. A bare service name ` +
2356
+ `(\`http://<service>:<port>\`) is container-to-container traffic and never passes through here.`);
2357
+ }
2358
+ const resv = reserveEvent();
2359
+ const it = INTERCEPTORS.register(host, path, handler);
2360
+ const seq = recordStep({
2361
+ kind: "intercept",
2362
+ title: `intercept ${host}${it.path === "/" ? "" : it.path}`,
2363
+ blocks: [
2364
+ {
2365
+ type: "kv",
2366
+ rows: [
2367
+ { label: "Host", value: host },
2368
+ { label: "Path", value: it.path === "/" ? "/ (every path)" : `${it.path} and below` },
2369
+ ],
2370
+ },
2371
+ ],
2372
+ durationMs: 0,
2373
+ }, resv);
2374
+ if (seq !== undefined)
2375
+ INTERCEPT_STEP_SEQ.set(it.id, seq);
2376
+ return {
2377
+ hostname: host,
2378
+ path: it.path,
2379
+ get calls() {
2380
+ return wrap(it.calls, seq);
2381
+ },
2382
+ get requests() {
2383
+ return wrap(it.requests.map((r) => ({ ...r })), seq);
2384
+ },
2385
+ remove() {
2386
+ INTERCEPTORS.remove(it.id);
2387
+ INTERCEPT_STEP_SEQ.delete(it.id);
2388
+ },
2389
+ };
2390
+ }
2266
2391
  /**
2267
2392
  * Register a hostname at runtime — the implementation behind `ctx.dnsName`.
2268
2393
  * Validates via the same `dnsName` primitive the static path uses, resolves
@@ -3491,6 +3616,7 @@ async function spectestContext(scope = {}) {
3491
3616
  certificate: mintCertificate,
3492
3617
  startService: startRuntimeService,
3493
3618
  stopService: stopRuntimeService,
3619
+ intercept: registerInterceptor,
3494
3620
  };
3495
3621
  }
3496
3622
  /** Handles for a service hook: its dependencies (always up by the time the
@@ -4306,6 +4432,9 @@ async function runOne(testCase) {
4306
4432
  // time — so from here on a service helper's `docker exec` lands on the
4307
4433
  // timeline (and in the cast) exactly like the test's own `ctx.exec`.
4308
4434
  RECORDING_EXEC = recordedExec;
4435
+ // Interceptors this case registers die with it (see harness/intercept.ts
4436
+ // — the post-state snapshot must not carry a forced outage into children).
4437
+ INTERCEPTORS.beginScope(testCase.id);
4309
4438
  const timeoutMs = testCase.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
4310
4439
  let timer;
4311
4440
  const timedOut = new Promise((_, reject) => {
@@ -4335,6 +4464,7 @@ async function runOne(testCase) {
4335
4464
  if (timer)
4336
4465
  clearTimeout(timer);
4337
4466
  RECORDING_EXEC = undefined;
4467
+ INTERCEPTORS.endScope();
4338
4468
  restoreFetch();
4339
4469
  restoreConsole();
4340
4470
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Request interceptors on the ingress — the mechanism behind `ctx.intercept`.
3
+ *
4
+ * An interceptor is middleware in the Hono/Koa sense: it sees a request
5
+ * that reached the daemon's ingress for a hostname it claims, and either
6
+ * answers it itself or calls `next()` to let the real upstream (a proxied
7
+ * service, or a fake) answer. That is what lets a test make its *own*
8
+ * backend misbehave for one case — force a 500 from an edge function, add
9
+ * latency, fail twice then pass — without redefining the service.
10
+ *
11
+ * ## Why this is a chain and not a replacement handler
12
+ *
13
+ * A replacement handler covers "answer instead of the upstream" and nothing
14
+ * else. Every other shape a test needs — observe and count, mutate a real
15
+ * response, delay it, fail N times — needs the real answer in hand, which is
16
+ * what `next()` gives. Registration order is the chain order, as in Hono
17
+ * or Koa: the first interceptor sees the request first, and each one
18
+ * decides whether the next runs.
19
+ *
20
+ * ## Scope
21
+ *
22
+ * The registry is module memory in the harness process, so like fake state
23
+ * and the route tables it forks with the environment. That alone would make
24
+ * a parent's interceptor leak into every `dependsOn` child through the
25
+ * post-state snapshot, so a test's interceptors are **scoped to the case**:
26
+ * {@link InterceptRegistry.beginScope} opens the case, and
27
+ * {@link InterceptRegistry.endScope} removes everything registered inside it
28
+ * — the harness calls both around the test body. An interceptor registered
29
+ * outside a case (`eval`, project `setup`) has no scope and lasts until
30
+ * `remove()`.
31
+ *
32
+ * Pure module: no Bun, no listener, no recorder — so it is testable with
33
+ * plain `Request`/`Response` objects.
34
+ */
35
+ /** The continuation an interceptor calls to reach the upstream (or the next
36
+ * interceptor in the chain). Passing a `Request` replaces the one that goes
37
+ * on — the way to forward a modified request, since a fetch `Request` is
38
+ * immutable. */
39
+ export type InterceptNext = (req?: Request) => Promise<Response>;
40
+ export type InterceptHandler = (req: Request, next: InterceptNext) => Response | Promise<Response>;
41
+ /** One observed request, as the handle reports it. */
42
+ export interface InterceptedRequest {
43
+ method: string;
44
+ /** Path + query, as requested. */
45
+ path: string;
46
+ status: number;
47
+ /** Who produced the response: the interceptor itself (`handler`), the
48
+ * upstream via `next()` untouched (`upstream`), or the upstream's answer
49
+ * replaced by the interceptor after `next()` (`modified`). */
50
+ answeredBy: "handler" | "upstream" | "modified";
51
+ }
52
+ export interface Interceptor {
53
+ id: number;
54
+ hostname: string;
55
+ /** Mount path, like `app.use(path, fn)`: matches the path itself and
56
+ * everything below it. `"/"` (the default) matches every path. */
57
+ path: string;
58
+ handler: InterceptHandler;
59
+ /** The case this interceptor belongs to, if registered inside one. */
60
+ scope?: string;
61
+ calls: number;
62
+ requests: InterceptedRequest[];
63
+ }
64
+ /** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
65
+ * never `/apix`. `/` matches everything. Query strings do not take part. */
66
+ export declare function pathMounts(mount: string, pathname: string): boolean;
67
+ /** Exact hostname, or a `*.suffix` pattern that covers the host at any
68
+ * depth — the same rule the route tables use for a wildcard route. */
69
+ export declare function hostnameMatches(pattern: string, host: string): boolean;
70
+ /** Normalise a mount path: must start with `/`, no query, no trailing
71
+ * slash (except the root). */
72
+ export declare function normalizeMount(path: string | undefined): string;
73
+ export declare class InterceptRegistry {
74
+ private list;
75
+ private nextId;
76
+ private scope;
77
+ /** Every interceptor registered from now on belongs to `scope`, until
78
+ * {@link endScope}. */
79
+ beginScope(scope: string): void;
80
+ /** Close the current scope and remove every interceptor registered in it.
81
+ * Returns how many were removed. */
82
+ endScope(): number;
83
+ register(hostname: string, path: string | undefined, handler: InterceptHandler): Interceptor;
84
+ /** Idempotent: removing twice, or after the scope ended, is a no-op. */
85
+ remove(id: number): void;
86
+ /** The interceptors that apply to a request, in registration order. */
87
+ chainFor(host: string, pathname: string): Interceptor[];
88
+ size(): number;
89
+ clear(): void;
90
+ }
91
+ /** What `runChain` reports about one interceptor's part in a request. */
92
+ export interface ChainObserver {
93
+ (interceptor: Interceptor, record: InterceptedRequest, durationMs: number): void;
94
+ }
95
+ /**
96
+ * Run `req` through `chain`, ending at `upstream`.
97
+ *
98
+ * Each interceptor's `next` runs the rest of the chain; an interceptor
99
+ * that returns without calling `next` answers the request itself. A thrown
100
+ * error becomes a 500 naming the interceptor — the same rule a fake handler
101
+ * gets — so a bug in test code is a visible failure of that request, not a
102
+ * hung browser.
103
+ *
104
+ * `observe` is called once per interceptor that saw the request, after it
105
+ * returned, with what it did.
106
+ */
107
+ export declare function runChain(chain: readonly Interceptor[], req: Request, upstream: (req: Request) => Promise<Response>, observe?: ChainObserver): Promise<Response>;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Request interceptors on the ingress — the mechanism behind `ctx.intercept`.
3
+ *
4
+ * An interceptor is middleware in the Hono/Koa sense: it sees a request
5
+ * that reached the daemon's ingress for a hostname it claims, and either
6
+ * answers it itself or calls `next()` to let the real upstream (a proxied
7
+ * service, or a fake) answer. That is what lets a test make its *own*
8
+ * backend misbehave for one case — force a 500 from an edge function, add
9
+ * latency, fail twice then pass — without redefining the service.
10
+ *
11
+ * ## Why this is a chain and not a replacement handler
12
+ *
13
+ * A replacement handler covers "answer instead of the upstream" and nothing
14
+ * else. Every other shape a test needs — observe and count, mutate a real
15
+ * response, delay it, fail N times — needs the real answer in hand, which is
16
+ * what `next()` gives. Registration order is the chain order, as in Hono
17
+ * or Koa: the first interceptor sees the request first, and each one
18
+ * decides whether the next runs.
19
+ *
20
+ * ## Scope
21
+ *
22
+ * The registry is module memory in the harness process, so like fake state
23
+ * and the route tables it forks with the environment. That alone would make
24
+ * a parent's interceptor leak into every `dependsOn` child through the
25
+ * post-state snapshot, so a test's interceptors are **scoped to the case**:
26
+ * {@link InterceptRegistry.beginScope} opens the case, and
27
+ * {@link InterceptRegistry.endScope} removes everything registered inside it
28
+ * — the harness calls both around the test body. An interceptor registered
29
+ * outside a case (`eval`, project `setup`) has no scope and lasts until
30
+ * `remove()`.
31
+ *
32
+ * Pure module: no Bun, no listener, no recorder — so it is testable with
33
+ * plain `Request`/`Response` objects.
34
+ */
35
+ import { isWildcard, wildcardSuffix } from "./hostmatch";
36
+ /** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
37
+ * never `/apix`. `/` matches everything. Query strings do not take part. */
38
+ export function pathMounts(mount, pathname) {
39
+ if (mount === "/" || mount === "")
40
+ return true;
41
+ const m = mount.endsWith("/") ? mount.slice(0, -1) : mount;
42
+ return pathname === m || pathname.startsWith(`${m}/`);
43
+ }
44
+ /** Exact hostname, or a `*.suffix` pattern that covers the host at any
45
+ * depth — the same rule the route tables use for a wildcard route. */
46
+ export function hostnameMatches(pattern, host) {
47
+ if (pattern === host)
48
+ return true;
49
+ if (!isWildcard(pattern))
50
+ return false;
51
+ return host.endsWith(wildcardSuffix(pattern));
52
+ }
53
+ /** Normalise a mount path: must start with `/`, no query, no trailing
54
+ * slash (except the root). */
55
+ export function normalizeMount(path) {
56
+ if (path === undefined || path === "" || path === "/")
57
+ return "/";
58
+ if (!path.startsWith("/")) {
59
+ throw new Error(`intercept: path ${JSON.stringify(path)} must start with "/"`);
60
+ }
61
+ if (path.includes("?") || path.includes("#")) {
62
+ throw new Error(`intercept: path ${JSON.stringify(path)} is a mount prefix and cannot carry a query or fragment`);
63
+ }
64
+ return path.endsWith("/") ? path.slice(0, -1) : path;
65
+ }
66
+ export class InterceptRegistry {
67
+ list = [];
68
+ nextId = 1;
69
+ scope;
70
+ /** Every interceptor registered from now on belongs to `scope`, until
71
+ * {@link endScope}. */
72
+ beginScope(scope) {
73
+ this.scope = scope;
74
+ }
75
+ /** Close the current scope and remove every interceptor registered in it.
76
+ * Returns how many were removed. */
77
+ endScope() {
78
+ const scope = this.scope;
79
+ this.scope = undefined;
80
+ if (scope === undefined)
81
+ return 0;
82
+ const before = this.list.length;
83
+ this.list = this.list.filter((i) => i.scope !== scope);
84
+ return before - this.list.length;
85
+ }
86
+ register(hostname, path, handler) {
87
+ if (typeof handler !== "function") {
88
+ throw new Error("intercept: the handler must be a function (req, next) => Response");
89
+ }
90
+ const it = {
91
+ id: this.nextId++,
92
+ hostname: hostname.toLowerCase(),
93
+ path: normalizeMount(path),
94
+ handler,
95
+ scope: this.scope,
96
+ calls: 0,
97
+ requests: [],
98
+ };
99
+ this.list.push(it);
100
+ return it;
101
+ }
102
+ /** Idempotent: removing twice, or after the scope ended, is a no-op. */
103
+ remove(id) {
104
+ this.list = this.list.filter((i) => i.id !== id);
105
+ }
106
+ /** The interceptors that apply to a request, in registration order. */
107
+ chainFor(host, pathname) {
108
+ return this.list.filter((i) => hostnameMatches(i.hostname, host) && pathMounts(i.path, pathname));
109
+ }
110
+ size() {
111
+ return this.list.length;
112
+ }
113
+ clear() {
114
+ this.list = [];
115
+ this.scope = undefined;
116
+ }
117
+ }
118
+ /**
119
+ * Run `req` through `chain`, ending at `upstream`.
120
+ *
121
+ * Each interceptor's `next` runs the rest of the chain; an interceptor
122
+ * that returns without calling `next` answers the request itself. A thrown
123
+ * error becomes a 500 naming the interceptor — the same rule a fake handler
124
+ * gets — so a bug in test code is a visible failure of that request, not a
125
+ * hung browser.
126
+ *
127
+ * `observe` is called once per interceptor that saw the request, after it
128
+ * returned, with what it did.
129
+ */
130
+ export async function runChain(chain, req, upstream, observe) {
131
+ const run = async (index, current) => {
132
+ const it = chain[index];
133
+ if (!it)
134
+ return upstream(current);
135
+ let calledNext = false;
136
+ let fromUpstream;
137
+ const next = async (replacement) => {
138
+ calledNext = true;
139
+ fromUpstream = await run(index + 1, replacement ?? current);
140
+ return fromUpstream;
141
+ };
142
+ const started = Date.now();
143
+ const url = new URL(current.url);
144
+ const record = {
145
+ method: current.method,
146
+ path: `${url.pathname}${url.search}`,
147
+ status: 0,
148
+ answeredBy: "handler",
149
+ };
150
+ let res;
151
+ try {
152
+ res = await it.handler(current, next);
153
+ if (!(res instanceof Response)) {
154
+ throw new Error(`interceptor for ${it.hostname}${it.path === "/" ? "" : it.path} returned ${res === undefined ? "undefined" : typeof res} — return a Response, or the result of next()`);
155
+ }
156
+ }
157
+ catch (err) {
158
+ const e = err;
159
+ res = new Response(`spectest-daemon: interceptor for ${it.hostname} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
160
+ record.answeredBy = "handler";
161
+ record.status = 500;
162
+ it.calls++;
163
+ it.requests.push(record);
164
+ observe?.(it, record, Date.now() - started);
165
+ return res;
166
+ }
167
+ record.status = res.status;
168
+ record.answeredBy = !calledNext ? "handler" : res === fromUpstream ? "upstream" : "modified";
169
+ it.calls++;
170
+ it.requests.push(record);
171
+ observe?.(it, record, Date.now() - started);
172
+ return res;
173
+ };
174
+ return run(0, req);
175
+ }
package/dist/index.d.ts CHANGED
@@ -25,6 +25,27 @@ import { type ServiceCoverage } from "./coverage.js";
25
25
  export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
26
26
  export type { CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
27
27
  import type { DnsTarget } from "./ingress.js";
28
+ import type { InterceptHandler, InterceptNext, InterceptedRequest } from "./harness/intercept.js";
29
+ export type { InterceptHandler, InterceptNext, InterceptedRequest };
30
+ /**
31
+ * The handle `ctx.intercept` returns: what the interceptor has seen so far,
32
+ * and the way to take it down early. A test's interceptors are removed
33
+ * when the test ends whether or not `remove()` was called.
34
+ */
35
+ export interface Interception {
36
+ hostname: string;
37
+ /** The normalised mount path (`"/"` when none was given). */
38
+ path: string;
39
+ /** How many requests the interceptor has seen. Provenance-wrapped, so an
40
+ * `expect(outage.calls)` nests under the intercept step; `.unwrap()` for
41
+ * the raw number. */
42
+ readonly calls: Wrapped<number>;
43
+ /** Every request it saw, in order, with the status it got and who
44
+ * answered it (`handler` | `upstream` | `modified`). */
45
+ readonly requests: Wrapped<InterceptedRequest[]>;
46
+ /** Stop intercepting now. Idempotent. */
47
+ remove(): void;
48
+ }
28
49
  export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
29
50
  /** Human-friendly name for the environment, e.g. "my-app". */
30
51
  name: string;
@@ -355,6 +376,40 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
355
376
  * ```
356
377
  */
357
378
  dnsName(hostname: string, target: DnsTarget): Promise<void>;
379
+ /**
380
+ * Put middleware in front of a hostname the ingress serves — the way to
381
+ * make your **own** backend misbehave for one test: force a 500 from an
382
+ * API route, add latency, fail twice then pass, or just count calls.
383
+ *
384
+ * Hono/Koa-style middleware: `(req, next)` where `next()` returns the real upstream's
385
+ * response (a proxied service, or a fake). Return a `Response` to answer
386
+ * 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.
390
+ *
391
+ * Only traffic that reaches the daemon can be intercepted: the browser,
392
+ * `ctx.fetch`, and any container that calls the hostname — so the service
393
+ * needs a `tls`/`hostnames` entry (or `supabase({ hostname })`), or the
394
+ * target is a fake. A bare `http://<service>:<port>` call between
395
+ * containers never passes through here, and a hostname nothing claims is
396
+ * refused at registration.
397
+ *
398
+ * From a test, the interceptor lasts until the test ends (a `dependsOn`
399
+ * child never inherits it); from `eval` or `setup` it lasts until
400
+ * `remove()`. Every request it sees is recorded under the intercept step.
401
+ *
402
+ * ```ts
403
+ * const outage = ctx.intercept("api.test", "/functions/v1/sync", () =>
404
+ * new Response("boom", { status: 500 }));
405
+ * await page.getByRole("button", { name: "Sync" }).click();
406
+ * await expect(page.getByText("Retry")).toBeVisible();
407
+ * expect(outage.calls).toBe(1);
408
+ * outage.remove(); // the retry now reaches the real function
409
+ * ```
410
+ */
411
+ intercept(hostname: string, handler: InterceptHandler): Interception;
412
+ intercept(hostname: string, path: string, handler: InterceptHandler): Interception;
358
413
  /**
359
414
  * Mint a leaf certificate from the in-VM root CA and return the PEMs.
360
415
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.62.0",
3
+ "version": "0.64.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",