@specific.dev/spectest 0.60.2 → 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";
@@ -114,7 +114,7 @@ let loaded = null;
114
114
  /** The catalogue mapping lives in `harness/catalogue.ts`; these wrappers
115
115
  * keep the call sites reading off the suite. */
116
116
  function casesMetadata(suite) {
117
- return catalogueCases(suite?.tests);
117
+ return catalogueCases(suite?.tests, APP_DIR);
118
118
  }
119
119
  function groupsMetadata(suite) {
120
120
  return catalogueGroups(suite?.tests);
@@ -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
+ }
@@ -21,6 +21,8 @@ export interface CatalogueCase {
21
21
  id: string;
22
22
  };
23
23
  };
24
+ /** Absolute path of the file that registered the test, if known. */
25
+ file?: string;
24
26
  }
25
27
  export interface CaseMeta {
26
28
  id: string;
@@ -30,6 +32,9 @@ export interface CaseMeta {
30
32
  /** The group this case is shown in, if any. Display only — a group never
31
33
  * changes the DAG, which `dependsOn` still describes in full. */
32
34
  groupId?: string;
35
+ /** Project-relative path (`spectest/tests/…`) of the file that defines
36
+ * the test, when the SDK recorded one and it lies under `appDir`. */
37
+ file?: string;
33
38
  }
34
39
  /** A display-only test group (`env.group(...)`), for the CLI tree and the
35
40
  * dashboard. `dependsOn` is the group's own level: every member of the
@@ -39,7 +44,12 @@ export interface GroupMeta {
39
44
  name: string;
40
45
  dependsOn?: string;
41
46
  }
42
- export declare function casesMetadata(cases: readonly CatalogueCase[] | undefined): CaseMeta[];
47
+ export declare function casesMetadata(cases: readonly CatalogueCase[] | undefined, appDir?: string): CaseMeta[];
48
+ /** `file` relative to `appDir` (the app dir holds the project's `spectest/`
49
+ * directory, so the result reads `spectest/tests/x.ts`). A file outside it
50
+ * — or no `appDir` — is reported as nothing: the selector then treats a
51
+ * change to a test file as unknown, which is the safe reading. */
52
+ export declare function relativeTestFile(file: string | undefined, appDir: string | undefined): string | undefined;
43
53
  /**
44
54
  * The groups the suite uses, in the order their first member appears —
45
55
  * which is the order both surfaces render in.
@@ -5,7 +5,7 @@
5
5
  * Ported out of `daemon.ts`. Three harness methods (`load`, `loadTests`,
6
6
  * `cases`) return this, so the mapping lives in one place.
7
7
  */
8
- export function casesMetadata(cases) {
8
+ export function casesMetadata(cases, appDir) {
9
9
  if (!cases)
10
10
  return [];
11
11
  return cases.map((t) => ({
@@ -14,8 +14,21 @@ export function casesMetadata(cases) {
14
14
  dependsOn: t.dependsOn?.id,
15
15
  timeoutMs: t.timeoutMs,
16
16
  groupId: t.group?.id,
17
+ file: relativeTestFile(t.file, appDir),
17
18
  }));
18
19
  }
20
+ /** `file` relative to `appDir` (the app dir holds the project's `spectest/`
21
+ * directory, so the result reads `spectest/tests/x.ts`). A file outside it
22
+ * — or no `appDir` — is reported as nothing: the selector then treats a
23
+ * change to a test file as unknown, which is the safe reading. */
24
+ export function relativeTestFile(file, appDir) {
25
+ if (!file || !appDir)
26
+ return undefined;
27
+ const root = appDir.endsWith("/") ? appDir : appDir + "/";
28
+ if (!file.startsWith(root))
29
+ return undefined;
30
+ return file.slice(root.length);
31
+ }
19
32
  /**
20
33
  * The groups the suite uses, in the order their first member appears —
21
34
  * which is the order both surfaces render in.
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
@@ -903,6 +953,11 @@ export interface TestCase<T = unknown, S extends ServicesMap = ServicesMap, F ex
903
953
  readonly group?: TestGroup<unknown, S, F>;
904
954
  /** Override the default per-test timeout (default 60s). */
905
955
  readonly timeoutMs?: number;
956
+ /** @internal — absolute path of the file that called `env.test(...)`,
957
+ * read off the call stack at registration. The catalogue reports it
958
+ * project-relative so a change to a test file can select exactly the
959
+ * cases it defines (subset runs). Absent when the stack gave none. */
960
+ readonly file?: string;
906
961
  /** @internal — the body that the in-sandbox daemon invokes. */
907
962
  readonly run: TestFn<T, unknown, S, F>;
908
963
  }
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,
@@ -470,9 +534,36 @@ maybeFn) {
470
534
  dependsOn: opts.dependsOn ?? opts.group?.dependsOn,
471
535
  group: opts.group,
472
536
  timeoutMs: opts.timeoutMs,
537
+ file: callerFile(),
473
538
  run: fn,
474
539
  };
475
540
  }
