@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 +25 -9
- package/dist/harness/build-context.d.ts +3 -0
- package/dist/harness/build-context.js +12 -0
- package/dist/harness/catalogue.d.ts +11 -1
- package/dist/harness/catalogue.js +14 -1
- package/dist/index.d.ts +55 -0
- package/dist/index.js +92 -1
- package/package.json +1 -1
- package/src/daemon.ts +35 -10
- package/src/dockerfile-path.test.ts +87 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +14 -1
- package/src/harness/catalogue.test.ts +9 -1
- package/src/harness/catalogue.ts +21 -1
- package/src/index.ts +139 -1
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
|
|
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
|
|
2327
|
-
//
|
|
2328
|
-
|
|
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,
|
|
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
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
2818
|
-
//
|
|
2819
|
-
|
|
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
|
+
});
|
package/src/harness/catalogue.ts
CHANGED
|
@@ -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(
|
|
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,
|
|
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 {
|