@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.
@@ -1043,12 +1043,60 @@ async function validJwt(token: string): Promise<boolean> {
1043
1043
  }
1044
1044
  }
1045
1045
 
1046
+ /** One isolate per function, reused across requests (the runtime pools by
1047
+ * servicePath). The idle timeout is a day, not upstream's five minutes: a
1048
+ * test environment is a snapshot lineage whose forks inherit these
1049
+ * isolates, and an isolate reaped for idleness is rebooted by the next
1050
+ * test that calls the function — a boot's worth of dirtied memory in that
1051
+ * test's own snapshot diff. */
1052
+ function workerFor(name: string) {
1053
+ return EdgeRuntime.userWorkers.create({
1054
+ servicePath: FUNCTIONS_DIR + "/" + name,
1055
+ memoryLimitMb: 256,
1056
+ workerTimeoutMs: 24 * 60 * 60 * 1000,
1057
+ noModuleCache: false,
1058
+ importMapPath: null,
1059
+ envVars: Object.entries(Deno.env.toObject()),
1060
+ });
1061
+ }
1062
+
1063
+ /** Boot every function's isolate now, without calling it. Creating the
1064
+ * worker loads the module graph and runs the module's top level — for a
1065
+ * function that is its \`Deno.serve(handler)\` registration, no business
1066
+ * logic. Called once at environment setup, before the warm snapshot, so
1067
+ * the isolates are shared, clean pages of the snapshot rather than a boot
1068
+ * in every test that first touches a function. */
1069
+ async function warmAll(): Promise<{ warmed: string[]; failed: Record<string, string> }> {
1070
+ const names: string[] = [];
1071
+ for await (const entry of Deno.readDir(FUNCTIONS_DIR)) {
1072
+ if (!entry.isDirectory || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
1073
+ try {
1074
+ await Deno.stat(FUNCTIONS_DIR + "/" + entry.name + "/index.ts");
1075
+ names.push(entry.name);
1076
+ } catch {
1077
+ // not a function directory
1078
+ }
1079
+ }
1080
+ // All at once: a project can have dozens of functions, and each boot is
1081
+ // mostly module loading — the isolates come up concurrently.
1082
+ const results = await Promise.allSettled(names.map((name) => workerFor(name)));
1083
+ const warmed: string[] = [];
1084
+ const failed: Record<string, string> = {};
1085
+ results.forEach((r, i) => {
1086
+ if (r.status === "fulfilled") warmed.push(names[i]);
1087
+ else failed[names[i]] = String(r.reason);
1088
+ });
1089
+ return { warmed: warmed.sort(), failed };
1090
+ }
1091
+
1046
1092
  Deno.serve(async (req: Request) => {
1047
1093
  const { pathname } = new URL(req.url);
1048
1094
 
1049
1095
  // Ready probe — answered before any auth check, so bring-up never needs a
1050
1096
  // token, and never treated as a function name.
1051
1097
  if (pathname === "/_spectest/health") return new Response("ok");
1098
+ // Setup-time warm-up (see warmAll); never treated as a function name.
1099
+ if (pathname === "/_spectest/warm") return json(await warmAll());
1052
1100
 
1053
1101
  const name = pathname.split("/")[1] ?? "";
1054
1102
  if (name === "") return json({ msg: "missing function name in request" }, 400);
@@ -1063,14 +1111,7 @@ Deno.serve(async (req: Request) => {
1063
1111
 
1064
1112
  const servicePath = FUNCTIONS_DIR + "/" + name;
1065
1113
  try {
1066
- const worker = await EdgeRuntime.userWorkers.create({
1067
- servicePath,
1068
- memoryLimitMb: 256,
1069
- workerTimeoutMs: 5 * 60 * 1000,
1070
- noModuleCache: false,
1071
- importMapPath: null,
1072
- envVars: Object.entries(Deno.env.toObject()),
1073
- });
1114
+ const worker = await workerFor(name);
1074
1115
  return await worker.fetch(req);
1075
1116
  } catch (e) {
1076
1117
  // A missing directory lands here too, so say which function was asked
@@ -1917,7 +1958,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1917
1958
 
1918
1959
  // ── functions (edge runtime) ────────────────────────────────────
1919
1960
  if (withFunctions) {
1920
- parts.functions = {
1961
+ const functionsDef: ServiceDefinition = {
1921
1962
  // Built rather than pulled, so the functions' remote imports are
1922
1963
  // fetched by the host builder and cached into the image — see
1923
1964
  // `functionsDockerfile`.
@@ -1976,7 +2017,27 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1976
2017
  path: "/_spectest/health",
1977
2018
  timeoutSecs: 120,
1978
2019
  },
2020
+ // Boot every function's isolate before the warm snapshot is
2021
+ // taken (see warmAll in the router). Measured on a real project
2022
+ // (Harmony, 2026-08-28): a test that first touched a few
2023
+ // functions dirtied ~1 GB booting them — memory that then had to
2024
+ // be written into that test's snapshot diff and paid for by every
2025
+ // fork of it. Warm, the isolates are clean shared pages of the
2026
+ // snapshot and a test's diff is what its requests mutate.
2027
+ setup: async () => {
2028
+ const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
2029
+ if (!res.ok) {
2030
+ console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
2031
+ return;
2032
+ }
2033
+ const { warmed, failed } = (await res.json()) as { warmed: string[]; failed: Record<string, string> };
2034
+ console.log(`supabase(): warmed ${warmed.length} edge function${warmed.length === 1 ? "" : "s"}${warmed.length ? ` (${warmed.join(", ")})` : ""}.`);
2035
+ for (const [name, err] of Object.entries(failed)) {
2036
+ console.warn(`supabase(): edge function "${name}" failed to boot at warm-up — it will be retried on first call: ${err}`);
2037
+ }
2038
+ },
1979
2039
  };
2040
+ parts.functions = functionsDef;
1980
2041
  }
1981
2042
 
1982
2043
  // ── kong (gateway / primary) — no explicit dependsOn: the group
package/src/daemon.ts CHANGED
@@ -98,6 +98,11 @@ import {
98
98
  parseContentLength,
99
99
  } from "./harness/http-body.js";
100
100
  import { encodeRegistry } from "./harness/names-registry.js";
101
+ import {
102
+ InterceptRegistry,
103
+ runChain,
104
+ type Interceptor,
105
+ } from "./harness/intercept.js";
101
106
  import {
102
107
  HOP_BY_HOP_HEADERS,
103
108
  augmentCorsResponse,
@@ -151,6 +156,7 @@ import { openMcp } from "./mcp.js";
151
156
  import { setRawFetch } from "./harness/raw-fetch.js";
152
157
  import { readAnnotation, type RenderAnnotation } from "./annotate.js";
153
158
  import {
159
+ isRecording,
154
160
  pauseRecording,
155
161
  recordEmail,
156
162
  recordEnv,
@@ -191,6 +197,9 @@ import type {
191
197
  FakeContext,
192
198
  FakeDefinition,
193
199
  FileMount,
200
+ InterceptHandler,
201
+ Interception,
202
+ InterceptedRequest,
194
203
  Project,
195
204
  ProjectSetupContext,
196
205
  ReadyCheck,
@@ -2449,26 +2458,30 @@ async function dispatchIngress(
2449
2458
  // Cache-Control, Pragma, … — isn't rejected by whatever the upstream happens
2450
2459
  // to list in Access-Control-Allow-Headers.
2451
2460
  if (isCorsPreflight(req)) return corsPreflightResponse(req);
2452
- if (route.kind === "fake") {
2453
- try {
2454
- const res = await route.fake.def.handler(req, route.fake.state, FAKE_CTX);
2455
- return augmentCorsResponse(req, res);
2456
- } catch (err) {
2457
- const e = err as Error;
2458
- return new Response(
2459
- `spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`,
2460
- { status: 500, headers: { "content-type": "text/plain" } },
2461
- );
2461
+ // The real answer — a fake handled in-process, or the proxied container.
2462
+ const upstream = async (current: Request): Promise<Response> => {
2463
+ if (route.kind === "fake") {
2464
+ try {
2465
+ return await route.fake.def.handler(current, route.fake.state, FAKE_CTX);
2466
+ } catch (err) {
2467
+ const e = err as Error;
2468
+ return new Response(
2469
+ `spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`,
2470
+ { status: 500, headers: { "content-type": "text/plain" } },
2471
+ );
2472
+ }
2462
2473
  }
2463
- }
2464
- const res = await proxyToService(
2465
- req,
2466
- server,
2467
- route.service,
2468
- route.port,
2469
- listenerLabel,
2470
- proto,
2471
- );
2474
+ return proxyToService(current, server, route.service, route.port, listenerLabel, proto);
2475
+ };
2476
+ // `ctx.intercept` middleware runs first, in registration order, and reaches
2477
+ // the upstream through `next()`. The CORS headers go on *after* the chain,
2478
+ // so a forced 500 reaches the browser as a 500 and not as a CORS error —
2479
+ // the trap `page.route`-style interception falls into.
2480
+ const chain = INTERCEPTORS.chainFor(host, new URL(req.url).pathname);
2481
+ const res =
2482
+ chain.length === 0
2483
+ ? await upstream(req)
2484
+ : await runChain(chain, req, upstream, recordInterceptedRequest);
2472
2485
  return augmentCorsResponse(req, res);
2473
2486
  }
2474
2487
 
@@ -2755,6 +2768,137 @@ async function seedNamesRegistry(opts: { servicesUp: boolean }): Promise<void> {
2755
2768
  await writeRegistry();
2756
2769
  }
2757
2770
 
2771
+ /**
2772
+ * The live interceptors (`ctx.intercept`). Module memory, so they fork with
2773
+ * the environment like the route tables; a test's own are removed when its
2774
+ * case ends (`runOne` opens and closes the scope), so a `dependsOn` child
2775
+ * never inherits a parent's forced outage.
2776
+ */
2777
+ const INTERCEPTORS = new InterceptRegistry();
2778
+
2779
+ /** The recorder seq of the `intercept` step each interceptor was registered
2780
+ * under, so every request it sees can nest below that step. */
2781
+ const INTERCEPT_STEP_SEQ = new Map<number, number>();
2782
+
2783
+ /** What every port's route table together claims — the hostnames a request
2784
+ * can reach the ingress under at all. */
2785
+ function ingressClaimsHostname(hostname: string): boolean {
2786
+ for (const byHost of INGRESS.routesByPort.values()) {
2787
+ if (matchRoute(byHost, hostname)) return true;
2788
+ if (isWildcard(hostname)) {
2789
+ // A wildcard interceptor is fine when any route sits under it.
2790
+ const suffix = wildcardSuffix(hostname);
2791
+ for (const key of byHost.keys()) {
2792
+ if (key === hostname || key.endsWith(suffix)) return true;
2793
+ }
2794
+ }
2795
+ }
2796
+ return false;
2797
+ }
2798
+
2799
+ /** Record one request an interceptor saw, nested under its `intercept` step. */
2800
+ function recordInterceptedRequest(
2801
+ it: Interceptor,
2802
+ rec: { method: string; path: string; status: number; answeredBy: string },
2803
+ durationMs: number,
2804
+ ): void {
2805
+ const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
2806
+ if (parentSeq === undefined || !isRecording()) return;
2807
+ const by =
2808
+ rec.answeredBy === "handler"
2809
+ ? "answered by the interceptor"
2810
+ : rec.answeredBy === "modified"
2811
+ ? "upstream answer replaced by the interceptor"
2812
+ : "passed through to the upstream";
2813
+ recordStep({
2814
+ kind: "intercept-request",
2815
+ parentSeq,
2816
+ title: `${rec.method} ${rec.path} → ${rec.status}`,
2817
+ status: rec.status >= 500 && rec.answeredBy !== "upstream" ? "failed" : "passed",
2818
+ blocks: [
2819
+ {
2820
+ type: "kv",
2821
+ rows: [
2822
+ { label: "Host", value: it.hostname },
2823
+ { label: "Request", value: `${rec.method} ${rec.path}` },
2824
+ { label: "Status", value: String(rec.status) },
2825
+ { label: "Answered", value: by },
2826
+ ],
2827
+ },
2828
+ ],
2829
+ durationMs,
2830
+ });
2831
+ }
2832
+
2833
+ /**
2834
+ * Put middleware in front of a hostname the ingress serves — the
2835
+ * implementation behind `ctx.intercept`.
2836
+ *
2837
+ * Refuses a hostname no route claims: a request for it would never reach
2838
+ * the daemon (DNS does not point here), so the interceptor could only be
2839
+ * silent — and silence is the failure mode this whole layer is designed
2840
+ * against. The message names the two ways to get a route.
2841
+ */
2842
+ function registerInterceptor(
2843
+ hostname: string,
2844
+ pathOrHandler: string | InterceptHandler,
2845
+ maybeHandler?: InterceptHandler,
2846
+ ): Interception {
2847
+ const path = typeof pathOrHandler === "string" ? pathOrHandler : undefined;
2848
+ const handler = typeof pathOrHandler === "function" ? pathOrHandler : maybeHandler;
2849
+ if (typeof hostname !== "string" || hostname.length === 0) {
2850
+ throw new Error("ctx.intercept: a hostname is required");
2851
+ }
2852
+ const host = hostname.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
2853
+ if (!handler) {
2854
+ throw new Error("ctx.intercept: a handler (req, next) => Response is required");
2855
+ }
2856
+ if (!ingressClaimsHostname(host)) {
2857
+ throw new Error(
2858
+ `ctx.intercept(${JSON.stringify(host)}): no request can reach the ingress under that hostname. ` +
2859
+ `Only traffic that passes through the daemon can be intercepted: give the service a \`tls\`/\`hostnames\` ` +
2860
+ `entry (or \`supabase({ hostname })\`), or target a fake's hostname. A bare service name ` +
2861
+ `(\`http://<service>:<port>\`) is container-to-container traffic and never passes through here.`,
2862
+ );
2863
+ }
2864
+ const resv = reserveEvent();
2865
+ const it = INTERCEPTORS.register(host, path, handler);
2866
+ const seq = recordStep(
2867
+ {
2868
+ kind: "intercept",
2869
+ title: `intercept ${host}${it.path === "/" ? "" : it.path}`,
2870
+ blocks: [
2871
+ {
2872
+ type: "kv",
2873
+ rows: [
2874
+ { label: "Host", value: host },
2875
+ { label: "Path", value: it.path === "/" ? "/ (every path)" : `${it.path} and below` },
2876
+ ],
2877
+ },
2878
+ ],
2879
+ durationMs: 0,
2880
+ },
2881
+ resv,
2882
+ );
2883
+ if (seq !== undefined) INTERCEPT_STEP_SEQ.set(it.id, seq);
2884
+ return {
2885
+ hostname: host,
2886
+ path: it.path,
2887
+ get calls(): Wrapped<number> {
2888
+ return wrap(it.calls, seq) as unknown as Wrapped<number>;
2889
+ },
2890
+ get requests(): Wrapped<InterceptedRequest[]> {
2891
+ return wrap(it.requests.map((r) => ({ ...r })), seq) as unknown as Wrapped<
2892
+ InterceptedRequest[]
2893
+ >;
2894
+ },
2895
+ remove(): void {
2896
+ INTERCEPTORS.remove(it.id);
2897
+ INTERCEPT_STEP_SEQ.delete(it.id);
2898
+ },
2899
+ };
2900
+ }
2901
+
2758
2902
  /**
2759
2903
  * Register a hostname at runtime — the implementation behind `ctx.dnsName`.
2760
2904
  * Validates via the same `dnsName` primitive the static path uses, resolves
@@ -4303,6 +4447,7 @@ async function spectestContext(scope: ContextScope = {}): Promise<SpectestContex
4303
4447
  certificate: mintCertificate,
4304
4448
  startService: startRuntimeService,
4305
4449
  stopService: stopRuntimeService,
4450
+ intercept: registerInterceptor as SpectestContext["intercept"],
4306
4451
  };
4307
4452
  }
4308
4453
 
@@ -5253,6 +5398,9 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5253
5398
  // time — so from here on a service helper's `docker exec` lands on the
5254
5399
  // timeline (and in the cast) exactly like the test's own `ctx.exec`.
5255
5400
  RECORDING_EXEC = recordedExec;
5401
+ // Interceptors this case registers die with it (see harness/intercept.ts
5402
+ // — the post-state snapshot must not carry a forced outage into children).
5403
+ INTERCEPTORS.beginScope(testCase.id);
5256
5404
 
5257
5405
  const timeoutMs = testCase.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
5258
5406
  let timer: NodeJS.Timeout | undefined;
@@ -5284,6 +5432,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5284
5432
  } finally {
5285
5433
  if (timer) clearTimeout(timer);
5286
5434
  RECORDING_EXEC = undefined;
5435
+ INTERCEPTORS.endScope();
5287
5436
  restoreFetch();
5288
5437
  restoreConsole();
5289
5438
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -0,0 +1,182 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ InterceptRegistry,
4
+ hostnameMatches,
5
+ normalizeMount,
6
+ pathMounts,
7
+ runChain,
8
+ type Interceptor,
9
+ } from "./intercept";
10
+
11
+ const upstream = async (_req: Request) => new Response("real", { status: 200 });
12
+
13
+ function req(path = "/", init?: RequestInit): Request {
14
+ return new Request(`https://api.test${path}`, init);
15
+ }
16
+
17
+ describe("pathMounts", () => {
18
+ test("root matches everything", () => {
19
+ expect(pathMounts("/", "/")).toBe(true);
20
+ expect(pathMounts("/", "/a/b")).toBe(true);
21
+ });
22
+ test("a mount matches itself and below, on a segment boundary", () => {
23
+ expect(pathMounts("/api", "/api")).toBe(true);
24
+ expect(pathMounts("/api", "/api/")).toBe(true);
25
+ expect(pathMounts("/api", "/api/x")).toBe(true);
26
+ expect(pathMounts("/api", "/apix")).toBe(false);
27
+ expect(pathMounts("/api", "/")).toBe(false);
28
+ });
29
+ });
30
+
31
+ describe("normalizeMount", () => {
32
+ test("defaults and trims", () => {
33
+ expect(normalizeMount(undefined)).toBe("/");
34
+ expect(normalizeMount("/api/")).toBe("/api");
35
+ });
36
+ test("rejects a relative path or a query", () => {
37
+ expect(() => normalizeMount("api")).toThrow(/must start with/);
38
+ expect(() => normalizeMount("/api?x=1")).toThrow(/mount prefix/);
39
+ });
40
+ });
41
+
42
+ describe("hostnameMatches", () => {
43
+ test("exact and wildcard", () => {
44
+ expect(hostnameMatches("api.test", "api.test")).toBe(true);
45
+ expect(hostnameMatches("api.test", "www.api.test")).toBe(false);
46
+ expect(hostnameMatches("*.api.test", "www.api.test")).toBe(true);
47
+ expect(hostnameMatches("*.api.test", "a.b.api.test")).toBe(true);
48
+ expect(hostnameMatches("*.api.test", "api.test")).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe("runChain", () => {
53
+ test("no interceptors reaches the upstream", async () => {
54
+ const res = await runChain([], req(), upstream);
55
+ expect(await res.text()).toBe("real");
56
+ });
57
+
58
+ test("a handler that answers stops the chain", async () => {
59
+ const reg = new InterceptRegistry();
60
+ reg.register("api.test", undefined, () => new Response("boom", { status: 500 }));
61
+ let upstreamHit = 0;
62
+ const res = await runChain(reg.chainFor("api.test", "/x"), req("/x"), async (r) => {
63
+ upstreamHit++;
64
+ return upstream(r);
65
+ });
66
+ expect(res.status).toBe(500);
67
+ expect(upstreamHit).toBe(0);
68
+ const it = reg.chainFor("api.test", "/x")[0]!;
69
+ expect(it.calls).toBe(1);
70
+ expect(it.requests[0]).toEqual({ method: "GET", path: "/x", status: 500, answeredBy: "handler" });
71
+ });
72
+
73
+ test("next() reaches the upstream and reports it", async () => {
74
+ const reg = new InterceptRegistry();
75
+ const it = reg.register("api.test", undefined, (_r, next) => next());
76
+ const res = await runChain([it], req("/x?y=1"), upstream);
77
+ expect(res.status).toBe(200);
78
+ expect(it.requests[0]).toEqual({ method: "GET", path: "/x?y=1", status: 200, answeredBy: "upstream" });
79
+ });
80
+
81
+ test("a replaced upstream answer reports modified", async () => {
82
+ const it: Interceptor = new InterceptRegistry().register("api.test", undefined, async (_r, next) => {
83
+ const real = await next();
84
+ return new Response(await real.text(), { status: 503 });
85
+ });
86
+ const res = await runChain([it], req(), upstream);
87
+ expect(res.status).toBe(503);
88
+ expect(it.requests[0]!.answeredBy).toBe("modified");
89
+ });
90
+
91
+ test("next(request) forwards the replacement", async () => {
92
+ const it = new InterceptRegistry().register("api.test", undefined, (r, next) =>
93
+ next(new Request(r.url, { method: "POST", headers: { "x-added": "1" } })),
94
+ );
95
+ let seen: Request | undefined;
96
+ await runChain([it], req(), async (r) => {
97
+ seen = r;
98
+ return upstream(r);
99
+ });
100
+ expect(seen!.method).toBe("POST");
101
+ expect(seen!.headers.get("x-added")).toBe("1");
102
+ });
103
+
104
+ test("chains run in registration order, each deciding on the next", async () => {
105
+ const reg = new InterceptRegistry();
106
+ const order: string[] = [];
107
+ reg.register("api.test", undefined, (_r, next) => {
108
+ order.push("first");
109
+ return next();
110
+ });
111
+ reg.register("api.test", "/api", () => {
112
+ order.push("second");
113
+ return new Response("second", { status: 418 });
114
+ });
115
+ reg.register("api.test", undefined, () => {
116
+ order.push("third");
117
+ return new Response("third");
118
+ });
119
+ const res = await runChain(reg.chainFor("api.test", "/api/x"), req("/api/x"), upstream);
120
+ expect(res.status).toBe(418);
121
+ expect(order).toEqual(["first", "second"]);
122
+ // A path outside the second mount skips it.
123
+ order.length = 0;
124
+ const res2 = await runChain(reg.chainFor("api.test", "/other"), req("/other"), upstream);
125
+ expect(await res2.text()).toBe("third");
126
+ expect(order).toEqual(["first", "third"]);
127
+ });
128
+
129
+ test("a throwing handler becomes a 500 naming the host", async () => {
130
+ const it = new InterceptRegistry().register("api.test", undefined, () => {
131
+ throw new Error("kaput");
132
+ });
133
+ const res = await runChain([it], req(), upstream);
134
+ expect(res.status).toBe(500);
135
+ expect(await res.text()).toContain("kaput");
136
+ expect(it.requests[0]!.answeredBy).toBe("handler");
137
+ });
138
+
139
+ test("a handler that returns nothing is a 500, not a hang", async () => {
140
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
141
+ const it = new InterceptRegistry().register("api.test", undefined, (() => undefined) as any);
142
+ const res = await runChain([it], req(), upstream);
143
+ expect(res.status).toBe(500);
144
+ expect(await res.text()).toContain("returned undefined");
145
+ });
146
+
147
+ test("the observer sees every interceptor that ran", async () => {
148
+ const reg = new InterceptRegistry();
149
+ reg.register("api.test", undefined, (_r, next) => next());
150
+ reg.register("api.test", undefined, () => new Response("x", { status: 404 }));
151
+ const seen: string[] = [];
152
+ await runChain(reg.chainFor("api.test", "/"), req(), upstream, (it, rec) => {
153
+ seen.push(`${it.id}:${rec.status}:${rec.answeredBy}`);
154
+ });
155
+ // Inner finishes first (its observer fires as the stack unwinds).
156
+ expect(seen).toEqual(["2:404:handler", "1:404:upstream"]);
157
+ });
158
+ });
159
+
160
+ describe("InterceptRegistry scopes", () => {
161
+ test("endScope removes what the scope registered and nothing else", () => {
162
+ const reg = new InterceptRegistry();
163
+ const global = reg.register("api.test", undefined, (_r, n) => n());
164
+ reg.beginScope("case-1");
165
+ reg.register("api.test", "/a", (_r, n) => n());
166
+ reg.register("api.test", "/b", (_r, n) => n());
167
+ expect(reg.size()).toBe(3);
168
+ expect(reg.endScope()).toBe(2);
169
+ expect(reg.size()).toBe(1);
170
+ expect(reg.chainFor("api.test", "/a")[0]!.id).toBe(global.id);
171
+ // Closing again is a no-op.
172
+ expect(reg.endScope()).toBe(0);
173
+ });
174
+
175
+ test("remove is idempotent", () => {
176
+ const reg = new InterceptRegistry();
177
+ const it = reg.register("api.test", undefined, (_r, n) => n());
178
+ reg.remove(it.id);
179
+ reg.remove(it.id);
180
+ expect(reg.size()).toBe(0);
181
+ });
182
+ });