@specific.dev/spectest 0.61.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 +165 -19
- package/dist/harness/build-context.d.ts +3 -0
- package/dist/harness/build-context.js +12 -0
- package/dist/harness/intercept.d.ts +107 -0
- package/dist/harness/intercept.js +175 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +65 -1
- package/package.json +1 -1
- package/src/daemon.ts +202 -28
- package/src/dockerfile-path.test.ts +87 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +14 -1
- package/src/harness/intercept.test.ts +182 -0
- package/src/harness/intercept.ts +238 -0
- package/src/index.ts +169 -1
package/dist/daemon.js
CHANGED
|
@@ -21,7 +21,7 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
|
|
|
21
21
|
import net from "node:net";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
|
-
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
|
|
24
|
+
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, resolveServiceImage, proxy as makeProxyDecl, } from "./index.js";
|
|
25
25
|
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, } from "./harness/coverage.js";
|
|
26
26
|
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
27
27
|
import { applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, nodeCoverageToolsAvailable, nodeCoverageToolsDir, NODE_COVERAGE_TOOLS_CONTAINER_DIR, } from "./coverage.js";
|
|
@@ -32,7 +32,7 @@ import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
|
32
32
|
// harness/hostmatch.ts). Keeping ONE implementation is the point: the
|
|
33
33
|
// exact-then-longest-suffix rule and the one-label certificate rule are
|
|
34
34
|
// each easy to restate subtly differently.
|
|
35
|
-
import { buildContentKey as computeBuildContentKey, imageTag, isGeneratedDockerignore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
|
|
35
|
+
import { buildArgFlags, buildContentKey as computeBuildContentKey, imageTag, isGeneratedDockerignore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
|
|
36
36
|
import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
|
|
37
37
|
import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
|
|
38
38
|
import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
@@ -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";
|
|
@@ -831,7 +832,7 @@ async function prepareServiceImage(svc, opts) {
|
|
|
831
832
|
// (/workspace) is an input too — a runtime service started mid-test
|
|
832
833
|
// after setup/test code mutated /workspace must rebuild, not share a
|
|
833
834
|
// pre-mutation image.
|
|
834
|
-
const image = svc
|
|
835
|
+
const image = dockerfileContent(svc);
|
|
835
836
|
if (opts?.dedup) {
|
|
836
837
|
const key = buildContentKey(image);
|
|
837
838
|
const inflight = BUILD_DEDUP.get(key);
|
|
@@ -922,6 +923,16 @@ RUN P='[spectest-ca]'; \\
|
|
|
922
923
|
fi
|
|
923
924
|
`;
|
|
924
925
|
}
|
|
926
|
+
/** The built form of a dockerfile image. `resolveServiceImage` read a
|
|
927
|
+
* `path` into `content` at config time, so a service that still carries
|
|
928
|
+
* `path` here skipped that step — a programming error, not user input. */
|
|
929
|
+
function dockerfileContent(svc) {
|
|
930
|
+
const image = svc.image;
|
|
931
|
+
if (image.type !== "dockerfile" || typeof image.content !== "string") {
|
|
932
|
+
throw new Error(`service "${svc.name}" image was not resolved to Dockerfile contents`);
|
|
933
|
+
}
|
|
934
|
+
return image;
|
|
935
|
+
}
|
|
925
936
|
/**
|
|
926
937
|
* Build a dockerfile service's image.
|
|
927
938
|
*
|
|
@@ -997,6 +1008,9 @@ async function runServiceBuild(name, image, tag, caSuffix) {
|
|
|
997
1008
|
const useBuildKit = useRemote || (await hasBuildx());
|
|
998
1009
|
const buildEnv = {};
|
|
999
1010
|
let buildArgs;
|
|
1011
|
+
// The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
|
|
1012
|
+
// so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
|
|
1013
|
+
const argFlags = buildArgFlags(image.buildArgs);
|
|
1000
1014
|
if (useRemote) {
|
|
1001
1015
|
// Build on the host-side shared buildkitd (persistent cross-VM cache);
|
|
1002
1016
|
// `--load` brings the finished image back into the in-VM dockerd so
|
|
@@ -1007,15 +1021,16 @@ async function runServiceBuild(name, image, tag, caSuffix) {
|
|
|
1007
1021
|
"--builder", REMOTE_BUILDER_NAME,
|
|
1008
1022
|
"--load",
|
|
1009
1023
|
"--progress=plain",
|
|
1024
|
+
...argFlags,
|
|
1010
1025
|
"-t", tag, "-f", dfPath, WORKSPACE,
|
|
1011
1026
|
];
|
|
1012
1027
|
}
|
|
1013
1028
|
else if (useBuildKit) {
|
|
1014
|
-
buildArgs = ["build", "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
|
|
1029
|
+
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
|
|
1015
1030
|
buildEnv.DOCKER_BUILDKIT = "1";
|
|
1016
1031
|
}
|
|
1017
1032
|
else {
|
|
1018
|
-
buildArgs = ["build", "-t", tag, "-f", dfPath, WORKSPACE];
|
|
1033
|
+
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, WORKSPACE];
|
|
1019
1034
|
}
|
|
1020
1035
|
progressService(name, { status: "building", detail: "starting build" });
|
|
1021
1036
|
const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
|
|
@@ -1979,17 +1994,27 @@ server, byHost, listenerLabel, proto) {
|
|
|
1979
1994
|
// to list in Access-Control-Allow-Headers.
|
|
1980
1995
|
if (isCorsPreflight(req))
|
|
1981
1996
|
return corsPreflightResponse(req);
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
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
|
+
}
|
|
1990
2007
|
}
|
|
1991
|
-
|
|
1992
|
-
|
|
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);
|
|
1993
2018
|
return augmentCorsResponse(req, res);
|
|
1994
2019
|
}
|
|
1995
2020
|
/**
|
|
@@ -2249,6 +2274,120 @@ async function seedNamesRegistry(opts) {
|
|
|
2249
2274
|
}
|
|
2250
2275
|
await writeRegistry();
|
|
2251
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
|
+
}
|
|
2252
2391
|
/**
|
|
2253
2392
|
* Register a hostname at runtime — the implementation behind `ctx.dnsName`.
|
|
2254
2393
|
* Validates via the same `dnsName` primitive the static path uses, resolves
|
|
@@ -2323,9 +2462,11 @@ const RUNTIME_SERVICES = new Map();
|
|
|
2323
2462
|
// the orchestration helpers want a NamedService, which is the same shape.
|
|
2324
2463
|
function specToNamedService(spec) {
|
|
2325
2464
|
const { name, ...rest } = spec;
|
|
2326
|
-
// A runtime spec skipped `defineEnvironment`, so its
|
|
2327
|
-
//
|
|
2328
|
-
|
|
2465
|
+
// A runtime spec skipped `defineEnvironment`, so its config-time passes
|
|
2466
|
+
// run here: a `path` Dockerfile is read into `content`, and coverage
|
|
2467
|
+
// adapters get their configure step.
|
|
2468
|
+
const svc = { ...rest, image: resolveServiceImage(name, rest.image) };
|
|
2469
|
+
return { name, ...applyCoverageAdapters(name, svc) };
|
|
2329
2470
|
}
|
|
2330
2471
|
/** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
|
|
2331
2472
|
* Prepares the image (pulling on first use through the host cache), runs
|
|
@@ -3475,6 +3616,7 @@ async function spectestContext(scope = {}) {
|
|
|
3475
3616
|
certificate: mintCertificate,
|
|
3476
3617
|
startService: startRuntimeService,
|
|
3477
3618
|
stopService: stopRuntimeService,
|
|
3619
|
+
intercept: registerInterceptor,
|
|
3478
3620
|
};
|
|
3479
3621
|
}
|
|
3480
3622
|
/** Handles for a service hook: its dependencies (always up by the time the
|
|
@@ -4290,6 +4432,9 @@ async function runOne(testCase) {
|
|
|
4290
4432
|
// time — so from here on a service helper's `docker exec` lands on the
|
|
4291
4433
|
// timeline (and in the cast) exactly like the test's own `ctx.exec`.
|
|
4292
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);
|
|
4293
4438
|
const timeoutMs = testCase.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
|
|
4294
4439
|
let timer;
|
|
4295
4440
|
const timedOut = new Promise((_, reject) => {
|
|
@@ -4319,6 +4464,7 @@ async function runOne(testCase) {
|
|
|
4319
4464
|
if (timer)
|
|
4320
4465
|
clearTimeout(timer);
|
|
4321
4466
|
RECORDING_EXEC = undefined;
|
|
4467
|
+
INTERCEPTORS.endScope();
|
|
4322
4468
|
restoreFetch();
|
|
4323
4469
|
restoreConsole();
|
|
4324
4470
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -79,4 +79,7 @@ export interface Hasher {
|
|
|
79
79
|
export declare function buildContentKey(createHasher: () => Hasher, image: {
|
|
80
80
|
content: string;
|
|
81
81
|
exclude?: readonly string[];
|
|
82
|
+
buildArgs?: Readonly<Record<string, string>>;
|
|
82
83
|
}): string;
|
|
84
|
+
/** `--build-arg NAME=value` pairs, in a stable (sorted) order. */
|
|
85
|
+
export declare function buildArgFlags(buildArgs: Readonly<Record<string, string>> | undefined): string[];
|
|
@@ -109,5 +109,17 @@ export function buildContentKey(createHasher, image) {
|
|
|
109
109
|
// genuinely different builds would collapse into one.
|
|
110
110
|
.update("\0")
|
|
111
111
|
.update(JSON.stringify(image.exclude ?? []))
|
|
112
|
+
// Build args are an input to the image (an ARG picks the base image,
|
|
113
|
+
// the NODE_ENV of an install step…), so two services on one
|
|
114
|
+
// Dockerfile with different args must not share a build. Sorted, so
|
|
115
|
+
// key order in the user's object doesn't split identical builds.
|
|
116
|
+
.update("\0")
|
|
117
|
+
.update(JSON.stringify(buildArgFlags(image.buildArgs)))
|
|
112
118
|
.digest("hex"));
|
|
113
119
|
}
|
|
120
|
+
/** `--build-arg NAME=value` pairs, in a stable (sorted) order. */
|
|
121
|
+
export function buildArgFlags(buildArgs) {
|
|
122
|
+
return Object.entries(buildArgs ?? {})
|
|
123
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
124
|
+
.flatMap(([k, v]) => ["--build-arg", `${k}=${v}`]);
|
|
125
|
+
}
|
|
@@ -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
|
+
}
|