@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/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,
|
|
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,
|