@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.
@@ -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
+ }