@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/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, svc);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.61.0",
3
+ "version": "0.63.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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
- function buildContentKey(image: { content: string; exclude?: readonly string[] }): string {
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.image;
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: { content: string; exclude?: readonly string[] },
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: { content: string; exclude?: readonly string[] },
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
- if (route.kind === "fake") {
2430
- try {
2431
- const res = await route.fake.def.handler(req, route.fake.state, FAKE_CTX);
2432
- return augmentCorsResponse(req, res);
2433
- } catch (err) {
2434
- const e = err as Error;
2435
- return new Response(
2436
- `spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`,
2437
- { status: 500, headers: { "content-type": "text/plain" } },
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
- const res = await proxyToService(
2442
- req,
2443
- server,
2444
- route.service,
2445
- route.port,
2446
- listenerLabel,
2447
- proto,
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 coverage adapters
2818
- // get their config-time pass here.
2819
- return { name, ...applyCoverageAdapters(name, rest as ServiceConfig) };
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