@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/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // over HTTP.
6
6
 
7
7
  import { strict as nodeAssert } from "node:assert";
8
+ import { readFileSync } from "node:fs";
8
9
 
9
10
  import { recordAssertion, safeSerialize } from "./recorder.js";
10
11
  import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
@@ -144,6 +145,7 @@ export {
144
145
  type ServiceCoverage,
145
146
  } from "./coverage.js";
146
147
  import { applyCoverageAdapters, validateCoverage, type ServiceCoverage } from "./coverage.js";
148
+ import { resolveExistingProjectPath } from "./project-files.js";
147
149
 
148
150
  // Low-level ingress primitives + the framework lowering that the friendly
149
151
  // `tls` / `hostnames` fields and `defineFake(...)` are built on. See
@@ -166,6 +168,32 @@ export type {
166
168
  LoweredIngress,
167
169
  } from "./ingress.js";
168
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
+ }
169
197
  import { isWildcard as isWildcardHost } from "./ingress.js";
170
198
 
171
199
  // ──────────────────────────────────────────────────────────────────────────
@@ -512,6 +540,40 @@ export interface SpectestContext<
512
540
  * ```
513
541
  */
514
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;
515
577
  /**
516
578
  * Mint a leaf certificate from the in-VM root CA and return the PEMs.
517
579
  *
@@ -1043,18 +1105,121 @@ export interface ServiceTls {
1043
1105
 
1044
1106
  export type ServiceImage =
1045
1107
  | { type: "registry"; reference: string }
1108
+ | {
1109
+ type: "dockerfile";
1110
+ /**
1111
+ * Path of a Dockerfile in your repo, relative to the project root
1112
+ * (where `spectest/` lives) — `"Dockerfile"`, `"apps/api/Dockerfile"`.
1113
+ * This is the preferred form: the test environment builds the same
1114
+ * Dockerfile production does, so the two cannot drift. The build
1115
+ * context is always the project root, as with
1116
+ * `docker build -f apps/api/Dockerfile .`, so `COPY` / `ADD` resolve
1117
+ * against the repo root wherever the file sits. The file is read when
1118
+ * the environment loads; a missing file fails the load and names the
1119
+ * path. Mutually exclusive with `content`.
1120
+ */
1121
+ path: string;
1122
+ content?: never;
1123
+ /** Extra glob patterns to exclude from the build context. */
1124
+ exclude?: readonly string[];
1125
+ /**
1126
+ * `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
1127
+ * Build-time only: they are not in the container's environment (use
1128
+ * `env` for that), and they are part of the image's identity, so two
1129
+ * services building one Dockerfile with different args build twice.
1130
+ * Build args land in the image history; pass nothing secret.
1131
+ */
1132
+ buildArgs?: Readonly<Record<string, string>>;
1133
+ }
1046
1134
  | {
1047
1135
  type: "dockerfile";
1048
1136
  /**
1049
1137
  * Dockerfile contents, written verbatim into the build context. The
1050
1138
  * build context is the project root (where `spectest/` lives), so any
1051
1139
  * `COPY` / `ADD` references resolve relative to that directory.
1140
+ * Prefer `path`, which points at the Dockerfile your repo already has.
1141
+ * Mutually exclusive with `path`.
1052
1142
  */
1053
1143
  content: string;
1144
+ path?: never;
1054
1145
  /** Extra glob patterns to exclude from the build context. */
1055
1146
  exclude?: readonly string[];
1147
+ /**
1148
+ * `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
1149
+ * Build-time only: they are not in the container's environment (use
1150
+ * `env` for that), and they are part of the image's identity, so two
1151
+ * services building one Dockerfile with different args build twice.
1152
+ * Build args land in the image history; pass nothing secret.
1153
+ */
1154
+ buildArgs?: Readonly<Record<string, string>>;
1056
1155
  };
1057
1156
 
