Files
openclaw/src/docker-build-cache.test.ts
Peter Steinberger ec42788cef perf(ci): cache packaged dependency reify in the docker e2e functional image (#109665)
* perf(ci): cache packaged dependency reify in the docker e2e functional image

Split the shared functional E2E image into manifest -> deps -> app stages so
the 328-package npm reify is layer-keyed on package.json + npm-shrinkwrap.json
alone. Per-PR tarballs (changed dist bytes, unchanged deps) now hit the cached
dependency layer on warm builders (Blacksmith sticky docker layers, local
buildkit) instead of re-running npm install on every build; the per-PR work
drops to manifest extract + package extract + packaged postinstall.

The deps stage reify also matches the shipped shrinkwrap exactly; the previous
npm install -g <tarball> path silently drifted ~15 transitive packages to newer
registry versions and lost the node-domexception -> @nolyfill/domexception
alias. npm install (not npm ci) because the generated shrinkwrap does not
guarantee npm ci's strict manifest sync (p-retry pins @types/retry@0.12.0 while
the shrinkwrap resolves 0.12.5).

Local proof (Apple Silicon, docker-container builder): per-PR warm image build
59s -> 43s with the 29s npm layer CACHED; lane total 164s -> 139s warm,
229s -> 160s cold; system-agent-first-run lane passes end to end, and the
installed tree matches the old image structure except for the documented
version-drift fixes.

* test: update e2e image guard for the staged dependency layer

The guard pinned the retired npm install -g invocation; assert the new
invariants instead: manifest-keyed deps layer, no global-prefix install,
deps COPY into /app, and postinstall ordered before the self-link so its
prune walks cannot cycle.
2026-07-16 22:34:13 -07:00

195 lines
7.9 KiB
TypeScript

