@specific.dev/spectest 0.62.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  *