1157
+ /** A Dockerfile `ARG` name: what `docker build --build-arg` accepts as a key. */
1158
+ const BUILD_ARG_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1159
+
1160
+ function validateBuildArgs(serviceName: string, buildArgs: Readonly<Record<string, string>> | undefined): void {
1161
+ if (buildArgs === undefined) return;
1162
+ if (typeof buildArgs !== "object" || buildArgs === null || Array.isArray(buildArgs)) {
1163
+ throw new Error(`service "${serviceName}" image buildArgs must be a { NAME: "value" } record`);
1164
+ }
1165
+ for (const [k, v] of Object.entries(buildArgs)) {
1166
+ if (!BUILD_ARG_NAME_RE.test(k)) {
1167
+ throw new Error(
1168
+ `service "${serviceName}" image buildArgs key ${JSON.stringify(k)} is not a valid ARG name (letters, digits, underscore)`,
1169
+ );
1170
+ }
1171
+ if (typeof v !== "string") {
1172
+ throw new Error(
1173
+ `service "${serviceName}" image buildArgs ${k} must be a string (a build arg is text; got ${typeof v})`,
1174
+ );
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Resolve a service's `image` to the form the harness builds: a `path`
1181
+ * Dockerfile is read from the repo and becomes `content`. The wire config
1182
+ * therefore never carries `path` — the control plane and the daemon's build
1183
+ * code only ever see Dockerfile bytes, and the warm-template hash covers
1184
+ * them because the file is part of the project tree.
1185
+ *
1186
+ * Runs at config time (`defineEnvironment`, and the daemon's runtime
1187
+ * `startService` twin), inside the VM, where the repo sits at the project
1188
+ * root. The read goes through the project-file resolver, so the same rules
1189
+ * as `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
1190
+ * instead of read stale).
1191
+ */
1192
+ export function resolveServiceImage(serviceName: string, image: ServiceImage): ServiceImage {
1193
+ if (image.type !== "dockerfile") return image;
1194
+ validateBuildArgs(serviceName, image.buildArgs);
1195
+ const hasPath = typeof image.path === "string";
1196
+ const hasContent = typeof image.content === "string";
1197
+ if (hasPath && hasContent) {
1198
+ throw new Error(
1199
+ `service "${serviceName}" image sets both \`path\` and \`content\` — point at the Dockerfile in your repo with \`path\`, or inline it with \`content\`, not both`,
1200
+ );
1201
+ }
1202
+ if (!hasPath && !hasContent) {
1203
+ throw new Error(
1204
+ `service "${serviceName}" image { type: "dockerfile" } needs \`path\` (a Dockerfile in your repo, relative to the project root) or \`content\``,
1205
+ );
1206
+ }
1207
+ if (!hasPath) return image;
1208
+ const file = resolveExistingProjectPath(image.path as string, `service "${serviceName}" Dockerfile`);
1209
+ let content: string;
1210
+ try {
1211
+ content = readFileSync(file, "utf8");
1212
+ } catch (e) {
1213
+ throw new Error(
1214
+ `service "${serviceName}" Dockerfile ${JSON.stringify(image.path)} could not be read: ${(e as Error).message}`,
1215
+ );
1216
+ }
1217
+ const lowered: ServiceImage = { type: "dockerfile", content };
1218
+ if (image.exclude !== undefined) lowered.exclude = image.exclude;
1219
+ if (image.buildArgs !== undefined) lowered.buildArgs = image.buildArgs;
1220
+ return lowered;
1221
+ }
1222
+
1058
1223
  // Coverage adapters live in `coverage.ts`; the type is re-declared here
1059
1224
  // through the import below so `ServiceConfig.coverage` reads in one place.
1060
1225
 
@@ -2204,7 +2369,10 @@ export function defineEnvironment<
2204
2369
  const { fakes, services: rawServices, ...rest } = input;
2205
2370
  const expanded = expandServiceGroups(rawServices);
2206
2371
  for (const [key, svc] of Object.entries(expanded)) {
2207
- expanded[key] = applyCoverageAdapters(key, svc);
2372
+ expanded[key] = applyCoverageAdapters(key, {
2373
+ ...svc,
2374
+ image: resolveServiceImage(key, svc.image),
2375
+ });
2208
2376
  }
2209
2377
  const config: EnvironmentConfig<S> = {
2210
2378
  ...rest,