@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.
- package/dist/components/supabase.js +70 -9
- package/dist/daemon.js +141 -11
- package/dist/harness/intercept.d.ts +107 -0
- package/dist/harness/intercept.js +175 -0
- package/dist/index.d.ts +55 -0
- package/package.json +1 -1
- package/src/components/supabase.ts +70 -9
- package/src/daemon.ts +168 -19
- package/src/harness/intercept.test.ts +182 -0
- package/src/harness/intercept.ts +238 -0
- package/src/index.ts +60 -0
|
@@ -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
|
*
|