@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/index.d.ts
CHANGED
|
@@ -25,6 +25,27 @@ import { type ServiceCoverage } from "./coverage.js";
|
|
|
25
25
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
|
26
26
|
export type { CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
|
|
27
27
|
import type { DnsTarget } from "./ingress.js";
|
|
28
|
+
import type { InterceptHandler, InterceptNext, InterceptedRequest } from "./harness/intercept.js";
|
|
29
|
+
export type { InterceptHandler, InterceptNext, InterceptedRequest };
|
|
30
|
+
/**
|
|
31
|
+
* The handle `ctx.intercept` returns: what the interceptor has seen so far,
|
|
32
|
+
* and the way to take it down early. A test's interceptors are removed
|
|
33
|
+
* when the test ends whether or not `remove()` was called.
|
|
34
|
+
*/
|
|
35
|
+
export interface Interception {
|
|
36
|
+
hostname: string;
|
|
37
|
+
/** The normalised mount path (`"/"` when none was given). */
|
|
38
|
+
path: string;
|
|
39
|
+
/** How many requests the interceptor has seen. Provenance-wrapped, so an
|
|
40
|
+
* `expect(outage.calls)` nests under the intercept step; `.unwrap()` for
|
|
41
|
+
* the raw number. */
|
|
42
|
+
readonly calls: Wrapped<number>;
|
|
43
|
+
/** Every request it saw, in order, with the status it got and who
|
|
44
|
+
* answered it (`handler` | `upstream` | `modified`). */
|
|
45
|
+
readonly requests: Wrapped<InterceptedRequest[]>;
|
|
46
|
+
/** Stop intercepting now. Idempotent. */
|
|
47
|
+
remove(): void;
|
|
48
|
+
}
|
|
28
49
|
export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
|
|
29
50
|
/** Human-friendly name for the environment, e.g. "my-app". */
|
|
30
51
|
name: string;
|
|
@@ -355,6 +376,40 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
355
376
|
* ```
|
|
356
377
|
*/
|
|
357
378
|
dnsName(hostname: string, target: DnsTarget): Promise<void>;
|
|
379
|
+
/**
|
|
380
|
+
* Put middleware in front of a hostname the ingress serves — the way to
|
|
381
|
+
* make your **own** backend misbehave for one test: force a 500 from an
|
|
382
|
+
* API route, add latency, fail twice then pass, or just count calls.
|
|
383
|
+
*
|
|
384
|
+
* Hono/Koa-style middleware: `(req, next)` where `next()` returns the real upstream's
|
|
385
|
+
* response (a proxied service, or a fake). Return a `Response` to answer
|
|
386
|
+
* yourself, `next()` to pass through, or change what `next()` returned.
|
|
387
|
+
* An optional mount `path` limits it to that path and everything below
|
|
388
|
+
* (`"/functions/v1/sync"`), like `app.use(path, fn)`. Interceptors run in
|
|
389
|
+
* registration order.
|
|
390
|
+
*
|
|
391
|
+
* Only traffic that reaches the daemon can be intercepted: the browser,
|
|
392
|
+
* `ctx.fetch`, and any container that calls the hostname — so the service
|
|
393
|
+
* needs a `tls`/`hostnames` entry (or `supabase({ hostname })`), or the
|
|
394
|
+
* target is a fake. A bare `http://<service>:<port>` call between
|
|
395
|
+
* containers never passes through here, and a hostname nothing claims is
|
|
396
|
+
* refused at registration.
|
|
397
|
+
*
|
|
398
|
+
* From a test, the interceptor lasts until the test ends (a `dependsOn`
|
|
399
|
+
* child never inherits it); from `eval` or `setup` it lasts until
|
|
400
|
+
* `remove()`. Every request it sees is recorded under the intercept step.
|
|
401
|
+
*
|
|
402
|
+
* ```ts
|
|
403
|
+
* const outage = ctx.intercept("api.test", "/functions/v1/sync", () =>
|
|
404
|
+
* new Response("boom", { status: 500 }));
|
|
405
|
+
* await page.getByRole("button", { name: "Sync" }).click();
|
|
406
|
+
* await expect(page.getByText("Retry")).toBeVisible();
|
|
407
|
+
* expect(outage.calls).toBe(1);
|
|
408
|
+
* outage.remove(); // the retry now reaches the real function
|
|
409
|
+
* ```
|
|
410
|
+
*/
|
|
411
|
+
intercept(hostname: string, handler: InterceptHandler): Interception;
|
|
412
|
+
intercept(hostname: string, path: string, handler: InterceptHandler): Interception;
|
|
358
413
|
/**
|
|
359
414
|
* Mint a leaf certificate from the in-VM root CA and return the PEMs.
|
|
360
415
|
*
|
|
@@ -658,17 +713,67 @@ export interface ServiceTls {
|
|
|
658
713
|
export type ServiceImage = {
|
|
659
714
|
type: "registry";
|
|
660
715
|
reference: string;
|
|
716
|
+
} | {
|
|
717
|
+
type: "dockerfile";
|
|
718
|
+
/**
|
|
719
|
+
* Path of a Dockerfile in your repo, relative to the project root
|
|
720
|
+
* (where `spectest/` lives) — `"Dockerfile"`, `"apps/api/Dockerfile"`.
|
|
721
|
+
* This is the preferred form: the test environment builds the same
|
|
722
|
+
* Dockerfile production does, so the two cannot drift. The build
|
|
723
|
+
* context is always the project root, as with
|
|
724
|
+
* `docker build -f apps/api/Dockerfile .`, so `COPY` / `ADD` resolve
|
|
725
|
+
* against the repo root wherever the file sits. The file is read when
|
|
726
|
+
* the environment loads; a missing file fails the load and names the
|
|
727
|
+
* path. Mutually exclusive with `content`.
|
|
728
|
+
*/
|
|
729
|
+
path: string;
|
|
730
|
+
content?: never;
|
|
731
|
+
/** Extra glob patterns to exclude from the build context. */
|
|
732
|
+
exclude?: readonly string[];
|
|
733
|
+
/**
|
|
734
|
+
* `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
|
|
735
|
+
* Build-time only: they are not in the container's environment (use
|
|
736
|
+
* `env` for that), and they are part of the image's identity, so two
|
|
737
|
+
* services building one Dockerfile with different args build twice.
|
|
738
|
+
* Build args land in the image history; pass nothing secret.
|
|
739
|
+
*/
|
|
740
|
+
buildArgs?: Readonly<Record<string, string>>;
|
|
661
741
|
} | {
|
|
662
742
|
type: "dockerfile";
|
|
663
743
|
/**
|
|
664
744
|
* Dockerfile contents, written verbatim into the build context. The
|
|
665
745
|
* build context is the project root (where `spectest/` lives), so any
|
|
666
746
|
* `COPY` / `ADD` references resolve relative to that directory.
|
|
747
|
+
* Prefer `path`, which points at the Dockerfile your repo already has.
|
|
748
|
+
* Mutually exclusive with `path`.
|
|
667
749
|
*/
|
|
668
750
|
content: string;
|
|
751
|
+
path?: never;
|
|
669
752
|
/** Extra glob patterns to exclude from the build context. */
|
|
670
753
|
exclude?: readonly string[];
|
|
754
|
+
/**
|
|
755
|
+
* `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
|
|
756
|
+
* Build-time only: they are not in the container's environment (use
|
|
757
|
+
* `env` for that), and they are part of the image's identity, so two
|
|
758
|
+
* services building one Dockerfile with different args build twice.
|
|
759
|
+
* Build args land in the image history; pass nothing secret.
|
|
760
|
+
*/
|
|
761
|
+
buildArgs?: Readonly<Record<string, string>>;
|
|
671
762
|
};
|
|
763
|
+
/**
|
|
764
|
+
* Resolve a service's `image` to the form the harness builds: a `path`
|
|
765
|
+
* Dockerfile is read from the repo and becomes `content`. The wire config
|
|
766
|
+
* therefore never carries `path` — the control plane and the daemon's build
|
|
767
|
+
* code only ever see Dockerfile bytes, and the warm-template hash covers
|
|
768
|
+
* them because the file is part of the project tree.
|
|
769
|
+
*
|
|
770
|
+
* Runs at config time (`defineEnvironment`, and the daemon's runtime
|
|
771
|
+
* `startService` twin), inside the VM, where the repo sits at the project
|
|
772
|
+
* root. The read goes through the project-file resolver, so the same rules
|
|
773
|
+
* as `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
|
|
774
|
+
* instead of read stale).
|
|
775
|
+
*/
|
|
776
|
+
export declare function resolveServiceImage(serviceName: string, image: ServiceImage): ServiceImage;
|
|
672
777
|
export interface VolumeMount {
|
|
673
778
|
/**
|
|
674
779
|
* Named shared volume. Two services mounting the same `name` share one
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// The daemon loads this file on boot and the control plane talks to it
|
|
5
5
|
// over HTTP.
|
|
6
6
|
import { strict as nodeAssert } from "node:assert";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
7
8
|
import { recordAssertion, safeSerialize } from "./recorder.js";
|
|
8
9
|
import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
|
|
9
10
|
// `field` — a provenance-preserving, null-safe selector: a `null`/`undefined`
|
|
@@ -41,6 +42,7 @@ import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, DEFAULT_
|
|
|
41
42
|
import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
|
|
42
43
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
43
44
|
import { applyCoverageAdapters, validateCoverage } from "./coverage.js";
|
|
45
|
+
import { resolveExistingProjectPath } from "./project-files.js";
|
|
44
46
|
// Low-level ingress primitives + the framework lowering that the friendly
|
|
45
47
|
// `tls` / `hostnames` fields and `defineFake(...)` are built on. See
|
|
46
48
|
// `ingress.ts`.
|
|
@@ -216,6 +218,65 @@ function expandServiceGroups(input) {
|
|
|
216
218
|
}
|
|
217
219
|
return out;
|
|
218
220
|
}
|
|
221
|
+
/** A Dockerfile `ARG` name: what `docker build --build-arg` accepts as a key. */
|
|
222
|
+
const BUILD_ARG_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
223
|
+
function validateBuildArgs(serviceName, buildArgs) {
|
|
224
|
+
if (buildArgs === undefined)
|
|
225
|
+
return;
|
|
226
|
+
if (typeof buildArgs !== "object" || buildArgs === null || Array.isArray(buildArgs)) {
|
|
227
|
+
throw new Error(`service "${serviceName}" image buildArgs must be a { NAME: "value" } record`);
|
|
228
|
+
}
|
|
229
|
+
for (const [k, v] of Object.entries(buildArgs)) {
|
|
230
|
+
if (!BUILD_ARG_NAME_RE.test(k)) {
|
|
231
|
+
throw new Error(`service "${serviceName}" image buildArgs key ${JSON.stringify(k)} is not a valid ARG name (letters, digits, underscore)`);
|
|
232
|
+
}
|
|
233
|
+
if (typeof v !== "string") {
|
|
234
|
+
throw new Error(`service "${serviceName}" image buildArgs ${k} must be a string (a build arg is text; got ${typeof v})`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Resolve a service's `image` to the form the harness builds: a `path`
|
|
240
|
+
* Dockerfile is read from the repo and becomes `content`. The wire config
|
|
241
|
+
* therefore never carries `path` — the control plane and the daemon's build
|
|
242
|
+
* code only ever see Dockerfile bytes, and the warm-template hash covers
|
|
243
|
+
* them because the file is part of the project tree.
|
|
244
|
+
*
|
|
245
|
+
* Runs at config time (`defineEnvironment`, and the daemon's runtime
|
|
246
|
+
* `startService` twin), inside the VM, where the repo sits at the project
|
|
247
|
+
* root. The read goes through the project-file resolver, so the same rules
|
|
248
|
+
* as `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
|
|
249
|
+
* instead of read stale).
|
|
250
|
+
*/
|
|
251
|
+
export function resolveServiceImage(serviceName, image) {
|
|
252
|
+
if (image.type !== "dockerfile")
|
|
253
|
+
return image;
|
|
254
|
+
validateBuildArgs(serviceName, image.buildArgs);
|
|
255
|
+
const hasPath = typeof image.path === "string";
|
|
256
|
+
const hasContent = typeof image.content === "string";
|
|
257
|
+
if (hasPath && hasContent) {
|
|
258
|
+
throw new Error(`service "${serviceName}" image sets both \`path\` and \`content\` — point at the Dockerfile in your repo with \`path\`, or inline it with \`content\`, not both`);
|
|
259
|
+
}
|
|
260
|
+
if (!hasPath && !hasContent) {
|
|
261
|
+
throw new Error(`service "${serviceName}" image { type: "dockerfile" } needs \`path\` (a Dockerfile in your repo, relative to the project root) or \`content\``);
|
|
262
|
+
}
|
|
263
|
+
if (!hasPath)
|
|
264
|
+
return image;
|
|
265
|
+
const file = resolveExistingProjectPath(image.path, `service "${serviceName}" Dockerfile`);
|
|
266
|
+
let content;
|
|
267
|
+
try {
|
|
268
|
+
content = readFileSync(file, "utf8");
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
throw new Error(`service "${serviceName}" Dockerfile ${JSON.stringify(image.path)} could not be read: ${e.message}`);
|
|
272
|
+
}
|
|
273
|
+
const lowered = { type: "dockerfile", content };
|
|
274
|
+
if (image.exclude !== undefined)
|
|
275
|
+
lowered.exclude = image.exclude;
|
|
276
|
+
if (image.buildArgs !== undefined)
|
|
277
|
+
lowered.buildArgs = image.buildArgs;
|
|
278
|
+
return lowered;
|
|
279
|
+
}
|
|
219
280
|
function validateEnvironmentConfig(config) {
|
|
220
281
|
const entries = Object.entries(config.services);
|
|
221
282
|
const serviceNames = new Set(entries.map(([n]) => n));
|
|
@@ -325,7 +386,10 @@ export function defineEnvironment(input) {
|
|
|
325
386
|
const { fakes, services: rawServices, ...rest } = input;
|
|
326
387
|
const expanded = expandServiceGroups(rawServices);
|
|
327
388
|
for (const [key, svc] of Object.entries(expanded)) {
|
|
328
|
-
expanded[key] = applyCoverageAdapters(key,
|
|
389
|
+
expanded[key] = applyCoverageAdapters(key, {
|
|
390
|
+
...svc,
|
|
391
|
+
image: resolveServiceImage(key, svc.image),
|
|
392
|
+
});
|
|
329
393
|
}
|
|
330
394
|
const config = {
|
|
331
395
|
...rest,
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
lowerIngress,
|
|
32
32
|
dnsName as makeDnsDecl,
|
|
33
33
|
isWildcard,
|
|
34
|
+
resolveServiceImage,
|
|
34
35
|
proxy as makeProxyDecl,
|
|
35
36
|
} from "./index.js";
|
|
36
37
|
import type { DnsTarget, LoweredIngress } from "./index.js";
|
|
@@ -68,6 +69,7 @@ import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
|
68
69
|
import {
|
|
69
70
|
DEFAULT_DOCKERIGNORE,
|
|
70
71
|
GENERATED_DOCKERIGNORE_HEADER,
|
|
72
|
+
buildArgFlags,
|
|
71
73
|
buildContentKey as computeBuildContentKey,
|
|
72
74
|
imageTag,
|
|
73
75
|
isGeneratedDockerignore,
|
|
@@ -96,6 +98,11 @@ import {
|
|
|
96
98
|
parseContentLength,
|
|
97
99
|
} from "./harness/http-body.js";
|
|
98
100
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
101
|
+
import {
|
|
102
|
+
InterceptRegistry,
|
|
103
|
+
runChain,
|
|
104
|
+
type Interceptor,
|
|
105
|
+
} from "./harness/intercept.js";
|
|
99
106
|
import {
|
|
100
107
|
HOP_BY_HOP_HEADERS,
|
|
101
108
|
augmentCorsResponse,
|
|
@@ -149,6 +156,7 @@ import { openMcp } from "./mcp.js";
|
|
|
149
156
|
import { setRawFetch } from "./harness/raw-fetch.js";
|
|
150
157
|
import { readAnnotation, type RenderAnnotation } from "./annotate.js";
|
|
151
158
|
import {
|
|
159
|
+
isRecording,
|
|
152
160
|
pauseRecording,
|
|
153
161
|
recordEmail,
|
|
154
162
|
recordEnv,
|
|
@@ -189,6 +197,9 @@ import type {
|
|
|
189
197
|
FakeContext,
|
|
190
198
|
FakeDefinition,
|
|
191
199
|
FileMount,
|
|
200
|
+
InterceptHandler,
|
|
201
|
+
Interception,
|
|
202
|
+
InterceptedRequest,
|
|
192
203
|
Project,
|
|
193
204
|
ProjectSetupContext,
|
|
194
205
|
ReadyCheck,
|
|
@@ -1097,7 +1108,13 @@ const BUILD_DEDUP = new Map<
|
|
|
1097
1108
|
{ name: string; promise: Promise<{ tag: string; buildSteps?: BuildStep[] }> }
|
|
1098
1109
|
>();
|
|
1099
1110
|
|
|
1100
|
-
|
|
1111
|
+
type DockerfileBuild = {
|
|
1112
|
+
content: string;
|
|
1113
|
+
exclude?: readonly string[];
|
|
1114
|
+
buildArgs?: Readonly<Record<string, string>>;
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
function buildContentKey(image: DockerfileBuild): string {
|
|
1101
1118
|
return computeBuildContentKey(() => new Bun.CryptoHasher("sha256") as any, image);
|
|
1102
1119
|
}
|
|
1103
1120
|
|
|
@@ -1142,7 +1159,7 @@ async function prepareServiceImage(
|
|
|
1142
1159
|
// (/workspace) is an input too — a runtime service started mid-test
|
|
1143
1160
|
// after setup/test code mutated /workspace must rebuild, not share a
|
|
1144
1161
|
// pre-mutation image.
|
|
1145
|
-
const image = svc
|
|
1162
|
+
const image = dockerfileContent(svc);
|
|
1146
1163
|
if (opts?.dedup) {
|
|
1147
1164
|
const key = buildContentKey(image);
|
|
1148
1165
|
const inflight = BUILD_DEDUP.get(key);
|
|
@@ -1232,6 +1249,17 @@ RUN P='[spectest-ca]'; \\
|
|
|
1232
1249
|
`;
|
|
1233
1250
|
}
|
|
1234
1251
|
|
|
1252
|
+
/** The built form of a dockerfile image. `resolveServiceImage` read a
|
|
1253
|
+
* `path` into `content` at config time, so a service that still carries
|
|
1254
|
+
* `path` here skipped that step — a programming error, not user input. */
|
|
1255
|
+
function dockerfileContent(svc: NamedService): DockerfileBuild {
|
|
1256
|
+
const image = svc.image;
|
|
1257
|
+
if (image.type !== "dockerfile" || typeof image.content !== "string") {
|
|
1258
|
+
throw new Error(`service "${svc.name}" image was not resolved to Dockerfile contents`);
|
|
1259
|
+
}
|
|
1260
|
+
return image;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1235
1263
|
/**
|
|
1236
1264
|
* Build a dockerfile service's image.
|
|
1237
1265
|
*
|
|
@@ -1246,7 +1274,7 @@ RUN P='[spectest-ca]'; \\
|
|
|
1246
1274
|
*/
|
|
1247
1275
|
async function buildServiceImage(
|
|
1248
1276
|
name: string,
|
|
1249
|
-
image:
|
|
1277
|
+
image: DockerfileBuild,
|
|
1250
1278
|
tag: string,
|
|
1251
1279
|
): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
|
|
1252
1280
|
const suffix = await caTrustSuffix();
|
|
@@ -1291,7 +1319,7 @@ async function imageRunsAsRoot(tag: string): Promise<boolean> {
|
|
|
1291
1319
|
|
|
1292
1320
|
async function runServiceBuild(
|
|
1293
1321
|
name: string,
|
|
1294
|
-
image:
|
|
1322
|
+
image: DockerfileBuild,
|
|
1295
1323
|
tag: string,
|
|
1296
1324
|
caSuffix: string | null,
|
|
1297
1325
|
): Promise<{ ok: boolean; folded: boolean; log: string; buildSteps?: BuildStep[] }> {
|
|
@@ -1322,6 +1350,9 @@ async function runServiceBuild(
|
|
|
1322
1350
|
const useBuildKit = useRemote || (await hasBuildx());
|
|
1323
1351
|
const buildEnv: Record<string, string> = {};
|
|
1324
1352
|
let buildArgs: string[];
|
|
1353
|
+
// The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
|
|
1354
|
+
// so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
|
|
1355
|
+
const argFlags = buildArgFlags(image.buildArgs);
|
|
1325
1356
|
if (useRemote) {
|
|
1326
1357
|
// Build on the host-side shared buildkitd (persistent cross-VM cache);
|
|
1327
1358
|
// `--load` brings the finished image back into the in-VM dockerd so
|
|
@@ -1332,13 +1363,14 @@ async function runServiceBuild(
|
|
|
1332
1363
|
"--builder", REMOTE_BUILDER_NAME,
|
|
1333
1364
|
"--load",
|
|
1334
1365
|
"--progress=plain",
|
|
1366
|
+
...argFlags,
|
|
1335
1367
|
"-t", tag, "-f", dfPath, WORKSPACE,
|
|
1336
1368
|
];
|
|
1337
1369
|
} else if (useBuildKit) {
|
|
1338
|
-
buildArgs = ["build", "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
|
|
1370
|
+
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
|
|
1339
1371
|
buildEnv.DOCKER_BUILDKIT = "1";
|
|
1340
1372
|
} else {
|
|
1341
|
-
buildArgs = ["build", "-t", tag, "-f", dfPath, WORKSPACE];
|
|
1373
|
+
buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, WORKSPACE];
|
|
1342
1374
|
}
|
|
1343
1375
|
progressService(name, { status: "building", detail: "starting build" });
|
|
1344
1376
|
const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
|
|
@@ -2426,26 +2458,30 @@ async function dispatchIngress(
|
|
|
2426
2458
|
// Cache-Control, Pragma, … — isn't rejected by whatever the upstream happens
|
|
2427
2459
|
// to list in Access-Control-Allow-Headers.
|
|
2428
2460
|
if (isCorsPreflight(req)) return corsPreflightResponse(req);
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2461
|
+
// The real answer — a fake handled in-process, or the proxied container.
|
|
2462
|
+
const upstream = async (current: Request): Promise<Response> => {
|
|
2463
|
+
if (route.kind === "fake") {
|
|
2464
|
+
try {
|
|
2465
|
+
return await route.fake.def.handler(current, route.fake.state, FAKE_CTX);
|
|
2466
|
+
} catch (err) {
|
|
2467
|
+
const e = err as Error;
|
|
2468
|
+
return new Response(
|
|
2469
|
+
`spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`,
|
|
2470
|
+
{ status: 500, headers: { "content-type": "text/plain" } },
|
|
2471
|
+
);
|
|
2472
|
+
}
|
|
2439
2473
|
}
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2474
|
+
return proxyToService(current, server, route.service, route.port, listenerLabel, proto);
|
|
2475
|
+
};
|
|
2476
|
+
// `ctx.intercept` middleware runs first, in registration order, and reaches
|
|
2477
|
+
// the upstream through `next()`. The CORS headers go on *after* the chain,
|
|
2478
|
+
// so a forced 500 reaches the browser as a 500 and not as a CORS error —
|
|
2479
|
+
// the trap `page.route`-style interception falls into.
|
|
2480
|
+
const chain = INTERCEPTORS.chainFor(host, new URL(req.url).pathname);
|
|
2481
|
+
const res =
|
|
2482
|
+
chain.length === 0
|
|
2483
|
+
? await upstream(req)
|
|
2484
|
+
: await runChain(chain, req, upstream, recordInterceptedRequest);
|
|
2449
2485
|
return augmentCorsResponse(req, res);
|
|
2450
2486
|
}
|
|
2451
2487
|
|
|
@@ -2732,6 +2768,137 @@ async function seedNamesRegistry(opts: { servicesUp: boolean }): Promise<void> {
|
|
|
2732
2768
|
await writeRegistry();
|
|
2733
2769
|
}
|
|
2734
2770
|
|
|
2771
|
+
/**
|
|
2772
|
+
* The live interceptors (`ctx.intercept`). Module memory, so they fork with
|
|
2773
|
+
* the environment like the route tables; a test's own are removed when its
|
|
2774
|
+
* case ends (`runOne` opens and closes the scope), so a `dependsOn` child
|
|
2775
|
+
* never inherits a parent's forced outage.
|
|
2776
|
+
*/
|
|
2777
|
+
const INTERCEPTORS = new InterceptRegistry();
|
|
2778
|
+
|
|
2779
|
+
/** The recorder seq of the `intercept` step each interceptor was registered
|
|
2780
|
+
* under, so every request it sees can nest below that step. */
|
|
2781
|
+
const INTERCEPT_STEP_SEQ = new Map<number, number>();
|
|
2782
|
+
|
|
2783
|
+
/** What every port's route table together claims — the hostnames a request
|
|
2784
|
+
* can reach the ingress under at all. */
|
|
2785
|
+
function ingressClaimsHostname(hostname: string): boolean {
|
|
2786
|
+
for (const byHost of INGRESS.routesByPort.values()) {
|
|
2787
|
+
if (matchRoute(byHost, hostname)) return true;
|
|
2788
|
+
if (isWildcard(hostname)) {
|
|
2789
|
+
// A wildcard interceptor is fine when any route sits under it.
|
|
2790
|
+
const suffix = wildcardSuffix(hostname);
|
|
2791
|
+
for (const key of byHost.keys()) {
|
|
2792
|
+
if (key === hostname || key.endsWith(suffix)) return true;
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
return false;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
/** Record one request an interceptor saw, nested under its `intercept` step. */
|
|
2800
|
+
function recordInterceptedRequest(
|
|
2801
|
+
it: Interceptor,
|
|
2802
|
+
rec: { method: string; path: string; status: number; answeredBy: string },
|
|
2803
|
+
durationMs: number,
|
|
2804
|
+
): void {
|
|
2805
|
+
const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
|
|
2806
|
+
if (parentSeq === undefined || !isRecording()) return;
|
|
2807
|
+
const by =
|
|
2808
|
+
rec.answeredBy === "handler"
|
|
2809
|
+
? "answered by the interceptor"
|
|
2810
|
+
: rec.answeredBy === "modified"
|
|
2811
|
+
? "upstream answer replaced by the interceptor"
|
|
2812
|
+
: "passed through to the upstream";
|
|
2813
|
+
recordStep({
|
|
2814
|
+
kind: "intercept-request",
|
|
2815
|
+
parentSeq,
|
|
2816
|
+
title: `${rec.method} ${rec.path} → ${rec.status}`,
|
|
2817
|
+
status: rec.status >= 500 && rec.answeredBy !== "upstream" ? "failed" : "passed",
|
|
2818
|
+
blocks: [
|
|
2819
|
+
{
|
|
2820
|
+
type: "kv",
|
|
2821
|
+
rows: [
|
|
2822
|
+
{ label: "Host", value: it.hostname },
|
|
2823
|
+
{ label: "Request", value: `${rec.method} ${rec.path}` },
|
|
2824
|
+
{ label: "Status", value: String(rec.status) },
|
|
2825
|
+
{ label: "Answered", value: by },
|
|
2826
|
+
],
|
|
2827
|
+
},
|
|
2828
|
+
],
|
|
2829
|
+
durationMs,
|
|
2830
|
+
});
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
/**
|
|
2834
|
+
* Put middleware in front of a hostname the ingress serves — the
|
|
2835
|
+
* implementation behind `ctx.intercept`.
|
|
2836
|
+
*
|
|
2837
|
+
* Refuses a hostname no route claims: a request for it would never reach
|
|
2838
|
+
* the daemon (DNS does not point here), so the interceptor could only be
|
|
2839
|
+
* silent — and silence is the failure mode this whole layer is designed
|
|
2840
|
+
* against. The message names the two ways to get a route.
|
|
2841
|
+
*/
|
|
2842
|
+
function registerInterceptor(
|
|
2843
|
+
hostname: string,
|
|
2844
|
+
pathOrHandler: string | InterceptHandler,
|
|
2845
|
+
maybeHandler?: InterceptHandler,
|
|
2846
|
+
): Interception {
|
|
2847
|
+
const path = typeof pathOrHandler === "string" ? pathOrHandler : undefined;
|
|
2848
|
+
const handler = typeof pathOrHandler === "function" ? pathOrHandler : maybeHandler;
|
|
2849
|
+
if (typeof hostname !== "string" || hostname.length === 0) {
|
|
2850
|
+
throw new Error("ctx.intercept: a hostname is required");
|
|
2851
|
+
}
|
|
2852
|
+
const host = hostname.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
2853
|
+
if (!handler) {
|
|
2854
|
+
throw new Error("ctx.intercept: a handler (req, next) => Response is required");
|
|
2855
|
+
}
|
|
2856
|
+
if (!ingressClaimsHostname(host)) {
|
|
2857
|
+
throw new Error(
|
|
2858
|
+
`ctx.intercept(${JSON.stringify(host)}): no request can reach the ingress under that hostname. ` +
|
|
2859
|
+
`Only traffic that passes through the daemon can be intercepted: give the service a \`tls\`/\`hostnames\` ` +
|
|
2860
|
+
`entry (or \`supabase({ hostname })\`), or target a fake's hostname. A bare service name ` +
|
|
2861
|
+
`(\`http://<service>:<port>\`) is container-to-container traffic and never passes through here.`,
|
|
2862
|
+
);
|
|
2863
|
+
}
|
|
2864
|
+
const resv = reserveEvent();
|
|
2865
|
+
const it = INTERCEPTORS.register(host, path, handler);
|
|
2866
|
+
const seq = recordStep(
|
|
2867
|
+
{
|
|
2868
|
+
kind: "intercept",
|
|
2869
|
+
title: `intercept ${host}${it.path === "/" ? "" : it.path}`,
|
|
2870
|
+
blocks: [
|
|
2871
|
+
{
|
|
2872
|
+
type: "kv",
|
|
2873
|
+
rows: [
|
|
2874
|
+
{ label: "Host", value: host },
|
|
2875
|
+
{ label: "Path", value: it.path === "/" ? "/ (every path)" : `${it.path} and below` },
|
|
2876
|
+
],
|
|
2877
|
+
},
|
|
2878
|
+
],
|
|
2879
|
+
durationMs: 0,
|
|
2880
|
+
},
|
|
2881
|
+
resv,
|
|
2882
|
+
);
|
|
2883
|
+
if (seq !== undefined) INTERCEPT_STEP_SEQ.set(it.id, seq);
|
|
2884
|
+
return {
|
|
2885
|
+
hostname: host,
|
|
2886
|
+
path: it.path,
|
|
2887
|
+
get calls(): Wrapped<number> {
|
|
2888
|
+
return wrap(it.calls, seq) as unknown as Wrapped<number>;
|
|
2889
|
+
},
|
|
2890
|
+
get requests(): Wrapped<InterceptedRequest[]> {
|
|
2891
|
+
return wrap(it.requests.map((r) => ({ ...r })), seq) as unknown as Wrapped<
|
|
2892
|
+
InterceptedRequest[]
|
|
2893
|
+
>;
|
|
2894
|
+
},
|
|
2895
|
+
remove(): void {
|
|
2896
|
+
INTERCEPTORS.remove(it.id);
|
|
2897
|
+
INTERCEPT_STEP_SEQ.delete(it.id);
|
|
2898
|
+
},
|
|
2899
|
+
};
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2735
2902
|
/**
|
|
2736
2903
|
* Register a hostname at runtime — the implementation behind `ctx.dnsName`.
|
|
2737
2904
|
* Validates via the same `dnsName` primitive the static path uses, resolves
|
|
@@ -2814,9 +2981,11 @@ const RUNTIME_SERVICES = new Map<string, NamedService>();
|
|
|
2814
2981
|
// the orchestration helpers want a NamedService, which is the same shape.
|
|
2815
2982
|
function specToNamedService(spec: RuntimeServiceSpec): NamedService {
|
|
2816
2983
|
const { name, ...rest } = spec;
|
|
2817
|
-
// A runtime spec skipped `defineEnvironment`, so its
|
|
2818
|
-
//
|
|
2819
|
-
|
|
2984
|
+
// A runtime spec skipped `defineEnvironment`, so its config-time passes
|
|
2985
|
+
// run here: a `path` Dockerfile is read into `content`, and coverage
|
|
2986
|
+
// adapters get their configure step.
|
|
2987
|
+
const svc = { ...(rest as ServiceConfig), image: resolveServiceImage(name, rest.image) };
|
|
2988
|
+
return { name, ...applyCoverageAdapters(name, svc) };
|
|
2820
2989
|
}
|
|
2821
2990
|
|
|
2822
2991
|
/** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
|
|
@@ -4278,6 +4447,7 @@ async function spectestContext(scope: ContextScope = {}): Promise<SpectestContex
|
|
|
4278
4447
|
certificate: mintCertificate,
|
|
4279
4448
|
startService: startRuntimeService,
|
|
4280
4449
|
stopService: stopRuntimeService,
|
|
4450
|
+
intercept: registerInterceptor as SpectestContext["intercept"],
|
|
4281
4451
|
};
|
|
4282
4452
|
}
|
|
4283
4453
|
|
|
@@ -5228,6 +5398,9 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5228
5398
|
// time — so from here on a service helper's `docker exec` lands on the
|
|
5229
5399
|
// timeline (and in the cast) exactly like the test's own `ctx.exec`.
|
|
5230
5400
|
RECORDING_EXEC = recordedExec;
|
|
5401
|
+
// Interceptors this case registers die with it (see harness/intercept.ts
|
|
5402
|
+
// — the post-state snapshot must not carry a forced outage into children).
|
|
5403
|
+
INTERCEPTORS.beginScope(testCase.id);
|
|
5231
5404
|
|
|
5232
5405
|
const timeoutMs = testCase.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
|
|
5233
5406
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -5259,6 +5432,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5259
5432
|
} finally {
|
|
5260
5433
|
if (timer) clearTimeout(timer);
|
|
5261
5434
|
RECORDING_EXEC = undefined;
|
|
5435
|
+
INTERCEPTORS.endScope();
|
|
5262
5436
|
restoreFetch();
|
|
5263
5437
|
restoreConsole();
|
|
5264
5438
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|