@specific.dev/spectest 0.131.13 → 0.131.15
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/coverage-next.js +8 -3
- package/dist/coverage.js +5 -1
- package/dist/daemon.js +46 -2
- package/dist/harness/built-image.d.ts +11 -2
- package/dist/harness/built-image.js +11 -3
- package/dist/harness/component-images.d.ts +7 -0
- package/dist/harness/component-images.js +12 -1
- package/dist/harness/sticky-consumer.js +6 -0
- package/package.json +1 -1
- package/src/coverage-next.test.ts +59 -1
- package/src/coverage-next.ts +8 -3
- package/src/coverage.test.ts +4 -2
- package/src/coverage.ts +5 -1
- package/src/daemon.ts +45 -2
- package/src/harness/built-image.ts +11 -3
- package/src/harness/component-images.test.ts +19 -1
- package/src/harness/component-images.ts +15 -1
- package/src/harness/sticky-consumer.ts +6 -0
package/dist/coverage-next.js
CHANGED
|
@@ -9,7 +9,11 @@ export const NEXT_COVERAGE_HELPER_PATH = "/spectest/next-coverage.cjs";
|
|
|
9
9
|
export const NEXT_COVERAGE_HELPER = String.raw `"use strict";
|
|
10
10
|
const fs = require("node:fs"), path = require("node:path");
|
|
11
11
|
const options = JSON.parse(process.env.SPECTEST_NEXT_COVERAGE_OPTIONS || "{}");
|
|
12
|
-
|
|
12
|
+
// Preloads run again in spawned Node processes. Carry the original runtime
|
|
13
|
+
// root into children instead of interpreting appDir against each child's cwd.
|
|
14
|
+
const root = process.env.SPECTEST_COVERAGE_ROOT || process.cwd();
|
|
15
|
+
process.env.SPECTEST_COVERAGE_ROOT = root;
|
|
16
|
+
const app = path.resolve(root, options.appDir || ".");
|
|
13
17
|
const dist = path.resolve(app, options.distDir || ".next");
|
|
14
18
|
function manifest() { return JSON.parse(fs.readFileSync(path.join(dist, "required-server-files.json"), "utf8")); }
|
|
15
19
|
function launcher(m) {
|
|
@@ -17,7 +21,7 @@ function launcher(m) {
|
|
|
17
21
|
if (m.config.output !== "standalone" || !fs.existsSync(file)) return null;
|
|
18
22
|
const text = fs.readFileSync(file, "utf8");
|
|
19
23
|
if (!text.includes("__NEXT_PRIVATE_STANDALONE_CONFIG") || !text.includes("next/dist/server/lib/start-server")) return null;
|
|
20
|
-
return path.relative(
|
|
24
|
+
return path.relative(root, file);
|
|
21
25
|
}
|
|
22
26
|
try {
|
|
23
27
|
const file = launcher(manifest());
|
|
@@ -50,7 +54,8 @@ const GENERATED_SOURCES = [
|
|
|
50
54
|
"*/data:* <text/javascript>", // Turbopack virtual modules, including action proxies.
|
|
51
55
|
"*/.next-internal/server/app/*/actions.js (server actions loader)",
|
|
52
56
|
"*/app/*--route-entry.js",
|
|
53
|
-
"
|
|
57
|
+
"*/*.mjs (structured image object)",
|
|
58
|
+
"*/*.mjs (structured image object with data url)",
|
|
54
59
|
];
|
|
55
60
|
/** Server + browser coverage for a built Next app. Build-time maps are required;
|
|
56
61
|
* enable native Next source maps and retain them in the runtime image.
|
package/dist/coverage.js
CHANGED
|
@@ -339,9 +339,13 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
339
339
|
if (!bunRuntime) post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
|
|
340
340
|
|
|
341
341
|
const GENERATED = new Map();
|
|
342
|
+
// Generated names are service-root-relative unit identities, not paths
|
|
343
|
+
// relative to a wrapper's child process. Preserve that root through execs.
|
|
344
|
+
const generatedRoot = process.env.SPECTEST_COVERAGE_ROOT || process.cwd();
|
|
345
|
+
process.env.SPECTEST_COVERAGE_ROOT = generatedRoot;
|
|
342
346
|
try {
|
|
343
347
|
for (const file of JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]")) {
|
|
344
|
-
GENERATED.set(url.pathToFileURL(path.resolve(file)).href, file);
|
|
348
|
+
GENERATED.set(url.pathToFileURL(path.resolve(generatedRoot, file)).href, file);
|
|
345
349
|
}
|
|
346
350
|
} catch (e) {}
|
|
347
351
|
const generatedUnits = new Map();
|
package/dist/daemon.js
CHANGED
|
@@ -5,7 +5,7 @@ import { phoneScope, phoneRecordingChunk, releasePhoneRecording } from "./phone.
|
|
|
5
5
|
import { runWithBackgroundFailures } from "./harness/background-failures.js";
|
|
6
6
|
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
7
7
|
import { containerdImageOutput } from "./harness/built-image.js";
|
|
8
|
-
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
8
|
+
import { componentBuildTasks, COMPONENT_RUNTIME, isComponentImageTask, isDigestReference } from "./harness/component-images.js";
|
|
9
9
|
import { waitForAll } from "./harness/concurrency.js";
|
|
10
10
|
import { updateServiceProgress, serviceProgressSnapshot } from "./harness/service-progress.js";
|
|
11
11
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
|
|
@@ -492,6 +492,18 @@ function ensureLocalBuildkitd() {
|
|
|
492
492
|
})();
|
|
493
493
|
return _localBuilder;
|
|
494
494
|
}
|
|
495
|
+
/** `true` when a digest-pinned reference is already complete in the store. */
|
|
496
|
+
async function imagePresentByDigest(ref) {
|
|
497
|
+
if (!isDigestReference(ref))
|
|
498
|
+
return false;
|
|
499
|
+
// `inspect` succeeds only when every layer of the image for this
|
|
500
|
+
// platform is in the store; a partial or missing image falls through to
|
|
501
|
+
// the ordinary pull, which fetches what is absent. dockerd's containerd
|
|
502
|
+
// store also unpacks on demand at container creation, so a record whose
|
|
503
|
+
// snapshots were reclaimed is repaired there rather than failing.
|
|
504
|
+
const present = await docker(["image", "inspect", "--format", "{{.Id}}", ref], 30_000);
|
|
505
|
+
return present.code === 0;
|
|
506
|
+
}
|
|
495
507
|
/** Host directory backing one volume mount. Rules (notably which volumes
|
|
496
508
|
* survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
|
|
497
509
|
function resolveHostPath(service, vol) {
|
|
@@ -898,6 +910,19 @@ async function prepareServiceImageInner(svc, opts) {
|
|
|
898
910
|
// tags (`foo:latest`) at whatever the previous generation pulled, for
|
|
899
911
|
// as long as the delta chain lives — a silent semantic divergence
|
|
900
912
|
// from the cold build a delta restore must be equivalent to.
|
|
913
|
+
//
|
|
914
|
+
// The one reference that cannot float is a digest. `name@sha256:…`
|
|
915
|
+
// names exactly one image for as long as it exists, so a copy already
|
|
916
|
+
// in the store is the image the registry would return, and the
|
|
917
|
+
// round-trip buys nothing: skip it. Component images pinned by digest
|
|
918
|
+
// and user references pinned by digest take this path; a tag never does.
|
|
919
|
+
if (await imagePresentByDigest(ref)) {
|
|
920
|
+
progressService(svc.name, { status: "pulling", detail: `present by digest` });
|
|
921
|
+
const tagged = await docker(["tag", ref, tag], 30_000);
|
|
922
|
+
if (tagged.code !== 0)
|
|
923
|
+
throw new Error(`docker tag ${ref} ${tag} failed: ${tagged.stderr.trim()}`);
|
|
924
|
+
return { tag };
|
|
925
|
+
}
|
|
901
926
|
progressService(svc.name, { status: "pulling", detail: `pulling ${ref}` });
|
|
902
927
|
let layers = 0;
|
|
903
928
|
const pull = await shxStream("docker", ["pull", ref], 900_000, undefined, (line) => {
|
|
@@ -1042,11 +1067,30 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1042
1067
|
// containerd exporter replaces the tag directly, bypassing Docker's
|
|
1043
1068
|
// dangling-image retention. Old versions must remain usable until our
|
|
1044
1069
|
// image GC decides they are no longer needed (including by nested stores).
|
|
1070
|
+
//
|
|
1071
|
+
// `compression=uncompressed` (`containerdImageOutput`): the image never leaves this store, so
|
|
1072
|
+
// gzip on export only to gunzip on unpack is pure cost — on Listen,
|
|
1073
|
+
// 64 s compressing a 2.7 GB layer and 56 s decompressing it again,
|
|
1074
|
+
// single-threaded, in the same guest that already held the layer as
|
|
1075
|
+
// a build snapshot (CONTAINER_STORE.md, "Image delivery inside the
|
|
1076
|
+
// guest"). With the default `force-compression=false` this applies
|
|
1077
|
+
// to layers this build creates; a pulled base image's gzip blobs are
|
|
1078
|
+
// reused as they are, so no existing layer is re-encoded or
|
|
1079
|
+
// re-unpacked. The uncompressed blob is a second on-disk copy of the
|
|
1080
|
+
// layer; the sticky headroom logic already budgets for blobs.
|
|
1081
|
+
//
|
|
1082
|
+
// `unpack`: dockerd needs the snapshot chain to start a container,
|
|
1083
|
+
// so a service image is unpacked here rather than at `docker run`.
|
|
1084
|
+
// A component image task (a `k3s()` build) is consumed only through
|
|
1085
|
+
// the content store — `k3s-image-loader.ts` serves its blobs and the
|
|
1086
|
+
// cluster's containerd unpacks them into its own store — so unpacking
|
|
1087
|
+
// it in dockerd's store would be a third copy nobody reads.
|
|
1088
|
+
const unpack = !isComponentImageTask(name);
|
|
1045
1089
|
buildArgs = [
|
|
1046
1090
|
"buildx", "build",
|
|
1047
1091
|
"--builder", LOCAL_BUILDER_NAME,
|
|
1048
1092
|
"--progress=plain",
|
|
1049
|
-
"--output", containerdImageOutput(tag),
|
|
1093
|
+
"--output", containerdImageOutput(tag, { unpack }),
|
|
1050
1094
|
...argFlags,
|
|
1051
1095
|
"-f", dfPath, contextDir,
|
|
1052
1096
|
];
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/** Containerd exports replace mutable tags without Docker's dangling-image
|
|
2
|
-
* retention. Keep each result addressable until the SDK's image GC evicts it.
|
|
3
|
-
|
|
2
|
+
* retention. Keep each result addressable until the SDK's image GC evicts it.
|
|
3
|
+
*
|
|
4
|
+
* Layers this build creates are stored uncompressed: the image never leaves
|
|
5
|
+
* the VM's store, so compressing on export only to decompress on unpack was
|
|
6
|
+
* pure cost (64 s + 56 s on Listen's 2.7 GB layer). `force-compression` stays
|
|
7
|
+
* off, so a pulled base image's blobs are reused as they are. `unpack` is
|
|
8
|
+
* false for images only a nested store reads (CONTAINER_STORE.md, "Image
|
|
9
|
+
* delivery inside the guest"). */
|
|
10
|
+
export declare function containerdImageOutput(reference: string, options?: {
|
|
11
|
+
unpack?: boolean;
|
|
12
|
+
}): string;
|
|
4
13
|
export interface Descriptor {
|
|
5
14
|
digest: string;
|
|
6
15
|
size: number;
|
|
@@ -4,9 +4,17 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { canonicalImageReference } from "./component-images.js";
|
|
6
6
|
/** Containerd exports replace mutable tags without Docker's dangling-image
|
|
7
|
-
* retention. Keep each result addressable until the SDK's image GC evicts it.
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
* retention. Keep each result addressable until the SDK's image GC evicts it.
|
|
8
|
+
*
|
|
9
|
+
* Layers this build creates are stored uncompressed: the image never leaves
|
|
10
|
+
* the VM's store, so compressing on export only to decompress on unpack was
|
|
11
|
+
* pure cost (64 s + 56 s on Listen's 2.7 GB layer). `force-compression` stays
|
|
12
|
+
* off, so a pulled base image's blobs are reused as they are. `unpack` is
|
|
13
|
+
* false for images only a nested store reads (CONTAINER_STORE.md, "Image
|
|
14
|
+
* delivery inside the guest"). */
|
|
15
|
+
export function containerdImageOutput(reference, options = {}) {
|
|
16
|
+
const unpack = options.unpack ?? true;
|
|
17
|
+
return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=${unpack},compression=uncompressed`;
|
|
10
18
|
}
|
|
11
19
|
const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
12
20
|
const descriptor = (bytes, mediaType) => ({ digest: digest(bytes), size: bytes.length, mediaType });
|
|
@@ -5,10 +5,17 @@ export type KubernetesImage = string | (BuildImage & {
|
|
|
5
5
|
});
|
|
6
6
|
/** containerd/CRI use fully qualified names, including the default tag. */
|
|
7
7
|
export declare function canonicalImageReference(reference: string): string;
|
|
8
|
+
/** A `name@sha256:…` reference names one immutable image; a tag can move. */
|
|
9
|
+
export declare function isDigestReference(reference: string): boolean;
|
|
8
10
|
export declare function kubernetesImages(images: readonly KubernetesImage[]): {
|
|
9
11
|
pulls: string[];
|
|
10
12
|
builds: ComponentImageBuild[];
|
|
11
13
|
};
|
|
14
|
+
/** Prefix of every component image task name; `isComponentImageTask` is the test. */
|
|
15
|
+
export declare const COMPONENT_IMAGE_TASK_PREFIX = "spectest-image-";
|
|
16
|
+
/** A component image task builds for a component's own runtime (today: a
|
|
17
|
+
* `k3s()` cluster's containerd), never for a container dockerd starts. */
|
|
18
|
+
export declare function isComponentImageTask(name: string): boolean;
|
|
12
19
|
/** Stable internal task names, separate from the service graph. */
|
|
13
20
|
export declare function componentBuildTasks<T extends ServiceConfig & {
|
|
14
21
|
name: string;
|
|
@@ -24,6 +24,10 @@ export function canonicalImageReference(reference) {
|
|
|
24
24
|
result += ":latest";
|
|
25
25
|
return result;
|
|
26
26
|
}
|
|
27
|
+
/** A `name@sha256:…` reference names one immutable image; a tag can move. */
|
|
28
|
+
export function isDigestReference(reference) {
|
|
29
|
+
return /@sha256:[a-f0-9]{64}$/.test(reference);
|
|
30
|
+
}
|
|
27
31
|
export function kubernetesImages(images) {
|
|
28
32
|
if (!Array.isArray(images))
|
|
29
33
|
throw new Error("k3s(): images must be an array of registry references or Dockerfile builds");
|
|
@@ -54,10 +58,17 @@ export function kubernetesImages(images) {
|
|
|
54
58
|
}
|
|
55
59
|
return { pulls, builds };
|
|
56
60
|
}
|
|
61
|
+
/** Prefix of every component image task name; `isComponentImageTask` is the test. */
|
|
62
|
+
export const COMPONENT_IMAGE_TASK_PREFIX = "spectest-image-";
|
|
63
|
+
/** A component image task builds for a component's own runtime (today: a
|
|
64
|
+
* `k3s()` cluster's containerd), never for a container dockerd starts. */
|
|
65
|
+
export function isComponentImageTask(name) {
|
|
66
|
+
return name.startsWith(COMPONENT_IMAGE_TASK_PREFIX);
|
|
67
|
+
}
|
|
57
68
|
/** Stable internal task names, separate from the service graph. */
|
|
58
69
|
export function componentBuildTasks(services) {
|
|
59
70
|
return services.flatMap(svc => (svc.imageBuilds ?? []).map(build => ({
|
|
60
|
-
name:
|
|
71
|
+
name: `${COMPONENT_IMAGE_TASK_PREFIX}${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
|
|
61
72
|
owner: svc.name,
|
|
62
73
|
reference: build.reference,
|
|
63
74
|
image: build.image,
|
|
@@ -101,6 +101,12 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
101
101
|
dockerConfig["data-root"] = `${config.path}/docker`;
|
|
102
102
|
dockerConfig.features = { "containerd-snapshotter": true };
|
|
103
103
|
dockerConfig["storage-driver"] = "overlayfs";
|
|
104
|
+
// Pull up to ten layers at once instead of dockerd's default three. The
|
|
105
|
+
// containerd image store honours this since Docker 29.7.0; extraction is
|
|
106
|
+
// still one layer at a time, so this only moves the download side off
|
|
107
|
+
// the critical path of many-layer images. Set here as well as in the
|
|
108
|
+
// golden's daemon.json so an SDK release carries it without a rebuild.
|
|
109
|
+
dockerConfig["max-concurrent-downloads"] = 10;
|
|
104
110
|
await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
|
|
105
111
|
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config, (await diskUsage(config.path)).reservedBytes));
|
|
106
112
|
const start = async (unit, program, args) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
2
|
import { next, NEXT_COVERAGE_HELPER, NEXT_COVERAGE_HELPER_PATH, type NextBuildMetadata } from "./coverage-next.js";
|
|
3
|
-
import { applyCoverageAdapters, resolveCoverageSourceOptions, attachCoverageSourceOptions, node, browser, type CoverageSourceContext } from "./coverage.js";
|
|
3
|
+
import { applyCoverageAdapters, resolveCoverageSourceOptions, attachCoverageSourceOptions, node, browser, NODE_COVERAGE_HOOK, NODE_COVERAGE_SCRIPTS_SUBDIR, NODE_COVERAGE_MARKERS_SUBDIR, type CoverageSourceContext } from "./coverage.js";
|
|
4
4
|
import { applyCoverageDelta, encodeCoverageBundle } from "./harness/coverage.js";
|
|
5
5
|
import { gunzipSync } from "node:zlib";
|
|
6
6
|
import { promises as fs } from "node:fs";
|
|
@@ -18,6 +18,64 @@ function context(): CoverageSourceContext {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
describe("Next adapter", () => {
|
|
21
|
+
test.each(["next", "node"])("%s preserves generated unit identity through children with different working directories", async (kind) => {
|
|
22
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "next-child-cov-"));
|
|
23
|
+
try {
|
|
24
|
+
const app = path.join(dir, "ops"), reports = path.join(dir, "reports");
|
|
25
|
+
const helper = path.join(dir, "next-coverage.cjs"), hook = path.join(dir, "coverage-hook.cjs");
|
|
26
|
+
for (const folder of ["ops/.next/server", "ops/.next/static", "elsewhere", `reports/${NODE_COVERAGE_SCRIPTS_SUBDIR}`, `reports/${NODE_COVERAGE_MARKERS_SUBDIR}`]) {
|
|
27
|
+
await fs.mkdir(path.join(dir, folder), {recursive:true});
|
|
28
|
+
}
|
|
29
|
+
await fs.writeFile(helper, NEXT_COVERAGE_HELPER);
|
|
30
|
+
await fs.writeFile(hook, NODE_COVERAGE_HOOK);
|
|
31
|
+
await fs.writeFile(path.join(app, ".next/required-server-files.json"), JSON.stringify({config:{output:"standalone"}}));
|
|
32
|
+
for (const folder of ["server", "static"]) await fs.writeFile(path.join(app, `.next/${folder}/a.js.map`), "{}");
|
|
33
|
+
const launcher = '// __NEXT_PRIVATE_STANDALONE_CONFIG next/dist/server/lib/start-server\nrequire("./authored.cjs");\n';
|
|
34
|
+
await fs.writeFile(path.join(app, "server.js"), launcher);
|
|
35
|
+
await fs.writeFile(path.join(app, "authored.cjs"), "globalThis.applicationRan = true;\n");
|
|
36
|
+
await fs.writeFile(path.join(app, "bridge.cjs"), `
|
|
37
|
+
const {execFileSync} = require("node:child_process");
|
|
38
|
+
execFileSync(process.execPath, [${JSON.stringify(path.join(app,"server.js"))}], {cwd:${JSON.stringify(path.join(dir,"elsewhere"))}, stdio:"inherit"});
|
|
39
|
+
`);
|
|
40
|
+
await fs.writeFile(path.join(dir, "wrapper.cjs"), `
|
|
41
|
+
const {execFileSync} = require("node:child_process");
|
|
42
|
+
execFileSync(process.execPath, [${JSON.stringify(path.join(app,"bridge.cjs"))}], {cwd:${JSON.stringify(app)}, stdio:"inherit"});
|
|
43
|
+
`);
|
|
44
|
+
const adapter = kind === "next" ? next({appDir:"ops"}) : node({generated:["ops/server.js"]});
|
|
45
|
+
const configured = applyCoverageAdapters("web", {image, coverage:{adapters:[adapter]}});
|
|
46
|
+
const env = {...process.env, ...configured.env,
|
|
47
|
+
SPECTEST_COVERAGE_DIR:reports,
|
|
48
|
+
NODE_OPTIONS: `${kind === "next" ? `--require ${helper} ` : ""}--require ${hook}`,
|
|
49
|
+
} as NodeJS.ProcessEnv;
|
|
50
|
+
const ctx: CoverageSourceContext = {
|
|
51
|
+
service:"web", signal:new AbortController().signal,
|
|
52
|
+
projectFiles:async()=>[], readProjectFile:async()=>null,
|
|
53
|
+
exec:async command=>({stdout:execFileSync("sh", ["-c", command.replaceAll(NEXT_COVERAGE_HELPER_PATH, helper)], {cwd:dir,env,stdio:["ignore","pipe","pipe"]}).toString(),stderr:""}),
|
|
54
|
+
};
|
|
55
|
+
const inventory = (await adapter.inventory!(ctx)).filter(unit=>unit.startsWith("generated:"));
|
|
56
|
+
expect(inventory).toHaveLength(1);
|
|
57
|
+
expect(inventory[0]).toMatch(/^generated:web:ops\/server.js#[a-f0-9]{64}$/);
|
|
58
|
+
execFileSync("node", [path.join(dir,"wrapper.cjs")], {cwd:dir,env,stdio:["ignore","pipe","pipe"]});
|
|
59
|
+
const dumps = await Promise.all((await fs.readdir(reports)).filter(n=>/^node-.*\.json$/.test(n)).map(async n=>JSON.parse(await fs.readFile(path.join(reports,n),"utf8"))));
|
|
60
|
+
const urls = dumps.flatMap(d=>d.result.map((s:{url:string})=>s.url));
|
|
61
|
+
const units = new Set(dumps.flatMap(d=>d.units ?? []).filter((unit:string)=>unit.startsWith("generated:")));
|
|
62
|
+
expect([...units]).toEqual(inventory);
|
|
63
|
+
expect(urls.some(url=>url.endsWith("/ops/server.js"))).toBe(false);
|
|
64
|
+
expect(urls.some(url=>url.endsWith("/ops/authored.cjs"))).toBe(true);
|
|
65
|
+
expect(urls.some(url=>url.endsWith("/wrapper.cjs"))).toBe(true);
|
|
66
|
+
} finally { await fs.rm(dir, {recursive:true,force:true}); }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("image wrapper exclusions cover monorepo public assets and are archived on both report types", async () => {
|
|
70
|
+
const policies = await resolveCoverageSourceOptions([next({appDir:"ops"})], context());
|
|
71
|
+
const capture = attachCoverageSourceOptions({reports:[{name:"node-1.json"},{name:"browser.lcov"}]}, policies);
|
|
72
|
+
for (const report of capture.reports) {
|
|
73
|
+
expect(report.sourceOptions!.excludeSources).toContain("*/*.mjs (structured image object)");
|
|
74
|
+
expect(report.sourceOptions!.excludeSources).toContain("*/*.mjs (structured image object with data url)");
|
|
75
|
+
expect(report.sourceOptions!.excludeSources).not.toContain("*/public/*");
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
21
79
|
test("one adapter configures both collectors, uses its declared source root, and owns only its reports", async () => {
|
|
22
80
|
const adapter = next({sourceRoot:"packages"});
|
|
23
81
|
const configured = applyCoverageAdapters("web", {image,coverage:{adapters:[adapter]}});
|
package/src/coverage-next.ts
CHANGED
|
@@ -21,7 +21,11 @@ export const NEXT_COVERAGE_HELPER_PATH = "/spectest/next-coverage.cjs";
|
|
|
21
21
|
export const NEXT_COVERAGE_HELPER = String.raw`"use strict";
|
|
22
22
|
const fs = require("node:fs"), path = require("node:path");
|
|
23
23
|
const options = JSON.parse(process.env.SPECTEST_NEXT_COVERAGE_OPTIONS || "{}");
|
|
24
|
-
|
|
24
|
+
// Preloads run again in spawned Node processes. Carry the original runtime
|
|
25
|
+
// root into children instead of interpreting appDir against each child's cwd.
|
|
26
|
+
const root = process.env.SPECTEST_COVERAGE_ROOT || process.cwd();
|
|
27
|
+
process.env.SPECTEST_COVERAGE_ROOT = root;
|
|
28
|
+
const app = path.resolve(root, options.appDir || ".");
|
|
25
29
|
const dist = path.resolve(app, options.distDir || ".next");
|
|
26
30
|
function manifest() { return JSON.parse(fs.readFileSync(path.join(dist, "required-server-files.json"), "utf8")); }
|
|
27
31
|
function launcher(m) {
|
|
@@ -29,7 +33,7 @@ function launcher(m) {
|
|
|
29
33
|
if (m.config.output !== "standalone" || !fs.existsSync(file)) return null;
|
|
30
34
|
const text = fs.readFileSync(file, "utf8");
|
|
31
35
|
if (!text.includes("__NEXT_PRIVATE_STANDALONE_CONFIG") || !text.includes("next/dist/server/lib/start-server")) return null;
|
|
32
|
-
return path.relative(
|
|
36
|
+
return path.relative(root, file);
|
|
33
37
|
}
|
|
34
38
|
try {
|
|
35
39
|
const file = launcher(manifest());
|
|
@@ -69,7 +73,8 @@ const GENERATED_SOURCES = [
|
|
|
69
73
|
"*/data:* <text/javascript>", // Turbopack virtual modules, including action proxies.
|
|
70
74
|
"*/.next-internal/server/app/*/actions.js (server actions loader)",
|
|
71
75
|
"*/app/*--route-entry.js",
|
|
72
|
-
"
|
|
76
|
+
"*/*.mjs (structured image object)",
|
|
77
|
+
"*/*.mjs (structured image object with data url)",
|
|
73
78
|
];
|
|
74
79
|
|
|
75
80
|
/** Server + browser coverage for a built Next app. Build-time maps are required;
|
package/src/coverage.test.ts
CHANGED
|
@@ -246,7 +246,9 @@ const hasNode = await new Promise<boolean>((resolve) => {
|
|
|
246
246
|
p.on("exit", (code) => resolve(code === 0));
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
-
|
|
249
|
+
// Readiness must follow the first tick; otherwise a fast capture can win the
|
|
250
|
+
// race against the timer and correctly report that tick has not run yet.
|
|
251
|
+
const SERVER_JS = "function tick() { return 1 }\ntick(); setInterval(tick, 5); process.stdout.write('up\\n');\n";
|
|
250
252
|
// A wrapper that spawns the real server as a child node process and
|
|
251
253
|
// waits on it — what `pnpm exec`, `npm run` and the `tsx` binary do.
|
|
252
254
|
// It takes its child down with it on SIGTERM, so the test leaves no
|
|
@@ -334,7 +336,7 @@ describe.if(hasNode)("nodeCoverage hook (real node)", () => {
|
|
|
334
336
|
expect(fns.every((f) => f.some((_, i) => i % 3 === 2 && f[i]! > 0))).toBe(true);
|
|
335
337
|
const rec = (await records(dir)).get(`file://${path.join(dir, "server.js")}`)!;
|
|
336
338
|
expect(ranNames(dump, rec).sort()).toEqual(["", "tick"]);
|
|
337
|
-
expect(rec.lineLengths).toEqual([28,
|
|
339
|
+
expect(rec.lineLengths).toEqual([28, 59, 0]);
|
|
338
340
|
expect(rec.functions.map((f) => f[0])).toEqual(["", "tick"]);
|
|
339
341
|
expect(rec.sourceMap).toBeNull();
|
|
340
342
|
expect(dump.result[0]!.spectestScript).toBeDefined();
|
package/src/coverage.ts
CHANGED
|
@@ -517,9 +517,13 @@ export const NODE_COVERAGE_HOOK = `"use strict";
|
|
|
517
517
|
if (!bunRuntime) post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
|
|
518
518
|
|
|
519
519
|
const GENERATED = new Map();
|
|
520
|
+
// Generated names are service-root-relative unit identities, not paths
|
|
521
|
+
// relative to a wrapper's child process. Preserve that root through execs.
|
|
522
|
+
const generatedRoot = process.env.SPECTEST_COVERAGE_ROOT || process.cwd();
|
|
523
|
+
process.env.SPECTEST_COVERAGE_ROOT = generatedRoot;
|
|
520
524
|
try {
|
|
521
525
|
for (const file of JSON.parse(process.env.SPECTEST_COVERAGE_GENERATED_FILES || "[]")) {
|
|
522
|
-
GENERATED.set(url.pathToFileURL(path.resolve(file)).href, file);
|
|
526
|
+
GENERATED.set(url.pathToFileURL(path.resolve(generatedRoot, file)).href, file);
|
|
523
527
|
}
|
|
524
528
|
} catch (e) {}
|
|
525
529
|
const generatedUnits = new Map();
|
package/src/daemon.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { phoneScope, phoneRecordingChunk, releasePhoneRecording, type PhoneRecor
|
|
|
5
5
|
import { runWithBackgroundFailures } from "./harness/background-failures.js";
|
|
6
6
|
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
7
7
|
import { containerdImageOutput } from "./harness/built-image.js";
|
|
8
|
-
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
8
|
+
import { componentBuildTasks, COMPONENT_RUNTIME, isComponentImageTask, isDigestReference } from "./harness/component-images.js";
|
|
9
9
|
import { waitForAll } from "./harness/concurrency.js";
|
|
10
10
|
import { updateServiceProgress, serviceProgressSnapshot, type ServiceProgress } from "./harness/service-progress.js";
|
|
11
11
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
@@ -749,6 +749,18 @@ function ensureLocalBuildkitd(): Promise<boolean> {
|
|
|
749
749
|
return _localBuilder;
|
|
750
750
|
}
|
|
751
751
|
|
|
752
|
+
/** `true` when a digest-pinned reference is already complete in the store. */
|
|
753
|
+
async function imagePresentByDigest(ref: string): Promise<boolean> {
|
|
754
|
+
if (!isDigestReference(ref)) return false;
|
|
755
|
+
// `inspect` succeeds only when every layer of the image for this
|
|
756
|
+
// platform is in the store; a partial or missing image falls through to
|
|
757
|
+
// the ordinary pull, which fetches what is absent. dockerd's containerd
|
|
758
|
+
// store also unpacks on demand at container creation, so a record whose
|
|
759
|
+
// snapshots were reclaimed is repaired there rather than failing.
|
|
760
|
+
const present = await docker(["image", "inspect", "--format", "{{.Id}}", ref], 30_000);
|
|
761
|
+
return present.code === 0;
|
|
762
|
+
}
|
|
763
|
+
|
|
752
764
|
/** Host directory backing one volume mount. Rules (notably which volumes
|
|
753
765
|
* survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
|
|
754
766
|
function resolveHostPath(service: string, vol: VolumeMount): string {
|
|
@@ -1214,6 +1226,18 @@ async function prepareServiceImageInner(
|
|
|
1214
1226
|
// tags (`foo:latest`) at whatever the previous generation pulled, for
|
|
1215
1227
|
// as long as the delta chain lives — a silent semantic divergence
|
|
1216
1228
|
// from the cold build a delta restore must be equivalent to.
|
|
1229
|
+
//
|
|
1230
|
+
// The one reference that cannot float is a digest. `name@sha256:…`
|
|
1231
|
+
// names exactly one image for as long as it exists, so a copy already
|
|
1232
|
+
// in the store is the image the registry would return, and the
|
|
1233
|
+
// round-trip buys nothing: skip it. Component images pinned by digest
|
|
1234
|
+
// and user references pinned by digest take this path; a tag never does.
|
|
1235
|
+
if (await imagePresentByDigest(ref)) {
|
|
1236
|
+
progressService(svc.name, { status: "pulling", detail: `present by digest` });
|
|
1237
|
+
const tagged = await docker(["tag", ref, tag], 30_000);
|
|
1238
|
+
if (tagged.code !== 0) throw new Error(`docker tag ${ref} ${tag} failed: ${tagged.stderr.trim()}`);
|
|
1239
|
+
return { tag };
|
|
1240
|
+
}
|
|
1217
1241
|
progressService(svc.name, { status: "pulling", detail: `pulling ${ref}` });
|
|
1218
1242
|
let layers = 0;
|
|
1219
1243
|
const pull = await shxStream("docker", ["pull", ref], 900_000, undefined, (line) => {
|
|
@@ -1368,11 +1392,30 @@ async function runServiceBuild(
|
|
|
1368
1392
|
// containerd exporter replaces the tag directly, bypassing Docker's
|
|
1369
1393
|
// dangling-image retention. Old versions must remain usable until our
|
|
1370
1394
|
// image GC decides they are no longer needed (including by nested stores).
|
|
1395
|
+
//
|
|
1396
|
+
// `compression=uncompressed` (`containerdImageOutput`): the image never leaves this store, so
|
|
1397
|
+
// gzip on export only to gunzip on unpack is pure cost — on Listen,
|
|
1398
|
+
// 64 s compressing a 2.7 GB layer and 56 s decompressing it again,
|
|
1399
|
+
// single-threaded, in the same guest that already held the layer as
|
|
1400
|
+
// a build snapshot (CONTAINER_STORE.md, "Image delivery inside the
|
|
1401
|
+
// guest"). With the default `force-compression=false` this applies
|
|
1402
|
+
// to layers this build creates; a pulled base image's gzip blobs are
|
|
1403
|
+
// reused as they are, so no existing layer is re-encoded or
|
|
1404
|
+
// re-unpacked. The uncompressed blob is a second on-disk copy of the
|
|
1405
|
+
// layer; the sticky headroom logic already budgets for blobs.
|
|
1406
|
+
//
|
|
1407
|
+
// `unpack`: dockerd needs the snapshot chain to start a container,
|
|
1408
|
+
// so a service image is unpacked here rather than at `docker run`.
|
|
1409
|
+
// A component image task (a `k3s()` build) is consumed only through
|
|
1410
|
+
// the content store — `k3s-image-loader.ts` serves its blobs and the
|
|
1411
|
+
// cluster's containerd unpacks them into its own store — so unpacking
|
|
1412
|
+
// it in dockerd's store would be a third copy nobody reads.
|
|
1413
|
+
const unpack = !isComponentImageTask(name);
|
|
1371
1414
|
buildArgs = [
|
|
1372
1415
|
"buildx", "build",
|
|
1373
1416
|
"--builder", LOCAL_BUILDER_NAME,
|
|
1374
1417
|
"--progress=plain",
|
|
1375
|
-
"--output", containerdImageOutput(tag),
|
|
1418
|
+
"--output", containerdImageOutput(tag, { unpack }),
|
|
1376
1419
|
...argFlags,
|
|
1377
1420
|
"-f", dfPath, contextDir,
|
|
1378
1421
|
];
|
|
@@ -5,9 +5,17 @@ import { promisify } from "node:util";
|
|
|
5
5
|
import { canonicalImageReference } from "./component-images.js";
|
|
6
6
|
|
|
7
7
|
/** Containerd exports replace mutable tags without Docker's dangling-image
|
|
8
|
-
* retention. Keep each result addressable until the SDK's image GC evicts it.
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
* retention. Keep each result addressable until the SDK's image GC evicts it.
|
|
9
|
+
*
|
|
10
|
+
* Layers this build creates are stored uncompressed: the image never leaves
|
|
11
|
+
* the VM's store, so compressing on export only to decompress on unpack was
|
|
12
|
+
* pure cost (64 s + 56 s on Listen's 2.7 GB layer). `force-compression` stays
|
|
13
|
+
* off, so a pulled base image's blobs are reused as they are. `unpack` is
|
|
14
|
+
* false for images only a nested store reads (CONTAINER_STORE.md, "Image
|
|
15
|
+
* delivery inside the guest"). */
|
|
16
|
+
export function containerdImageOutput(reference: string, options: { unpack?: boolean } = {}): string {
|
|
17
|
+
const unpack = options.unpack ?? true;
|
|
18
|
+
return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=${unpack},compression=uncompressed`;
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
export interface Descriptor { digest: string; size: number; mediaType: string; platform?: { os: string; architecture: string } }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { canonicalImageReference, componentBuildTasks, kubernetesImages } from "./component-images";
|
|
2
|
+
import { canonicalImageReference, componentBuildTasks, isComponentImageTask, isDigestReference, kubernetesImages } from "./component-images";
|
|
3
3
|
import { validateServiceImage, type BuildImage } from "../index";
|
|
4
4
|
|
|
5
5
|
test("services and Kubernetes share all Dockerfile build options without a type discriminator", () => {
|
|
@@ -42,3 +42,21 @@ test("build task identity survives list reordering but is scoped to the owning c
|
|
|
42
42
|
expect(() => validateServiceImage("app", { content: "FROM scratch", buildArgs: { "bad-key": "x" } })).toThrow("ARG");
|
|
43
43
|
expect(() => validateServiceImage("app", { content: "FROM scratch", exclude: [1] as never })).toThrow("exclude");
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
test("component image tasks are recognised by name and services are not", () => {
|
|
47
|
+
const [task] = componentBuildTasks([{ name: "cluster", image: { type: "registry", reference: "rancher/k3s:v1.33.1-k3s1" },
|
|
48
|
+
imageBuilds: [{ reference: "registry.internal/app:dev", image: { type: "dockerfile", content: "FROM scratch" } }] } as any]);
|
|
49
|
+
expect(isComponentImageTask(task!.name)).toBe(true);
|
|
50
|
+
expect(isComponentImageTask("cluster")).toBe(false);
|
|
51
|
+
expect(isComponentImageTask("spectest-image")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("only a full sha256 digest reference is immutable", () => {
|
|
55
|
+
const digest = "a".repeat(64);
|
|
56
|
+
expect(isDigestReference(`postgres@sha256:${digest}`)).toBe(true);
|
|
57
|
+
expect(isDigestReference(`ghcr.io/org/app:1.2@sha256:${digest}`)).toBe(true);
|
|
58
|
+
expect(isDigestReference("postgres:16.4")).toBe(false);
|
|
59
|
+
expect(isDigestReference("postgres:latest")).toBe(false);
|
|
60
|
+
expect(isDigestReference(`postgres@sha256:${digest.slice(1)}`)).toBe(false);
|
|
61
|
+
expect(isDigestReference(`postgres@sha512:${digest}`)).toBe(false);
|
|
62
|
+
});
|
|
@@ -26,6 +26,11 @@ export function canonicalImageReference(reference: string): string {
|
|
|
26
26
|
return result;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** A `name@sha256:…` reference names one immutable image; a tag can move. */
|
|
30
|
+
export function isDigestReference(reference: string): boolean {
|
|
31
|
+
return /@sha256:[a-f0-9]{64}$/.test(reference);
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: string[]; builds: ComponentImageBuild[] } {
|
|
30
35
|
if (!Array.isArray(images)) throw new Error("k3s(): images must be an array of registry references or Dockerfile builds");
|
|
31
36
|
const pulls: string[] = [];
|
|
@@ -53,10 +58,19 @@ export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: s
|
|
|
53
58
|
return { pulls, builds };
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
/** Prefix of every component image task name; `isComponentImageTask` is the test. */
|
|
62
|
+
export const COMPONENT_IMAGE_TASK_PREFIX = "spectest-image-";
|
|
63
|
+
|
|
64
|
+
/** A component image task builds for a component's own runtime (today: a
|
|
65
|
+
* `k3s()` cluster's containerd), never for a container dockerd starts. */
|
|
66
|
+
export function isComponentImageTask(name: string): boolean {
|
|
67
|
+
return name.startsWith(COMPONENT_IMAGE_TASK_PREFIX);
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
/** Stable internal task names, separate from the service graph. */
|
|
57
71
|
export function componentBuildTasks<T extends ServiceConfig & { name: string }>(services: readonly T[]) {
|
|
58
72
|
return services.flatMap(svc => (svc.imageBuilds ?? []).map(build => ({
|
|
59
|
-
name:
|
|
73
|
+
name: `${COMPONENT_IMAGE_TASK_PREFIX}${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
|
|
60
74
|
owner: svc.name,
|
|
61
75
|
reference: build.reference,
|
|
62
76
|
image: build.image,
|
|
@@ -104,6 +104,12 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
104
104
|
dockerConfig["data-root"] = `${config.path}/docker`;
|
|
105
105
|
dockerConfig.features = { "containerd-snapshotter": true };
|
|
106
106
|
dockerConfig["storage-driver"] = "overlayfs";
|
|
107
|
+
// Pull up to ten layers at once instead of dockerd's default three. The
|
|
108
|
+
// containerd image store honours this since Docker 29.7.0; extraction is
|
|
109
|
+
// still one layer at a time, so this only moves the download side off
|
|
110
|
+
// the critical path of many-layer images. Set here as well as in the
|
|
111
|
+
// golden's daemon.json so an SDK release carries it without a rebuild.
|
|
112
|
+
dockerConfig["max-concurrent-downloads"] = 10;
|
|
107
113
|
await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
|
|
108
114
|
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config, (await diskUsage(config.path)).reservedBytes));
|
|
109
115
|
const start = async (unit: string, program: string, args: string[]) => {
|