@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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// `image: { type: "dockerfile", path }` — a Dockerfile in the repo is read
|
|
2
|
+
// into `content` when the environment loads, so the harness only ever sees
|
|
3
|
+
// bytes. The project root is `SPECTEST_WORKSPACE` (the VM's /workspace).
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
// Set before the SDK reads it (module-load time), so no top-level import.
|
|
10
|
+
let root = "";
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
root = mkdtempSync(path.join(tmpdir(), "spectest-dockerfile-"));
|
|
13
|
+
process.env.SPECTEST_WORKSPACE = root;
|
|
14
|
+
process.env.SPECTEST_APP_DIR = path.join(root, "app-copy");
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
17
|
+
|
|
18
|
+
const load = async () => await import("./index");
|
|
19
|
+
|
|
20
|
+
describe("dockerfile path", () => {
|
|
21
|
+
test("a repo-relative path is read into content, exclude kept", async () => {
|
|
22
|
+
mkdirSync(path.join(root, "apps/api"), { recursive: true });
|
|
23
|
+
writeFileSync(path.join(root, "apps/api/Dockerfile"), "FROM node:20\nCOPY . /app\n");
|
|
24
|
+
const { defineEnvironment } = await load();
|
|
25
|
+
const env = defineEnvironment({
|
|
26
|
+
name: "p",
|
|
27
|
+
services: {
|
|
28
|
+
api: {
|
|
29
|
+
image: {
|
|
30
|
+
type: "dockerfile",
|
|
31
|
+
path: "apps/api/Dockerfile",
|
|
32
|
+
exclude: ["docs/**"],
|
|
33
|
+
buildArgs: { NODE_ENV: "test" },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
expect(env.config.services.api.image).toEqual({
|
|
39
|
+
type: "dockerfile",
|
|
40
|
+
content: "FROM node:20\nCOPY . /app\n",
|
|
41
|
+
exclude: ["docs/**"],
|
|
42
|
+
buildArgs: { NODE_ENV: "test" },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a missing file fails the load and names the path", async () => {
|
|
47
|
+
const { defineEnvironment } = await load();
|
|
48
|
+
expect(() =>
|
|
49
|
+
defineEnvironment({
|
|
50
|
+
name: "p",
|
|
51
|
+
services: { api: { image: { type: "dockerfile", path: "nope/Dockerfile" } } },
|
|
52
|
+
}),
|
|
53
|
+
).toThrow(/service "api" Dockerfile "nope\/Dockerfile" is not in the VM/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("path and content together, or neither, is an error", async () => {
|
|
57
|
+
const { resolveServiceImage } = await load();
|
|
58
|
+
expect(() =>
|
|
59
|
+
resolveServiceImage("api", { type: "dockerfile", path: "Dockerfile", content: "FROM x" } as never),
|
|
60
|
+
).toThrow(/both `path` and `content`/);
|
|
61
|
+
expect(() => resolveServiceImage("api", { type: "dockerfile" } as never)).toThrow(
|
|
62
|
+
/needs `path`/,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("buildArgs keys must be ARG names and values strings", async () => {
|
|
67
|
+
const { resolveServiceImage } = await load();
|
|
68
|
+
expect(() =>
|
|
69
|
+
resolveServiceImage("api", { type: "dockerfile", content: "FROM x", buildArgs: { "NO-DASH": "1" } }),
|
|
70
|
+
).toThrow(/not a valid ARG name/);
|
|
71
|
+
expect(() =>
|
|
72
|
+
resolveServiceImage("api", {
|
|
73
|
+
type: "dockerfile",
|
|
74
|
+
content: "FROM x",
|
|
75
|
+
buildArgs: { PORT: 3000 } as never,
|
|
76
|
+
}),
|
|
77
|
+
).toThrow(/must be a string/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("content and registry images pass through untouched", async () => {
|
|
81
|
+
const { resolveServiceImage } = await load();
|
|
82
|
+
const inline = { type: "dockerfile", content: "FROM x\n" } as const;
|
|
83
|
+
expect(resolveServiceImage("a", inline)).toBe(inline);
|
|
84
|
+
const reg = { type: "registry", reference: "nginx" } as const;
|
|
85
|
+
expect(resolveServiceImage("a", reg)).toBe(reg);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
Binary file
|
|
@@ -130,7 +130,7 @@ export interface Hasher {
|
|
|
130
130
|
|
|
131
131
|
export function buildContentKey(
|
|
132
132
|
createHasher: () => Hasher,
|
|
133
|
-
image: { content: string; exclude?: readonly string[] },
|
|
133
|
+
image: { content: string; exclude?: readonly string[]; buildArgs?: Readonly<Record<string, string>> },
|
|
134
134
|
): string {
|
|
135
135
|
return (
|
|
136
136
|
createHasher()
|
|
@@ -141,6 +141,19 @@ export function buildContentKey(
|
|
|
141
141
|
// genuinely different builds would collapse into one.
|
|
142
142
|
.update("\0")
|
|
143
143
|
.update(JSON.stringify(image.exclude ?? []))
|
|
144
|
+
// Build args are an input to the image (an ARG picks the base image,
|
|
145
|
+
// the NODE_ENV of an install step…), so two services on one
|
|
146
|
+
// Dockerfile with different args must not share a build. Sorted, so
|
|
147
|
+
// key order in the user's object doesn't split identical builds.
|
|
148
|
+
.update("\0")
|
|
149
|
+
.update(JSON.stringify(buildArgFlags(image.buildArgs)))
|
|
144
150
|
.digest("hex")
|
|
145
151
|
);
|
|
146
152
|
}
|
|
153
|
+
|
|
154
|
+
/** `--build-arg NAME=value` pairs, in a stable (sorted) order. */
|
|
155
|
+
export function buildArgFlags(buildArgs: Readonly<Record<string, string>> | undefined): string[] {
|
|
156
|
+
return Object.entries(buildArgs ?? {})
|
|
157
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
158
|
+
.flatMap(([k, v]) => ["--build-arg", `${k}=${v}`]);
|
|
159
|
+
}
|
|
@@ -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
|
+
}
|