@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.
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.
@@ -698,6 +698,14 @@ async function validJwt(token: string): Promise<boolean> {
698
698
  }
699
699
  }
700
700
 
701
+ /** Where the generated entry of the shared isolate lives — outside the repo
702
+ * mount, which is read-only and the user's checkout, and NOT under /tmp:
703
+ * the runtime gives its workers an in-memory /tmp, so a file the main
704
+ * worker writes there does not exist for the user worker's bootstrap
705
+ * ("could not find an appropriate entrypoint"). */
706
+ const SHARED_DIR = "/home/deno/spectest-functions";
707
+ const WORKER_TIMEOUT_MS = 24 * 60 * 60 * 1000;
708
+
701
709
  /** One isolate per function, reused across requests (the runtime pools by
702
710
  * servicePath). The idle timeout is a day, not upstream's five minutes: a
703
711
  * test environment is a snapshot lineage whose forks inherit these
@@ -708,32 +716,268 @@ function workerFor(name: string) {
708
716
  return EdgeRuntime.userWorkers.create({
709
717
  servicePath: FUNCTIONS_DIR + "/" + name,
710
718
  memoryLimitMb: 256,
711
- workerTimeoutMs: 24 * 60 * 60 * 1000,
719
+ workerTimeoutMs: WORKER_TIMEOUT_MS,
712
720
  noModuleCache: false,
713
721
  importMapPath: null,
714
722
  envVars: Object.entries(Deno.env.toObject()),
715
723
  });
716
724
  }
717
725
 
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> }> {
726
+ async function exists(path: string): Promise<boolean> {
727
+ try {
728
+ await Deno.stat(path);
729
+ return true;
730
+ } catch {
731
+ return false;
732
+ }
733
+ }
734
+
735
+ /** The function directories: every child of FUNCTIONS_DIR with an index.ts,
736
+ * minus the conventional private ones. */
737
+ async function functionNames(): Promise<string[]> {
725
738
  const names: string[] = [];
726
739
  for await (const entry of Deno.readDir(FUNCTIONS_DIR)) {
727
740
  if (!entry.isDirectory || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
741
+ if (await exists(FUNCTIONS_DIR + "/" + entry.name + "/index.ts")) names.push(entry.name);
742
+ }
743
+ return names.sort();
744
+ }
745
+
746
+ /** Every "https://deno.land/std@…/http/server.ts" a source under the
747
+ * functions directory imports — the legacy serve() each of them has to be
748
+ * redirected to the shim in the shared isolate. */
749
+ async function stdServerUrls(dir: string, out: Set<string>): Promise<void> {
750
+ for await (const e of Deno.readDir(dir)) {
751
+ const p = dir + "/" + e.name;
752
+ if (e.isDirectory) {
753
+ if (e.name !== "node_modules" && !e.name.startsWith(".")) await stdServerUrls(p, out);
754
+ continue;
755
+ }
756
+ if (!/\.(ts|tsx|js|mjs|jsx)$/.test(e.name)) continue;
757
+ const src = await Deno.readTextFile(p);
758
+ for (const m of src.matchAll(/https:\/\/deno\.land\/std@[0-9.]+\/http\/server\.ts/g)) out.add(m[0]);
759
+ }
760
+ }
761
+
762
+ // ── The shared isolate ────────────────────────────────────────────────────
763
+ //
764
+ // The runtime's own model is one isolate per function, each loading its own
765
+ // copy of the module graph. On a project with dozens of functions sharing a
766
+ // \`_shared/\` tree that is dozens of copies of the same graph — measured at
767
+ // ~90 MB and ~1 s per function, against 18 MB for an empty isolate — and in
768
+ // an 8 GB guest that memory comes straight out of the page cache every test
769
+ // needs. So by default every function is loaded into ONE user worker: a
770
+ // generated entry module imports each function's index.ts in turn, a prelude
771
+ // (imported first, so it runs first) has replaced \`Deno.serve\` with a
772
+ // registration that records the handler under the function whose module is
773
+ // calling, the std \`serve()\` the older functions use is redirected to the
774
+ // same registration by an import map, and the worker's own server routes a
775
+ // request to the handler of the function its path names. The runtime, the
776
+ // request a function receives and the per-function JWT check are unchanged;
777
+ // what differs from hosted Supabase is that module-level state is one
778
+ // object for every function rather than one per function.
779
+ //
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
782
+ // deno.json / import map (per-function resolution the one config cannot
783
+ // express), a functions/deno.jsonc (comments; not merged) or a config that
784
+ // names an importMap keep the per-function isolates, and so does a shared
785
+ // isolate that fails to boot — with the reason in the log, since a boot
786
+ // failure names the module at fault.
787
+
788
+ interface SharedSpec {
789
+ names: string[];
790
+ reason: string | null;
791
+ }
792
+ let sharedSpec: Promise<SharedSpec> | null = null;
793
+
794
+ /** Why the shared isolate cannot stand in for per-function ones, or null. */
795
+ async function sharedBlocker(names: string[]): Promise<string | null> {
796
+ for (const n of names) {
797
+ for (const f of ["deno.json", "deno.jsonc", "import_map.json"]) {
798
+ if (await exists(FUNCTIONS_DIR + "/" + n + "/" + f)) return "function \"" + n + "\" has its own " + f;
799
+ }
800
+ }
801
+ if (await exists(FUNCTIONS_DIR + "/deno.jsonc")) return "functions/deno.jsonc is not merged (comments)";
802
+ return null;
803
+ }
804
+
805
+ /** POSIX-relative path from directory \`from\` to \`to\`. The entry imports the
806
+ * functions relatively: an absolute file specifier is refused by the
807
+ * runtime's module bundler ("Module not found"), a relative one resolves. */
808
+ function relativePath(from: string, to: string): string {
809
+ const a = from.split("/").filter(Boolean);
810
+ const b = to.split("/").filter(Boolean);
811
+ let i = 0;
812
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
813
+ const up = a.slice(i).map(() => "..");
814
+ const rel = [...up, ...b.slice(i)].join("/");
815
+ return rel.startsWith(".") ? rel : "./" + rel;
816
+ }
817
+
818
+ function reString(s: string): string {
819
+ return s.replace(/[.*+?^$()|[\]\\\/{}]/g, "\\$&");
820
+ }
821
+
822
+ /** Write SHARED_DIR: the entry, the prelude, the std shim and the config. */
823
+ async function writeShared(names: string[]): Promise<string | null> {
824
+ await Deno.mkdir(SHARED_DIR, { recursive: true });
825
+
826
+ // Config: the project's functions/deno.json (its imports are what every
827
+ // function resolves against) plus the std shim redirections.
828
+ let cfg: Record<string, unknown> = {};
829
+ if (await exists(FUNCTIONS_DIR + "/deno.json")) {
728
830
  try {
729
- await Deno.stat(FUNCTIONS_DIR + "/" + entry.name + "/index.ts");
730
- names.push(entry.name);
731
- } catch {
732
- // not a function directory
831
+ cfg = JSON.parse(await Deno.readTextFile(FUNCTIONS_DIR + "/deno.json"));
832
+ } catch (e) {
833
+ return "functions/deno.json could not be parsed: " + String(e);
733
834
  }
835
+ if (typeof cfg.importMap === "string") return "functions/deno.json names an importMap";
734
836
  }
735
- // All at once: a project can have dozens of functions, and each boot is
736
- // mostly module loading — the isolates come up concurrently.
837
+ const urls = new Set<string>();
838
+ await stdServerUrls(FUNCTIONS_DIR, urls);
839
+ const imports: Record<string, string> = { ...((cfg.imports as Record<string, string>) ?? {}) };
840
+ for (const u of urls) imports[u] = "./std-http-server.ts";
841
+ await Deno.writeTextFile(
842
+ SHARED_DIR + "/deno.json",
843
+ JSON.stringify({ ...cfg, lock: false, imports }, null, 2),
844
+ );
845
+
846
+ // The registration. Which function is calling comes from the stack: the
847
+ // first frame under FUNCTIONS_DIR whose directory is a function.
848
+ const known = JSON.stringify(names);
849
+ const prelude = [
850
+ "// spectest-generated. Do not edit.",
851
+ "const g = globalThis as any;",
852
+ "g.__spectestHandlers = {};",
853
+ "const known = new Set(" + known + ");",
854
+ "const frame = new RegExp(\"" + reString(FUNCTIONS_DIR) + "/([^/]+)/\", \"g\");",
855
+ "// serve()'s onError is part of the contract: a project maps its thrown",
856
+ "// errors to responses there, and without it every throw is a bare 500.",
857
+ "g.__spectestRegister = (handler: unknown, onError?: unknown) => {",
858
+ " if (typeof handler !== \"function\") throw new TypeError(\"spectest: serve() without a handler function\");",
859
+ " const stack = new Error().stack ?? \"\";",
860
+ " let name: string | null = null;",
861
+ " for (const m of stack.matchAll(frame)) { if (known.has(m[1])) { name = m[1]; break; } }",
862
+ " 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 + "\"));",
863
+ " g.__spectestHandlers[name] = { handler, onError: typeof onError === \"function\" ? onError : null };",
864
+ "};",
865
+ "g.__spectestRealServe = Deno.serve.bind(Deno);",
866
+ "const listener = { finished: new Promise<void>(() => {}), addr: { transport: \"tcp\", hostname: \"0.0.0.0\", port: 8000 }, shutdown: async () => {}, ref() {}, unref() {} };",
867
+ "(Deno as any).serve = (a: any, b: any) => {",
868
+ " const h = typeof a === \"function\" ? a : typeof b === \"function\" ? b : a?.handler ?? a?.fetch;",
869
+ " const opts = typeof a === \"function\" ? b : a;",
870
+ " g.__spectestRegister(h, opts?.onError);",
871
+ " return listener;",
872
+ "};",
873
+ "",
874
+ ].join("\n");
875
+ await Deno.writeTextFile(SHARED_DIR + "/prelude.ts", prelude);
876
+
877
+ // std's legacy serve(handler, options): the same registration.
878
+ await Deno.writeTextFile(
879
+ SHARED_DIR + "/std-http-server.ts",
880
+ [
881
+ "// spectest-generated stand-in for https://deno.land/std/http/server.ts. Do not edit.",
882
+ "export function serve(handler: unknown, options?: { onError?: unknown }): Promise<void> {",
883
+ " (globalThis as any).__spectestRegister(handler, options?.onError);",
884
+ " return new Promise(() => {});",
885
+ "}",
886
+ "export type ConnInfo = { localAddr: Deno.Addr; remoteAddr: Deno.Addr };",
887
+ "export type Handler = (request: Request, connInfo: ConnInfo) => Response | Promise<Response>;",
888
+ "export type ServeInit = Record<string, unknown>;",
889
+ "",
890
+ ].join("\n"),
891
+ );
892
+
893
+ // The entry: the prelude first, then every function, then the router. A
894
+ // function written the declarative way (export default { fetch }) never
895
+ // calls serve, so its default export is picked up here.
896
+ const lines = ["// spectest-generated entry of the shared functions isolate. Do not edit.", "import \"./prelude.ts\";"];
897
+ const fnRel = relativePath(SHARED_DIR, FUNCTIONS_DIR);
898
+ names.forEach((n, i) => lines.push("import * as f" + i + " from " + JSON.stringify(fnRel + "/" + n + "/index.ts") + ";"));
899
+ lines.push("const mods: Record<string, any> = {");
900
+ names.forEach((n, i) => lines.push(" " + JSON.stringify(n) + ": f" + i + ","));
901
+ lines.push("};");
902
+ lines.push(
903
+ "const g = globalThis as any;",
904
+ "for (const [n, m] of Object.entries(mods)) {",
905
+ " const d = m.default;",
906
+ " if (!g.__spectestHandlers[n] && d && typeof d.fetch === \"function\") g.__spectestHandlers[n] = { handler: d.fetch.bind(d), onError: null };",
907
+ "}",
908
+ "const addr = { transport: \"tcp\", hostname: \"127.0.0.1\", port: 0 };",
909
+ "g.__spectestRealServe(async (req: Request) => {",
910
+ " const name = new URL(req.url).pathname.split(\"/\")[1] ?? \"\";",
911
+ " const h = g.__spectestHandlers[name];",
912
+ " if (!h) return new Response(JSON.stringify({ msg: \"function \" + name + \" registered no handler\" }), { status: 500, headers: { \"content-type\": \"application/json\" } });",
913
+ " try {",
914
+ " return await h.handler(req, { remoteAddr: addr, localAddr: addr, completed: Promise.resolve() });",
915
+ " } catch (e) {",
916
+ " if (h.onError) return await h.onError(e);",
917
+ " console.error(e);",
918
+ " return new Response(\"Internal Server Error\", { status: 500 });",
919
+ " }",
920
+ "});",
921
+ "",
922
+ );
923
+ await Deno.writeTextFile(SHARED_DIR + "/index.ts", lines.join("\n"));
924
+ return null;
925
+ }
926
+
927
+ /** The shared isolate (pooled by the runtime under SHARED_DIR), or null when
928
+ * the functions run one isolate each — decided once, with the reason logged. */
929
+ async function sharedWorker(): Promise<any | null> {
930
+ if (!sharedSpec) {
931
+ sharedSpec = (async () => {
932
+ const names = await functionNames();
933
+ let reason = names.length === 0 ? "no functions" : await sharedBlocker(names);
934
+ if (reason === null) reason = await writeShared(names);
935
+ if (reason === null) {
936
+ try {
937
+ await createShared();
938
+ } catch (e) {
939
+ reason = "the shared isolate failed to boot: " + String(e);
940
+ }
941
+ }
942
+ if (reason !== null) console.warn("spectest: functions run one isolate each — " + reason);
943
+ return { names, reason };
944
+ })();
945
+ }
946
+ const spec = await sharedSpec;
947
+ return spec.reason === null ? createShared() : null;
948
+ }
949
+
950
+ function createShared() {
951
+ return EdgeRuntime.userWorkers.create({
952
+ servicePath: SHARED_DIR,
953
+ // A cap on the one isolate that holds every function, not a reservation.
954
+ memoryLimitMb: 2048,
955
+ workerTimeoutMs: WORKER_TIMEOUT_MS,
956
+ // The supervisor counts CPU time over the worker's LIFETIME (soft limit:
957
+ // retire, hard limit: terminate and cancel in-flight requests with
958
+ // "request has been cancelled by supervisor"). One isolate serving every
959
+ // function for a day of tests spends what 51 short-lived ones never
960
+ // would, so its budget is the day itself. Per-function isolates keep the
961
+ // runtime's defaults.
962
+ cpuTimeSoftLimitMs: WORKER_TIMEOUT_MS,
963
+ cpuTimeHardLimitMs: WORKER_TIMEOUT_MS,
964
+ noModuleCache: false,
965
+ importMapPath: null,
966
+ envVars: Object.entries(Deno.env.toObject()),
967
+ });
968
+ }
969
+
970
+ /** Boot the functions now, without calling them: the shared isolate, or —
971
+ * where it cannot stand in — every function's own. Loading a function
972
+ * runs its module top level, which for a function is its serve()
973
+ * registration, no business logic. Called once at environment setup,
974
+ * before the warm snapshot, so the isolates are shared, clean pages of the
975
+ * snapshot rather than a boot in every test that first touches a function. */
976
+ async function warmAll(): Promise<{ mode: "shared" | "per-function"; warmed: string[]; failed: Record<string, string> }> {
977
+ const names = await functionNames();
978
+ if ((await sharedWorker()) !== null) return { mode: "shared", warmed: names, failed: {} };
979
+ // All at once: each boot is mostly module loading, and the isolates come
980
+ // up concurrently.
737
981
  const results = await Promise.allSettled(names.map((name) => workerFor(name)));
738
982
  const warmed: string[] = [];
739
983
  const failed: Record<string, string> = {};
@@ -741,7 +985,7 @@ async function warmAll(): Promise<{ warmed: string[]; failed: Record<string, str
741
985
  if (r.status === "fulfilled") warmed.push(names[i]);
742
986
  else failed[names[i]] = String(r.reason);
743
987
  });
744
- return { warmed: warmed.sort(), failed };
988
+ return { mode: "per-function", warmed: warmed.sort(), failed };
745
989
  }
