@specific.dev/spectest 0.76.0 → 0.77.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.
@@ -47,10 +47,24 @@ export function isGeneratedDockerignore(text: string): boolean {
47
47
  return text.startsWith(GENERATED_DOCKERIGNORE_HEADER);
48
48
  }
49
49
 
50
+ /**
51
+ * The project's own ignore rules for one build, chosen the way BuildKit
52
+ * chooses them: a `<Dockerfile>.dockerignore` next to the Dockerfile wins
53
+ * outright, else the context root's `.dockerignore`, else nothing. The two
54
+ * are never merged — a Dockerfile-adjacent file is the whole rule set for
55
+ * that build, exactly as `docker build` reads it.
56
+ */
57
+ export function pickProjectIgnore(
58
+ adjacent: string | null,
59
+ contextRoot: string | null,
60
+ ): string | null {
61
+ return adjacent ?? contextRoot;
62
+ }
63
+
50
64
  /**
51
65
  * Ignore rules for one dockerfile build, in precedence order: our
52
- * defaults, then the project's own `.dockerignore` verbatim, then that
53
- * service's `exclude`.
66
+ * defaults, then the project's own ignore file verbatim (see
67
+ * {@link pickProjectIgnore}), then that service's `exclude`.
54
68
  *
55
69
  * **The order is load-bearing**, because of the `**`-plus-negations idiom
56
70
  * that monorepos use to keep a build context small:
@@ -113,44 +127,69 @@ export function unionDockerignore(
113
127
  return [GENERATED_DOCKERIGNORE_HEADER, ...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n";
114
128
  }
115
129
 
130
+ /**
131
+ * Everything one dockerfile build reads, resolved from the project: the
132
+ * Dockerfile's bytes (from `path` or `content`), the context directory
133
+ * (project-root-relative, `.` by default), the composed ignore rules, the
134
+ * stage to stop at, and the build args. The harness resolves this once per
135
+ * service and both the dedup key and the build itself work from it.
136
+ */
137
+ export interface ResolvedBuild {
138
+ /** The Dockerfile text. */
139
+ content: string;
140
+ /** Project-root-relative build context directory (`.` for the root). */
141
+ context: string;
142
+ /** The generated `<Dockerfile>.dockerignore` text. */
143
+ ignore: string;
144
+ /** `--target` stage, if any. */
145
+ target?: string;
146
+ buildArgs?: Readonly<Record<string, string>>;
147
+ }
148
+
116
149
  /**
117
150
  * Identity of a dockerfile build within one bootstrap, so services sharing
118
151
  * an image definition (an api and a worker on the same codebase with
119
152
  * different entrypoints) build once and the rest just re-tag.
120
153
  *
121
- * The build **context** is an input too, but it isn't hashed: the context
122
- * is `/workspace`, which is fixed within a bootstrap and mutable between
123
- * them, so this key is only ever valid inside one workspace generation.
124
- * The dedup map is cleared per bootstrap for exactly that reason.
154
+ * The context *directory* is part of the key, but its *contents* are not:
155
+ * `/workspace` is fixed within a bootstrap and mutable between them, so
156
+ * this key is only ever valid inside one workspace generation. The dedup
157
+ * map is cleared per bootstrap for exactly that reason.
125
158
  */
126
159
  export interface Hasher {
127
160
  update(s: string): Hasher;
128
161
  digest(encoding: "hex"): string;
129
162
  }
130
163
 
