@specific.dev/spectest 0.61.0 → 0.62.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 CHANGED
@@ -21,7 +21,7 @@ import { existsSync, promises as fs, readFileSync } from "node:fs";
21
21
  import net from "node:net";
22
22
  import path from "node:path";
23
23
  import { pathToFileURL } from "node:url";
24
- import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
24
+ import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, resolveServiceImage, proxy as makeProxyDecl, } from "./index.js";
25
25
  import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, } from "./harness/coverage.js";
26
26
  import { configureBrowserCoverage } from "./browser-coverage.js";
27
27
  import { applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, nodeCoverageToolsAvailable, nodeCoverageToolsDir, NODE_COVERAGE_TOOLS_CONTAINER_DIR, } from "./coverage.js";
@@ -32,7 +32,7 @@ import { isMobileApp, openPersistentMobile } from "./mobile.js";
32
32
  // harness/hostmatch.ts). Keeping ONE implementation is the point: the
33
33
  // exact-then-longest-suffix rule and the one-label certificate rule are
34
34
  // each easy to restate subtly differently.
35
- import { buildContentKey as computeBuildContentKey, imageTag, isGeneratedDockerignore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
35
+ import { buildArgFlags, buildContentKey as computeBuildContentKey, imageTag, isGeneratedDockerignore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
36
36
  import { validateServiceGraph as validateGraph } from "./harness/service-graph.js";
37
37
  import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
38
38
  import { summarizeBuildKit } from "./harness/buildkit-progress.js";
@@ -831,7 +831,7 @@ async function prepareServiceImage(svc, opts) {
831
831
  // (/workspace) is an input too — a runtime service started mid-test
832
832
  // after setup/test code mutated /workspace must rebuild, not share a
833
833
  // pre-mutation image.
834
- const image = svc.image;
834
+ const image = dockerfileContent(svc);
835
835
  if (opts?.dedup) {
836
836
  const key = buildContentKey(image);
837
837
  const inflight = BUILD_DEDUP.get(key);
@@ -922,6 +922,16 @@ RUN P='[spectest-ca]'; \\
922
922
  fi
923
923
  `;
924
924
  }
925
+ /** The built form of a dockerfile image. `resolveServiceImage` read a
926
+ * `path` into `content` at config time, so a service that still carries
927
+ * `path` here skipped that step — a programming error, not user input. */
928
+ function dockerfileContent(svc) {
929
+ const image = svc.image;
930
+ if (image.type !== "dockerfile" || typeof image.content !== "string") {
931
+ throw new Error(`service "${svc.name}" image was not resolved to Dockerfile contents`);
932
+ }
933
+ return image;
934
+ }
925
935
  /**
926
936
  * Build a dockerfile service's image.
927
937
  *
@@ -997,6 +1007,9 @@ async function runServiceBuild(name, image, tag, caSuffix) {
997
1007
  const useBuildKit = useRemote || (await hasBuildx());
998
1008
  const buildEnv = {};
999
1009
  let buildArgs;
1010
+ // The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
1011
+ // so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
1012
+ const argFlags = buildArgFlags(image.buildArgs);
1000
1013
  if (useRemote) {
1001
1014
  // Build on the host-side shared buildkitd (persistent cross-VM cache);
1002
1015
  // `--load` brings the finished image back into the in-VM dockerd so
@@ -1007,15 +1020,16 @@ async function runServiceBuild(name, image, tag, caSuffix) {
1007
1020
  "--builder", REMOTE_BUILDER_NAME,
1008
1021
  "--load",
1009
1022
  "--progress=plain",
1023
+ ...argFlags,
1010
1024
  "-t", tag, "-f", dfPath, WORKSPACE,
1011
1025
  ];
1012
1026
  }
1013
1027
  else if (useBuildKit) {
1014
- buildArgs = ["build", "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
1028
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
1015
1029
  buildEnv.DOCKER_BUILDKIT = "1";
1016
1030
  }
1017
1031
  else {
1018
- buildArgs = ["build", "-t", tag, "-f", dfPath, WORKSPACE];
1032
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, WORKSPACE];
1019
1033
  }
1020
1034
  progressService(name, { status: "building", detail: "starting build" });
1021
1035
  const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
@@ -2323,9 +2337,11 @@ const RUNTIME_SERVICES = new Map();
2323
2337
  // the orchestration helpers want a NamedService, which is the same shape.
2324
2338
  function specToNamedService(spec) {
2325
2339
  const { name, ...rest } = spec;
2326
- // A runtime spec skipped `defineEnvironment`, so its coverage adapters
2327
- // get their config-time pass here.
2328
- return { name, ...applyCoverageAdapters(name, rest) };
2340
+ // A runtime spec skipped `defineEnvironment`, so its config-time passes
2341
+ // run here: a `path` Dockerfile is read into `content`, and coverage
2342
+ // adapters get their configure step.
2343
+ const svc = { ...rest, image: resolveServiceImage(name, rest.image) };
2344
+ return { name, ...applyCoverageAdapters(name, svc) };
2329
2345
  }
2330
2346
  /** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
2331
2347
  * Prepares the image (pulling on first use through the host cache), runs
@@ -79,4 +79,7 @@ export interface Hasher {
79
79
  export declare function buildContentKey(createHasher: () => Hasher, image: {
80
80
  content: string;
81
81
  exclude?: readonly string[];
82
+ buildArgs?: Readonly<Record<string, string>>;
82
83
  }): string;
84
+ /** `--build-arg NAME=value` pairs, in a stable (sorted) order. */
85
+ export declare function buildArgFlags(buildArgs: Readonly<Record<string, string>> | undefined): string[];
@@ -109,5 +109,17 @@ export function buildContentKey(createHasher, image) {
109
109
  // genuinely different builds would collapse into one.
110
110
  .update("\0")
111
111
  .update(JSON.stringify(image.exclude ?? []))
112
+ // Build args are an input to the image (an ARG picks the base image,
113
+ // the NODE_ENV of an install step…), so two services on one
114
+ // Dockerfile with different args must not share a build. Sorted, so
115
+ // key order in the user's object doesn't split identical builds.
116
+ .update("\0")
117
+ .update(JSON.stringify(buildArgFlags(image.buildArgs)))
112
118
  .digest("hex"));
113
119
  }
120
+ /** `--build-arg NAME=value` pairs, in a stable (sorted) order. */
121
+ export function buildArgFlags(buildArgs) {
122
+ return Object.entries(buildArgs ?? {})
123
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
124
+ .flatMap(([k, v]) => ["--build-arg", `${k}=${v}`]);
125
+ }
package/dist/index.d.ts CHANGED
@@ -658,17 +658,67 @@ export interface ServiceTls {
658
658
  export type ServiceImage = {
659
659
  type: "registry";
660
660
  reference: string;
661
+ } | {
662
+ type: "dockerfile";
663
+ /**
664
+ * Path of a Dockerfile in your repo, relative to the project root
665
+ * (where `spectest/` lives) — `"Dockerfile"`, `"apps/api/Dockerfile"`.
666
+ * This is the preferred form: the test environment builds the same
667
+ * Dockerfile production does, so the two cannot drift. The build
668
+ * context is always the project root, as with
669
+ * `docker build -f apps/api/Dockerfile .`, so `COPY` / `ADD` resolve
670
+ * against the repo root wherever the file sits. The file is read when
671
+ * the environment loads; a missing file fails the load and names the
672
+ * path. Mutually exclusive with `content`.
673
+ */
674
+ path: string;
675
+ content?: never;
676
+ /** Extra glob patterns to exclude from the build context. */
677
+ exclude?: readonly string[];
678
+ /**
679
+ * `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
680
+ * Build-time only: they are not in the container's environment (use
681
+ * `env` for that), and they are part of the image's identity, so two
682
+ * services building one Dockerfile with different args build twice.
683
+ * Build args land in the image history; pass nothing secret.
684
+ */
685
+ buildArgs?: Readonly<Record<string, string>>;
661
686
  } | {
662
687
  type: "dockerfile";
663
688
  /**
664
689
  * Dockerfile contents, written verbatim into the build context. The
665
690
  * build context is the project root (where `spectest/` lives), so any
666
691
  * `COPY` / `ADD` references resolve relative to that directory.
692
+ * Prefer `path`, which points at the Dockerfile your repo already has.
693
+ * Mutually exclusive with `path`.
667
694
  */
668
695
  content: string;
696
+ path?: never;
669
697
  /** Extra glob patterns to exclude from the build context. */
670
698
  exclude?: readonly string[];
699
+ /**
700
+ * `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
701
+ * Build-time only: they are not in the container's environment (use
702
+ * `env` for that), and they are part of the image's identity, so two
703
+ * services building one Dockerfile with different args build twice.
704
+ * Build args land in the image history; pass nothing secret.
705
+ */
706
+ buildArgs?: Readonly<Record<string, string>>;
671
707
  };
708
+ /**
709
+ * Resolve a service's `image` to the form the harness builds: a `path`
710
+ * Dockerfile is read from the repo and becomes `content`. The wire config
711
+ * therefore never carries `path` — the control plane and the daemon's build
712
+ * code only ever see Dockerfile bytes, and the warm-template hash covers
713
+ * them because the file is part of the project tree.
714
+ *
715
+ * Runs at config time (`defineEnvironment`, and the daemon's runtime
716
+ * `startService` twin), inside the VM, where the repo sits at the project
717
+ * root. The read goes through the project-file resolver, so the same rules
718
+ * as `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
719
+ * instead of read stale).
720
+ */
721
+ export declare function resolveServiceImage(serviceName: string, image: ServiceImage): ServiceImage;
672
722
  export interface VolumeMount {
673
723
  /**
674
724
  * 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.62.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,
@@ -1097,7 +1099,13 @@ const BUILD_DEDUP = new Map<
1097
1099
  { name: string; promise: Promise<{ tag: string; buildSteps?: BuildStep[] }> }
1098
1100
  >();
1099
1101
 
1100
- function buildContentKey(image: { content: string; exclude?: readonly string[] }): string {
1102
+ type DockerfileBuild = {
1103
+ content: string;
1104
+ exclude?: readonly string[];
1105
+ buildArgs?: Readonly<Record<string, string>>;
1106
+ };
1107
+
1108
+ function buildContentKey(image: DockerfileBuild): string {
1101
1109
  return computeBuildContentKey(() => new Bun.CryptoHasher("sha256") as any, image);
1102
1110
  }
1103
1111
 
@@ -1142,7 +1150,7 @@ async function prepareServiceImage(
1142
1150
  // (/workspace) is an input too — a runtime service started mid-test
1143
1151
  // after setup/test code mutated /workspace must rebuild, not share a
1144
1152
  // pre-mutation image.
1145
- const image = svc.image;
1153
+ const image = dockerfileContent(svc);
1146
1154
  if (opts?.dedup) {
1147
1155
  const key = buildContentKey(image);
1148
1156
  const inflight = BUILD_DEDUP.get(key);
@@ -1232,6 +1240,17 @@ RUN P='[spectest-ca]'; \\
1232
1240
  `;
1233
1241
  }
1234
1242
 
1243
+ /** The built form of a dockerfile image. `resolveServiceImage` read a
1244
+ * `path` into `content` at config time, so a service that still carries
1245
+ * `path` here skipped that step — a programming error, not user input. */
1246
+ function dockerfileContent(svc: NamedService): DockerfileBuild {
1247
+ const image = svc.image;
1248
+ if (image.type !== "dockerfile" || typeof image.content !== "string") {
1249
+ throw new Error(`service "${svc.name}" image was not resolved to Dockerfile contents`);
1250
+ }
1251
+ return image;
1252
+ }
1253
+
1235
1254
  /**
1236
1255
  * Build a dockerfile service's image.
1237
1256
  *
@@ -1246,7 +1265,7 @@ RUN P='[spectest-ca]'; \\
1246
1265
  */
1247
1266
  async function buildServiceImage(
1248
1267
  name: string,
1249
- image: { content: string; exclude?: readonly string[] },
1268
+ image: DockerfileBuild,
1250
1269
  tag: string,
1251
1270
  ): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
1252
1271
  const suffix = await caTrustSuffix();
@@ -1291,7 +1310,7 @@ async function imageRunsAsRoot(tag: string): Promise<boolean> {
1291
1310
 
1292
1311
  async function runServiceBuild(
1293
1312
  name: string,
1294
- image: { content: string; exclude?: readonly string[] },
1313
+ image: DockerfileBuild,
1295
1314
  tag: string,
1296
1315
  caSuffix: string | null,
1297
1316
  ): Promise<{ ok: boolean; folded: boolean; log: string; buildSteps?: BuildStep[] }> {
@@ -1322,6 +1341,9 @@ async function runServiceBuild(
1322
1341
  const useBuildKit = useRemote || (await hasBuildx());
1323
1342
  const buildEnv: Record<string, string> = {};
1324
1343
  let buildArgs: string[];
1344
+ // The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
1345
+ // so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
1346
+ const argFlags = buildArgFlags(image.buildArgs);
1325
1347
  if (useRemote) {
1326
1348
  // Build on the host-side shared buildkitd (persistent cross-VM cache);
1327
1349
  // `--load` brings the finished image back into the in-VM dockerd so
@@ -1332,13 +1354,14 @@ async function runServiceBuild(
1332
1354
  "--builder", REMOTE_BUILDER_NAME,
1333
1355
  "--load",
1334
1356
  "--progress=plain",
1357
+ ...argFlags,
1335
1358
  "-t", tag, "-f", dfPath, WORKSPACE,
1336
1359
  ];
1337
1360
  } else if (useBuildKit) {
1338
- buildArgs = ["build", "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
1361
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
1339
1362
  buildEnv.DOCKER_BUILDKIT = "1";
1340
1363
  } else {
1341
- buildArgs = ["build", "-t", tag, "-f", dfPath, WORKSPACE];
1364
+ buildArgs = ["build", ...argFlags, "-t", tag, "-f", dfPath, WORKSPACE];
1342
1365
  }
1343
1366
  progressService(name, { status: "building", detail: "starting build" });
1344
1367
  const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
@@ -2814,9 +2837,11 @@ const RUNTIME_SERVICES = new Map<string, NamedService>();
2814
2837
  // the orchestration helpers want a NamedService, which is the same shape.
2815
2838
  function specToNamedService(spec: RuntimeServiceSpec): NamedService {
2816
2839
  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) };
2840
+ // A runtime spec skipped `defineEnvironment`, so its config-time passes
2841
+ // run here: a `path` Dockerfile is read into `content`, and coverage
2842
+ // adapters get their configure step.
2843
+ const svc = { ...(rest as ServiceConfig), image: resolveServiceImage(name, rest.image) };
2844
+ return { name, ...applyCoverageAdapters(name, svc) };
2820
2845
  }
2821
2846
 
2822
2847
  /** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
@@ -0,0 +1,87 @@
1
+ // `image: { type: "dockerfile", path }` — a Dockerfile in the repo is read
2
+ // into `content` when the environment loads, so the harness only ever sees
3
+ // bytes. The project root is `SPECTEST_WORKSPACE` (the VM's /workspace).
4
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import path from "node:path";
8
+
9
+ // Set before the SDK reads it (module-load time), so no top-level import.
10
+ let root = "";
11
+ beforeEach(() => {
12
+ root = mkdtempSync(path.join(tmpdir(), "spectest-dockerfile-"));
13
+ process.env.SPECTEST_WORKSPACE = root;
14
+ process.env.SPECTEST_APP_DIR = path.join(root, "app-copy");
15
+ });
16
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
17
+
18
+ const load = async () => await import("./index");
19
+
20
+ describe("dockerfile path", () => {
21
+ test("a repo-relative path is read into content, exclude kept", async () => {
22
+ mkdirSync(path.join(root, "apps/api"), { recursive: true });
23
+ writeFileSync(path.join(root, "apps/api/Dockerfile"), "FROM node:20\nCOPY . /app\n");
24
+ const { defineEnvironment } = await load();
25
+ const env = defineEnvironment({
26
+ name: "p",
27
+ services: {
28
+ api: {
29
+ image: {
30
+ type: "dockerfile",
31
+ path: "apps/api/Dockerfile",
32
+ exclude: ["docs/**"],
33
+ buildArgs: { NODE_ENV: "test" },
34
+ },
35
+ },
36
+ },
37
+ });
38
+ expect(env.config.services.api.image).toEqual({
39
+ type: "dockerfile",
40
+ content: "FROM node:20\nCOPY . /app\n",
41
+ exclude: ["docs/**"],
42
+ buildArgs: { NODE_ENV: "test" },
43
+ });
44
+ });
45
+
46
+ test("a missing file fails the load and names the path", async () => {
47
+ const { defineEnvironment } = await load();
48
+ expect(() =>
49
+ defineEnvironment({
50
+ name: "p",
51
+ services: { api: { image: { type: "dockerfile", path: "nope/Dockerfile" } } },
52
+ }),
53
+ ).toThrow(/service "api" Dockerfile "nope\/Dockerfile" is not in the VM/);
54
+ });
55
+
56
+ test("path and content together, or neither, is an error", async () => {
57
+ const { resolveServiceImage } = await load();
58
+ expect(() =>
59
+ resolveServiceImage("api", { type: "dockerfile", path: "Dockerfile", content: "FROM x" } as never),
60
+ ).toThrow(/both `path` and `content`/);
61
+ expect(() => resolveServiceImage("api", { type: "dockerfile" } as never)).toThrow(
62
+ /needs `path`/,
63
+ );
64
+ });
65
+
66
+ test("buildArgs keys must be ARG names and values strings", async () => {
67
+ const { resolveServiceImage } = await load();
68
+ expect(() =>
69
+ resolveServiceImage("api", { type: "dockerfile", content: "FROM x", buildArgs: { "NO-DASH": "1" } }),
70
+ ).toThrow(/not a valid ARG name/);
71
+ expect(() =>
72
+ resolveServiceImage("api", {
73
+ type: "dockerfile",
74
+ content: "FROM x",
75
+ buildArgs: { PORT: 3000 } as never,
76
+ }),
77
+ ).toThrow(/must be a string/);
78
+ });
79
+
80
+ test("content and registry images pass through untouched", async () => {
81
+ const { resolveServiceImage } = await load();
82
+ const inline = { type: "dockerfile", content: "FROM x\n" } as const;
83
+ expect(resolveServiceImage("a", inline)).toBe(inline);
84
+ const reg = { type: "registry", reference: "nginx" } as const;
85
+ expect(resolveServiceImage("a", reg)).toBe(reg);
86
+ });
87
+ });
Binary file
@@ -130,7 +130,7 @@ export interface Hasher {
130
130
 
131
131
  export function buildContentKey(
132
132
  createHasher: () => Hasher,
133
- image: { content: string; exclude?: readonly string[] },
133
+ image: { content: string; exclude?: readonly string[]; buildArgs?: Readonly<Record<string, string>> },
134
134
  ): string {
135
135
  return (
136
136
  createHasher()
@@ -141,6 +141,19 @@ export function buildContentKey(
141
141
  // genuinely different builds would collapse into one.
142
142
  .update("\0")
143
143
  .update(JSON.stringify(image.exclude ?? []))
144
+ // Build args are an input to the image (an ARG picks the base image,
145
+ // the NODE_ENV of an install step…), so two services on one
146
+ // Dockerfile with different args must not share a build. Sorted, so
147
+ // key order in the user's object doesn't split identical builds.
148
+ .update("\0")
149
+ .update(JSON.stringify(buildArgFlags(image.buildArgs)))
144
150
  .digest("hex")
145
151
  );
146
152
  }
153
+
154
+ /** `--build-arg NAME=value` pairs, in a stable (sorted) order. */
155
+ export function buildArgFlags(buildArgs: Readonly<Record<string, string>> | undefined): string[] {
156
+ return Object.entries(buildArgs ?? {})
157
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
158
+ .flatMap(([k, v]) => ["--build-arg", `${k}=${v}`]);
159
+ }
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
@@ -1043,18 +1045,121 @@ export interface ServiceTls {
1043
1045
 
1044
1046
  export type ServiceImage =
1045
1047
  | { type: "registry"; reference: string }
1048
+ | {
1049
+ type: "dockerfile";
1050
+ /**
1051
+ * Path of a Dockerfile in your repo, relative to the project root
1052
+ * (where `spectest/` lives) — `"Dockerfile"`, `"apps/api/Dockerfile"`.
1053
+ * This is the preferred form: the test environment builds the same
1054
+ * Dockerfile production does, so the two cannot drift. The build
1055
+ * context is always the project root, as with
1056
+ * `docker build -f apps/api/Dockerfile .`, so `COPY` / `ADD` resolve
1057
+ * against the repo root wherever the file sits. The file is read when
1058
+ * the environment loads; a missing file fails the load and names the
1059
+ * path. Mutually exclusive with `content`.
1060
+ */
1061
+ path: string;
1062
+ content?: never;
1063
+ /** Extra glob patterns to exclude from the build context. */
1064
+ exclude?: readonly string[];
1065
+ /**
1066
+ * `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
1067
+ * Build-time only: they are not in the container's environment (use
1068
+ * `env` for that), and they are part of the image's identity, so two
1069
+ * services building one Dockerfile with different args build twice.
1070
+ * Build args land in the image history; pass nothing secret.
1071
+ */
1072
+ buildArgs?: Readonly<Record<string, string>>;
1073
+ }
1046
1074
  | {
1047
1075
  type: "dockerfile";
1048
1076
  /**
1049
1077
  * Dockerfile contents, written verbatim into the build context. The
1050
1078
  * build context is the project root (where `spectest/` lives), so any
1051
1079
  * `COPY` / `ADD` references resolve relative to that directory.
1080
+ * Prefer `path`, which points at the Dockerfile your repo already has.
1081
+ * Mutually exclusive with `path`.
1052
1082
  */
1053
1083
  content: string;
1084
+ path?: never;
1054
1085
  /** Extra glob patterns to exclude from the build context. */
1055
1086
  exclude?: readonly string[];
1087
+ /**
1088
+ * `--build-arg` values for the Dockerfile's `ARG`s — `{ NODE_ENV: "test" }`.
1089
+ * Build-time only: they are not in the container's environment (use
1090
+ * `env` for that), and they are part of the image's identity, so two
1091
+ * services building one Dockerfile with different args build twice.
1092
+ * Build args land in the image history; pass nothing secret.
1093
+ */
1094
+ buildArgs?: Readonly<Record<string, string>>;
1056
1095
  };
1057
1096
 
1097
+ /** A Dockerfile `ARG` name: what `docker build --build-arg` accepts as a key. */
1098
+ const BUILD_ARG_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1099
+
1100
+ function validateBuildArgs(serviceName: string, buildArgs: Readonly<Record<string, string>> | undefined): void {
1101
+ if (buildArgs === undefined) return;
1102
+ if (typeof buildArgs !== "object" || buildArgs === null || Array.isArray(buildArgs)) {
1103
+ throw new Error(`service "${serviceName}" image buildArgs must be a { NAME: "value" } record`);
1104
+ }
1105
+ for (const [k, v] of Object.entries(buildArgs)) {
1106
+ if (!BUILD_ARG_NAME_RE.test(k)) {
1107
+ throw new Error(
1108
+ `service "${serviceName}" image buildArgs key ${JSON.stringify(k)} is not a valid ARG name (letters, digits, underscore)`,
1109
+ );
1110
+ }
1111
+ if (typeof v !== "string") {
1112
+ throw new Error(
1113
+ `service "${serviceName}" image buildArgs ${k} must be a string (a build arg is text; got ${typeof v})`,
1114
+ );
1115
+ }
1116
+ }
1117
+ }
1118
+
1119
+ /**
1120
+ * Resolve a service's `image` to the form the harness builds: a `path`
1121
+ * Dockerfile is read from the repo and becomes `content`. The wire config
1122
+ * therefore never carries `path` — the control plane and the daemon's build
1123
+ * code only ever see Dockerfile bytes, and the warm-template hash covers
1124
+ * them because the file is part of the project tree.
1125
+ *
1126
+ * Runs at config time (`defineEnvironment`, and the daemon's runtime
1127
+ * `startService` twin), inside the VM, where the repo sits at the project
1128
+ * root. The read goes through the project-file resolver, so the same rules
1129
+ * as `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
1130
+ * instead of read stale).
1131
+ */
1132
+ export function resolveServiceImage(serviceName: string, image: ServiceImage): ServiceImage {
1133
+ if (image.type !== "dockerfile") return image;
1134
+ validateBuildArgs(serviceName, image.buildArgs);
1135
+ const hasPath = typeof image.path === "string";
1136
+ const hasContent = typeof image.content === "string";
1137
+ if (hasPath && hasContent) {
1138
+ throw new Error(
1139
+ `service "${serviceName}" image sets both \`path\` and \`content\` — point at the Dockerfile in your repo with \`path\`, or inline it with \`content\`, not both`,
1140
+ );
1141
+ }
1142
+ if (!hasPath && !hasContent) {
1143
+ throw new Error(
1144
+ `service "${serviceName}" image { type: "dockerfile" } needs \`path\` (a Dockerfile in your repo, relative to the project root) or \`content\``,
1145
+ );
1146
+ }
1147
+ if (!hasPath) return image;
1148
+ const file = resolveExistingProjectPath(image.path as string, `service "${serviceName}" Dockerfile`);
1149
+ let content: string;
1150
+ try {
1151
+ content = readFileSync(file, "utf8");
1152
+ } catch (e) {
1153
+ throw new Error(
1154
+ `service "${serviceName}" Dockerfile ${JSON.stringify(image.path)} could not be read: ${(e as Error).message}`,
1155
+ );
1156
+ }
1157
+ const lowered: ServiceImage = { type: "dockerfile", content };
1158
+ if (image.exclude !== undefined) lowered.exclude = image.exclude;
1159
+ if (image.buildArgs !== undefined) lowered.buildArgs = image.buildArgs;
1160
+ return lowered;
1161
+ }
1162
+
1058
1163
  // Coverage adapters live in `coverage.ts`; the type is re-declared here
1059
1164
  // through the import below so `ServiceConfig.coverage` reads in one place.
1060
1165
 
@@ -2204,7 +2309,10 @@ export function defineEnvironment<
2204
2309
  const { fakes, services: rawServices, ...rest } = input;
2205
2310
  const expanded = expandServiceGroups(rawServices);
2206
2311
  for (const [key, svc] of Object.entries(expanded)) {
2207
- expanded[key] = applyCoverageAdapters(key, svc);
2312
+ expanded[key] = applyCoverageAdapters(key, {
2313
+ ...svc,
2314
+ image: resolveServiceImage(key, svc.image),
2315
+ });
2208
2316
  }
2209
2317
  const config: EnvironmentConfig<S> = {
2210
2318
  ...rest,