746
990
 
747
991
  Deno.serve(async (req: Request) => {
@@ -766,7 +1010,7 @@ Deno.serve(async (req: Request) => {
766
1010
 
767
1011
  const servicePath = FUNCTIONS_DIR + "/" + name;
768
1012
  try {
769
- const worker = await workerFor(name);
1013
+ const worker = (await sharedWorker()) ?? (await workerFor(name));
770
1014
  return await worker.fetch(req);
771
1015
  } catch (e) {
772
1016
  // A missing directory lands here too, so say which function was asked
@@ -1521,13 +1765,16 @@ export function supabase(opts = {}) {
1521
1765
  // fork of it. Warm, the isolates are clean shared pages of the
1522
1766
  // snapshot and a test's diff is what its requests mutate.
1523
1767
  setup: async () => {
1768
+ const t0 = Date.now();
1524
1769
  const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
1525
1770
  if (!res.ok) {
1526
1771
  console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
1527
1772
  return;
1528
1773
  }
1529
- const { warmed, failed } = (await res.json());
1530
- console.log(`supabase(): warmed ${warmed.length} edge function${warmed.length === 1 ? "" : "s"}${warmed.length ? ` (${warmed.join(", ")})` : ""}.`);
1774
+ const { mode, warmed, failed } = (await res.json());
1775
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
1776
+ const how = mode === "shared" ? "in one isolate" : "one isolate each (see the functions service log for why)";
1777
+ console.log(`supabase(): warmed ${warmed.length} edge function${warmed.length === 1 ? "" : "s"} ${how} in ${secs}s${warmed.length ? ` (${warmed.join(", ")})` : ""}.`);
1531
1778
  for (const [name, err] of Object.entries(failed)) {
1532
1779
  console.warn(`supabase(): edge function "${name}" failed to boot at warm-up — it will be retried on first call: ${err}`);
1533
1780
  }
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);