@specific.dev/spectest 0.73.0 → 0.74.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/browser.js CHANGED
@@ -236,11 +236,17 @@ async function applySafeAreaInsets(cdp, d) {
236
236
  // ────────────────────────────────────────────────────────────────────────
237
237
  // rrweb bootstrap
238
238
  // ────────────────────────────────────────────────────────────────────────
239
- // Vendored `@rrweb/record` UMD bundle (rrweb 2.x). Its global `rrwebRecord`
240
- // is a module object — the record function is `rrwebRecord.record` (the
241
- // bootstrap resolves both this and the legacy function-shaped global). Read
242
- // once at module init — the SDK ships this file in the base snapshot so
243
- // no network fetch happens inside the VM.
239
+ // Vendored `@rrweb/record` UMD bundle (rrweb 2.x), BUILT FROM OUR FORK by
240
+ // `scripts/rrweb-record/build.sh` (specific-dev/rrweb, branch `spectest` =
241
+ // the `rrweb@2.1.0` tag + upstream PR 1802). Never copy the npm dist over
242
+ // it: upstream's `@rrweb/utils` lists `querySelector` as an accessor, so its
243
+ // pristine-prototype test always fails and `record()` appends an <iframe>
244
+ // to every document — measured at ~1 s per navigation (see the top-frame
245
+ // gate on PAGE_INIT_SCRIPT below). Its global `rrwebRecord` is a module
246
+ // object — the record function is `rrwebRecord.record` (the bootstrap
247
+ // resolves both this and the legacy function-shaped global). Read once at
248
+ // module init — the SDK ships this file in the base snapshot so no network
249
+ // fetch happens inside the VM.
244
250
  const RRWEB_BUNDLE = (() => {
245
251
  try {
246
252
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -711,8 +717,22 @@ const RRWEB_BOOTSTRAP = `
711
717
  // call expression — the TypeError kills everything after the bundle, so
712
718
  // the globals define but the bootstrap never runs (zero rrweb events,
713
719
  // empty replays).
720
+ //
721
+ // The whole script runs in the TOP frame only. `Page.addScriptToEvaluateOnNewDocument`
722
+ // fires for every frame, and until 2026-09-06 that was a storm: rrweb's
723
+ // `getUntaintedPrototype` appends a hidden <iframe> to read pristine DOM
724
+ // prototypes, this script then ran inside it, the child's `record()` (which
725
+ // treats every frame as an emitting frame unless `recordCrossOriginIframes`
726
+ // is set) appended an iframe of its own, and so on to Chromium's frame-depth
727
+ // cap — 44 nested frames and 45 executions of ~180 KB of JS per navigation,
728
+ // ~0.8–1.3 s inside the page's `load` handler even for a one-line document
729
+ // (`goto` p50 was ~1.5 s in every project). The vendored bundle's fork patch
730
+ // removes the iframe on an unpatched page; this gate bounds it on a page
731
+ // whose prototypes really are patched (zone.js). Nothing is lost: rrweb
732
+ // records same-origin iframes from the parent, and a cross-origin child's
733
+ // events were never drained (they land in the child's own buffer).
714
734
  const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
715
- ? `${RRWEB_BUNDLE}\n;\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n;\n${RRWEB_BOOTSTRAP}`
735
+ ? `if (window === window.top) {\n${RRWEB_BUNDLE}\n;\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n;\n${RRWEB_BOOTSTRAP}\n}`
716
736
  : "";
717
737
  // Page-side expression that atomically swaps in a fresh buffer and
718
738
  // returns the old one. Wrapped as a single expression so view.evaluate's
package/dist/daemon.js CHANGED
@@ -35,7 +35,7 @@ import { isMobileApp, openPersistentMobile } from "./mobile.js";
35
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
- import { summarizeBuildKit } from "./harness/buildkit-progress.js";
38
+ import { executedSteps, summarizeBuildKit } from "./harness/buildkit-progress.js";
39
39
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
40
40
  import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
41
41
  import { pollUntilReady } from "./harness/ready-poll.js";
@@ -50,7 +50,7 @@ import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectC
50
50
  import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
51
51
  import { startTlsTerminator } from "./harness/tls-terminator.js";
52
52
  import { runContainerArgs } from "./harness/container-run.js";
53
- import { IMAGE_CACHE_MANIFEST, imageCachePathsSync, isOnImageCache } from "./harness/image-cache.js";
53
+ import { BUILDKIT_CACHE_DIR, BUILDKIT_CACHE_UNION, IMAGE_CACHE_MANIFEST, imageCachePathsSync, isOnImageCache, mergeCacheIndex, } from "./harness/image-cache.js";
54
54
  import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
55
55
  import { conflict, notFound, requireString, } from "./harness/methods.js";
56
56
  import { openTerminal } from "./terminal.js";
@@ -507,14 +507,88 @@ async function ensureLocalBuildkitd() {
507
507
  }
508
508
  // eslint-disable-next-line no-console
509
509
  console.log(`[build] building in this VM against the image cache (root ${paths.root}, layers ${paths.layers})`);
510
- // Imports come from the merged cache on the read-only layers disk and
511
- // from this lineage's own exports on the root; exports go to the root,
512
- // where the merge picks them up (image_cache/merge.rs).
513
- _localCacheDir = `${state}/spectest-buildkit-cache`;
514
- _localCacheImports = [`${paths.layers}/spectest-buildkit-cache`, `${state}/spectest-buildkit-cache`];
510
+ const union = await ensureBuildkitCacheUnion(paths);
511
+ if (union) {
512
+ // One directory for both flags: the merged cache on the layers disk
513
+ // seen through this lineage's own exports on the root. See
514
+ // `BUILDKIT_CACHE_UNION` for why the exporter needs the union.
515
+ _localCacheDir = union;
516
+ _localCacheImports = [union];
517
+ }
518
+ else {
519
+ // Imports come from the merged cache on the read-only layers disk and
520
+ // from this lineage's own exports on the root; exports go to the root,
521
+ // where the merge picks them up (image_cache/merge.rs). Every cold
522
+ // build re-exports every blob this way, which is the cost the union
523
+ // removes.
524
+ _localCacheDir = `${state}/${BUILDKIT_CACHE_DIR}`;
525
+ _localCacheImports = [`${paths.layers}/${BUILDKIT_CACHE_DIR}`, `${state}/${BUILDKIT_CACHE_DIR}`];
526
+ }
515
527
  _localBuilder = true;
516
528
  return true;
517
529
  }
530
+ /**
531
+ * Mount the BuildKit cache union (`BUILDKIT_CACHE_UNION`): overlayfs with
532
+ * the layers disk's cache directory as the lower and the root disk's as
533
+ * the upper, so one path serves `--cache-from` and `--cache-to`.
534
+ *
535
+ * Idempotent — a delta restore restarts the harness on a guest that
536
+ * still has the mount. Returns null, and the caller keeps the two-directory
537
+ * scheme, when the lower does not exist yet (a volume that has never
538
+ * been merged has no cache directory on its layers disk) or the mount is
539
+ * refused; the build is then slower, never failed. The work directory
540
+ * has to share a filesystem with the upper, so it lives on the root disk
541
+ * beside it; the merge reads neither it nor anything but the upper.
542
+ */
543
+ async function ensureBuildkitCacheUnion(paths) {
544
+ const lower = `${paths.layers}/${BUILDKIT_CACHE_DIR}`;
545
+ const upper = `${paths.root}/${BUILDKIT_CACHE_DIR}`;
546
+ const work = `${paths.root}/spectest-buildkit-work`;
547
+ if (!existsSync(lower)) {
548
+ // eslint-disable-next-line no-console
549
+ console.log(`[build] no merged BuildKit cache on the layers disk yet; exporting to the root directly`);
550
+ return null;
551
+ }
552
+ // overlayfs shows the upper's `index.json` INSTEAD of the lower's, so
553
+ // an index on the root (a lineage's exports, or one left behind with
554
+ // its blobs gone) would hide every merged tag and, naming manifests
555
+ // that are not there, make BuildKit skip the import altogether. Write
556
+ // the union of the two into the upper first (`mergeCacheIndex`), and
557
+ // say what each side held so a miss can be read off the boot log.
558
+ try {
559
+ const readIndex = (dir) => existsSync(`${dir}/index.json`) ? JSON.parse(readFileSync(`${dir}/index.json`, "utf8")) : null;
560
+ const lowerIdx = readIndex(lower);
561
+ const upperIdx = readIndex(upper);
562
+ if (upperIdx && lowerIdx) {
563
+ const merged = mergeCacheIndex(lowerIdx, upperIdx, (hex) => existsSync(`${upper}/blobs/sha256/${hex}`));
564
+ await fs.mkdir(upper, { recursive: true });
565
+ await fs.writeFile(`${upper}/index.json`, JSON.stringify(merged));
566
+ await fs.writeFile(`${upper}/oci-layout`, JSON.stringify({ imageLayoutVersion: "1.0.0" }));
567
+ // eslint-disable-next-line no-console
568
+ console.log(`[build] BuildKit cache union: ${lowerIdx.manifests.length} tag(s) from the merged cache, ${upperIdx.manifests.length} on this root, ${merged.manifests.length} after the merge`);
569
+ }
570
+ else {
571
+ // eslint-disable-next-line no-console
572
+ console.log(`[build] BuildKit cache union: ${lowerIdx?.manifests.length ?? 0} tag(s) from the merged cache, none on this root`);
573
+ }
574
+ }
575
+ catch (err) {
576
+ // eslint-disable-next-line no-console
577
+ console.warn(`[build] could not reconcile the BuildKit cache indexes (${err}); the union shows the root's`);
578
+ }
579
+ const r = await shx("/bin/sh", [
580
+ "-c",
581
+ `mkdir -p "${BUILDKIT_CACHE_UNION}" "${upper}" "${work}" && ` +
582
+ `{ mountpoint -q "${BUILDKIT_CACHE_UNION}" || ` +
583
+ `mount -t overlay overlay -o "lowerdir=${lower},upperdir=${upper},workdir=${work}" "${BUILDKIT_CACHE_UNION}"; }`,
584
+ ], 60_000);
585
+ if (r.code !== 0) {
586
+ // eslint-disable-next-line no-console
587
+ console.warn(`[build] could not mount the BuildKit cache union; exporting to the root directly:\n${(r.stderr || r.stdout).trim()}`);
588
+ return null;
589
+ }
590
+ return BUILDKIT_CACHE_UNION;
591
+ }
518
592
  /** The exported-cache directory on the cache disk, once the in-VM builder is up. */
519
593
  let _localCacheDir = null;
520
594
  /** The cache directories a build imports from: the merged one on the
@@ -1018,6 +1092,10 @@ async function runServiceBuild(name, image, tag) {
1018
1092
  const useBuildKit = useLocal || (await hasBuildx());
1019
1093
  const buildEnv = {};
1020
1094
  let buildArgs;
1095
+ // The `--cache-to` flag, held back for a second pass that runs only
1096
+ // when the first one executed a step (see below). Null off the in-VM
1097
+ // builder.
1098
+ let cacheExport = null;
1021
1099
  // The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
1022
1100
  // so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
1023
1101
  const argFlags = buildArgFlags(image.buildArgs);
@@ -1030,6 +1108,10 @@ async function runServiceBuild(name, image, tag) {
1030
1108
  // intermediate layer, uncompressed so an import never inflates,
1031
1109
  // and one tag per service so exports do not replace each other.
1032
1110
  const cacheTag = name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1111
+ cacheExport = [
1112
+ "--cache-to",
1113
+ `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
1114
+ ];
1033
1115
  // LANDMINE: the local cache importer reads the `latest` entry of
1034
1116
  // the directory's index unless told otherwise, and the exporter
1035
1117
  // below writes this service's entry under `tag=<service>`. An
@@ -1042,7 +1124,6 @@ async function runServiceBuild(name, image, tag) {
1042
1124
  "--progress=plain",
1043
1125
  "--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
1044
1126
  ..._localCacheImports.flatMap((src) => ["--cache-from", `type=local,src=${src},tag=${cacheTag}`]),
1045
- "--cache-to", `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
1046
1127
  ...argFlags,
1047
1128
  "-f", dfPath, WORKSPACE,
1048
1129
  ];
@@ -1093,6 +1174,27 @@ async function runServiceBuild(name, image, tag) {
1093
1174
  if (build.code !== 0) {
1094
1175
  return { ok: false, log };
1095
1176
  }
1177
+ // Export the cache only when there is something new to export. A
1178
+ // `mode=max` export costs ~3 s per service even when every blob is
1179
+ // already in the destination (measured 2026-09-06, api: 2.2–3.0 s
1180
+ // for the cached build alone, 4.9–7.5 s with the export), and a cold
1181
+ // start of a cached project is nothing but such builds. When a step
1182
+ // did run, the second pass is that same build fully cached from the
1183
+ // daemon's own state plus the export — seconds on top of a build
1184
+ // that took tens of them. Best-effort: the image is already in the
1185
+ // store, so a failed export is a slower next cold start, not a
1186
+ // failed service.
1187
+ if (cacheExport) {
1188
+ const ran = executedSteps(build.stderr);
1189
+ if (ran.length > 0) {
1190
+ progressService(name, { status: "building", detail: `exporting cache (${ran.length} step(s) ran)` });
1191
+ const exported = await shxStream("docker", [...buildArgs, ...cacheExport], 1_800_000, buildEnv, () => { });
1192
+ if (exported.code !== 0) {
1193
+ // eslint-disable-next-line no-console
1194
+ console.warn(`[build] ${name}: cache export failed; the next cold start rebuilds it:\n${exported.stderr.trim().slice(-2000)}`);
1195
+ }
1196
+ }
1197
+ }
1096
1198
  if (useBuildKit) {
1097
1199
  // Keep only the slowest dozen steps ≥1s — enough to profile, small
1098
1200
  // enough to ride back in the /bootstrap response and the journal.
@@ -35,3 +35,21 @@ export interface BuildStep {
35
35
  * omitting it makes a fully-cached build look like it did nothing.
36
36
  */
37
37
  export declare function summarizeBuildKit(out: string): BuildStep[];
38
+ /**
39
+ * The Dockerfile steps a build actually ran — the ones a cache export
40
+ * would have something new to record.
41
+ *
42
+ * A fully cached build still has to export its cache today, and that
43
+ * export costs ~3 s per service in `mode=max` even when every blob is
44
+ * already in the destination (measured 2026-09-06: 2.2–3.0 s for the
45
+ * build, 4.9–7.5 s with the export). So the harness builds without
46
+ * `--cache-to` first and exports only when this says something ran.
47
+ *
48
+ * What counts: a bracketed stage step (`[builder 3/6] RUN …`) that ended
49
+ * in `DONE` rather than `CACHED`. What does not: BuildKit's own
50
+ * `[internal]`/`[auth]` bookkeeping, which is never cached and never a
51
+ * layer, and a `FROM` step that finished in the blink it takes to find
52
+ * the base image locally — a pulled base takes seconds and does count,
53
+ * since the export records its layers too.
54
+ */
55
+ export declare function executedSteps(out: string): string[];
@@ -64,3 +64,57 @@ export function summarizeBuildKit(out) {
64
64
  }
65
65
  return steps.sort((a, b) => b.secs - a.secs);
66
66
  }
67
+ /**
68
+ * The Dockerfile steps a build actually ran — the ones a cache export
69
+ * would have something new to record.
70
+ *
71
+ * A fully cached build still has to export its cache today, and that
72
+ * export costs ~3 s per service in `mode=max` even when every blob is
73
+ * already in the destination (measured 2026-09-06: 2.2–3.0 s for the
74
+ * build, 4.9–7.5 s with the export). So the harness builds without
75
+ * `--cache-to` first and exports only when this says something ran.
76
+ *
77
+ * What counts: a bracketed stage step (`[builder 3/6] RUN …`) that ended
78
+ * in `DONE` rather than `CACHED`. What does not: BuildKit's own
79
+ * `[internal]`/`[auth]` bookkeeping, which is never cached and never a
80
+ * layer, and a `FROM` step that finished in the blink it takes to find
81
+ * the base image locally — a pulled base takes seconds and does count,
82
+ * since the export records its layers too.
83
+ */
84
+ export function executedSteps(out) {
85
+ const names = new Map();
86
+ const stages = new Map();
87
+ const secs = new Map();
88
+ const cached = new Set();
89
+ for (const line of out.split("\n")) {
90
+ let m = line.match(/^#(\d+)\s+\[([^\]]*)\]\s+(.+)$/);
91
+ if (m) {
92
+ const id = `#${m[1]}`;
93
+ if (!names.has(id)) {
94
+ names.set(id, m[3].trim().slice(0, MAX_NAME));
95
+ stages.set(id, m[2].trim());
96
+ }
97
+ continue;
98
+ }
99
+ m = line.match(/^#(\d+)\s+DONE\s+([\d.]+)s/);
100
+ if (m) {
101
+ secs.set(`#${m[1]}`, parseFloat(m[2]));
102
+ continue;
103
+ }
104
+ m = line.match(/^#(\d+)\s+CACHED/);
105
+ if (m)
106
+ cached.add(`#${m[1]}`);
107
+ }
108
+ const ran = [];
109
+ for (const [id, name] of names) {
110
+ if (cached.has(id))
111
+ continue;
112
+ const stage = stages.get(id) ?? "";
113
+ if (stage === "internal" || stage === "auth")
114
+ continue;
115
+ if (/^FROM\s/.test(name) && (secs.get(id) ?? 0) < 0.5)
116
+ continue;
117
+ ran.push(name);
118
+ }
119
+ return ran;
120
+ }
@@ -35,6 +35,27 @@ export declare const MKFS_EROFS_PATH = "/usr/local/bin/mkfs.erofs";
35
35
  /** The guest's adopt helper (`base.rs::STORE_ADOPT_SH`), POSIX sh so a
36
36
  * nested runtime's busybox can run the same file. */
37
37
  export declare const STORE_ADOPT_PATH = "/usr/local/bin/spectest-store-adopt";
38
+ /**
39
+ * The BuildKit cache directory under each cache disk (`merge.rs::CACHE_DIR`):
40
+ * on the layers disk the merged export of every earlier build, read-only;
41
+ * on the root disk this lineage's own exports, which the merge harvests.
42
+ */
43
+ export declare const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
44
+ /**
45
+ * One directory that is both: an overlayfs with the layers disk's cache
46
+ * directory as the read-only lower and the root disk's as the upper,
47
+ * mounted here by the harness when the in-VM builder comes up
48
+ * (`daemon.ts::ensureBuildkitCacheUnion`). A build imports from it and
49
+ * exports to it. BuildKit's local exporter skips a blob its destination
50
+ * already holds, and through the union every merged blob is already
51
+ * there — so a fully cached build exports its index and manifest and
52
+ * nothing else, where a fresh directory on the root cost it every
53
+ * uncompressed layer again (1.1 GB and 20 s for one service, measured
54
+ * 2026-09-06). New layers land in the upper, which is exactly the
55
+ * directory the merge read before. A user-run BuildKit mounts this one
56
+ * path for both of its cache flags.
57
+ */
58
+ export declare const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
38
59
  /** The cache's paths, or `null` when this VM carries none.
39
60
  * `SPECTEST_IMAGE_CACHE_MANIFEST` points a test at another file. */
40
61
  export declare function imageCachePathsSync(manifest?: string): ImageCachePaths | null;
@@ -46,3 +67,33 @@ export declare function nestedStoreDir(paths: ImageCachePaths, service: string):
46
67
  * delta-restore teardown must not wipe it.
47
68
  */
48
69
  export declare function isOnImageCache(hostPath: string, paths: ImageCachePaths | null): boolean;
70
+ /** The shape of an OCI layout's `index.json` the BuildKit local cache
71
+ * exporter writes: one manifest per tag, the tag in the ref-name
72
+ * annotation. */
73
+ export interface OciIndex {
74
+ schemaVersion?: number;
75
+ mediaType?: string;
76
+ manifests: Array<{
77
+ mediaType?: string;
78
+ digest: string;
79
+ size?: number;
80
+ annotations?: Record<string, string>;
81
+ }>;
82
+ }
83
+ /**
84
+ * The index the BuildKit cache union must show: every tag of the merged
85
+ * cache (lower), overridden by this lineage's own exports (upper) where
86
+ * the upper still holds the manifest.
87
+ *
88
+ * overlayfs shadows whole files, so an `index.json` in the upper hides
89
+ * the lower's completely — and a root disk can carry one: the exports
90
+ * of a lineage that built before the union existed, or an index left
91
+ * behind with its blobs gone. Either way BuildKit then imports from an
92
+ * index naming a few tags whose manifests may not exist, skips the
93
+ * import, and every step runs (seen 2026-09-06: a cold build with
94
+ * every service's cache on the layers disk and 4 stale tags in the
95
+ * upper built everything from scratch). `upperHasBlob` says whether the
96
+ * upper holds a manifest's blob; an upper entry without one is dropped,
97
+ * since it would fail the whole import.
98
+ */
99
+ export declare function mergeCacheIndex(lower: OciIndex | null, upper: OciIndex | null, upperHasBlob: (hex: string) => boolean): OciIndex;
@@ -29,6 +29,27 @@ export const MKFS_EROFS_PATH = "/usr/local/bin/mkfs.erofs";
29
29
  /** The guest's adopt helper (`base.rs::STORE_ADOPT_SH`), POSIX sh so a
30
30
  * nested runtime's busybox can run the same file. */
31
31
  export const STORE_ADOPT_PATH = "/usr/local/bin/spectest-store-adopt";
32
+ /**
33
+ * The BuildKit cache directory under each cache disk (`merge.rs::CACHE_DIR`):
34
+ * on the layers disk the merged export of every earlier build, read-only;
35
+ * on the root disk this lineage's own exports, which the merge harvests.
36
+ */
37
+ export const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
38
+ /**
39
+ * One directory that is both: an overlayfs with the layers disk's cache
40
+ * directory as the read-only lower and the root disk's as the upper,
41
+ * mounted here by the harness when the in-VM builder comes up
42
+ * (`daemon.ts::ensureBuildkitCacheUnion`). A build imports from it and
43
+ * exports to it. BuildKit's local exporter skips a blob its destination
44
+ * already holds, and through the union every merged blob is already
45
+ * there — so a fully cached build exports its index and manifest and
46
+ * nothing else, where a fresh directory on the root cost it every
47
+ * uncompressed layer again (1.1 GB and 20 s for one service, measured
48
+ * 2026-09-06). New layers land in the upper, which is exactly the
49
+ * directory the merge read before. A user-run BuildKit mounts this one
50
+ * path for both of its cache flags.
51
+ */
52
+ export const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
32
53
  function parse(raw) {
33
54
  const parsed = JSON.parse(raw);
34
55
  const root = (parsed.disks ?? []).find((d) => d.role === "root" && d.path)?.path;
@@ -59,5 +80,51 @@ export function nestedStoreDir(paths, service) {
59
80
  export function isOnImageCache(hostPath, paths) {
60
81
  if (!paths)
61
82
  return false;
83
+ // The union is the root's cache directory seen through the layers
84
+ // disk: wiping it would wipe the upper, i.e. this lineage's exports.
85
+ if (hostPath === BUILDKIT_CACHE_UNION || hostPath.startsWith(`${BUILDKIT_CACHE_UNION}/`))
86
+ return true;
62
87
  return hostPath === paths.root || hostPath.startsWith(`${paths.root}/`) || hostPath === paths.layers || hostPath.startsWith(`${paths.layers}/`);
63
88
  }
89
+ const REF_NAME = "org.opencontainers.image.ref.name";
90
+ /**
91
+ * The index the BuildKit cache union must show: every tag of the merged
92
+ * cache (lower), overridden by this lineage's own exports (upper) where
93
+ * the upper still holds the manifest.
94
+ *
95
+ * overlayfs shadows whole files, so an `index.json` in the upper hides
96
+ * the lower's completely — and a root disk can carry one: the exports
97
+ * of a lineage that built before the union existed, or an index left
98
+ * behind with its blobs gone. Either way BuildKit then imports from an
99
+ * index naming a few tags whose manifests may not exist, skips the
100
+ * import, and every step runs (seen 2026-09-06: a cold build with
101
+ * every service's cache on the layers disk and 4 stale tags in the
102
+ * upper built everything from scratch). `upperHasBlob` says whether the
103
+ * upper holds a manifest's blob; an upper entry without one is dropped,
104
+ * since it would fail the whole import.
105
+ */
106
+ export function mergeCacheIndex(lower, upper, upperHasBlob) {
107
+ const byTag = new Map();
108
+ const untagged = [];
109
+ for (const m of lower?.manifests ?? []) {
110
+ const tag = m.annotations?.[REF_NAME];
111
+ if (tag)
112
+ byTag.set(tag, m);
113
+ else
114
+ untagged.push(m);
115
+ }
116
+ for (const m of upper?.manifests ?? []) {
117
+ if (!upperHasBlob(m.digest.replace(/^sha256:/, "")))
118
+ continue;
119
+ const tag = m.annotations?.[REF_NAME];
120
+ if (tag)
121
+ byTag.set(tag, m);
122
+ else
123
+ untagged.push(m);
124
+ }
125
+ return {
126
+ schemaVersion: 2,
127
+ mediaType: lower?.mediaType ?? upper?.mediaType ?? "application/vnd.oci.image.index.v1+json",
128
+ manifests: [...untagged, ...byTag.values()],
129
+ };
130
+ }
@@ -131,13 +131,13 @@ const testableAccessors = {
131
131
  "ownerDocument"
132
132
  ],
133
133
  ShadowRoot: ["host", "styleSheets"],
134
- Element: ["shadowRoot", "querySelector", "querySelectorAll"],
134
+ Element: ["shadowRoot"],
135
135
  MutationObserver: []
136
136
  };
137
137
  const testableMethods = {
138
138
  Node: ["contains", "getRootNode"],
139
139
  ShadowRoot: ["getSelection"],
140
- Element: [],
140
+ Element: ["querySelector", "querySelectorAll"],
141
141
  MutationObserver: ["constructor"]
142
142
  };
143
143
  const untaintedBasePrototype = {};
@@ -263,10 +263,10 @@ function shadowRoot(n) {
263
263
  return getUntaintedAccessor("Element", n, "shadowRoot");
264
264
  }
265
265
  function querySelector(n, selectors) {
266
- return getUntaintedAccessor("Element", n, "querySelector")(selectors);
266
+ return getUntaintedMethod("Element", n, "querySelector")(selectors);
267
267
  }
268
268
  function querySelectorAll(n, selectors) {
269
- return getUntaintedAccessor("Element", n, "querySelectorAll")(selectors);
269
+ return getUntaintedMethod("Element", n, "querySelectorAll")(selectors);
270
270
  }
271
271
  function mutationObserverCtor() {
272
272
  var _a2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.73.0",
3
+ "version": "0.74.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/browser.ts CHANGED
@@ -615,11 +615,17 @@ async function applySafeAreaInsets(
615
615
  // rrweb bootstrap
616
616
  // ────────────────────────────────────────────────────────────────────────
617
617
 
618
- // Vendored `@rrweb/record` UMD bundle (rrweb 2.x). Its global `rrwebRecord`
619
- // is a module object — the record function is `rrwebRecord.record` (the
620
- // bootstrap resolves both this and the legacy function-shaped global). Read
621
- // once at module init — the SDK ships this file in the base snapshot so
622
- // no network fetch happens inside the VM.
618
+ // Vendored `@rrweb/record` UMD bundle (rrweb 2.x), BUILT FROM OUR FORK by
619
+ // `scripts/rrweb-record/build.sh` (specific-dev/rrweb, branch `spectest` =
620
+ // the `rrweb@2.1.0` tag + upstream PR 1802). Never copy the npm dist over
621
+ // it: upstream's `@rrweb/utils` lists `querySelector` as an accessor, so its
622
+ // pristine-prototype test always fails and `record()` appends an <iframe>
623
+ // to every document — measured at ~1 s per navigation (see the top-frame
624
+ // gate on PAGE_INIT_SCRIPT below). Its global `rrwebRecord` is a module
625
+ // object — the record function is `rrwebRecord.record` (the bootstrap
626
+ // resolves both this and the legacy function-shaped global). Read once at
627
+ // module init — the SDK ships this file in the base snapshot so no network
628
+ // fetch happens inside the VM.
623
629
  const RRWEB_BUNDLE: string = (() => {
624
630
  try {
625
631
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -1094,8 +1100,22 @@ const RRWEB_BOOTSTRAP = `
1094
1100
  // call expression — the TypeError kills everything after the bundle, so
1095
1101
  // the globals define but the bootstrap never runs (zero rrweb events,
1096
1102
  // empty replays).
1103
+ //
1104
+ // The whole script runs in the TOP frame only. `Page.addScriptToEvaluateOnNewDocument`
1105
+ // fires for every frame, and until 2026-09-06 that was a storm: rrweb's
1106
+ // `getUntaintedPrototype` appends a hidden <iframe> to read pristine DOM
1107
+ // prototypes, this script then ran inside it, the child's `record()` (which
1108
+ // treats every frame as an emitting frame unless `recordCrossOriginIframes`
1109
+ // is set) appended an iframe of its own, and so on to Chromium's frame-depth
1110
+ // cap — 44 nested frames and 45 executions of ~180 KB of JS per navigation,
1111
+ // ~0.8–1.3 s inside the page's `load` handler even for a one-line document
1112
+ // (`goto` p50 was ~1.5 s in every project). The vendored bundle's fork patch
1113
+ // removes the iframe on an unpatched page; this gate bounds it on a page
1114
+ // whose prototypes really are patched (zone.js). Nothing is lost: rrweb
1115
+ // records same-origin iframes from the parent, and a cross-origin child's
1116
+ // events were never drained (they land in the child's own buffer).
1097
1117
  const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
1098
- ? `${RRWEB_BUNDLE}\n;\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n;\n${RRWEB_BOOTSTRAP}`
1118
+ ? `if (window === window.top) {\n${RRWEB_BUNDLE}\n;\n${RRWEB_CONSOLE_PLUGIN_BUNDLE}\n;\n${RRWEB_BOOTSTRAP}\n}`
1099
1119
  : "";
1100
1120
 
1101
1121
  // Page-side expression that atomically swaps in a fresh buffer and
package/src/daemon.ts CHANGED
@@ -83,7 +83,7 @@ import {
83
83
  type CaseMeta,
84
84
  type GroupMeta,
85
85
  } from "./harness/catalogue.js";
86
- import { summarizeBuildKit, type BuildStep } from "./harness/buildkit-progress.js";
86
+ import { executedSteps, summarizeBuildKit, type BuildStep } from "./harness/buildkit-progress.js";
87
87
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
88
88
  import {
89
89
  resolveHostPath as resolveVolumeHostPath,
@@ -135,7 +135,16 @@ import {
135
135
  } from "./harness/ingress-table.js";
136
136
  import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
137
137
  import { runContainerArgs } from "./harness/container-run.js";
138
- import { IMAGE_CACHE_MANIFEST, imageCachePathsSync, isOnImageCache, type ImageCachePaths } from "./harness/image-cache.js";
138
+ import {
139
+ BUILDKIT_CACHE_DIR,
140
+ BUILDKIT_CACHE_UNION,
141
+ IMAGE_CACHE_MANIFEST,
142
+ imageCachePathsSync,
143
+ isOnImageCache,
144
+ mergeCacheIndex,
145
+ type ImageCachePaths,
146
+ type OciIndex,
147
+ } from "./harness/image-cache.js";
139
148
  import {
140
149
  assertAbsolute,
141
150
  certificateHostnames,
@@ -778,14 +787,93 @@ async function ensureLocalBuildkitd(): Promise<boolean> {
778
787
  }
779
788
  // eslint-disable-next-line no-console
780
789
  console.log(`[build] building in this VM against the image cache (root ${paths.root}, layers ${paths.layers})`);
781
- // Imports come from the merged cache on the read-only layers disk and
782
- // from this lineage's own exports on the root; exports go to the root,
783
- // where the merge picks them up (image_cache/merge.rs).
784
- _localCacheDir = `${state}/spectest-buildkit-cache`;
785
- _localCacheImports = [`${paths.layers}/spectest-buildkit-cache`, `${state}/spectest-buildkit-cache`];
790
+ const union = await ensureBuildkitCacheUnion(paths);
791
+ if (union) {
792
+ // One directory for both flags: the merged cache on the layers disk
793
+ // seen through this lineage's own exports on the root. See
794
+ // `BUILDKIT_CACHE_UNION` for why the exporter needs the union.
795
+ _localCacheDir = union;
796
+ _localCacheImports = [union];
797
+ } else {
798
+ // Imports come from the merged cache on the read-only layers disk and
799
+ // from this lineage's own exports on the root; exports go to the root,
800
+ // where the merge picks them up (image_cache/merge.rs). Every cold
801
+ // build re-exports every blob this way, which is the cost the union
802
+ // removes.
803
+ _localCacheDir = `${state}/${BUILDKIT_CACHE_DIR}`;
804
+ _localCacheImports = [`${paths.layers}/${BUILDKIT_CACHE_DIR}`, `${state}/${BUILDKIT_CACHE_DIR}`];
805
+ }
786
806
  _localBuilder = true;
787
807
  return true;
788
808
  }
809
+
810
+ /**
811
+ * Mount the BuildKit cache union (`BUILDKIT_CACHE_UNION`): overlayfs with
812
+ * the layers disk's cache directory as the lower and the root disk's as
813
+ * the upper, so one path serves `--cache-from` and `--cache-to`.
814
+ *
815
+ * Idempotent — a delta restore restarts the harness on a guest that
816
+ * still has the mount. Returns null, and the caller keeps the two-directory
817
+ * scheme, when the lower does not exist yet (a volume that has never
818
+ * been merged has no cache directory on its layers disk) or the mount is
819
+ * refused; the build is then slower, never failed. The work directory
820
+ * has to share a filesystem with the upper, so it lives on the root disk
821
+ * beside it; the merge reads neither it nor anything but the upper.
822
+ */
823
+ async function ensureBuildkitCacheUnion(paths: ImageCachePaths): Promise<string | null> {
824
+ const lower = `${paths.layers}/${BUILDKIT_CACHE_DIR}`;
825
+ const upper = `${paths.root}/${BUILDKIT_CACHE_DIR}`;
826
+ const work = `${paths.root}/spectest-buildkit-work`;
827
+ if (!existsSync(lower)) {
828
+ // eslint-disable-next-line no-console
829
+ console.log(`[build] no merged BuildKit cache on the layers disk yet; exporting to the root directly`);
830
+ return null;
831
+ }
832
+ // overlayfs shows the upper's `index.json` INSTEAD of the lower's, so
833
+ // an index on the root (a lineage's exports, or one left behind with
834
+ // its blobs gone) would hide every merged tag and, naming manifests
835
+ // that are not there, make BuildKit skip the import altogether. Write
836
+ // the union of the two into the upper first (`mergeCacheIndex`), and
837
+ // say what each side held so a miss can be read off the boot log.
838
+ try {
839
+ const readIndex = (dir: string): OciIndex | null =>
840
+ existsSync(`${dir}/index.json`) ? (JSON.parse(readFileSync(`${dir}/index.json`, "utf8")) as OciIndex) : null;
841
+ const lowerIdx = readIndex(lower);
842
+ const upperIdx = readIndex(upper);
843
+ if (upperIdx && lowerIdx) {
844
+ const merged = mergeCacheIndex(lowerIdx, upperIdx, (hex) => existsSync(`${upper}/blobs/sha256/${hex}`));
845
+ await fs.mkdir(upper, { recursive: true });
846
+ await fs.writeFile(`${upper}/index.json`, JSON.stringify(merged));
847
+ await fs.writeFile(`${upper}/oci-layout`, JSON.stringify({ imageLayoutVersion: "1.0.0" }));
848
+ // eslint-disable-next-line no-console
849
+ console.log(
850
+ `[build] BuildKit cache union: ${lowerIdx.manifests.length} tag(s) from the merged cache, ${upperIdx.manifests.length} on this root, ${merged.manifests.length} after the merge`,
851
+ );
852
+ } else {
853
+ // eslint-disable-next-line no-console
854
+ console.log(`[build] BuildKit cache union: ${lowerIdx?.manifests.length ?? 0} tag(s) from the merged cache, none on this root`);
855
+ }
856
+ } catch (err) {
857
+ // eslint-disable-next-line no-console
858
+ console.warn(`[build] could not reconcile the BuildKit cache indexes (${err}); the union shows the root's`);
859
+ }
860
+ const r = await shx(
861
+ "/bin/sh",
862
+ [
863
+ "-c",
864
+ `mkdir -p "${BUILDKIT_CACHE_UNION}" "${upper}" "${work}" && ` +
865
+ `{ mountpoint -q "${BUILDKIT_CACHE_UNION}" || ` +
866
+ `mount -t overlay overlay -o "lowerdir=${lower},upperdir=${upper},workdir=${work}" "${BUILDKIT_CACHE_UNION}"; }`,
867
+ ],
868
+ 60_000,
869
+ );
870
+ if (r.code !== 0) {
871
+ // eslint-disable-next-line no-console
872
+ console.warn(`[build] could not mount the BuildKit cache union; exporting to the root directly:\n${(r.stderr || r.stdout).trim()}`);
873
+ return null;
874
+ }
875
+ return BUILDKIT_CACHE_UNION;
876
+ }
789
877
  /** The exported-cache directory on the cache disk, once the in-VM builder is up. */
790
878
  let _localCacheDir: string | null = null;
791
879
  /** The cache directories a build imports from: the merged one on the
@@ -1353,6 +1441,10 @@ async function runServiceBuild(
1353
1441
  const useBuildKit = useLocal || (await hasBuildx());
1354
1442
  const buildEnv: Record<string, string> = {};
1355
1443
  let buildArgs: string[];
1444
+ // The `--cache-to` flag, held back for a second pass that runs only
1445
+ // when the first one executed a step (see below). Null off the in-VM
1446
+ // builder.
1447
+ let cacheExport: string[] | null = null;
1356
1448
  // The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
1357
1449
  // so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
1358
1450
  const argFlags = buildArgFlags(image.buildArgs);
@@ -1365,6 +1457,10 @@ async function runServiceBuild(
1365
1457
  // intermediate layer, uncompressed so an import never inflates,
1366
1458
  // and one tag per service so exports do not replace each other.
1367
1459
  const cacheTag = name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1460
+ cacheExport = [
1461
+ "--cache-to",
1462
+ `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
1463
+ ];
1368
1464
  // LANDMINE: the local cache importer reads the `latest` entry of
1369
1465
  // the directory's index unless told otherwise, and the exporter
1370
1466
  // below writes this service's entry under `tag=<service>`. An
@@ -1377,7 +1473,6 @@ async function runServiceBuild(
1377
1473
  "--progress=plain",
1378
1474
  "--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
1379
1475
  ..._localCacheImports.flatMap((src) => ["--cache-from", `type=local,src=${src},tag=${cacheTag}`]),
1380
- "--cache-to", `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
1381
1476
  ...argFlags,
1382
1477
  "-f", dfPath, WORKSPACE,
1383
1478
  ];
@@ -1425,6 +1520,27 @@ async function runServiceBuild(
1425
1520
  if (build.code !== 0) {
1426
1521
  return { ok: false, log };
1427
1522
  }
1523
+ // Export the cache only when there is something new to export. A
1524
+ // `mode=max` export costs ~3 s per service even when every blob is
1525
+ // already in the destination (measured 2026-09-06, api: 2.2–3.0 s
1526
+ // for the cached build alone, 4.9–7.5 s with the export), and a cold
1527
+ // start of a cached project is nothing but such builds. When a step
1528
+ // did run, the second pass is that same build fully cached from the
1529
+ // daemon's own state plus the export — seconds on top of a build
1530
+ // that took tens of them. Best-effort: the image is already in the
1531
+ // store, so a failed export is a slower next cold start, not a
1532
+ // failed service.
1533
+ if (cacheExport) {
1534
+ const ran = executedSteps(build.stderr);
1535
+ if (ran.length > 0) {
1536
+ progressService(name, { status: "building", detail: `exporting cache (${ran.length} step(s) ran)` });
1537
+ const exported = await shxStream("docker", [...buildArgs, ...cacheExport], 1_800_000, buildEnv, () => {});
1538
+ if (exported.code !== 0) {
1539
+ // eslint-disable-next-line no-console
1540
+ console.warn(`[build] ${name}: cache export failed; the next cold start rebuilds it:\n${exported.stderr.trim().slice(-2000)}`);
1541
+ }
1542
+ }
1543
+ }
1428
1544
  if (useBuildKit) {
1429
1545
  // Keep only the slowest dozen steps ≥1s — enough to profile, small
1430
1546
  // enough to ride back in the /bootstrap response and the journal.
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { summarizeBuildKit } from "./buildkit-progress";
3
+ import { executedSteps, summarizeBuildKit } from "./buildkit-progress";
4
4
 
5
5
  /** A realistic slice of `docker build --progress=plain` output. */
6
6
  const SAMPLE = `
@@ -96,3 +96,56 @@ describe("summarizeBuildKit", () => {
96
96
  expect(out[0].cached).toBe(true);
97
97
  });
98
98
  });
99
+
100
+ /** Captured from a fully cached in-VM build of a real service
101
+ * (2026-09-06): bookkeeping steps report DONE, the base image resolves
102
+ * in no time, every Dockerfile step is CACHED. */
103
+ const FULLY_CACHED = `
104
+ #1 [internal] load build definition from Dockerfile
105
+ #1 transferring dockerfile: 3.38kB done
106
+ #1 DONE 0.0s
107
+ #2 resolve image config for docker-image://docker.io/docker/dockerfile:1
108
+ #2 DONE 0.1s
109
+ #4 [internal] load metadata for docker.io/library/node:25-slim
110
+ #4 DONE 0.1s
111
+ #5 [stage-0 1/19] FROM docker.io/library/node:25-slim@sha256:81db02c4b671288a03915da9534dbd54f96d0e7c24d80ccc54f5b36b2e684370
112
+ #5 DONE 0.0s
113
+ #6 [internal] load build context
114
+ #6 transferring context: 11.87kB done
115
+ #6 DONE 0.0s
116
+ #7 [stage-0 5/19] RUN --mount=type=cache,target=/pnpm-store,sharing=locked cd config && pnpm install --frozen-lockfile
117
+ #7 CACHED
118
+ #8 [stage-0 10/19] COPY packages/tunnel/client tunnel/client
119
+ #8 CACHED
120
+ #25 exporting to image
121
+ #25 exporting layers done
122
+ #25 DONE 0.3s
123
+ `;
124
+
125
+ describe("executedSteps", () => {
126
+ test("a fully cached build ran nothing", () => {
127
+ expect(executedSteps(FULLY_CACHED)).toEqual([]);
128
+ });
129
+
130
+ test("a step that ended in DONE ran", () => {
131
+ expect(executedSteps(SAMPLE)).toEqual([
132
+ "RUN go build -o /out/api ./cmd/api",
133
+ "COPY --from=builder /out/api /usr/local/bin/api",
134
+ ]);
135
+ });
136
+
137
+ /** A base image that had to be pulled is new content the export
138
+ * records; one found locally in 0.0s is not. */
139
+ test("a pulled base image counts, a local one does not", () => {
140
+ const pulled = FULLY_CACHED.replace("#5 DONE 0.0s", "#5 DONE 4.2s");
141
+ const ran = executedSteps(pulled);
142
+ expect(ran).toHaveLength(1);
143
+ // Names are capped like `summarizeBuildKit`'s, so match the prefix.
144
+ expect(ran[0].startsWith("FROM docker.io/library/node:25-slim@sha256:")).toBe(true);
145
+ });
146
+
147
+ test("bookkeeping steps never count", () => {
148
+ const out = "#1 [internal] load build context\n#1 DONE 2.5s\n#2 [auth] library/node:pull token\n#2 DONE 0.4s\n";
149
+ expect(executedSteps(out)).toEqual([]);
150
+ });
151
+ });
@@ -72,3 +72,54 @@ export function summarizeBuildKit(out: string): BuildStep[] {
72
72
  }
73
73
  return steps.sort((a, b) => b.secs - a.secs);
74
74
  }
75
+
76
+ /**
77
+ * The Dockerfile steps a build actually ran — the ones a cache export
78
+ * would have something new to record.
79
+ *
80
+ * A fully cached build still has to export its cache today, and that
81
+ * export costs ~3 s per service in `mode=max` even when every blob is
82
+ * already in the destination (measured 2026-09-06: 2.2–3.0 s for the
83
+ * build, 4.9–7.5 s with the export). So the harness builds without
84
+ * `--cache-to` first and exports only when this says something ran.
85
+ *
86
+ * What counts: a bracketed stage step (`[builder 3/6] RUN …`) that ended
87
+ * in `DONE` rather than `CACHED`. What does not: BuildKit's own
88
+ * `[internal]`/`[auth]` bookkeeping, which is never cached and never a
89
+ * layer, and a `FROM` step that finished in the blink it takes to find
90
+ * the base image locally — a pulled base takes seconds and does count,
91
+ * since the export records its layers too.
92
+ */
93
+ export function executedSteps(out: string): string[] {
94
+ const names = new Map<string, string>();
95
+ const stages = new Map<string, string>();
96
+ const secs = new Map<string, number>();
97
+ const cached = new Set<string>();
98
+ for (const line of out.split("\n")) {
99
+ let m = line.match(/^#(\d+)\s+\[([^\]]*)\]\s+(.+)$/);
100
+ if (m) {
101
+ const id = `#${m[1]}`;
102
+ if (!names.has(id)) {
103
+ names.set(id, m[3].trim().slice(0, MAX_NAME));
104
+ stages.set(id, m[2].trim());
105
+ }
106
+ continue;
107
+ }
108
+ m = line.match(/^#(\d+)\s+DONE\s+([\d.]+)s/);
109
+ if (m) {
110
+ secs.set(`#${m[1]}`, parseFloat(m[2]));
111
+ continue;
112
+ }
113
+ m = line.match(/^#(\d+)\s+CACHED/);
114
+ if (m) cached.add(`#${m[1]}`);
115
+ }
116
+ const ran: string[] = [];
117
+ for (const [id, name] of names) {
118
+ if (cached.has(id)) continue;
119
+ const stage = stages.get(id) ?? "";
120
+ if (stage === "internal" || stage === "auth") continue;
121
+ if (/^FROM\s/.test(name) && (secs.get(id) ?? 0) < 0.5) continue;
122
+ ran.push(name);
123
+ }
124
+ return ran;
125
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { BUILDKIT_CACHE_UNION, isOnImageCache } from "./image-cache";
4
+
5
+ const paths = { root: "/var/lib/containerd", layers: "/var/lib/spectest-image-cache" };
6
+
7
+ describe("isOnImageCache", () => {
8
+ test("the two cache disks and anything under them", () => {
9
+ expect(isOnImageCache("/var/lib/containerd", paths)).toBe(true);
10
+ expect(isOnImageCache("/var/lib/containerd/spectest-nested/k8s", paths)).toBe(true);
11
+ expect(isOnImageCache("/var/lib/spectest-image-cache/spectest-buildkit-cache", paths)).toBe(true);
12
+ });
13
+
14
+ /** The union is the root's export directory seen through the layers
15
+ * disk; wiping it at a delta restore would wipe this lineage's exports. */
16
+ test("the BuildKit cache union counts as cache", () => {
17
+ expect(isOnImageCache(BUILDKIT_CACHE_UNION, paths)).toBe(true);
18
+ expect(isOnImageCache(`${BUILDKIT_CACHE_UNION}/blobs`, paths)).toBe(true);
19
+ });
20
+
21
+ test("an ordinary project volume does not", () => {
22
+ expect(isOnImageCache("/var/cache/spectest/volumes/db", paths)).toBe(false);
23
+ expect(isOnImageCache("/var/lib/containerd-not", paths)).toBe(false);
24
+ });
25
+
26
+ test("no cache, nothing counts", () => {
27
+ expect(isOnImageCache(BUILDKIT_CACHE_UNION, null)).toBe(false);
28
+ });
29
+ });
30
+
31
+ import { mergeCacheIndex, type OciIndex } from "./image-cache";
32
+
33
+ const entry = (tag: string, hex: string) => ({ mediaType: "application/vnd.oci.image.manifest.v1+json", digest: `sha256:${hex}`, size: 1, annotations: { "org.opencontainers.image.ref.name": tag } });
34
+
35
+ describe("mergeCacheIndex", () => {
36
+ const lower: OciIndex = { schemaVersion: 2, manifests: [entry("api", "a1"), entry("cli", "c1")] };
37
+
38
+ test("no upper index: the merged cache's tags, unchanged", () => {
39
+ const out = mergeCacheIndex(lower, null, () => false);
40
+ expect(out.manifests.map((m) => m.annotations?.["org.opencontainers.image.ref.name"])).toEqual(["api", "cli"]);
41
+ });
42
+
43
+ /** The lineage's own export of a service wins over the merged one. */
44
+ test("an upper entry whose manifest exists overrides the lower's tag", () => {
45
+ const upper: OciIndex = { schemaVersion: 2, manifests: [entry("api", "a2")] };
46
+ const out = mergeCacheIndex(lower, upper, (hex) => hex === "a2");
47
+ const api = out.manifests.find((m) => m.annotations?.["org.opencontainers.image.ref.name"] === "api");
48
+ expect(api?.digest).toBe("sha256:a2");
49
+ expect(out.manifests).toHaveLength(2);
50
+ });
51
+
52
+ /** A stale upper index — blobs emptied, index left — would hide every
53
+ * lower tag and fail the import; its entries are dropped instead. */
54
+ test("an upper entry without its manifest blob is dropped", () => {
55
+ const upper: OciIndex = { schemaVersion: 2, manifests: [entry("api", "a2"), entry("worker", "w1")] };
56
+ const out = mergeCacheIndex(lower, upper, () => false);
57
+ expect(out.manifests.map((m) => m.digest)).toEqual(["sha256:a1", "sha256:c1"]);
58
+ });
59
+
60
+ test("a valid upper tag the lower lacks is added", () => {
61
+ const upper: OciIndex = { schemaVersion: 2, manifests: [entry("worker", "w1")] };
62
+ const out = mergeCacheIndex(lower, upper, () => true);
63
+ expect(out.manifests.map((m) => m.annotations?.["org.opencontainers.image.ref.name"])).toEqual(["api", "cli", "worker"]);
64
+ });
65
+ });
@@ -43,6 +43,29 @@ export const MKFS_EROFS_PATH = "/usr/local/bin/mkfs.erofs";
43
43
  * nested runtime's busybox can run the same file. */
44
44
  export const STORE_ADOPT_PATH = "/usr/local/bin/spectest-store-adopt";
45
45
 
46
+ /**
47
+ * The BuildKit cache directory under each cache disk (`merge.rs::CACHE_DIR`):
48
+ * on the layers disk the merged export of every earlier build, read-only;
49
+ * on the root disk this lineage's own exports, which the merge harvests.
50
+ */
51
+ export const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
52
+
53
+ /**
54
+ * One directory that is both: an overlayfs with the layers disk's cache
55
+ * directory as the read-only lower and the root disk's as the upper,
56
+ * mounted here by the harness when the in-VM builder comes up
57
+ * (`daemon.ts::ensureBuildkitCacheUnion`). A build imports from it and
58
+ * exports to it. BuildKit's local exporter skips a blob its destination
59
+ * already holds, and through the union every merged blob is already
60
+ * there — so a fully cached build exports its index and manifest and
61
+ * nothing else, where a fresh directory on the root cost it every
62
+ * uncompressed layer again (1.1 GB and 20 s for one service, measured
63
+ * 2026-09-06). New layers land in the upper, which is exactly the
64
+ * directory the merge read before. A user-run BuildKit mounts this one
65
+ * path for both of its cache flags.
66
+ */
67
+ export const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
68
+
46
69
  function parse(raw: string): ImageCachePaths | null {
47
70
  const parsed = JSON.parse(raw) as { disks?: { role?: string; path?: string }[] };
48
71
  const root = (parsed.disks ?? []).find((d) => d.role === "root" && d.path)?.path;
@@ -75,5 +98,56 @@ export function nestedStoreDir(paths: ImageCachePaths, service: string): string
75
98
  */
76
99
  export function isOnImageCache(hostPath: string, paths: ImageCachePaths | null): boolean {
77
100
  if (!paths) return false;
101
+ // The union is the root's cache directory seen through the layers
102
+ // disk: wiping it would wipe the upper, i.e. this lineage's exports.
103
+ if (hostPath === BUILDKIT_CACHE_UNION || hostPath.startsWith(`${BUILDKIT_CACHE_UNION}/`)) return true;
78
104
  return hostPath === paths.root || hostPath.startsWith(`${paths.root}/`) || hostPath === paths.layers || hostPath.startsWith(`${paths.layers}/`);
79
105
  }
106
+
107
+ /** The shape of an OCI layout's `index.json` the BuildKit local cache
108
+ * exporter writes: one manifest per tag, the tag in the ref-name
109
+ * annotation. */
110
+ export interface OciIndex {
111
+ schemaVersion?: number;
112
+ mediaType?: string;
113
+ manifests: Array<{ mediaType?: string; digest: string; size?: number; annotations?: Record<string, string> }>;
114
+ }
115
+
116
+ const REF_NAME = "org.opencontainers.image.ref.name";
117
+
118
+ /**
119
+ * The index the BuildKit cache union must show: every tag of the merged
120
+ * cache (lower), overridden by this lineage's own exports (upper) where
121
+ * the upper still holds the manifest.
122
+ *
123
+ * overlayfs shadows whole files, so an `index.json` in the upper hides
124
+ * the lower's completely — and a root disk can carry one: the exports
125
+ * of a lineage that built before the union existed, or an index left
126
+ * behind with its blobs gone. Either way BuildKit then imports from an
127
+ * index naming a few tags whose manifests may not exist, skips the
128
+ * import, and every step runs (seen 2026-09-06: a cold build with
129
+ * every service's cache on the layers disk and 4 stale tags in the
130
+ * upper built everything from scratch). `upperHasBlob` says whether the
131
+ * upper holds a manifest's blob; an upper entry without one is dropped,
132
+ * since it would fail the whole import.
133
+ */
134
+ export function mergeCacheIndex(lower: OciIndex | null, upper: OciIndex | null, upperHasBlob: (hex: string) => boolean): OciIndex {
135
+ const byTag = new Map<string, OciIndex["manifests"][number]>();
136
+ const untagged: OciIndex["manifests"] = [];
137
+ for (const m of lower?.manifests ?? []) {
138
+ const tag = m.annotations?.[REF_NAME];
139
+ if (tag) byTag.set(tag, m);
140
+ else untagged.push(m);
141
+ }
142
+ for (const m of upper?.manifests ?? []) {
143
+ if (!upperHasBlob(m.digest.replace(/^sha256:/, ""))) continue;
144
+ const tag = m.annotations?.[REF_NAME];
145
+ if (tag) byTag.set(tag, m);
146
+ else untagged.push(m);
147
+ }
148
+ return {
149
+ schemaVersion: 2,
150
+ mediaType: lower?.mediaType ?? upper?.mediaType ?? "application/vnd.oci.image.index.v1+json",
151
+ manifests: [...untagged, ...byTag.values()],
152
+ };
153
+ }
@@ -131,13 +131,13 @@ const testableAccessors = {
131
131
  "ownerDocument"
132
132
  ],
133
133
  ShadowRoot: ["host", "styleSheets"],
134
- Element: ["shadowRoot", "querySelector", "querySelectorAll"],
134
+ Element: ["shadowRoot"],
135
135
  MutationObserver: []
136
136
  };
137
137
  const testableMethods = {
138
138
  Node: ["contains", "getRootNode"],
139
139
  ShadowRoot: ["getSelection"],
140
- Element: [],
140
+ Element: ["querySelector", "querySelectorAll"],
141
141
  MutationObserver: ["constructor"]
142
142
  };
143
143
  const untaintedBasePrototype = {};
@@ -263,10 +263,10 @@ function shadowRoot(n) {
263
263
  return getUntaintedAccessor("Element", n, "shadowRoot");
264
264
  }
265
265
  function querySelector(n, selectors) {
266
- return getUntaintedAccessor("Element", n, "querySelector")(selectors);
266
+ return getUntaintedMethod("Element", n, "querySelector")(selectors);
267
267
  }
268
268
  function querySelectorAll(n, selectors) {
269
- return getUntaintedAccessor("Element", n, "querySelectorAll")(selectors);
269
+ return getUntaintedMethod("Element", n, "querySelectorAll")(selectors);
270
270
  }
271
271
  function mutationObserverCtor() {
272
272
  var _a2;