541
+ /**
542
+ * The file of the nearest stack frame outside this module — the test file
543
+ * that called `env.test(...)`. Frames look like `at fn (/abs/path.ts:1:2)`
544
+ * or `at /abs/path.ts:1:2`; the first one whose path is not this module's
545
+ * is the caller. `undefined` when the stack is unavailable.
546
+ */
547
+ function callerFile() {
548
+ const stack = new Error().stack;
549
+ if (!stack)
550
+ return undefined;
551
+ let self;
552
+ for (const line of stack.split("\n")) {
553
+ const m = /\(?((?:\/|[A-Za-z]:\\)[^\s()]+?):\d+:\d+\)?\s*$/.exec(line);
554
+ if (!m)
555
+ continue;
556
+ const file = m[1];
557
+ // The first path on the stack is this module (callerFile itself).
558
+ if (self === undefined) {
559
+ self = file;
560
+ continue;
561
+ }
562
+ if (file !== self)
563
+ return file;
564
+ }
565
+ return undefined;
566
+ }
476
567
  function validateSuite(suite) {
477
568
  const seenIds = new Map();
478
569
  for (const t of suite.tests) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.60.2",
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,
@@ -286,7 +288,7 @@ let loaded: Loaded | null = null;
286
288
  /** The catalogue mapping lives in `harness/catalogue.ts`; these wrappers
287
289
  * keep the call sites reading off the suite. */
288
290
  function casesMetadata(suite: TestSuite | undefined): CaseMeta[] {
289
- return catalogueCases(suite?.tests);
291
+ return catalogueCases(suite?.tests, APP_DIR);
290
292
  }
291
293
 
292
294
  function groupsMetadata(suite: TestSuite | undefined): GroupMeta[] {
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { casesMetadata, groupsMetadata, type CatalogueCase } from "./catalogue";
3
+ import { casesMetadata, groupsMetadata, relativeTestFile, type CatalogueCase } from "./catalogue";
4
4
 
5
5
  const billing = { id: "billing", name: "Billing" };
6
6
  const errors = {
@@ -59,3 +59,11 @@ describe("groupsMetadata", () => {
59
59
  expect(groupsMetadata(undefined)).toEqual([]);
60
60
  });
61
61
  });
62
+
63
+ test("relativeTestFile reports a test file relative to the app dir, or nothing", () => {
64
+ expect(relativeTestFile("/opt/spectest/app/spectest/tests/a.ts", "/opt/spectest/app")).toBe("spectest/tests/a.ts");
65
+ expect(relativeTestFile("/opt/spectest/app/spectest/tests/a.ts", "/opt/spectest/app/")).toBe("spectest/tests/a.ts");
66
+ expect(relativeTestFile("/elsewhere/a.ts", "/opt/spectest/app")).toBeUndefined();
67
+ expect(relativeTestFile(undefined, "/opt/spectest/app")).toBeUndefined();
68
+ expect(relativeTestFile("/opt/spectest/app/spectest/tests/a.ts", undefined)).toBeUndefined();
69
+ });
@@ -14,6 +14,8 @@ export interface CatalogueCase {
14
14
  dependsOn?: { id: string };
15
15
  timeoutMs?: number;
16
16
  group?: { id: string; name: string; dependsOn?: { id: string } };
17
+ /** Absolute path of the file that registered the test, if known. */
18
+ file?: string;
17
19
  }
18
20
 
19
21
  export interface CaseMeta {
@@ -24,6 +26,9 @@ export interface CaseMeta {
24
26
  /** The group this case is shown in, if any. Display only — a group never
25
27
  * changes the DAG, which `dependsOn` still describes in full. */
26
28
  groupId?: string;
29
+ /** Project-relative path (`spectest/tests/…`) of the file that defines
30
+ * the test, when the SDK recorded one and it lies under `appDir`. */
31
+ file?: string;
27
32
  }
28
33
 
29
34
  /** A display-only test group (`env.group(...)`), for the CLI tree and the
@@ -35,7 +40,10 @@ export interface GroupMeta {
35
40
  dependsOn?: string;
36
41
  }
37
42
 
38
- export function casesMetadata(cases: readonly CatalogueCase[] | undefined): CaseMeta[] {
43
+ export function casesMetadata(
44
+ cases: readonly CatalogueCase[] | undefined,
45
+ appDir?: string,
46
+ ): CaseMeta[] {
39
47
  if (!cases) return [];
40
48
  return cases.map((t) => ({
41
49
  id: t.id,
@@ -43,9 +51,21 @@ export function casesMetadata(cases: readonly CatalogueCase[] | undefined): Case
43
51
  dependsOn: t.dependsOn?.id,
44
52
  timeoutMs: t.timeoutMs,
45
53
  groupId: t.group?.id,
54
+ file: relativeTestFile(t.file, appDir),
46
55
  }));
47
56
  }
48
57
 
58
+ /** `file` relative to `appDir` (the app dir holds the project's `spectest/`
59
+ * directory, so the result reads `spectest/tests/x.ts`). A file outside it
60
+ * — or no `appDir` — is reported as nothing: the selector then treats a
61
+ * change to a test file as unknown, which is the safe reading. */
62
+ export function relativeTestFile(file: string | undefined, appDir: string | undefined): string | undefined {
63
+ if (!file || !appDir) return undefined;
64
+ const root = appDir.endsWith("/") ? appDir : appDir + "/";
65
+ if (!file.startsWith(root)) return undefined;
66
+ return file.slice(root.length);
67
+ }
68
+
49
69
  /**
50
70
  * The groups the suite uses, in the order their first member appears —
51
71
  * which is the order both surfaces render in.
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
 
@@ -1407,6 +1512,11 @@ export interface TestCase<
1407
1512
  readonly group?: TestGroup<unknown, S, F>;
1408
1513
  /** Override the default per-test timeout (default 60s). */
1409
1514
  readonly timeoutMs?: number;
1515
+ /** @internal — absolute path of the file that called `env.test(...)`,
1516
+ * read off the call stack at registration. The catalogue reports it
1517
+ * project-relative so a change to a test file can select exactly the
1518
+ * cases it defines (subset runs). Absent when the stack gave none. */
1519
+ readonly file?: string;
1410
1520
  /** @internal — the body that the in-sandbox daemon invokes. */
1411
1521
  readonly run: TestFn<T, unknown, S, F>;
1412
1522
  }
@@ -2199,7 +2309,10 @@ export function defineEnvironment<
2199
2309
  const { fakes, services: rawServices, ...rest } = input;
2200
2310
  const expanded = expandServiceGroups(rawServices);
2201
2311
  for (const [key, svc] of Object.entries(expanded)) {
2202
- expanded[key] = applyCoverageAdapters(key, svc);
2312
+ expanded[key] = applyCoverageAdapters(key, {
2313
+ ...svc,
2314
+ image: resolveServiceImage(key, svc.image),
2315
+ });
2203
2316
  }
2204
2317
  const config: EnvironmentConfig<S> = {
2205
2318
  ...rest,
@@ -2375,10 +2488,35 @@ function buildTestCase(
2375
2488
  dependsOn: opts.dependsOn ?? opts.group?.dependsOn,
2376
2489
  group: opts.group,
2377
2490
  timeoutMs: opts.timeoutMs,
2491
+ file: callerFile(),
2378
2492
  run: fn,
2379
2493
  };
2380
2494
  }
2381
2495
 
2496
+ /**
2497
+ * The file of the nearest stack frame outside this module — the test file
2498
+ * that called `env.test(...)`. Frames look like `at fn (/abs/path.ts:1:2)`
2499
+ * or `at /abs/path.ts:1:2`; the first one whose path is not this module's
2500
+ * is the caller. `undefined` when the stack is unavailable.
2501
+ */
2502
+ function callerFile(): string | undefined {
2503
+ const stack = new Error().stack;
2504
+ if (!stack) return undefined;
2505
+ let self: string | undefined;
2506
+ for (const line of stack.split("\n")) {
2507
+ const m = /\(?((?:\/|[A-Za-z]:\\)[^\s()]+?):\d+:\d+\)?\s*$/.exec(line);
2508
+ if (!m) continue;
2509
+ const file = m[1]!;
2510
+ // The first path on the stack is this module (callerFile itself).
2511
+ if (self === undefined) {
2512
+ self = file;
2513
+ continue;
2514
+ }
2515
+ if (file !== self) return file;
2516
+ }
2517
+ return undefined;
2518
+ }
2519
+
2382
2520
  /** The two option shapes as one, for the untyped runtime implementation.
2383
2521
  * The public overloads keep them apart — see {@link TestOpts}. */
2384
2522
  interface AnyTestOpts {