@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.
- package/dist/components/supabase.js +70 -9
- package/dist/daemon.js +141 -11
- package/dist/harness/intercept.d.ts +107 -0
- package/dist/harness/intercept.js +175 -0
- package/dist/index.d.ts +55 -0
- package/package.json +1 -1
- package/src/components/supabase.ts +70 -9
- package/src/daemon.ts +168 -19
- package/src/harness/intercept.test.ts +182 -0
- package/src/harness/intercept.ts +238 -0
- package/src/index.ts +60 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
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
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
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
|
+
});
|