@specific.dev/spectest 0.62.0 → 0.63.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/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/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
package/dist/daemon.js
CHANGED
|
@@ -42,6 +42,7 @@ import { pollUntilReady } from "./harness/ready-poll.js";
|
|
|
42
42
|
import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
|
|
43
43
|
import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
|
|
44
44
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
45
|
+
import { InterceptRegistry, runChain, } from "./harness/intercept.js";
|
|
45
46
|
import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
|
|
46
47
|
import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, wildcardSuffix, } from "./harness/hostmatch.js";
|
|
47
48
|
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, certEntries, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
@@ -54,7 +55,7 @@ import { openTerminal } from "./terminal.js";
|
|
|
54
55
|
import { openMcp } from "./mcp.js";
|
|
55
56
|
import { setRawFetch } from "./harness/raw-fetch.js";
|
|
56
57
|
import { readAnnotation } from "./annotate.js";
|
|
57
|
-
import { pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
|
|
58
|
+
import { isRecording, pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
|
|
58
59
|
import { deepUnwrap, readRaw, wrap, wrapResponse } from "./inspect.js";
|
|
59
60
|
import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
|
|
60
61
|
import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
|
|
@@ -1993,17 +1994,27 @@ server, byHost, listenerLabel, proto) {
|
|
|
1993
1994
|
// to list in Access-Control-Allow-Headers.
|
|
1994
1995
|
if (isCorsPreflight(req))
|
|
1995
1996
|
return corsPreflightResponse(req);
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
1997
|
+
// The real answer — a fake handled in-process, or the proxied container.
|
|
1998
|
+
const upstream = async (current) => {
|
|
1999
|
+
if (route.kind === "fake") {
|
|
2000
|
+
try {
|
|
2001
|
+
return await route.fake.def.handler(current, route.fake.state, FAKE_CTX);
|
|
2002
|
+
}
|
|
2003
|
+
catch (err) {
|
|
2004
|
+
const e = err;
|
|
2005
|
+
return new Response(`spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
|
|
2006
|
+
}
|
|
2004
2007
|
}
|
|
2005
|
-
|
|
2006
|
-
|
|
2008
|
+
return proxyToService(current, server, route.service, route.port, listenerLabel, proto);
|
|
2009
|
+
};
|
|
2010
|
+
// `ctx.intercept` middleware runs first, in registration order, and reaches
|
|
2011
|
+
// the upstream through `next()`. The CORS headers go on *after* the chain,
|
|
2012
|
+
// so a forced 500 reaches the browser as a 500 and not as a CORS error —
|
|
2013
|
+
// the trap `page.route`-style interception falls into.
|
|
2014
|
+
const chain = INTERCEPTORS.chainFor(host, new URL(req.url).pathname);
|
|
2015
|
+
const res = chain.length === 0
|
|
2016
|
+
? await upstream(req)
|
|
2017
|
+
: await runChain(chain, req, upstream, recordInterceptedRequest);
|
|
2007
2018
|
return augmentCorsResponse(req, res);
|
|
2008
2019
|
}
|
|
2009
2020
|
/**
|
|
@@ -2263,6 +2274,120 @@ async function seedNamesRegistry(opts) {
|
|
|
2263
2274
|
}
|
|
2264
2275
|
await writeRegistry();
|
|
2265
2276
|
}
|
|
2277
|
+
/**
|
|
2278
|
+
* The live interceptors (`ctx.intercept`). Module memory, so they fork with
|
|
2279
|
+
* the environment like the route tables; a test's own are removed when its
|
|
2280
|
+
* case ends (`runOne` opens and closes the scope), so a `dependsOn` child
|
|
2281
|
+
* never inherits a parent's forced outage.
|
|
2282
|
+
*/
|
|
2283
|
+
const INTERCEPTORS = new InterceptRegistry();
|
|
2284
|
+
/** The recorder seq of the `intercept` step each interceptor was registered
|
|
2285
|
+
* under, so every request it sees can nest below that step. */
|
|
2286
|
+
const INTERCEPT_STEP_SEQ = new Map();
|
|
2287
|
+
/** What every port's route table together claims — the hostnames a request
|
|
2288
|
+
* can reach the ingress under at all. */
|
|
2289
|
+
function ingressClaimsHostname(hostname) {
|
|
2290
|
+
for (const byHost of INGRESS.routesByPort.values()) {
|
|
2291
|
+
if (matchRoute(byHost, hostname))
|
|
2292
|
+
return true;
|
|
2293
|
+
if (isWildcard(hostname)) {
|
|
2294
|
+
// A wildcard interceptor is fine when any route sits under it.
|
|
2295
|
+
const suffix = wildcardSuffix(hostname);
|
|
2296
|
+
for (const key of byHost.keys()) {
|
|
2297
|
+
if (key === hostname || key.endsWith(suffix))
|
|
2298
|
+
return true;
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
return false;
|
|
2303
|
+
}
|
|
2304
|
+
/** Record one request an interceptor saw, nested under its `intercept` step. */
|
|
2305
|
+
function recordInterceptedRequest(it, rec, durationMs) {
|
|
2306
|
+
const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
|
|
2307
|
+
if (parentSeq === undefined || !isRecording())
|
|
2308
|
+
return;
|
|
2309
|
+
const by = rec.answeredBy === "handler"
|
|
2310
|
+
? "answered by the interceptor"
|
|
2311
|
+
: rec.answeredBy === "modified"
|
|
2312
|
+
? "upstream answer replaced by the interceptor"
|
|
2313
|
+
: "passed through to the upstream";
|
|
2314
|
+
recordStep({
|
|
2315
|
+
kind: "intercept-request",
|
|
2316
|
+
parentSeq,
|
|
2317
|
+
title: `${rec.method} ${rec.path} → ${rec.status}`,
|
|
2318
|
+
status: rec.status >= 500 && rec.answeredBy !== "upstream" ? "failed" : "passed",
|
|
2319
|
+
blocks: [
|
|
2320
|
+
{
|
|
2321
|
+
type: "kv",
|
|
2322
|
+
rows: [
|
|
2323
|
+
{ label: "Host", value: it.hostname },
|
|
2324
|
+
{ label: "Request", value: `${rec.method} ${rec.path}` },
|
|
2325
|
+
{ label: "Status", value: String(rec.status) },
|
|
2326
|
+
{ label: "Answered", value: by },
|
|
2327
|
+
],
|
|
2328
|
+
},
|
|
2329
|
+
],
|
|
2330
|
+
durationMs,
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
2334
|
+
* Put middleware in front of a hostname the ingress serves — the
|
|
2335
|
+
* implementation behind `ctx.intercept`.
|
|
2336
|
+
*
|
|
2337
|
+
* Refuses a hostname no route claims: a request for it would never reach
|
|
2338
|
+
* the daemon (DNS does not point here), so the interceptor could only be
|
|
2339
|
+
* silent — and silence is the failure mode this whole layer is designed
|
|
2340
|
+
* against. The message names the two ways to get a route.
|
|
2341
|
+
*/
|
|
2342
|
+
function registerInterceptor(hostname, pathOrHandler, maybeHandler) {
|
|
2343
|
+
const path = typeof pathOrHandler === "string" ? pathOrHandler : undefined;
|
|
2344
|
+
const handler = typeof pathOrHandler === "function" ? pathOrHandler : maybeHandler;
|
|
2345
|
+
if (typeof hostname !== "string" || hostname.length === 0) {
|
|
2346
|
+
throw new Error("ctx.intercept: a hostname is required");
|
|
2347
|
+
}
|
|
2348
|
+
const host = hostname.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
2349
|
+
if (!handler) {
|
|
2350
|
+
throw new Error("ctx.intercept: a handler (req, next) => Response is required");
|
|
2351
|
+
}
|
|
2352
|
+
if (!ingressClaimsHostname(host)) {
|
|
2353
|
+
throw new Error(`ctx.intercept(${JSON.stringify(host)}): no request can reach the ingress under that hostname. ` +
|
|
2354
|
+
`Only traffic that passes through the daemon can be intercepted: give the service a \`tls\`/\`hostnames\` ` +
|
|
2355
|
+
`entry (or \`supabase({ hostname })\`), or target a fake's hostname. A bare service name ` +
|
|
2356
|
+
`(\`http://<service>:<port>\`) is container-to-container traffic and never passes through here.`);
|
|
2357
|
+
}
|
|
2358
|
+
const resv = reserveEvent();
|
|
2359
|
+
const it = INTERCEPTORS.register(host, path, handler);
|
|
2360
|
+
const seq = recordStep({
|
|
2361
|
+
kind: "intercept",
|
|
2362
|
+
title: `intercept ${host}${it.path === "/" ? "" : it.path}`,
|
|
2363
|
+
blocks: [
|
|
2364
|
+
{
|
|
2365
|
+
type: "kv",
|
|
2366
|
+
rows: [
|
|
2367
|
+
{ label: "Host", value: host },
|
|
2368
|
+
{ label: "Path", value: it.path === "/" ? "/ (every path)" : `${it.path} and below` },
|
|
2369
|
+
],
|
|
2370
|
+
},
|
|
2371
|
+
],
|
|
2372
|
+
durationMs: 0,
|
|
2373
|
+
}, resv);
|
|
2374
|
+
if (seq !== undefined)
|
|
2375
|
+
INTERCEPT_STEP_SEQ.set(it.id, seq);
|
|
2376
|
+
return {
|
|
2377
|
+
hostname: host,
|
|
2378
|
+
path: it.path,
|
|
2379
|
+
get calls() {
|
|
2380
|
+
return wrap(it.calls, seq);
|
|
2381
|
+
},
|
|
2382
|
+
get requests() {
|
|
2383
|
+
return wrap(it.requests.map((r) => ({ ...r })), seq);
|
|
2384
|
+
},
|
|
2385
|
+
remove() {
|
|
2386
|
+
INTERCEPTORS.remove(it.id);
|
|
2387
|
+
INTERCEPT_STEP_SEQ.delete(it.id);
|
|
2388
|
+
},
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
2266
2391
|
/**
|
|
2267
2392
|
* Register a hostname at runtime — the implementation behind `ctx.dnsName`.
|
|
2268
2393
|
* Validates via the same `dnsName` primitive the static path uses, resolves
|
|
@@ -3491,6 +3616,7 @@ async function spectestContext(scope = {}) {
|
|
|
3491
3616
|
certificate: mintCertificate,
|
|
3492
3617
|
startService: startRuntimeService,
|
|
3493
3618
|
stopService: stopRuntimeService,
|
|
3619
|
+
intercept: registerInterceptor,
|
|
3494
3620
|
};
|
|
3495
3621
|
}
|
|
3496
3622
|
/** Handles for a service hook: its dependencies (always up by the time the
|
|
@@ -4306,6 +4432,9 @@ async function runOne(testCase) {
|
|
|
4306
4432
|
// time — so from here on a service helper's `docker exec` lands on the
|
|
4307
4433
|
// timeline (and in the cast) exactly like the test's own `ctx.exec`.
|
|
4308
4434
|
RECORDING_EXEC = recordedExec;
|
|
4435
|
+
// Interceptors this case registers die with it (see harness/intercept.ts
|
|
4436
|
+
// — the post-state snapshot must not carry a forced outage into children).
|
|
4437
|
+
INTERCEPTORS.beginScope(testCase.id);
|
|
4309
4438
|
const timeoutMs = testCase.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
|
|
4310
4439
|
let timer;
|
|
4311
4440
|
const timedOut = new Promise((_, reject) => {
|
|
@@ -4335,6 +4464,7 @@ async function runOne(testCase) {
|
|
|
4335
4464
|
if (timer)
|
|
4336
4465
|
clearTimeout(timer);
|
|
4337
4466
|
RECORDING_EXEC = undefined;
|
|
4467
|
+
INTERCEPTORS.endScope();
|
|
4338
4468
|
restoreFetch();
|
|
4339
4469
|
restoreConsole();
|
|
4340
4470
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request interceptors on the ingress — the mechanism behind `ctx.intercept`.
|
|
3
|
+
*
|
|
4
|
+
* An interceptor is middleware in the Hono/Koa sense: it sees a request
|
|
5
|
+
* that reached the daemon's ingress for a hostname it claims, and either
|
|
6
|
+
* answers it itself or calls `next()` to let the real upstream (a proxied
|
|
7
|
+
* service, or a fake) answer. That is what lets a test make its *own*
|
|
8
|
+
* backend misbehave for one case — force a 500 from an edge function, add
|
|
9
|
+
* latency, fail twice then pass — without redefining the service.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this is a chain and not a replacement handler
|
|
12
|
+
*
|
|
13
|
+
* A replacement handler covers "answer instead of the upstream" and nothing
|
|
14
|
+
* else. Every other shape a test needs — observe and count, mutate a real
|
|
15
|
+
* response, delay it, fail N times — needs the real answer in hand, which is
|
|
16
|
+
* what `next()` gives. Registration order is the chain order, as in Hono
|
|
17
|
+
* or Koa: the first interceptor sees the request first, and each one
|
|
18
|
+
* decides whether the next runs.
|
|
19
|
+
*
|
|
20
|
+
* ## Scope
|
|
21
|
+
*
|
|
22
|
+
* The registry is module memory in the harness process, so like fake state
|
|
23
|
+
* and the route tables it forks with the environment. That alone would make
|
|
24
|
+
* a parent's interceptor leak into every `dependsOn` child through the
|
|
25
|
+
* post-state snapshot, so a test's interceptors are **scoped to the case**:
|
|
26
|
+
* {@link InterceptRegistry.beginScope} opens the case, and
|
|
27
|
+
* {@link InterceptRegistry.endScope} removes everything registered inside it
|
|
28
|
+
* — the harness calls both around the test body. An interceptor registered
|
|
29
|
+
* outside a case (`eval`, project `setup`) has no scope and lasts until
|
|
30
|
+
* `remove()`.
|
|
31
|
+
*
|
|
32
|
+
* Pure module: no Bun, no listener, no recorder — so it is testable with
|
|
33
|
+
* plain `Request`/`Response` objects.
|
|
34
|
+
*/
|
|
35
|
+
/** The continuation an interceptor calls to reach the upstream (or the next
|
|
36
|
+
* interceptor in the chain). Passing a `Request` replaces the one that goes
|
|
37
|
+
* on — the way to forward a modified request, since a fetch `Request` is
|
|
38
|
+
* immutable. */
|
|
39
|
+
export type InterceptNext = (req?: Request) => Promise<Response>;
|
|
40
|
+
export type InterceptHandler = (req: Request, next: InterceptNext) => Response | Promise<Response>;
|
|
41
|
+
/** One observed request, as the handle reports it. */
|
|
42
|
+
export interface InterceptedRequest {
|
|
43
|
+
method: string;
|
|
44
|
+
/** Path + query, as requested. */
|
|
45
|
+
path: string;
|
|
46
|
+
status: number;
|
|
47
|
+
/** Who produced the response: the interceptor itself (`handler`), the
|
|
48
|
+
* upstream via `next()` untouched (`upstream`), or the upstream's answer
|
|
49
|
+
* replaced by the interceptor after `next()` (`modified`). */
|
|
50
|
+
answeredBy: "handler" | "upstream" | "modified";
|
|
51
|
+
}
|
|
52
|
+
export interface Interceptor {
|
|
53
|
+
id: number;
|
|
54
|
+
hostname: string;
|
|
55
|
+
/** Mount path, like `app.use(path, fn)`: matches the path itself and
|
|
56
|
+
* everything below it. `"/"` (the default) matches every path. */
|
|
57
|
+
path: string;
|
|
58
|
+
handler: InterceptHandler;
|
|
59
|
+
/** The case this interceptor belongs to, if registered inside one. */
|
|
60
|
+
scope?: string;
|
|
61
|
+
calls: number;
|
|
62
|
+
requests: InterceptedRequest[];
|
|
63
|
+
}
|
|
64
|
+
/** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
|
|
65
|
+
* never `/apix`. `/` matches everything. Query strings do not take part. */
|
|
66
|
+
export declare function pathMounts(mount: string, pathname: string): boolean;
|
|
67
|
+
/** Exact hostname, or a `*.suffix` pattern that covers the host at any
|
|
68
|
+
* depth — the same rule the route tables use for a wildcard route. */
|
|
69
|
+
export declare function hostnameMatches(pattern: string, host: string): boolean;
|
|
70
|
+
/** Normalise a mount path: must start with `/`, no query, no trailing
|
|
71
|
+
* slash (except the root). */
|
|
72
|
+
export declare function normalizeMount(path: string | undefined): string;
|
|
73
|
+
export declare class InterceptRegistry {
|
|
74
|
+
private list;
|
|
75
|
+
private nextId;
|
|
76
|
+
private scope;
|
|
77
|
+
/** Every interceptor registered from now on belongs to `scope`, until
|
|
78
|
+
* {@link endScope}. */
|
|
79
|
+
beginScope(scope: string): void;
|
|
80
|
+
/** Close the current scope and remove every interceptor registered in it.
|
|
81
|
+
* Returns how many were removed. */
|
|
82
|
+
endScope(): number;
|
|
83
|
+
register(hostname: string, path: string | undefined, handler: InterceptHandler): Interceptor;
|
|
84
|
+
/** Idempotent: removing twice, or after the scope ended, is a no-op. */
|
|
85
|
+
remove(id: number): void;
|
|
86
|
+
/** The interceptors that apply to a request, in registration order. */
|
|
87
|
+
chainFor(host: string, pathname: string): Interceptor[];
|
|
88
|
+
size(): number;
|
|
89
|
+
clear(): void;
|
|
90
|
+
}
|
|
91
|
+
/** What `runChain` reports about one interceptor's part in a request. */
|
|
92
|
+
export interface ChainObserver {
|
|
93
|
+
(interceptor: Interceptor, record: InterceptedRequest, durationMs: number): void;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Run `req` through `chain`, ending at `upstream`.
|
|
97
|
+
*
|
|
98
|
+
* Each interceptor's `next` runs the rest of the chain; an interceptor
|
|
99
|
+
* that returns without calling `next` answers the request itself. A thrown
|
|
100
|
+
* error becomes a 500 naming the interceptor — the same rule a fake handler
|
|
101
|
+
* gets — so a bug in test code is a visible failure of that request, not a
|
|
102
|
+
* hung browser.
|
|
103
|
+
*
|
|
104
|
+
* `observe` is called once per interceptor that saw the request, after it
|
|
105
|
+
* returned, with what it did.
|
|
106
|
+
*/
|
|
107
|
+
export declare function runChain(chain: readonly Interceptor[], req: Request, upstream: (req: Request) => Promise<Response>, observe?: ChainObserver): Promise<Response>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request interceptors on the ingress — the mechanism behind `ctx.intercept`.
|
|
3
|
+
*
|
|
4
|
+
* An interceptor is middleware in the Hono/Koa sense: it sees a request
|
|
5
|
+
* that reached the daemon's ingress for a hostname it claims, and either
|
|
6
|
+
* answers it itself or calls `next()` to let the real upstream (a proxied
|
|
7
|
+
* service, or a fake) answer. That is what lets a test make its *own*
|
|
8
|
+
* backend misbehave for one case — force a 500 from an edge function, add
|
|
9
|
+
* latency, fail twice then pass — without redefining the service.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this is a chain and not a replacement handler
|
|
12
|
+
*
|
|
13
|
+
* A replacement handler covers "answer instead of the upstream" and nothing
|
|
14
|
+
* else. Every other shape a test needs — observe and count, mutate a real
|
|
15
|
+
* response, delay it, fail N times — needs the real answer in hand, which is
|
|
16
|
+
* what `next()` gives. Registration order is the chain order, as in Hono
|
|
17
|
+
* or Koa: the first interceptor sees the request first, and each one
|
|
18
|
+
* decides whether the next runs.
|
|
19
|
+
*
|
|
20
|
+
* ## Scope
|
|
21
|
+
*
|
|
22
|
+
* The registry is module memory in the harness process, so like fake state
|
|
23
|
+
* and the route tables it forks with the environment. That alone would make
|
|
24
|
+
* a parent's interceptor leak into every `dependsOn` child through the
|
|
25
|
+
* post-state snapshot, so a test's interceptors are **scoped to the case**:
|
|
26
|
+
* {@link InterceptRegistry.beginScope} opens the case, and
|
|
27
|
+
* {@link InterceptRegistry.endScope} removes everything registered inside it
|
|
28
|
+
* — the harness calls both around the test body. An interceptor registered
|
|
29
|
+
* outside a case (`eval`, project `setup`) has no scope and lasts until
|
|
30
|
+
* `remove()`.
|
|
31
|
+
*
|
|
32
|
+
* Pure module: no Bun, no listener, no recorder — so it is testable with
|
|
33
|
+
* plain `Request`/`Response` objects.
|
|
34
|
+
*/
|
|
35
|
+
import { isWildcard, wildcardSuffix } from "./hostmatch";
|
|
36
|
+
/** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
|
|
37
|
+
* never `/apix`. `/` matches everything. Query strings do not take part. */
|
|
38
|
+
export function pathMounts(mount, pathname) {
|
|
39
|
+
if (mount === "/" || mount === "")
|
|
40
|
+
return true;
|
|
41
|
+
const m = mount.endsWith("/") ? mount.slice(0, -1) : mount;
|
|
42
|
+
return pathname === m || pathname.startsWith(`${m}/`);
|
|
43
|
+
}
|
|
44
|
+
/** Exact hostname, or a `*.suffix` pattern that covers the host at any
|
|
45
|
+
* depth — the same rule the route tables use for a wildcard route. */
|
|
46
|
+
export function hostnameMatches(pattern, host) {
|
|
47
|
+
if (pattern === host)
|
|
48
|
+
return true;
|
|
49
|
+
if (!isWildcard(pattern))
|
|
50
|
+
return false;
|
|
51
|
+
return host.endsWith(wildcardSuffix(pattern));
|
|
52
|
+
}
|
|
53
|
+
/** Normalise a mount path: must start with `/`, no query, no trailing
|
|
54
|
+
* slash (except the root). */
|
|
55
|
+
export function normalizeMount(path) {
|
|
56
|
+
if (path === undefined || path === "" || path === "/")
|
|
57
|
+
return "/";
|
|
58
|
+
if (!path.startsWith("/")) {
|
|
59
|
+
throw new Error(`intercept: path ${JSON.stringify(path)} must start with "/"`);
|
|
60
|
+
}
|
|
61
|
+
if (path.includes("?") || path.includes("#")) {
|
|
62
|
+
throw new Error(`intercept: path ${JSON.stringify(path)} is a mount prefix and cannot carry a query or fragment`);
|
|
63
|
+
}
|
|
64
|
+
return path.endsWith("/") ? path.slice(0, -1) : path;
|
|
65
|
+
}
|
|
66
|
+
export class InterceptRegistry {
|
|
67
|
+
list = [];
|
|
68
|
+
nextId = 1;
|
|
69
|
+
scope;
|
|
70
|
+
/** Every interceptor registered from now on belongs to `scope`, until
|
|
71
|
+
* {@link endScope}. */
|
|
72
|
+
beginScope(scope) {
|
|
73
|
+
this.scope = scope;
|
|
74
|
+
}
|
|
75
|
+
/** Close the current scope and remove every interceptor registered in it.
|
|
76
|
+
* Returns how many were removed. */
|
|
77
|
+
endScope() {
|
|
78
|
+
const scope = this.scope;
|
|
79
|
+
this.scope = undefined;
|
|
80
|
+
if (scope === undefined)
|
|
81
|
+
return 0;
|
|
82
|
+
const before = this.list.length;
|
|
83
|
+
this.list = this.list.filter((i) => i.scope !== scope);
|
|
84
|
+
return before - this.list.length;
|
|
85
|
+
}
|
|
86
|
+
register(hostname, path, handler) {
|
|
87
|
+
if (typeof handler !== "function") {
|
|
88
|
+
throw new Error("intercept: the handler must be a function (req, next) => Response");
|
|
89
|
+
}
|
|
90
|
+
const it = {
|
|
91
|
+
id: this.nextId++,
|
|
92
|
+
hostname: hostname.toLowerCase(),
|
|
93
|
+
path: normalizeMount(path),
|
|
94
|
+
handler,
|
|
95
|
+
scope: this.scope,
|
|
96
|
+
calls: 0,
|
|
97
|
+
requests: [],
|
|
98
|
+
};
|
|
99
|
+
this.list.push(it);
|
|
100
|
+
return it;
|
|
101
|
+
}
|
|
102
|
+
/** Idempotent: removing twice, or after the scope ended, is a no-op. */
|
|
103
|
+
remove(id) {
|
|
104
|
+
this.list = this.list.filter((i) => i.id !== id);
|
|
105
|
+
}
|
|
106
|
+
/** The interceptors that apply to a request, in registration order. */
|
|
107
|
+
chainFor(host, pathname) {
|
|
108
|
+
return this.list.filter((i) => hostnameMatches(i.hostname, host) && pathMounts(i.path, pathname));
|
|
109
|
+
}
|
|
110
|
+
size() {
|
|
111
|
+
return this.list.length;
|
|
112
|
+
}
|
|
113
|
+
clear() {
|
|
114
|
+
this.list = [];
|
|
115
|
+
this.scope = undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Run `req` through `chain`, ending at `upstream`.
|
|
120
|
+
*
|
|
121
|
+
* Each interceptor's `next` runs the rest of the chain; an interceptor
|
|
122
|
+
* that returns without calling `next` answers the request itself. A thrown
|
|
123
|
+
* error becomes a 500 naming the interceptor — the same rule a fake handler
|
|
124
|
+
* gets — so a bug in test code is a visible failure of that request, not a
|
|
125
|
+
* hung browser.
|
|
126
|
+
*
|
|
127
|
+
* `observe` is called once per interceptor that saw the request, after it
|
|
128
|
+
* returned, with what it did.
|
|
129
|
+
*/
|
|
130
|
+
export async function runChain(chain, req, upstream, observe) {
|
|
131
|
+
const run = async (index, current) => {
|
|
132
|
+
const it = chain[index];
|
|
133
|
+
if (!it)
|
|
134
|
+
return upstream(current);
|
|
135
|
+
let calledNext = false;
|
|
136
|
+
let fromUpstream;
|
|
137
|
+
const next = async (replacement) => {
|
|
138
|
+
calledNext = true;
|
|
139
|
+
fromUpstream = await run(index + 1, replacement ?? current);
|
|
140
|
+
return fromUpstream;
|
|
141
|
+
};
|
|
142
|
+
const started = Date.now();
|
|
143
|
+
const url = new URL(current.url);
|
|
144
|
+
const record = {
|
|
145
|
+
method: current.method,
|
|
146
|
+
path: `${url.pathname}${url.search}`,
|
|
147
|
+
status: 0,
|
|
148
|
+
answeredBy: "handler",
|
|
149
|
+
};
|
|
150
|
+
let res;
|
|
151
|
+
try {
|
|
152
|
+
res = await it.handler(current, next);
|
|
153
|
+
if (!(res instanceof Response)) {
|
|
154
|
+
throw new Error(`interceptor for ${it.hostname}${it.path === "/" ? "" : it.path} returned ${res === undefined ? "undefined" : typeof res} — return a Response, or the result of next()`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const e = err;
|
|
159
|
+
res = new Response(`spectest-daemon: interceptor for ${it.hostname} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
|
|
160
|
+
record.answeredBy = "handler";
|
|
161
|
+
record.status = 500;
|
|
162
|
+
it.calls++;
|
|
163
|
+
it.requests.push(record);
|
|
164
|
+
observe?.(it, record, Date.now() - started);
|
|
165
|
+
return res;
|
|
166
|
+
}
|
|
167
|
+
record.status = res.status;
|
|
168
|
+
record.answeredBy = !calledNext ? "handler" : res === fromUpstream ? "upstream" : "modified";
|
|
169
|
+
it.calls++;
|
|
170
|
+
it.requests.push(record);
|
|
171
|
+
observe?.(it, record, Date.now() - started);
|
|
172
|
+
return res;
|
|
173
|
+
};
|
|
174
|
+
return run(0, req);
|
|
175
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +25,27 @@ import { type ServiceCoverage } from "./coverage.js";
|
|
|
25
25
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
|
26
26
|
export type { CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
|
|
27
27
|
import type { DnsTarget } from "./ingress.js";
|
|
28
|
+
import type { InterceptHandler, InterceptNext, InterceptedRequest } from "./harness/intercept.js";
|
|
29
|
+
export type { InterceptHandler, InterceptNext, InterceptedRequest };
|
|
30
|
+
/**
|
|
31
|
+
* The handle `ctx.intercept` returns: what the interceptor has seen so far,
|
|
32
|
+
* and the way to take it down early. A test's interceptors are removed
|
|
33
|
+
* when the test ends whether or not `remove()` was called.
|
|
34
|
+
*/
|
|
35
|
+
export interface Interception {
|
|
36
|
+
hostname: string;
|
|
37
|
+
/** The normalised mount path (`"/"` when none was given). */
|
|
38
|
+
path: string;
|
|
39
|
+
/** How many requests the interceptor has seen. Provenance-wrapped, so an
|
|
40
|
+
* `expect(outage.calls)` nests under the intercept step; `.unwrap()` for
|
|
41
|
+
* the raw number. */
|
|
42
|
+
readonly calls: Wrapped<number>;
|
|
43
|
+
/** Every request it saw, in order, with the status it got and who
|
|
44
|
+
* answered it (`handler` | `upstream` | `modified`). */
|
|
45
|
+
readonly requests: Wrapped<InterceptedRequest[]>;
|
|
46
|
+
/** Stop intercepting now. Idempotent. */
|
|
47
|
+
remove(): void;
|
|
48
|
+
}
|
|
28
49
|
export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
|
|
29
50
|
/** Human-friendly name for the environment, e.g. "my-app". */
|
|
30
51
|
name: string;
|
|
@@ -355,6 +376,40 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
355
376
|
* ```
|
|
356
377
|
*/
|
|
357
378
|
dnsName(hostname: string, target: DnsTarget): Promise<void>;
|
|
379
|
+
/**
|
|
380
|
+
* Put middleware in front of a hostname the ingress serves — the way to
|
|
381
|
+
* make your **own** backend misbehave for one test: force a 500 from an
|
|
382
|
+
* API route, add latency, fail twice then pass, or just count calls.
|
|
383
|
+
*
|
|
384
|
+
* Hono/Koa-style middleware: `(req, next)` where `next()` returns the real upstream's
|
|
385
|
+
* response (a proxied service, or a fake). Return a `Response` to answer
|
|
386
|
+
* yourself, `next()` to pass through, or change what `next()` returned.
|
|
387
|
+
* An optional mount `path` limits it to that path and everything below
|
|
388
|
+
* (`"/functions/v1/sync"`), like `app.use(path, fn)`. Interceptors run in
|
|
389
|
+
* registration order.
|
|
390
|
+
*
|
|
391
|
+
* Only traffic that reaches the daemon can be intercepted: the browser,
|
|
392
|
+
* `ctx.fetch`, and any container that calls the hostname — so the service
|
|
393
|
+
* needs a `tls`/`hostnames` entry (or `supabase({ hostname })`), or the
|
|
394
|
+
* target is a fake. A bare `http://<service>:<port>` call between
|
|
395
|
+
* containers never passes through here, and a hostname nothing claims is
|
|
396
|
+
* refused at registration.
|
|
397
|
+
*
|
|
398
|
+
* From a test, the interceptor lasts until the test ends (a `dependsOn`
|
|
399
|
+
* child never inherits it); from `eval` or `setup` it lasts until
|
|
400
|
+
* `remove()`. Every request it sees is recorded under the intercept step.
|
|
401
|
+
*
|
|
402
|
+
* ```ts
|
|
403
|
+
* const outage = ctx.intercept("api.test", "/functions/v1/sync", () =>
|
|
404
|
+
* new Response("boom", { status: 500 }));
|
|
405
|
+
* await page.getByRole("button", { name: "Sync" }).click();
|
|
406
|
+
* await expect(page.getByText("Retry")).toBeVisible();
|
|
407
|
+
* expect(outage.calls).toBe(1);
|
|
408
|
+
* outage.remove(); // the retry now reaches the real function
|
|
409
|
+
* ```
|
|
410
|
+
*/
|
|
411
|
+
intercept(hostname: string, handler: InterceptHandler): Interception;
|
|
412
|
+
intercept(hostname: string, path: string, handler: InterceptHandler): Interception;
|
|
358
413
|
/**
|
|
359
414
|
* Mint a leaf certificate from the in-VM root CA and return the PEMs.
|
|
360
415
|
*
|
package/package.json
CHANGED
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
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request interceptors on the ingress — the mechanism behind `ctx.intercept`.
|
|
3
|
+
*
|
|
4
|
+
* An interceptor is middleware in the Hono/Koa sense: it sees a request
|
|
5
|
+
* that reached the daemon's ingress for a hostname it claims, and either
|
|
6
|
+
* answers it itself or calls `next()` to let the real upstream (a proxied
|
|
7
|
+
* service, or a fake) answer. That is what lets a test make its *own*
|
|
8
|
+
* backend misbehave for one case — force a 500 from an edge function, add
|
|
9
|
+
* latency, fail twice then pass — without redefining the service.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this is a chain and not a replacement handler
|
|
12
|
+
*
|
|
13
|
+
* A replacement handler covers "answer instead of the upstream" and nothing
|
|
14
|
+
* else. Every other shape a test needs — observe and count, mutate a real
|
|
15
|
+
* response, delay it, fail N times — needs the real answer in hand, which is
|
|
16
|
+
* what `next()` gives. Registration order is the chain order, as in Hono
|
|
17
|
+
* or Koa: the first interceptor sees the request first, and each one
|
|
18
|
+
* decides whether the next runs.
|
|
19
|
+
*
|
|
20
|
+
* ## Scope
|
|
21
|
+
*
|
|
22
|
+
* The registry is module memory in the harness process, so like fake state
|
|
23
|
+
* and the route tables it forks with the environment. That alone would make
|
|
24
|
+
* a parent's interceptor leak into every `dependsOn` child through the
|
|
25
|
+
* post-state snapshot, so a test's interceptors are **scoped to the case**:
|
|
26
|
+
* {@link InterceptRegistry.beginScope} opens the case, and
|
|
27
|
+
* {@link InterceptRegistry.endScope} removes everything registered inside it
|
|
28
|
+
* — the harness calls both around the test body. An interceptor registered
|
|
29
|
+
* outside a case (`eval`, project `setup`) has no scope and lasts until
|
|
30
|
+
* `remove()`.
|
|
31
|
+
*
|
|
32
|
+
* Pure module: no Bun, no listener, no recorder — so it is testable with
|
|
33
|
+
* plain `Request`/`Response` objects.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { isWildcard, wildcardSuffix } from "./hostmatch";
|
|
37
|
+
|
|
38
|
+
/** The continuation an interceptor calls to reach the upstream (or the next
|
|
39
|
+
* interceptor in the chain). Passing a `Request` replaces the one that goes
|
|
40
|
+
* on — the way to forward a modified request, since a fetch `Request` is
|
|
41
|
+
* immutable. */
|
|
42
|
+
export type InterceptNext = (req?: Request) => Promise<Response>;
|
|
43
|
+
|
|
44
|
+
export type InterceptHandler = (
|
|
45
|
+
req: Request,
|
|
46
|
+
next: InterceptNext,
|
|
47
|
+
) => Response | Promise<Response>;
|
|
48
|
+
|
|
49
|
+
/** One observed request, as the handle reports it. */
|
|
50
|
+
export interface InterceptedRequest {
|
|
51
|
+
method: string;
|
|
52
|
+
/** Path + query, as requested. */
|
|
53
|
+
path: string;
|
|
54
|
+
status: number;
|
|
55
|
+
/** Who produced the response: the interceptor itself (`handler`), the
|
|
56
|
+
* upstream via `next()` untouched (`upstream`), or the upstream's answer
|
|
57
|
+
* replaced by the interceptor after `next()` (`modified`). */
|
|
58
|
+
answeredBy: "handler" | "upstream" | "modified";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Interceptor {
|
|
62
|
+
id: number;
|
|
63
|
+
hostname: string;
|
|
64
|
+
/** Mount path, like `app.use(path, fn)`: matches the path itself and
|
|
65
|
+
* everything below it. `"/"` (the default) matches every path. */
|
|
66
|
+
path: string;
|
|
67
|
+
handler: InterceptHandler;
|
|
68
|
+
/** The case this interceptor belongs to, if registered inside one. */
|
|
69
|
+
scope?: string;
|
|
70
|
+
calls: number;
|
|
71
|
+
requests: InterceptedRequest[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
|
|
75
|
+
* never `/apix`. `/` matches everything. Query strings do not take part. */
|
|
76
|
+
export function pathMounts(mount: string, pathname: string): boolean {
|
|
77
|
+
if (mount === "/" || mount === "") return true;
|
|
78
|
+
const m = mount.endsWith("/") ? mount.slice(0, -1) : mount;
|
|
79
|
+
return pathname === m || pathname.startsWith(`${m}/`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Exact hostname, or a `*.suffix` pattern that covers the host at any
|
|
83
|
+
* depth — the same rule the route tables use for a wildcard route. */
|
|
84
|
+
export function hostnameMatches(pattern: string, host: string): boolean {
|
|
85
|
+
if (pattern === host) return true;
|
|
86
|
+
if (!isWildcard(pattern)) return false;
|
|
87
|
+
return host.endsWith(wildcardSuffix(pattern));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Normalise a mount path: must start with `/`, no query, no trailing
|
|
91
|
+
* slash (except the root). */
|
|
92
|
+
export function normalizeMount(path: string | undefined): string {
|
|
93
|
+
if (path === undefined || path === "" || path === "/") return "/";
|
|
94
|
+
if (!path.startsWith("/")) {
|
|
95
|
+
throw new Error(`intercept: path ${JSON.stringify(path)} must start with "/"`);
|
|
96
|
+
}
|
|
97
|
+
if (path.includes("?") || path.includes("#")) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`intercept: path ${JSON.stringify(path)} is a mount prefix and cannot carry a query or fragment`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return path.endsWith("/") ? path.slice(0, -1) : path;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class InterceptRegistry {
|
|
106
|
+
private list: Interceptor[] = [];
|
|
107
|
+
private nextId = 1;
|
|
108
|
+
private scope: string | undefined;
|
|
109
|
+
|
|
110
|
+
/** Every interceptor registered from now on belongs to `scope`, until
|
|
111
|
+
* {@link endScope}. */
|
|
112
|
+
beginScope(scope: string): void {
|
|
113
|
+
this.scope = scope;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Close the current scope and remove every interceptor registered in it.
|
|
117
|
+
* Returns how many were removed. */
|
|
118
|
+
endScope(): number {
|
|
119
|
+
const scope = this.scope;
|
|
120
|
+
this.scope = undefined;
|
|
121
|
+
if (scope === undefined) return 0;
|
|
122
|
+
const before = this.list.length;
|
|
123
|
+
this.list = this.list.filter((i) => i.scope !== scope);
|
|
124
|
+
return before - this.list.length;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
register(hostname: string, path: string | undefined, handler: InterceptHandler): Interceptor {
|
|
128
|
+
if (typeof handler !== "function") {
|
|
129
|
+
throw new Error("intercept: the handler must be a function (req, next) => Response");
|
|
130
|
+
}
|
|
131
|
+
const it: Interceptor = {
|
|
132
|
+
id: this.nextId++,
|
|
133
|
+
hostname: hostname.toLowerCase(),
|
|
134
|
+
path: normalizeMount(path),
|
|
135
|
+
handler,
|
|
136
|
+
scope: this.scope,
|
|
137
|
+
calls: 0,
|
|
138
|
+
requests: [],
|
|
139
|
+
};
|
|
140
|
+
this.list.push(it);
|
|
141
|
+
return it;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Idempotent: removing twice, or after the scope ended, is a no-op. */
|
|
145
|
+
remove(id: number): void {
|
|
146
|
+
this.list = this.list.filter((i) => i.id !== id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The interceptors that apply to a request, in registration order. */
|
|
150
|
+
chainFor(host: string, pathname: string): Interceptor[] {
|
|
151
|
+
return this.list.filter(
|
|
152
|
+
(i) => hostnameMatches(i.hostname, host) && pathMounts(i.path, pathname),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
size(): number {
|
|
157
|
+
return this.list.length;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
clear(): void {
|
|
161
|
+
this.list = [];
|
|
162
|
+
this.scope = undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What `runChain` reports about one interceptor's part in a request. */
|
|
167
|
+
export interface ChainObserver {
|
|
168
|
+
(interceptor: Interceptor, record: InterceptedRequest, durationMs: number): void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Run `req` through `chain`, ending at `upstream`.
|
|
173
|
+
*
|
|
174
|
+
* Each interceptor's `next` runs the rest of the chain; an interceptor
|
|
175
|
+
* that returns without calling `next` answers the request itself. A thrown
|
|
176
|
+
* error becomes a 500 naming the interceptor — the same rule a fake handler
|
|
177
|
+
* gets — so a bug in test code is a visible failure of that request, not a
|
|
178
|
+
* hung browser.
|
|
179
|
+
*
|
|
180
|
+
* `observe` is called once per interceptor that saw the request, after it
|
|
181
|
+
* returned, with what it did.
|
|
182
|
+
*/
|
|
183
|
+
export async function runChain(
|
|
184
|
+
chain: readonly Interceptor[],
|
|
185
|
+
req: Request,
|
|
186
|
+
upstream: (req: Request) => Promise<Response>,
|
|
187
|
+
observe?: ChainObserver,
|
|
188
|
+
): Promise<Response> {
|
|
189
|
+
const run = async (index: number, current: Request): Promise<Response> => {
|
|
190
|
+
const it = chain[index];
|
|
191
|
+
if (!it) return upstream(current);
|
|
192
|
+
let calledNext = false;
|
|
193
|
+
let fromUpstream: Response | undefined;
|
|
194
|
+
const next: InterceptNext = async (replacement?: Request) => {
|
|
195
|
+
calledNext = true;
|
|
196
|
+
fromUpstream = await run(index + 1, replacement ?? current);
|
|
197
|
+
return fromUpstream;
|
|
198
|
+
};
|
|
199
|
+
const started = Date.now();
|
|
200
|
+
const url = new URL(current.url);
|
|
201
|
+
const record: InterceptedRequest = {
|
|
202
|
+
method: current.method,
|
|
203
|
+
path: `${url.pathname}${url.search}`,
|
|
204
|
+
status: 0,
|
|
205
|
+
answeredBy: "handler",
|
|
206
|
+
};
|
|
207
|
+
let res: Response;
|
|
208
|
+
try {
|
|
209
|
+
res = await it.handler(current, next);
|
|
210
|
+
if (!(res instanceof Response)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`interceptor for ${it.hostname}${it.path === "/" ? "" : it.path} returned ${
|
|
213
|
+
res === undefined ? "undefined" : typeof res
|
|
214
|
+
} — return a Response, or the result of next()`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const e = err as Error;
|
|
219
|
+
res = new Response(
|
|
220
|
+
`spectest-daemon: interceptor for ${it.hostname} threw: ${e?.message ?? String(err)}\n`,
|
|
221
|
+
{ status: 500, headers: { "content-type": "text/plain" } },
|
|
222
|
+
);
|
|
223
|
+
record.answeredBy = "handler";
|
|
224
|
+
record.status = 500;
|
|
225
|
+
it.calls++;
|
|
226
|
+
it.requests.push(record);
|
|
227
|
+
observe?.(it, record, Date.now() - started);
|
|
228
|
+
return res;
|
|
229
|
+
}
|
|
230
|
+
record.status = res.status;
|
|
231
|
+
record.answeredBy = !calledNext ? "handler" : res === fromUpstream ? "upstream" : "modified";
|
|
232
|
+
it.calls++;
|
|
233
|
+
it.requests.push(record);
|
|
234
|
+
observe?.(it, record, Date.now() - started);
|
|
235
|
+
return res;
|
|
236
|
+
};
|
|
237
|
+
return run(0, req);
|
|
238
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -168,6 +168,32 @@ export type {
|
|
|
168
168
|
LoweredIngress,
|
|
169
169
|
} from "./ingress.js";
|
|
170
170
|
import type { DnsTarget } from "./ingress.js";
|
|
171
|
+
import type {
|
|
172
|
+
InterceptHandler,
|
|
173
|
+
InterceptNext,
|
|
174
|
+
InterceptedRequest,
|
|
175
|
+
} from "./harness/intercept.js";
|
|
176
|
+
export type { InterceptHandler, InterceptNext, InterceptedRequest };
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The handle `ctx.intercept` returns: what the interceptor has seen so far,
|
|
180
|
+
* and the way to take it down early. A test's interceptors are removed
|
|
181
|
+
* when the test ends whether or not `remove()` was called.
|
|
182
|
+
*/
|
|
183
|
+
export interface Interception {
|
|
184
|
+
hostname: string;
|
|
185
|
+
/** The normalised mount path (`"/"` when none was given). */
|
|
186
|
+
path: string;
|
|
187
|
+
/** How many requests the interceptor has seen. Provenance-wrapped, so an
|
|
188
|
+
* `expect(outage.calls)` nests under the intercept step; `.unwrap()` for
|
|
189
|
+
* the raw number. */
|
|
190
|
+
readonly calls: Wrapped<number>;
|
|
191
|
+
/** Every request it saw, in order, with the status it got and who
|
|
192
|
+
* answered it (`handler` | `upstream` | `modified`). */
|
|
193
|
+
readonly requests: Wrapped<InterceptedRequest[]>;
|
|
194
|
+
/** Stop intercepting now. Idempotent. */
|
|
195
|
+
remove(): void;
|
|
196
|
+
}
|
|
171
197
|
import { isWildcard as isWildcardHost } from "./ingress.js";
|
|
172
198
|
|
|
173
199
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -514,6 +540,40 @@ export interface SpectestContext<
|
|
|
514
540
|
* ```
|
|
515
541
|
*/
|
|
516
542
|
dnsName(hostname: string, target: DnsTarget): Promise<void>;
|
|
543
|
+
/**
|
|
544
|
+
* Put middleware in front of a hostname the ingress serves — the way to
|
|
545
|
+
* make your **own** backend misbehave for one test: force a 500 from an
|
|
546
|
+
* API route, add latency, fail twice then pass, or just count calls.
|
|
547
|
+
*
|
|
548
|
+
* Hono/Koa-style middleware: `(req, next)` where `next()` returns the real upstream's
|
|
549
|
+
* response (a proxied service, or a fake). Return a `Response` to answer
|
|
550
|
+
* yourself, `next()` to pass through, or change what `next()` returned.
|
|
551
|
+
* An optional mount `path` limits it to that path and everything below
|
|
552
|
+
* (`"/functions/v1/sync"`), like `app.use(path, fn)`. Interceptors run in
|
|
553
|
+
* registration order.
|
|
554
|
+
*
|
|
555
|
+
* Only traffic that reaches the daemon can be intercepted: the browser,
|
|
556
|
+
* `ctx.fetch`, and any container that calls the hostname — so the service
|
|
557
|
+
* needs a `tls`/`hostnames` entry (or `supabase({ hostname })`), or the
|
|
558
|
+
* target is a fake. A bare `http://<service>:<port>` call between
|
|
559
|
+
* containers never passes through here, and a hostname nothing claims is
|
|
560
|
+
* refused at registration.
|
|
561
|
+
*
|
|
562
|
+
* From a test, the interceptor lasts until the test ends (a `dependsOn`
|
|
563
|
+
* child never inherits it); from `eval` or `setup` it lasts until
|
|
564
|
+
* `remove()`. Every request it sees is recorded under the intercept step.
|
|
565
|
+
*
|
|
566
|
+
* ```ts
|
|
567
|
+
* const outage = ctx.intercept("api.test", "/functions/v1/sync", () =>
|
|
568
|
+
* new Response("boom", { status: 500 }));
|
|
569
|
+
* await page.getByRole("button", { name: "Sync" }).click();
|
|
570
|
+
* await expect(page.getByText("Retry")).toBeVisible();
|
|
571
|
+
* expect(outage.calls).toBe(1);
|
|
572
|
+
* outage.remove(); // the retry now reaches the real function
|
|
573
|
+
* ```
|
|
574
|
+
*/
|
|
575
|
+
intercept(hostname: string, handler: InterceptHandler): Interception;
|
|
576
|
+
intercept(hostname: string, path: string, handler: InterceptHandler): Interception;
|
|
517
577
|
/**
|
|
518
578
|
* Mint a leaf certificate from the in-VM root CA and return the PEMs.
|
|
519
579
|
*
|