@specific.dev/spectest 0.64.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.
@@ -1043,6 +1043,14 @@ async function validJwt(token: string): Promise<boolean> {
1043
1043
  }
1044
1044
  }
1045
1045
 
1046
+ /** Where the generated entry of the shared isolate lives — outside the repo
1047
+ * mount, which is read-only and the user's checkout, and NOT under /tmp:
1048
+ * the runtime gives its workers an in-memory /tmp, so a file the main
1049
+ * worker writes there does not exist for the user worker's bootstrap
1050
+ * ("could not find an appropriate entrypoint"). */
1051
+ const SHARED_DIR = "/home/deno/spectest-functions";
1052
+ const WORKER_TIMEOUT_MS = 24 * 60 * 60 * 1000;
1053
+
1046
1054
  /** One isolate per function, reused across requests (the runtime pools by
1047
1055
  * servicePath). The idle timeout is a day, not upstream's five minutes: a
1048
1056
  * test environment is a snapshot lineage whose forks inherit these
@@ -1053,32 +1061,268 @@ function workerFor(name: string) {
1053
1061
  return EdgeRuntime.userWorkers.create({
1054
1062
  servicePath: FUNCTIONS_DIR + "/" + name,
1055
1063
  memoryLimitMb: 256,
1056
- workerTimeoutMs: 24 * 60 * 60 * 1000,
1064
+ workerTimeoutMs: WORKER_TIMEOUT_MS,
1057
1065
  noModuleCache: false,
1058
1066
  importMapPath: null,
1059
1067
  envVars: Object.entries(Deno.env.toObject()),
1060
1068
  });
1061
1069
  }
1062
1070
 
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> }> {
1071
+ async function exists(path: string): Promise<boolean> {
1072
+ try {
1073
+ await Deno.stat(path);
1074
+ return true;
1075
+ } catch {
1076
+ return false;
1077
+ }
1078
+ }
1079
+
1080
+ /** The function directories: every child of FUNCTIONS_DIR with an index.ts,
1081
+ * minus the conventional private ones. */
1082
+ async function functionNames(): Promise<string[]> {
1070
1083
  const names: string[] = [];
1071
1084
  for await (const entry of Deno.readDir(FUNCTIONS_DIR)) {
1072
1085
  if (!entry.isDirectory || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
1086
+ if (await exists(FUNCTIONS_DIR + "/" + entry.name + "/index.ts")) names.push(entry.name);
1087
+ }
1088
+ return names.sort();
1089
+ }
1090
+
1091
+ /** Every "https://deno.land/std@…/http/server.ts" a source under the
1092
+ * functions directory imports — the legacy serve() each of them has to be
1093
+ * redirected to the shim in the shared isolate. */
1094
+ async function stdServerUrls(dir: string, out: Set<string>): Promise<void> {
1095
+ for await (const e of Deno.readDir(dir)) {
1096
+ const p = dir + "/" + e.name;
1097
+ if (e.isDirectory) {
1098
+ if (e.name !== "node_modules" && !e.name.startsWith(".")) await stdServerUrls(p, out);
1099
+ continue;
1100
+ }
1101
+ if (!/\.(ts|tsx|js|mjs|jsx)$/.test(e.name)) continue;
1102
+ const src = await Deno.readTextFile(p);
1103
+ for (const m of src.matchAll(/https:\/\/deno\.land\/std@[0-9.]+\/http\/server\.ts/g)) out.add(m[0]);
1104
+ }
1105
+ }
1106
+
1107
+ // ── The shared isolate ────────────────────────────────────────────────────
1108
+ //
1109
+ // The runtime's own model is one isolate per function, each loading its own
1110
+ // copy of the module graph. On a project with dozens of functions sharing a
1111
+ // \`_shared/\` tree that is dozens of copies of the same graph — measured at
1112
+ // ~90 MB and ~1 s per function, against 18 MB for an empty isolate — and in
1113
+ // an 8 GB guest that memory comes straight out of the page cache every test
1114
+ // needs. So by default every function is loaded into ONE user worker: a
1115
+ // generated entry module imports each function's index.ts in turn, a prelude
1116
+ // (imported first, so it runs first) has replaced \`Deno.serve\` with a
1117
+ // registration that records the handler under the function whose module is
1118
+ // calling, the std \`serve()\` the older functions use is redirected to the
1119
+ // same registration by an import map, and the worker's own server routes a
1120
+ // request to the handler of the function its path names. The runtime, the
1121
+ // request a function receives and the per-function JWT check are unchanged;
1122
+ // what differs from hosted Supabase is that module-level state is one
1123
+ // object for every function rather than one per function.
1124
+ //
1125
+ // There is deliberately no opt-out: it stands in wherever it can be exact,
1126
+ // and where it cannot the per-function isolates are used. A function with its own
1127
+ // deno.json / import map (per-function resolution the one config cannot
1128
+ // express), a functions/deno.jsonc (comments; not merged) or a config that
1129
+ // names an importMap keep the per-function isolates, and so does a shared
1130
+ // isolate that fails to boot — with the reason in the log, since a boot
1131
+ // failure names the module at fault.
1132
+
1133
+ interface SharedSpec {
1134
+ names: string[];
1135
+ reason: string | null;
1136
+ }
1137
+ let sharedSpec: Promise<SharedSpec> | null = null;
1138
+
1139
+ /** Why the shared isolate cannot stand in for per-function ones, or null. */
1140
+ async function sharedBlocker(names: string[]): Promise<string | null> {
1141
+ for (const n of names) {
1142
+ for (const f of ["deno.json", "deno.jsonc", "import_map.json"]) {
1143
+ if (await exists(FUNCTIONS_DIR + "/" + n + "/" + f)) return "function \"" + n + "\" has its own " + f;
1144
+ }
1145
+ }
1146
+ if (await exists(FUNCTIONS_DIR + "/deno.jsonc")) return "functions/deno.jsonc is not merged (comments)";
1147
+ return null;
1148
+ }
1149
+
1150
+ /** POSIX-relative path from directory \`from\` to \`to\`. The entry imports the
1151
+ * functions relatively: an absolute file specifier is refused by the
1152
+ * runtime's module bundler ("Module not found"), a relative one resolves. */
1153
+ function relativePath(from: string, to: string): string {
1154
+ const a = from.split("/").filter(Boolean);
1155
+ const b = to.split("/").filter(Boolean);
1156
+ let i = 0;
1157
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
1158
+ const up = a.slice(i).map(() => "..");
1159
+ const rel = [...up, ...b.slice(i)].join("/");
1160
+ return rel.startsWith(".") ? rel : "./" + rel;
1161
+ }
1162
+
1163
+ function reString(s: string): string {
1164
+ return s.replace(/[.*+?^$()|[\]\\\/{}]/g, "\\$&");
1165
+ }
1166
+
1167
+ /** Write SHARED_DIR: the entry, the prelude, the std shim and the config. */
1168
+ async function writeShared(names: string[]): Promise<string | null> {
1169
+ await Deno.mkdir(SHARED_DIR, { recursive: true });
1170
+
1171
+ // Config: the project's functions/deno.json (its imports are what every
1172
+ // function resolves against) plus the std shim redirections.
1173
+ let cfg: Record<string, unknown> = {};
1174
+ if (await exists(FUNCTIONS_DIR + "/deno.json")) {
1073
1175
  try {
1074
- await Deno.stat(FUNCTIONS_DIR + "/" + entry.name + "/index.ts");
1075
- names.push(entry.name);
1076
- } catch {
1077
- // not a function directory
1176
+ cfg = JSON.parse(await Deno.readTextFile(FUNCTIONS_DIR + "/deno.json"));
1177
+ } catch (e) {
1178
+ return "functions/deno.json could not be parsed: " + String(e);
1078
1179
  }
1180
+ if (typeof cfg.importMap === "string") return "functions/deno.json names an importMap";
1079
1181
  }
1080
- // All at once: a project can have dozens of functions, and each boot is
1081
- // mostly module loading — the isolates come up concurrently.
1182
+ const urls = new Set<string>();
1183
+ await stdServerUrls(FUNCTIONS_DIR, urls);
1184
+ const imports: Record<string, string> = { ...((cfg.imports as Record<string, string>) ?? {}) };
1185
+ for (const u of urls) imports[u] = "./std-http-server.ts";
1186
+ await Deno.writeTextFile(
1187
+ SHARED_DIR + "/deno.json",
1188
+ JSON.stringify({ ...cfg, lock: false, imports }, null, 2),
1189
+ );
1190
+
1191
+ // The registration. Which function is calling comes from the stack: the
1192
+ // first frame under FUNCTIONS_DIR whose directory is a function.
1193
+ const known = JSON.stringify(names);
1194
+ const prelude = [
1195
+ "// spectest-generated. Do not edit.",
1196
+ "const g = globalThis as any;",
1197
+ "g.__spectestHandlers = {};",
1198
+ "const known = new Set(" + known + ");",
1199
+ "const frame = new RegExp(\"" + reString(FUNCTIONS_DIR) + "/([^/]+)/\", \"g\");",
1200
+ "// serve()'s onError is part of the contract: a project maps its thrown",
1201
+ "// errors to responses there, and without it every throw is a bare 500.",
1202
+ "g.__spectestRegister = (handler: unknown, onError?: unknown) => {",
1203
+ " if (typeof handler !== \"function\") throw new TypeError(\"spectest: serve() without a handler function\");",
1204
+ " const stack = new Error().stack ?? \"\";",
1205
+ " let name: string | null = null;",
1206
+ " for (const m of stack.matchAll(frame)) { if (known.has(m[1])) { name = m[1]; break; } }",
1207
+ " if (name === null) throw new Error(\"spectest: could not tell which function registered a handler; the call must come from under \" + JSON.stringify(\"" + FUNCTIONS_DIR + "\"));",
1208
+ " g.__spectestHandlers[name] = { handler, onError: typeof onError === \"function\" ? onError : null };",
1209
+ "};",
1210
+ "g.__spectestRealServe = Deno.serve.bind(Deno);",
1211
+ "const listener = { finished: new Promise<void>(() => {}), addr: { transport: \"tcp\", hostname: \"0.0.0.0\", port: 8000 }, shutdown: async () => {}, ref() {}, unref() {} };",
1212
+ "(Deno as any).serve = (a: any, b: any) => {",
1213
+ " const h = typeof a === \"function\" ? a : typeof b === \"function\" ? b : a?.handler ?? a?.fetch;",
1214
+ " const opts = typeof a === \"function\" ? b : a;",
1215
+ " g.__spectestRegister(h, opts?.onError);",
1216
+ " return listener;",
1217
+ "};",
1218
+ "",
1219
+ ].join("\n");
1220
+ await Deno.writeTextFile(SHARED_DIR + "/prelude.ts", prelude);
1221
+
1222
+ // std's legacy serve(handler, options): the same registration.
1223
+ await Deno.writeTextFile(
1224
+ SHARED_DIR + "/std-http-server.ts",
1225
+ [
1226
+ "// spectest-generated stand-in for https://deno.land/std/http/server.ts. Do not edit.",
1227
+ "export function serve(handler: unknown, options?: { onError?: unknown }): Promise<void> {",
1228
+ " (globalThis as any).__spectestRegister(handler, options?.onError);",
1229
+ " return new Promise(() => {});",
1230
+ "}",
1231
+ "export type ConnInfo = { localAddr: Deno.Addr; remoteAddr: Deno.Addr };",
1232
+ "export type Handler = (request: Request, connInfo: ConnInfo) => Response | Promise<Response>;",
1233
+ "export type ServeInit = Record<string, unknown>;",
1234
+ "",
1235
+ ].join("\n"),
1236
+ );
1237
+
1238
+ // The entry: the prelude first, then every function, then the router. A
1239
+ // function written the declarative way (export default { fetch }) never
1240
+ // calls serve, so its default export is picked up here.
1241
+ const lines = ["// spectest-generated entry of the shared functions isolate. Do not edit.", "import \"./prelude.ts\";"];
1242
+ const fnRel = relativePath(SHARED_DIR, FUNCTIONS_DIR);
1243
+ names.forEach((n, i) => lines.push("import * as f" + i + " from " + JSON.stringify(fnRel + "/" + n + "/index.ts") + ";"));
1244
+ lines.push("const mods: Record<string, any> = {");
1245
+ names.forEach((n, i) => lines.push(" " + JSON.stringify(n) + ": f" + i + ","));
1246
+ lines.push("};");
1247
+ lines.push(
1248
+ "const g = globalThis as any;",
1249
+ "for (const [n, m] of Object.entries(mods)) {",
1250
+ " const d = m.default;",
1251
+ " if (!g.__spectestHandlers[n] && d && typeof d.fetch === \"function\") g.__spectestHandlers[n] = { handler: d.fetch.bind(d), onError: null };",
1252
+ "}",
1253
+ "const addr = { transport: \"tcp\", hostname: \"127.0.0.1\", port: 0 };",
1254
+ "g.__spectestRealServe(async (req: Request) => {",
1255
+ " const name = new URL(req.url).pathname.split(\"/\")[1] ?? \"\";",
1256
+ " const h = g.__spectestHandlers[name];",
1257
+ " if (!h) return new Response(JSON.stringify({ msg: \"function \" + name + \" registered no handler\" }), { status: 500, headers: { \"content-type\": \"application/json\" } });",
1258
+ " try {",
1259
+ " return await h.handler(req, { remoteAddr: addr, localAddr: addr, completed: Promise.resolve() });",
1260
+ " } catch (e) {",
1261
+ " if (h.onError) return await h.onError(e);",
1262
+ " console.error(e);",
1263
+ " return new Response(\"Internal Server Error\", { status: 500 });",
1264
+ " }",
1265
+ "});",
1266
+ "",
1267
+ );
1268
+ await Deno.writeTextFile(SHARED_DIR + "/index.ts", lines.join("\n"));
1269
+ return null;
1270
+ }
1271
+
1272
+ /** The shared isolate (pooled by the runtime under SHARED_DIR), or null when
1273
+ * the functions run one isolate each — decided once, with the reason logged. */
1274
+ async function sharedWorker(): Promise<any | null> {
1275
+ if (!sharedSpec) {
1276
+ sharedSpec = (async () => {
1277
+ const names = await functionNames();
1278
+ let reason = names.length === 0 ? "no functions" : await sharedBlocker(names);
1279
+ if (reason === null) reason = await writeShared(names);
1280
+ if (reason === null) {
1281
+ try {
1282
+ await createShared();
1283
+ } catch (e) {
1284
+ reason = "the shared isolate failed to boot: " + String(e);
1285
+ }
1286
+ }
1287
+ if (reason !== null) console.warn("spectest: functions run one isolate each — " + reason);
1288
+ return { names, reason };
1289
+ })();
1290
+ }
1291
+ const spec = await sharedSpec;
1292
+ return spec.reason === null ? createShared() : null;
1293
+ }
1294
+
1295
+ function createShared() {
1296
+ return EdgeRuntime.userWorkers.create({
1297
+ servicePath: SHARED_DIR,
1298
+ // A cap on the one isolate that holds every function, not a reservation.
1299
+ memoryLimitMb: 2048,
1300
+ workerTimeoutMs: WORKER_TIMEOUT_MS,
1301
+ // The supervisor counts CPU time over the worker's LIFETIME (soft limit:
1302
+ // retire, hard limit: terminate and cancel in-flight requests with
1303
+ // "request has been cancelled by supervisor"). One isolate serving every
1304
+ // function for a day of tests spends what 51 short-lived ones never
1305
+ // would, so its budget is the day itself. Per-function isolates keep the
1306
+ // runtime's defaults.
1307
+ cpuTimeSoftLimitMs: WORKER_TIMEOUT_MS,
1308
+ cpuTimeHardLimitMs: WORKER_TIMEOUT_MS,
1309
+ noModuleCache: false,
1310
+ importMapPath: null,
1311
+ envVars: Object.entries(Deno.env.toObject()),
1312
+ });
1313
+ }
1314
+
1315
+ /** Boot the functions now, without calling them: the shared isolate, or —
1316
+ * where it cannot stand in — every function's own. Loading a function
1317
+ * runs its module top level, which for a function is its serve()
1318
+ * registration, no business logic. Called once at environment setup,
1319
+ * before the warm snapshot, so the isolates are shared, clean pages of the
1320
+ * snapshot rather than a boot in every test that first touches a function. */
1321
+ async function warmAll(): Promise<{ mode: "shared" | "per-function"; warmed: string[]; failed: Record<string, string> }> {
1322
+ const names = await functionNames();
1323
+ if ((await sharedWorker()) !== null) return { mode: "shared", warmed: names, failed: {} };
1324
+ // All at once: each boot is mostly module loading, and the isolates come
1325
+ // up concurrently.
1082
1326
  const results = await Promise.allSettled(names.map((name) => workerFor(name)));
1083
1327
  const warmed: string[] = [];
1084
1328
  const failed: Record<string, string> = {};
@@ -1086,7 +1330,7 @@ async function warmAll(): Promise<{ warmed: string[]; failed: Record<string, str
1086
1330
  if (r.status === "fulfilled") warmed.push(names[i]);
1087
1331
  else failed[names[i]] = String(r.reason);
1088
1332
  });
1089
- return { warmed: warmed.sort(), failed };
1333
+ return { mode: "per-function", warmed: warmed.sort(), failed };
1090
1334
  }