131
- export function buildContentKey(
132
- createHasher: () => Hasher,
133
- image: { content: string; exclude?: readonly string[]; buildArgs?: Readonly<Record<string, string>> },
134
- ): string {
164
+ export function buildContentKey(createHasher: () => Hasher, build: ResolvedBuild): string {
135
165
  return (
136
166
  createHasher()
137
- .update(image.content)
138
- // A separator that cannot occur in either field. Without it a
139
- // Dockerfile whose text ends with an exclude list would hash the
167
+ .update(build.content)
168
+ // A separator that cannot occur in any field. Without it a
169
+ // Dockerfile whose text ends with an ignore list would hash the
140
170
  // same as that Dockerfile with the list actually set, and two
141
171
  // genuinely different builds would collapse into one.
142
172
  .update("\0")
143
- .update(JSON.stringify(image.exclude ?? []))
173
+ .update(build.context)
174
+ .update("\0")
175
+ .update(build.ignore)
176
+ .update("\0")
177
+ .update(build.target ?? "")
144
178
  // Build args are an input to the image (an ARG picks the base image,
145
179
  // the NODE_ENV of an install step…), so two services on one
146
180
  // Dockerfile with different args must not share a build. Sorted, so
147
181
  // key order in the user's object doesn't split identical builds.
148
182
  .update("\0")
149
- .update(JSON.stringify(buildArgFlags(image.buildArgs)))
183
+ .update(JSON.stringify(buildArgFlags(build.buildArgs)))
150
184
  .digest("hex")
151
185
  );
152
186
  }
153
187
 
188
+ /** `--target <stage>`, or nothing. A plain client flag every builder takes. */
189
+ export function buildTargetFlags(target: string | undefined): string[] {
190
+ return target ? ["--target", target] : [];
191
+ }
192
+
154
193
  /** `--build-arg NAME=value` pairs, in a stable (sorted) order. */
155
194
  export function buildArgFlags(buildArgs: Readonly<Record<string, string>> | undefined): string[] {
156
195
  return Object.entries(buildArgs ?? {})
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // over HTTP.
6
6
 
7
7
  import { strict as nodeAssert } from "node:assert";
8
- import { readFileSync } from "node:fs";
8
+ import { statSync } from "node:fs";
9
9
 
10
10
  import { recordAssertion, safeSerialize } from "./recorder.js";
11
11
  import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
@@ -1138,17 +1138,33 @@ export type ServiceImage =
1138
1138
  * Path of a Dockerfile in your repo, relative to the project root
1139
1139
  * (where `spectest/` lives) — `"Dockerfile"`, `"apps/api/Dockerfile"`.
1140
1140
  * This is the preferred form: the test environment builds the same
1141
- * Dockerfile production does, so the two cannot drift. The build
1142
- * context is always the project root, as with
1143
- * `docker build -f apps/api/Dockerfile .`, so `COPY` / `ADD` resolve
1144
- * against the repo root wherever the file sits. The file is read when
1145
- * the environment loads; a missing file fails the load and names the
1146
- * path. Mutually exclusive with `content`.
1141
+ * Dockerfile production does, so the two cannot drift. The file is
1142
+ * read from the repo at build time; a missing file fails the
1143
+ * environment load and names the path. Mutually exclusive with
1144
+ * `content`.
1145
+ *
1146
+ * Ignore rules come from the repo the way `docker build` reads them:
1147
+ * a `<Dockerfile>.dockerignore` beside this file, else the context
1148
+ * directory's `.dockerignore`. `exclude` adds to whichever applied.
1147
1149
  */
1148
1150
  path: string;
1149
1151
  content?: never;
1152
+ /**
1153
+ * Build context directory, relative to the project root. Defaults to
1154
+ * the project root, as with `docker build -f apps/api/Dockerfile .`.
1155
+ * `"apps/api"` is `docker build apps/api`: `COPY` / `ADD` resolve
1156
+ * against that directory, and so do the ignore rules.
1157
+ */
1158
+ context?: string;
1150
1159
  /** Extra glob patterns to exclude from the build context. */
1151
1160
  exclude?: readonly string[];
1161
+ /**
1162
+ * Stage to build, as `docker build --target <stage>`. Lets a
1163
+ * production Dockerfile keep a final stage the tests never need (an
1164
+ * ops layer, a slow install) while the environment stops at the
1165
+ * stage before it.
1166
+ */
1167
+ target?: string;
1152
1168
  /**
1153
1169
  * `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
1154
1170
  * Build-time only: they are not in the container's environment (use
@@ -1161,16 +1177,24 @@ export type ServiceImage =
1161
1177
  | {
1162
1178
  type: "dockerfile";
1163
1179
  /**
1164
- * Dockerfile contents, written verbatim into the build context. The
1165
- * build context is the project root (where `spectest/` lives), so any
1166
- * `COPY` / `ADD` references resolve relative to that directory.
1167
- * Prefer `path`, which points at the Dockerfile your repo already has.
1168
- * Mutually exclusive with `path`.
1180
+ * Dockerfile contents, built as if the file sat outside the build
1181
+ * context (it is never written into your repo). The context is the
1182
+ * project root (where `spectest/` lives) unless `context` says
1183
+ * otherwise, and `COPY` / `ADD` resolve against it. Prefer `path`,
1184
+ * which points at the Dockerfile your repo already has. Mutually
1185
+ * exclusive with `path`.
1169
1186
  */
1170
1187
  content: string;
1171
1188
  path?: never;
1189
+ /**
1190
+ * Build context directory, relative to the project root. Defaults to
1191
+ * the project root. The context's own `.dockerignore` applies.
1192
+ */
1193
+ context?: string;
1172
1194
  /** Extra glob patterns to exclude from the build context. */
1173
1195
  exclude?: readonly string[];
1196
+ /** Stage to build, as `docker build --target <stage>`. */
1197
+ target?: string;
1174
1198
  /**
1175
1199
  * `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
1176
1200
  * Build-time only: they are not in the container's environment (use
@@ -1203,20 +1227,27 @@ function validateBuildArgs(serviceName: string, buildArgs: Readonly<Record<strin
1203
1227
  }
1204
1228
  }
1205
1229
 
1230
+ /** A Dockerfile stage name, as `--target` accepts it: no whitespace. */
1231
+ const BUILD_TARGET_RE = /^[^\s]+$/;
1232
+
1206
1233
  /**
1207
- * Resolve a service's `image` to the form the harness builds: a `path`
1208
- * Dockerfile is read from the repo and becomes `content`. The wire config
1209
- * therefore never carries `path` — the control plane and the daemon's build
1210
- * code only ever see Dockerfile bytes, and the warm-template hash covers
1211
- * them because the file is part of the project tree.
1234
+ * Check a service's `image` at config time, so a mistake fails the
1235
+ * environment load with a message that names it, instead of a build
1236
+ * minutes later. Nothing is rewritten: a `path` Dockerfile stays a path,
1237
+ * and the harness reads it — and the ignore file beside it — from the
1238
+ * repo when it builds (`daemon.ts::resolveDockerfileBuild`). The wire
1239
+ * config therefore carries `path`, `context` and `target` as written; the
1240
+ * control plane only tells a Dockerfile build from a registry pull, and
1241
+ * the warm-template hash covers the files because they are part of the
1242
+ * project tree.
1212
1243
  *
1213
- * Runs at config time (`defineEnvironment`, and the daemon's runtime
1214
- * `startService` twin), inside the VM, where the repo sits at the project
1215
- * root. The read goes through the project-file resolver, so the same rules
1216
- * as `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
1244
+ * Runs in `defineEnvironment`, and in the daemon's runtime `startService`
1245
+ * twin, inside the VM, where the repo sits at the project root. Existence
1246
+ * checks go through the project-file resolver, so the same rules as
1247
+ * `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
1217
1248
  * instead of read stale).
1218
1249
  */
1219
- export function resolveServiceImage(serviceName: string, image: ServiceImage): ServiceImage {
1250
+ export function validateServiceImage(serviceName: string, image: ServiceImage): ServiceImage {
1220
1251
  if (image.type !== "dockerfile") return image;
1221
1252
  validateBuildArgs(serviceName, image.buildArgs);
1222
1253
  const hasPath = typeof image.path === "string";
@@ -1231,20 +1262,35 @@ export function resolveServiceImage(serviceName: string, image: ServiceImage): S
1231
1262
  `service "${serviceName}" image { type: "dockerfile" } needs \`path\` (a Dockerfile in your repo, relative to the project root) or \`content\``,
1232
1263
  );
1233
1264
  }
1234
- if (!hasPath) return image;
1235
- const file = resolveExistingProjectPath(image.path as string, `service "${serviceName}" Dockerfile`);
1236
- let content: string;
1237
- try {
1238
- content = readFileSync(file, "utf8");
1239
- } catch (e) {
1240
- throw new Error(
1241
- `service "${serviceName}" Dockerfile ${JSON.stringify(image.path)} could not be read: ${(e as Error).message}`,
1242
- );
1265
+ if (hasPath) {
1266
+ const file = resolveExistingProjectPath(image.path as string, `service "${serviceName}" Dockerfile`);
1267
+ if (!statSync(file).isFile()) {
1268
+ throw new Error(
1269
+ `service "${serviceName}" Dockerfile ${JSON.stringify(image.path)} is not a file — \`path\` names the Dockerfile itself, \`context\` names the directory to build from`,
1270
+ );
1271
+ }
1272
+ }
1273
+ if (image.context !== undefined) {
1274
+ if (typeof image.context !== "string" || image.context === "") {
1275
+ throw new Error(
1276
+ `service "${serviceName}" image context must be a directory path relative to the project root (\`"."\` for the root)`,
1277
+ );
1278
+ }
1279
+ const dir = resolveExistingProjectPath(image.context, `service "${serviceName}" build context`);
1280
+ if (!statSync(dir).isDirectory()) {
1281
+ throw new Error(
1282
+ `service "${serviceName}" build context ${JSON.stringify(image.context)} is not a directory`,
1283
+ );
1284
+ }
1285
+ }
1286
+ if (image.target !== undefined) {
1287
+ if (typeof image.target !== "string" || !BUILD_TARGET_RE.test(image.target)) {
1288
+ throw new Error(
1289
+ `service "${serviceName}" image target must be a Dockerfile stage name (\`FROM … AS <name>\`); got ${JSON.stringify(image.target)}`,
1290
+ );
1291
+ }
1243
1292
  }
1244
- const lowered: ServiceImage = { type: "dockerfile", content };
1245
- if (image.exclude !== undefined) lowered.exclude = image.exclude;
1246
- if (image.buildArgs !== undefined) lowered.buildArgs = image.buildArgs;
1247
- return lowered;
1293
+ return image;
1248
1294
  }
1249
1295
 
1250
1296
  // Coverage adapters live in `coverage.ts`; the type is re-declared here
@@ -2389,7 +2435,7 @@ export function defineEnvironment<
2389
2435
  for (const [key, svc] of Object.entries(expanded)) {
2390
2436
  expanded[key] = applyCoverageAdapters(key, {
2391
2437
  ...svc,
2392
- image: resolveServiceImage(key, svc.image),
2438
+ image: validateServiceImage(key, svc.image),
2393
2439
  });
2394
2440
  }
2395
2441
  const config: EnvironmentConfig<S> = {