// Tests Docker build cache configuration and dependency cache keys.
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const dockerfilePaths = [
"Dockerfile",
"scripts/docker/sandbox/Dockerfile",
"scripts/docker/sandbox/Dockerfile.browser",
"scripts/docker/sandbox/Dockerfile.common",
"scripts/docker/cleanup-smoke/Dockerfile",
"scripts/docker/install-sh-smoke/Dockerfile",
"scripts/docker/install-sh-e2e/Dockerfile",
"scripts/docker/install-sh-nonroot/Dockerfile",
"scripts/e2e/Dockerfile",
"scripts/e2e/Dockerfile.qr-import",
] as const;
const aptCacheDockerfilePaths = dockerfilePaths.filter(
(path) => path !== "scripts/e2e/Dockerfile.qr-import" && path !== "scripts/e2e/Dockerfile",
);
const shellContinuationDockerfilePaths = dockerfilePaths.filter(
(path) =>
path !== "Dockerfile" &&
path !== "scripts/e2e/Dockerfile" &&
path !== "scripts/e2e/Dockerfile.qr-import",
);
const repoFileCache = new Map<string, Promise<string>>();
async function readRepoFile(path: string): Promise<string> {
let cached = repoFileCache.get(path);
if (!cached) {
cached = readFile(resolve(repoRoot, path), "utf8");
repoFileCache.set(path, cached);
}
return cached;
}
function indexOfPattern(source: string, pattern: RegExp): number {
return source.search(pattern);
}
describe("docker build cache layout", () => {
beforeAll(async () => {
await Promise.all(dockerfilePaths.map((path) => readRepoFile(path)));
});
it("keeps the root dependency layer independent from scripts changes", async () => {
const dockerfile = await readRepoFile("Dockerfile");
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
const copyAllIndex = dockerfile.indexOf("COPY . .");
const scriptsCopyIndex = dockerfile.indexOf("COPY scripts ./scripts");
expect(installIndex).toBeGreaterThan(-1);
expect(copyAllIndex).toBeGreaterThan(installIndex);
if (scriptsCopyIndex === -1) {
expect(scriptsCopyIndex).toBe(-1);
} else {
expect(scriptsCopyIndex).toBeGreaterThan(installIndex);
}
});
it("uses pnpm cache mounts in Dockerfiles that install repo dependencies", async () => {
for (const path of [
"Dockerfile",
"scripts/e2e/Dockerfile.qr-import",
"scripts/docker/cleanup-smoke/Dockerfile",
]) {
const dockerfile = await readRepoFile(path);
expect(
dockerfile,
`${path} should use a shared pnpm store cache under the active user's home`,
).toMatch(
/--mount=type=cache,id=openclaw-pnpm-store,target=\/(?:root|home\/appuser)\/\.local\/share\/pnpm\/store,sharing=locked/,
);
}
});
it("uses apt cache mounts in Dockerfiles that install system packages", async () => {
for (const path of aptCacheDockerfilePaths) {
const dockerfile = await readRepoFile(path);
expect(dockerfile, `${path} should cache apt package archives`).toContain(
"target=/var/cache/apt,sharing=locked",
);
expect(dockerfile, `${path} should cache apt metadata`).toContain(
"target=/var/lib/apt,sharing=locked",
);
}
});
it("does not leave empty shell continuation lines in sandbox-common", async () => {
const dockerfile = await readRepoFile("scripts/docker/sandbox/Dockerfile.common");
expect(dockerfile).not.toContain("apt-get install -y --no-install-recommends ${PACKAGES} \\");
expect(dockerfile).toContain("ARG INSTALL_NODE=1");
expect(dockerfile).toContain("ARG NODE_MAJOR=24");
expect(
dockerfile.match(/curl -fsSL --connect-timeout 10 --max-time 120 -o "\$installer"/gu),
).toHaveLength(3);
expect(dockerfile.match(/installer="\$\(mktemp\)"/gu)).toHaveLength(3);
expect(dockerfile.match(/bash "\$installer" \|\| exit 1/gu)).toHaveLength(2);
expect(dockerfile.match(/rm -f "\$installer"/gu)).toHaveLength(3);
expect(dockerfile).toContain("apt-get install -y --no-install-recommends nodejs");
expect(dockerfile).toContain('ln -sf "${BUN_INSTALL_DIR}/bin/bun"');
expect(dockerfile).toMatch(
/chmod 0644 "\$installer"; \\\n\s+su - linuxbrew -c "NONINTERACTIVE=1 CI=1 \/bin\/bash '\$installer'" \|\| exit 1/u,
);
expect(dockerfile).not.toMatch(/curl[^\n]+\|\s*(?:bash|sh)/u);
expect(dockerfile).toContain(
'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm && pnpm --version; fi',
);
});
it("does not leave blank lines after shell continuation markers", async () => {
for (const path of shellContinuationDockerfilePaths) {
const dockerfile = await readRepoFile(path);
expect(
dockerfile,
`${path} should not have blank lines after a trailing backslash`,
).not.toMatch(/\\\n\s*\n/);
}
});
it("keeps the shared e2e image on the packaged tarball install path", async () => {
const dockerfile = await readRepoFile("scripts/e2e/Dockerfile");
expect(dockerfile).not.toContain("pnpm install --frozen-lockfile");
expect(dockerfile).not.toContain("COPY . .");
expect(dockerfile).toMatch(
/^COPY --from=openclaw_package --chown=appuser:appuser openclaw-current\.tgz \/tmp\/openclaw-current\.tgz$/m,
);
// The dependency reify layer must key on the extracted manifest, not the
// per-PR tarball bytes, so warm builders skip the full npm install.
expect(dockerfile).toContain(
"COPY --from=functional-manifest --chown=appuser:appuser /tmp/openclaw-deps /tmp/openclaw-deps",
);
expect(dockerfile).toContain("npm install --omit=dev --no-fund --no-audit");
expect(dockerfile).not.toContain("npm install -g --prefix");
expect(dockerfile).toContain(
"COPY --from=functional-deps --chown=appuser:appuser /tmp/openclaw-deps/node_modules /app/node_modules",
);
// Packaged postinstall must run before the self-link exists so its prune
// walks cannot cycle through /app/node_modules/openclaw -> /app.
const postinstallIndex = dockerfile.indexOf(
"node /app/scripts/postinstall-bundled-plugins.mjs",
);
const selfLinkIndex = dockerfile.indexOf("ln -sfn /app /app/node_modules/openclaw");
expect(postinstallIndex).toBeGreaterThan(-1);
expect(selfLinkIndex).toBeGreaterThan(postinstallIndex);
});
it("copies manifests before install in the qr-import image", async () => {
const dockerfile = await readRepoFile("scripts/e2e/Dockerfile.qr-import");
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
expect(
indexOfPattern(
dockerfile,
/^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.\/$/m,
),
).toBeLessThan(installIndex);
expect(
indexOfPattern(
dockerfile,
/^COPY(?:\s+--chown=\S+)?\s+ui\/package\.json \.\/ui\/package\.json$/m,
),
).toBeLessThan(installIndex);
expect(dockerfile).toContain("This image only exercises the root QR runtime dependency path.");
expect(
indexOfPattern(
dockerfile,
/^COPY(?:\s+--chown=\S+)?\s+extensions\/memory-core\/package\.json \.\/extensions\/memory-core\/package\.json$/m,
),
).toBe(-1);
expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+\.\s+\.$/m)).toBeGreaterThan(
installIndex,
);
});
it("copies .npmrc before install in the cleanup smoke image", async () => {
const dockerfile = await readRepoFile("scripts/docker/cleanup-smoke/Dockerfile");
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
expect(
indexOfPattern(
dockerfile,
/^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.npmrc \.\/$/m,
),
).toBeLessThan(installIndex);
expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+\.\s+\.$/m)).toBeGreaterThan(
installIndex,
);
});
});