1091
1335
 
1092
1336
  Deno.serve(async (req: Request) => {
@@ -1111,7 +1355,7 @@ Deno.serve(async (req: Request) => {
1111
1355
 
1112
1356
  const servicePath = FUNCTIONS_DIR + "/" + name;
1113
1357
  try {
1114
- const worker = await workerFor(name);
1358
+ const worker = (await sharedWorker()) ?? (await workerFor(name));
1115
1359
  return await worker.fetch(req);
1116
1360
  } catch (e) {
1117
1361
  // A missing directory lands here too, so say which function was asked
@@ -2025,13 +2269,20 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2025
2269
  // fork of it. Warm, the isolates are clean shared pages of the
2026
2270
  // snapshot and a test's diff is what its requests mutate.
2027
2271
  setup: async () => {
2272
+ const t0 = Date.now();
2028
2273
  const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
2029
2274
  if (!res.ok) {
2030
2275
  console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
2031
2276
  return;
2032
2277
  }
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(", ")})` : ""}.`);
2278
+ const { mode, warmed, failed } = (await res.json()) as {
2279
+ mode: "shared" | "per-function";
2280
+ warmed: string[];
2281
+ failed: Record<string, string>;
2282
+ };
2283
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
2284
+ const how = mode === "shared" ? "in one isolate" : "one isolate each (see the functions service log for why)";
2285
+ console.log(`supabase(): warmed ${warmed.length} edge function${warmed.length === 1 ? "" : "s"} ${how} in ${secs}s${warmed.length ? ` (${warmed.join(", ")})` : ""}.`);
2035
2286
  for (const [name, err] of Object.entries(failed)) {
2036
2287
  console.warn(`supabase(): edge function "${name}" failed to boot at warm-up — it will be retried on first call: ${err}`);
2037
2288
  }
package/src/daemon.ts CHANGED
@@ -100,6 +100,7 @@ import {
100
100
  import { encodeRegistry } from "./harness/names-registry.js";
101
101
  import {
102
102
  InterceptRegistry,
103
+ parseTarget,
103
104
  runChain,
104
105
  type Interceptor,
105
106
  } from "./harness/intercept.js";
@@ -174,6 +175,7 @@ import {
174
175
  startRecording,
175
176
  stopRecording,
176
177
  truncateUtf8,
178
+ type StepBlock,
177
179
  type TestEvent,
178
180
  type OmittedBody,
179
181
  } from "./recorder.js";
@@ -2796,16 +2798,22 @@ function ingressClaimsHostname(hostname: string): boolean {
2796
2798
  return false;
2797
2799
  }
2798
2800
 
2799
- /** Record one request an interceptor saw, nested under its `intercept` step. */
2801
+ /** Record one request an interceptor saw, nested under its `intercept` step.
2802
+ *
2803
+ * A forced 503 is the step doing exactly what its description says, so it is
2804
+ * **not** marked failed — reddening the outcome the test asked for trains the
2805
+ * reader to ignore the colour. Only a handler that threw is marked, because
2806
+ * that 500 is a bug in the middleware rather than the outage it stands for. */
2800
2807
  function recordInterceptedRequest(
2801
2808
  it: Interceptor,
2802
- rec: { method: string; path: string; status: number; answeredBy: string },
2809
+ rec: { method: string; path: string; status: number; answeredBy: string; threw?: boolean },
2803
2810
  durationMs: number,
2804
2811
  ): void {
2805
2812
  const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
2806
2813
  if (parentSeq === undefined || !isRecording()) return;
2807
- const by =
2808
- rec.answeredBy === "handler"
2814
+ const by = rec.threw
2815
+ ? "the interceptor threw — 500 from spectest, not from your handler"
2816
+ : rec.answeredBy === "handler"
2809
2817
  ? "answered by the interceptor"
2810
2818
  : rec.answeredBy === "modified"
2811
2819
  ? "upstream answer replaced by the interceptor"
@@ -2814,22 +2822,42 @@ function recordInterceptedRequest(
2814
2822
  kind: "intercept-request",
2815
2823
  parentSeq,
2816
2824
  title: `${rec.method} ${rec.path} → ${rec.status}`,
2817
- status: rec.status >= 500 && rec.answeredBy !== "upstream" ? "failed" : "passed",
2825
+ status: rec.threw ? "failed" : "passed",
2826
+ // Method, path and status are already the title; the one thing the row
2827
+ // cannot say for itself is who produced that status.
2818
2828
  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
- },
2829
+ { type: "kv", rows: [{ label: "Answered", value: by, error: rec.threw === true }] },
2828
2830
  ],
2829
2831
  durationMs,
2830
2832
  });
2831
2833
  }
2832
2834
 
2835
+ /** The handler's own source, which is what the interceptor actually does.
2836
+ *
2837
+ * Free and impossible to let rot: Bun runs the project's TypeScript
2838
+ * directly, so `toString()` is the text the author wrote. It is the step's
2839
+ * body, under the description — a mechanism the reader can check against
2840
+ * the intent the description claims. A handler long enough to be a wall of
2841
+ * code folds into a disclosure instead of pushing the requests off screen.
2842
+ */
2843
+ function handlerBlocks(handler: InterceptHandler): StepBlock[] {
2844
+ let src: string;
2845
+ try {
2846
+ src = String(handler);
2847
+ } catch {
2848
+ return [];
2849
+ }
2850
+ const code: StepBlock = { type: "code", lang: "ts", label: "Handler", code: src };
2851
+ return src.split("\n").length > HANDLER_FOLD_LINES
2852
+ ? [{ type: "details", summary: "Handler", blocks: [code] }]
2853
+ : [code];
2854
+ }
2855
+
2856
+ /** Past this many lines the handler source is folded away. Chosen so the
2857
+ * shapes this feature is for — a one-line forced status, a short
2858
+ * fail-twice-then-pass — always show, and only a real program hides. */
2859
+ const HANDLER_FOLD_LINES = 15;
2860
+
2833
2861
  /**
2834
2862
  * Put middleware in front of a hostname the ingress serves — the
2835
2863
  * implementation behind `ctx.intercept`.
@@ -2838,19 +2866,25 @@ function recordInterceptedRequest(
2838
2866
  * the daemon (DNS does not point here), so the interceptor could only be
2839
2867
  * silent — and silence is the failure mode this whole layer is designed
2840
2868
  * against. The message names the two ways to get a route.
2869
+ *
2870
+ * The `description` is required because it is the step's whole title in the
2871
+ * timeline and in the CLI's failure detail: it is what tells a reader why
2872
+ * the UI under test went to its error state. The handler source says how;
2873
+ * only the author can say what it means.
2841
2874
  */
2842
2875
  function registerInterceptor(
2843
- hostname: string,
2844
- pathOrHandler: string | InterceptHandler,
2845
- maybeHandler?: InterceptHandler,
2876
+ target: string,
2877
+ description: string,
2878
+ handler: InterceptHandler,
2846
2879
  ): 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");
2880
+ const { hostname: host, path } = parseTarget(target);
2881
+ if (typeof description !== "string" || description.trim() === "") {
2882
+ throw new Error(
2883
+ `ctx.intercept(${JSON.stringify(target)}): a description is required — what the ` +
2884
+ `environment now does, as the timeline reads it, e.g. "the sync endpoint returns 503".`,
2885
+ );
2851
2886
  }
2852
- const host = hostname.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
2853
- if (!handler) {
2887
+ if (typeof handler !== "function") {
2854
2888
  throw new Error("ctx.intercept: a handler (req, next) => Response is required");
2855
2889
  }
2856
2890
  if (!ingressClaimsHostname(host)) {
@@ -2863,20 +2897,42 @@ function registerInterceptor(
2863
2897
  }
2864
2898
  const resv = reserveEvent();
2865
2899
  const it = INTERCEPTORS.register(host, path, handler);
2900
+ const where = `${host}${it.path === "/" ? "" : it.path}`;
2866
2901
  const seq = recordStep(
2867
2902
  {
2868
2903
  kind: "intercept",
2869
- title: `intercept ${host}${it.path === "/" ? "" : it.path}`,
2904
+ // The description alone. The pill already says INTERCEPT, and the
2905
+ // target is in the panel — a row that repeats both spends its width
2906
+ // on what the reader can already see and none of it on the one thing
2907
+ // only the author knows.
2908
+ title: description.trim(),
2870
2909
  blocks: [
2910
+ ...handlerBlocks(handler),
2871
2911
  {
2872
- type: "kv",
2873
- rows: [
2874
- { label: "Host", value: host },
2875
- { label: "Path", value: it.path === "/" ? "/ (every path)" : `${it.path} and below` },
2912
+ // The target IS the summary line, so the closed disclosure still
2913
+ // says where the interceptor sits — a generic "Target" label
2914
+ // would spend the one visible line saying nothing.
2915
+ type: "details",
2916
+ summary: where,
2917
+ blocks: [
2918
+ {
2919
+ type: "kv",
2920
+ rows: [
2921
+ {
2922
+ label: "Matches",
2923
+ value: it.path === "/" ? "every path on this host" : `${it.path} and below`,
2924
+ },
2925
+ {
2926
+ label: "Until",
2927
+ value: it.scope === undefined ? "remove()" : "the end of this test",
2928
+ },
2929
+ ],
2930
+ },
2876
2931
  ],
2877
2932
  },
2878
2933
  ],
2879
- durationMs: 0,
2934
+ // No duration: this is a marker for the moment the interceptor went
2935
+ // up, and "(0 ms)" reads as a step that did nothing.
2880
2936
  },
2881
2937
  resv,
2882
2938
  );
@@ -3,6 +3,7 @@ import {
3
3
  InterceptRegistry,
4
4
  hostnameMatches,
5
5
  normalizeMount,
6
+ parseTarget,
6
7
  pathMounts,
7
8
  runChain,
8
9
  type Interceptor,
@@ -39,6 +40,41 @@ describe("normalizeMount", () => {
39
40
  });
40
41
  });
41
42
 
43
+ describe("parseTarget", () => {
44
+ test("a bare hostname mounts at the root", () => {
45
+ expect(parseTarget("app.test")).toEqual({ hostname: "app.test", path: "/" });
46
+ });
47
+ test("a path rides the target", () => {
48
+ expect(parseTarget("app.test/api/sync")).toEqual({
49
+ hostname: "app.test",
50
+ path: "/api/sync",
51
+ });
52
+ expect(parseTarget("app.test/api/")).toEqual({ hostname: "app.test", path: "/api" });
53
+ expect(parseTarget("app.test/")).toEqual({ hostname: "app.test", path: "/" });
54
+ });
55
+ test("a scheme and case are tolerated", () => {
56
+ expect(parseTarget("https://App.Test/API")).toEqual({
57
+ hostname: "app.test",
58
+ path: "/api",
59
+ });
60
+ });
61
+ test("a wildcard host keeps its pattern", () => {
62
+ expect(parseTarget("*.example.com/v1")).toEqual({
63
+ hostname: "*.example.com",
64
+ path: "/v1",
65
+ });
66
+ });
67
+ test("rejects a target that is only a path, or none at all", () => {
68
+ expect(() => parseTarget("/api")).toThrow(/names no hostname/);
69
+ expect(() => parseTarget("")).toThrow(/target hostname is required/);
70
+ expect(() => parseTarget(" ")).toThrow(/target hostname is required/);
71
+ });
72
+ test("rejects a query or fragment", () => {
73
+ expect(() => parseTarget("app.test/api?x=1")).toThrow(/mount prefix/);
74
+ expect(() => parseTarget("app.test?x=1")).toThrow(/query or fragment/);
75
+ });
76
+ });
77
+
42
78
  describe("hostnameMatches", () => {
43
79
  test("exact and wildcard", () => {
44
80
  expect(hostnameMatches("api.test", "api.test")).toBe(true);
@@ -56,6 +56,12 @@ export interface InterceptedRequest {
56
56
  * upstream via `next()` untouched (`upstream`), or the upstream's answer
57
57
  * replaced by the interceptor after `next()` (`modified`). */
58
58
  answeredBy: "handler" | "upstream" | "modified";
59
+ /** The handler threw (or returned something that is not a `Response`), so
60
+ * the 500 below is a bug in the test's own middleware rather than the
61
+ * outage it was asked to produce. The distinction cannot be recovered
62
+ * from the status: a deliberate 500 and a crashed handler are the same
63
+ * number, and only this tells the timeline which one to mark as wrong. */
64
+ threw?: boolean;
59
65
  }
60
66
 
61
67
  export interface Interceptor {
@@ -71,6 +77,39 @@ export interface Interceptor {
71
77
  requests: InterceptedRequest[];
72
78
  }
73
79
 
80
+ /**
81
+ * Split an intercept target — `app.test`, `app.test/api/sync`,
82
+ * `*.example.com/v1` — into the hostname and the mount path.
83
+ *
84
+ * The path rides the target rather than a second argument because that is
85
+ * what it is: part of what the interceptor claims, not a separate knob. It
86
+ * also keeps the signature down to one string, so the description that
87
+ * follows can never be mistaken for a path.
88
+ *
89
+ * A scheme is tolerated (`https://app.test/api`) since that is how the same
90
+ * address is written everywhere else.
91
+ */
92
+ export function parseTarget(target: string): { hostname: string; path: string } {
93
+ if (typeof target !== "string" || target.trim() === "") {
94
+ throw new Error("intercept: a target hostname is required, e.g. \"app.test\" or \"app.test/api\"");
95
+ }
96
+ const bare = target.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
97
+ const slash = bare.indexOf("/");
98
+ const hostname = slash === -1 ? bare : bare.slice(0, slash);
99
+ const rest = slash === -1 ? undefined : bare.slice(slash);
100
+ if (hostname === "") {
101
+ throw new Error(
102
+ `intercept: target ${JSON.stringify(target)} names no hostname — write it as "app.test/api", not "/api"`,
103
+ );
104
+ }
105
+ if (hostname.includes("?") || hostname.includes("#")) {
106
+ throw new Error(
107
+ `intercept: target ${JSON.stringify(target)} cannot carry a query or fragment`,
108
+ );
109
+ }
110
+ return { hostname, path: normalizeMount(rest) };
111
+ }
112
+
74
113
  /** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
75
114
  * never `/apix`. `/` matches everything. Query strings do not take part. */
76
115
  export function pathMounts(mount: string, pathname: string): boolean {
@@ -222,6 +261,7 @@ export async function runChain(
222
261
  );
223
262
  record.answeredBy = "handler";
224
263
  record.status = 500;
264
+ record.threw = true;
225
265
  it.calls++;
226
266
  it.requests.push(record);
227
267
  observe?.(it, record, Date.now() - started);