@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.
@@ -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
- const app = path.resolve(options.appDir || ".");
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(process.cwd(), file);
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
- "*/app/*.mjs (structured image object)",
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
- export declare function containerdImageOutput(reference: string): string;
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
- export function containerdImageOutput(reference) {
9
- return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=true`;
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: `spectest-image-${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
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
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.13",
3
+ "version": "0.131.15",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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]}});
@@ -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
- const app = path.resolve(options.appDir || ".");
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(process.cwd(), file);
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
- "*/app/*.mjs (structured image object)",
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;
@@ -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
- const SERVER_JS = "function tick() { return 1 }\nsetInterval(tick, 5); process.stdout.write('up\\n');\n";
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, 51, 0]);
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
- export function containerdImageOutput(reference: string): string {
10
- return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=true`;
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: `spectest-image-${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
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[]) => {