Files
openclaw/test/scripts/build-all.test.ts
T
Peter Steinberger 8b501c1c13 fix(build): reject incomplete Plugin SDK declaration caches (#120961)
Require the unified declaration cache to contain every production Plugin SDK declaration before accepting or stamping it. Matching partial v4 stamps are now stale and rebuild instead of failing later in the canonical declaration writer.
2026-08-09 03:38:37 -07:00

1200 lines
44 KiB
TypeScript

// Build All tests cover build all script behavior.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
import {
BUILD_ALL_PROFILES,
BUILD_ALL_PROFILE_STEP_ENV,
BUILD_ALL_STEPS,
buildAllUsage,
finalizeBuildAllStepCache,
formatBuildAllDuration,
formatBuildAllTimingSummary,
parseBuildAllArgs,
resolveBuildAllEnvironment,
resolveBuildAllStepCacheState,
resolveBuildAllStepCacheStampState,
resolveBuildAllStep,
resolveBuildAllStepOnCacheHit,
resolveBuildAllSteps,
restoreBuildAllStepCacheOutputs,
writeBuildAllStepCacheStamp,
} from "../../scripts/build-all.mjs";
import {
listPluginSdkDeclarationOutputs,
pluginSdkEntrypoints,
} from "../../scripts/lib/plugin-sdk-entries.mjs";
function getBuildAllStep(label: string) {
const step = BUILD_ALL_STEPS.find((entry) => entry.label === label);
if (!step) {
throw new Error(`Missing build-all step ${label}`);
}
return step;
}
function withBuildCacheFixture(
run: (fixture: {
rootDir: string;
inputPath: string;
outputPath: string;
step: {
label: string;
cache: {
inputs: Array<
| string
| {
path: string;
excludeDirectories?: string[];
extensions?: string[];
recursive?: boolean;
}
>;
outputs: Array<
| string
| {
path: string;
excludeDirectories?: string[];
extensions?: string[];
recursive?: boolean;
}
>;
requiredOutputs?: string[] | ((env: NodeJS.ProcessEnv) => string[]);
restore?: "always";
runOnHit?: {
env?: NodeJS.ProcessEnv;
finalize?: "refresh";
};
};
};
}) => void,
) {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-cache-"));
try {
const inputPath = path.join(rootDir, "src/input.ts");
const outputPath = path.join(rootDir, "dist/output.js");
fs.mkdirSync(path.dirname(inputPath), { recursive: true });
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(inputPath, "input");
fs.writeFileSync(outputPath, "output");
run({
rootDir,
inputPath,
outputPath,
step: {
label: "cached",
cache: {
inputs: ["src"],
outputs: ["dist"],
},
},
});
} finally {
fs.rmSync(rootDir, { force: true, recursive: true });
}
}
describe("resolveBuildAllStep", () => {
it("pins one generated timestamp across every child build", () => {
const commit = "0123456789abcdef0123456789abcdef01234567";
const buildEnv = resolveBuildAllEnvironment(
{ FOO: "bar" },
() => new Date("2026-07-10T12:34:56.789Z"),
() => commit,
);
const uiInvocation = resolveBuildAllStep(getBuildAllStep("ui:build"), {
env: buildEnv,
});
const buildInfoInvocation = resolveBuildAllStep(getBuildAllStep("write-build-info"), {
env: buildEnv,
});
expect(uiInvocation.options.env).toMatchObject({
FOO: "bar",
GIT_COMMIT: commit,
OPENCLAW_BUILD_TIMESTAMP: "2026-07-10T12:34:56.789Z",
});
expect(buildInfoInvocation.options.env.OPENCLAW_BUILD_TIMESTAMP).toBe(
uiInvocation.options.env.OPENCLAW_BUILD_TIMESTAMP,
);
});
it("pins the first explicit full commit alias and rejects malformed values", () => {
const gitSha = "A".repeat(40);
expect(
resolveBuildAllEnvironment(
{ GIT_SHA: gitSha, GITHUB_SHA: "b".repeat(40) },
() => new Date("2026-07-10T12:34:56.000Z"),
() => "c".repeat(40),
).GIT_COMMIT,
).toBe(gitSha.toLowerCase());
expect(() =>
resolveBuildAllEnvironment({ GIT_COMMIT: "deadbeef" }, undefined, () => null),
).toThrow("full 40-character hexadecimal SHA");
});
it("uses checked-out Git instead of unverified GitHub workflow context", () => {
const checkedOutCommit = "b".repeat(40);
const ambientCommit = "a".repeat(40);
expect(
resolveBuildAllEnvironment(
{ GITHUB_SHA: ambientCommit },
() => new Date("2026-07-10T12:34:56.000Z"),
() => checkedOutCommit,
).GIT_COMMIT,
).toBe(checkedOutCommit);
expect(
resolveBuildAllEnvironment(
{ GITHUB_SHA: ambientCommit },
() => new Date("2026-07-10T12:34:56.000Z"),
() => null,
).GIT_COMMIT,
).toBe(ambientCommit);
expect(() =>
resolveBuildAllEnvironment(
{ GITHUB_SHA: "bad" },
() => new Date("2026-07-10T12:34:56.000Z"),
() => null,
),
).toThrow("full 40-character hexadecimal SHA");
});
it("preserves an explicit build timestamp after trimming outer whitespace", () => {
expect(
resolveBuildAllEnvironment({
OPENCLAW_BUILD_TIMESTAMP: " 2026-07-10T01:02:03.000Z ",
}).OPENCLAW_BUILD_TIMESTAMP,
).toBe("2026-07-10T01:02:03.000Z");
});
it("routes pnpm steps through the npm_execpath pnpm runner on Windows", () => {
const step = getBuildAllStep("plugins:assets:build");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-runner-"));
const npmExecPath = path.join(tempDir, "pnpm.cjs");
fs.writeFileSync(npmExecPath, "console.log('pnpm');\n");
try {
const result = resolveBuildAllStep(step, {
platform: "win32",
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
npmExecPath,
env: {},
});
expect(result).toEqual({
command: "C:\\Program Files\\nodejs\\node.exe",
args: [npmExecPath, "plugins:assets:build"],
options: {
stdio: "inherit",
env: {},
shell: false,
windowsVerbatimArguments: undefined,
},
});
} finally {
fs.rmSync(tempDir, { force: true, recursive: true });
}
});
it("keeps node steps on the current node binary", () => {
const step = getBuildAllStep("runtime-postbuild");
const result = resolveBuildAllStep(step, {
nodeExecPath: "/custom/node",
env: { FOO: "bar" },
});
expect(result).toEqual({
command: "/custom/node",
args: ["scripts/runtime-postbuild.mjs"],
options: {
stdio: "inherit",
env: { FOO: "bar" },
},
});
});
it.each([
{
label: "write-plugin-sdk-entry-dts",
scriptPath: "scripts/write-plugin-sdk-entry-dts.ts",
expectedEnv: { FOO: "bar", OPENCLAW_PLUGIN_SDK_CANONICAL_DTS: "1" },
},
{
label: "copy-hook-metadata",
scriptPath: "scripts/copy-hook-metadata.ts",
expectedEnv: { FOO: "bar" },
},
{
label: "write-build-info",
scriptPath: "scripts/write-build-info.ts",
expectedEnv: { FOO: "bar" },
},
{
label: "write-cli-startup-metadata",
scriptPath: "scripts/write-cli-startup-metadata.ts",
expectedEnv: { FOO: "bar" },
},
])("runs the $label TypeScript step through tsx", ({ label, scriptPath, expectedEnv }) => {
const step = getBuildAllStep(label);
const result = resolveBuildAllStep(step, {
nodeExecPath: "/custom/node",
env: { FOO: "bar" },
});
expect(result).toEqual({
command: "/custom/node",
args: ["--import", "tsx", scriptPath],
options: {
stdio: "inherit",
env: expectedEnv,
},
});
});
it("can route pnpm script steps through direct node entrypoints", () => {
const step = getBuildAllStep("plugins:assets:build");
const result = resolveBuildAllStep(step, {
nodeExecPath: "/custom/node",
env: { OPENCLAW_BUILD_ALL_NO_PNPM: "1" },
});
expect(result).toEqual({
command: "/custom/node",
args: ["scripts/bundled-plugin-assets.mjs", "--phase", "build"],
options: {
stdio: "inherit",
env: { OPENCLAW_BUILD_ALL_NO_PNPM: "1" },
},
});
});
it("restores startup metadata as a validator seed and refreshes it after validation", () => {
const step = getBuildAllStep("write-cli-startup-metadata");
expect(step.cache).toMatchObject({
inputs: [
"scripts/write-cli-startup-metadata.ts",
"scripts/lib/cli-startup-root-help-bundle.ts",
],
outputs: ["dist/cli-startup-metadata.json"],
restore: "always",
runOnHit: { finalize: "refresh" },
});
expect(resolveBuildAllStepOnCacheHit(step)).not.toBeNull();
});
});
describe("resolveBuildAllSteps", () => {
it("parses build-all CLI args before any build work", () => {
expect(parseBuildAllArgs([])).toEqual({ help: false, profile: "full" });
expect(parseBuildAllArgs(["cliStartup"])).toEqual({ help: false, profile: "cliStartup" });
expect(parseBuildAllArgs(["cliStartup", "--help"])).toEqual({
help: true,
profile: "cliStartup",
});
expect(() => parseBuildAllArgs(["cliStartup", "--bogus"])).toThrow("unknown argument: --bogus");
expect(() => parseBuildAllArgs(["wat"])).toThrow("Unknown build profile: wat");
});
it("prints CLI help without starting build steps", () => {
for (const args of [["--help"], ["cliStartup", "--help"]]) {
const result = spawnSync(process.execPath, ["scripts/build-all.mjs", ...args], {
cwd: process.cwd(),
encoding: "utf8",
});
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("Usage: node scripts/build-all.mjs [profile]");
expect(result.stdout).toContain("cliStartup");
expect(result.stdout).not.toContain("[build-all]");
}
});
it("rejects unknown CLI args without starting build steps", () => {
const result = spawnSync(process.execPath, ["scripts/build-all.mjs", "cliStartup", "--bogus"], {
cwd: process.cwd(),
encoding: "utf8",
});
expect(result.status).toBe(2);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("unknown argument: --bogus");
expect(result.stderr).toContain(buildAllUsage());
expect(result.stderr).not.toContain("[build-all]");
expect(result.stderr).not.toContain("at ");
});
it("uses declaration-cache groups only for the full build", () => {
expect(resolveBuildAllSteps("full").map((step) => step.label)).toEqual([
"plugins:assets:build",
"tsdown-ai",
"tsdown-packages",
"tsdown-unified",
"check-cli-bootstrap-imports",
"plugins:assets:copy",
"runtime-postbuild",
"build-stamp",
"runtime-postbuild-stamp",
"write-plugin-sdk-entry-dts",
"check-plugin-sdk-exports",
"copy-hook-metadata",
"ui:build",
"write-build-info",
"write-cli-startup-metadata",
]);
expect(BUILD_ALL_PROFILES.ciArtifacts).toContain("tsdown");
expect(BUILD_ALL_PROFILES.ciArtifacts).not.toContain("tsdown-unified");
});
it("rebuilds runtime JS while reusing fresh declaration groups", () => {
const ai = getBuildAllStep("tsdown-ai");
const packages = getBuildAllStep("tsdown-packages");
const unified = getBuildAllStep("tsdown-unified");
expect(ai.args).toEqual(["scripts/tsdown-build.mjs", "--config", "tsdown.ai.config.ts"]);
expect(packages.args).toEqual(
expect.arrayContaining(["--config", "tsdown.config.ts", "--filter", "openclaw-packages"]),
);
expect(unified.args).toEqual(
expect.arrayContaining(["--config", "tsdown.config.ts", "--filter", "openclaw-unified"]),
);
for (const step of [ai, packages, unified]) {
expect(step.cache?.restore).toBe("always");
expect(step.cache?.env).toContain("OPENCLAW_RUN_NODE_SKIP_DTS_BUILD");
expect(step.cache?.runOnHit?.env).toEqual({ OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1" });
expect(resolveBuildAllStepOnCacheHit(step)?.env).toMatchObject({
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1",
});
expect(step.cache?.outputs).toEqual(
expect.arrayContaining([
expect.objectContaining({ extensions: [".d.ts", ".d.mts", ".d.cts"] }),
]),
);
}
expect(unified.cache?.env).toContain("OPENCLAW_BUILD_PRIVATE_QA");
expect(unified.cache?.inputs).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "src",
extensions: expect.arrayContaining([".sql"]),
}),
]),
);
const requiredOutputs = unified.cache?.requiredOutputs;
if (typeof requiredOutputs !== "function") {
throw new Error("Missing tsdown-unified required output resolver");
}
const productionOutputs = requiredOutputs({});
const privateQaOutputs = requiredOutputs({ OPENCLAW_BUILD_PRIVATE_QA: "1" });
expect(productionOutputs).toEqual(listPluginSdkDeclarationOutputs());
expect(privateQaOutputs).toEqual(listPluginSdkDeclarationOutputs(pluginSdkEntrypoints));
expect(productionOutputs).toContain("dist/plugin-sdk/core.d.ts");
expect(productionOutputs).toContain("dist/plugin-sdk/provider-auth-runtime.d.ts");
expect(productionOutputs).not.toContain("dist/plugin-sdk/test-fixtures.d.ts");
expect(privateQaOutputs).toContain("dist/plugin-sdk/test-fixtures.d.ts");
});
it("uses a runtime artifact plus plugin SDK export profile for ci artifacts", () => {
expect(resolveBuildAllSteps("ciArtifacts").map((step) => step.label)).toEqual([
"plugins:assets:build",
"tsdown",
"check-cli-bootstrap-imports",
"plugins:assets:copy",
"runtime-postbuild",
"build-stamp",
"runtime-postbuild-stamp",
"write-plugin-sdk-entry-dts",
"check-plugin-sdk-exports",
"copy-hook-metadata",
"ui:build",
"write-build-info",
"write-cli-startup-metadata",
]);
});
it("skips bundled tsdown declarations for runtime-only profiles", () => {
for (const profile of [
"gatewayWatch",
"qaRuntime",
"sourcePerformance",
"cliStartup",
] as const) {
const tsdown = resolveBuildAllSteps(profile).find((step) => step.label === "tsdown");
if (!tsdown) {
throw new Error(`Missing ${profile} tsdown step`);
}
expect(
expectDefined(BUILD_ALL_PROFILE_STEP_ENV[profile], `${profile} build step env`).tsdown,
).toMatchObject({
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1",
});
expect(
resolveBuildAllStep(tsdown, { env: { OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "0" } }).options.env,
).toMatchObject({
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1",
});
}
});
it("skips global declarations on CI artifacts and self-builds the plugin-sdk gate", () => {
// Global dts emission is ~95% of the tsdown wall clock; PR CI dist
// consumers are runtime JS, and the plugin-sdk export gate validates
// self-built scoped declarations instead. Release/package builds keep
// canonical declarations (full profile, docker packaging).
const steps = resolveBuildAllSteps("ciArtifacts");
const tsdown = steps.find((step) => step.label === "tsdown");
if (!tsdown) {
throw new Error("Missing ciArtifacts tsdown step");
}
expect(resolveBuildAllStep(tsdown, { env: {} }).options.env).toMatchObject({
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1",
OPENCLAW_PRESERVE_CLI_STARTUP_METADATA: "1",
});
const entryDts = steps.find((step) => step.label === "write-plugin-sdk-entry-dts");
if (!entryDts) {
throw new Error("Missing ciArtifacts write-plugin-sdk-entry-dts step");
}
expect(entryDts.env).toMatchObject({ OPENCLAW_PLUGIN_SDK_CANONICAL_DTS: "0" });
expect(entryDts.cache?.inputs).toEqual(
expect.arrayContaining([
"package.json",
"pnpm-lock.yaml",
"tsconfig.plugin-sdk.dts.json",
expect.objectContaining({
path: "src",
excludeDirectories: ["dist", "node_modules"],
}),
expect.objectContaining({
path: "packages",
excludeDirectories: ["dist", "node_modules"],
}),
]),
);
expect(entryDts.cache?.outputs).toEqual(
expect.arrayContaining([
{ path: "dist/plugin-sdk", extensions: [".d.ts"], recursive: false },
"dist/plugin-sdk/.boundary-entry-shims.stamp",
]),
);
expect(entryDts.cache?.restore).toBe("always");
const fullEntryDts = resolveBuildAllSteps("full").find(
(step) => step.label === "write-plugin-sdk-entry-dts",
);
expect(fullEntryDts?.env).toMatchObject({ OPENCLAW_PLUGIN_SDK_CANONICAL_DTS: "1" });
expect(fullEntryDts?.cache).toBeDefined();
expect(fullEntryDts?.cache?.inputs).not.toContainEqual(
expect.objectContaining({ path: "src" }),
);
expect(fullEntryDts?.cache?.outputs).not.toContainEqual({
path: "dist/plugin-sdk",
extensions: [".d.ts"],
recursive: false,
});
});
it("preserves startup metadata only for profiles that regenerate it", () => {
const fullTsdown = resolveBuildAllSteps("full").find((step) => step.label === "tsdown-unified");
if (!fullTsdown) {
throw new Error("Missing full tsdown-unified step");
}
expect(resolveBuildAllStep(fullTsdown, { env: {} }).options.env).toMatchObject({
OPENCLAW_PRESERVE_CLI_STARTUP_METADATA: "1",
});
for (const profile of ["ciArtifacts", "sourcePerformance", "cliStartup"]) {
const tsdown = resolveBuildAllSteps(profile).find((step) => step.label === "tsdown");
if (!tsdown) {
throw new Error(`Missing ${profile} tsdown step`);
}
expect(resolveBuildAllStep(tsdown, { env: {} }).options.env).toMatchObject({
OPENCLAW_PRESERVE_CLI_STARTUP_METADATA: "1",
});
}
for (const profile of ["gatewayWatch", "qaRuntime"]) {
const tsdown = resolveBuildAllSteps(profile).find((step) => step.label === "tsdown");
if (!tsdown) {
throw new Error(`Missing ${profile} tsdown step`);
}
expect(resolveBuildAllStep(tsdown, { env: {} }).options.env).not.toHaveProperty(
"OPENCLAW_PRESERVE_CLI_STARTUP_METADATA",
);
}
});
it("uses a minimal built runtime profile for gateway watch regression", () => {
expect(resolveBuildAllSteps("gatewayWatch").map((step) => step.label)).toEqual([
"tsdown",
"check-cli-bootstrap-imports",
"runtime-postbuild",
"build-stamp",
"runtime-postbuild-stamp",
]);
});
it("uses a QA runtime profile with generated plugin assets but no startup metadata", () => {
expect(resolveBuildAllSteps("qaRuntime").map((step) => step.label)).toEqual([
"plugins:assets:build",
"tsdown",
"check-cli-bootstrap-imports",
"plugins:assets:copy",
"runtime-postbuild",
"build-stamp",
"runtime-postbuild-stamp",
]);
});
it("uses a source performance profile with QA assets and immutable build provenance", () => {
expect(resolveBuildAllSteps("sourcePerformance").map((step) => step.label)).toEqual([
"plugins:assets:build",
"tsdown",
"check-cli-bootstrap-imports",
"plugins:assets:copy",
"runtime-postbuild",
"build-stamp",
"runtime-postbuild-stamp",
"write-build-info",
"write-cli-startup-metadata",
]);
});
it("uses a CLI startup profile without generated plugin assets", () => {
expect(resolveBuildAllSteps("cliStartup").map((step) => step.label)).toEqual([
"tsdown",
"check-cli-bootstrap-imports",
"runtime-postbuild",
"build-stamp",
"runtime-postbuild-stamp",
"write-cli-startup-metadata",
]);
});
it("skips generated static plugin assets for minimal backend-only profiles", () => {
for (const profile of ["gatewayWatch", "cliStartup"] as const) {
const runtimePostbuild = resolveBuildAllSteps(profile).find(
(step) => step.label === "runtime-postbuild",
);
if (!runtimePostbuild) {
throw new Error(`Missing ${profile} runtime-postbuild step`);
}
expect(
expectDefined(BUILD_ALL_PROFILE_STEP_ENV[profile], `${profile} build step env`)[
"runtime-postbuild"
],
).toEqual({
OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "0",
});
expect(
resolveBuildAllStep(runtimePostbuild, {
env: { OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "1" },
}).options.env,
).toMatchObject({
OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "0",
});
}
});
it("keeps generated static plugin assets enabled for QA-backed profiles", () => {
for (const profile of ["qaRuntime", "sourcePerformance"] as const) {
const runtimePostbuild = resolveBuildAllSteps(profile).find(
(step) => step.label === "runtime-postbuild",
);
if (!runtimePostbuild) {
throw new Error(`Missing ${profile} runtime-postbuild step`);
}
expect(
expectDefined(BUILD_ALL_PROFILE_STEP_ENV[profile], `${profile} build step env`)[
"runtime-postbuild"
],
).toBeUndefined();
expect(
resolveBuildAllStep(runtimePostbuild, {
env: { OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "1" },
}).options.env,
).toMatchObject({
OPENCLAW_RUNTIME_POSTBUILD_STATIC_ASSETS: "1",
});
}
});
it("copies generated plugin assets before runtime postbuild snapshots static outputs", () => {
for (const profile of ["full", "ciArtifacts", "qaRuntime", "sourcePerformance"]) {
const labels = resolveBuildAllSteps(profile).map((step) => step.label);
const lastTsdown = profile === "full" ? "tsdown-unified" : "tsdown";
expect(labels.indexOf("plugins:assets:copy")).toBeGreaterThan(labels.indexOf(lastTsdown));
expect(labels.indexOf("runtime-postbuild")).toBeGreaterThan(
labels.indexOf("plugins:assets:copy"),
);
expect(labels.indexOf("runtime-postbuild-stamp")).toBeGreaterThan(
labels.indexOf("runtime-postbuild"),
);
}
});
it("writes the runtime postbuild stamp after the build stamp", () => {
const labels = resolveBuildAllSteps("full").map((step) => step.label);
expect(labels).toContain("runtime-postbuild");
expect(labels).toContain("build-stamp");
expect(labels).toContain("runtime-postbuild-stamp");
expect(labels.indexOf("runtime-postbuild-stamp")).toBeGreaterThan(
labels.indexOf("build-stamp"),
);
});
it("includes ui:build in the full and ciArtifacts profiles after runtime postbuild", () => {
for (const profile of ["full", "ciArtifacts"]) {
const labels = resolveBuildAllSteps(profile).map((step) => step.label);
const lastTsdown = profile === "full" ? "tsdown-unified" : "tsdown";
expect(labels).toContain("ui:build");
// Control UI bundling must run after tsdown clears dist so that
// dist/control-ui survives `pnpm build` without a second command.
expect(labels.indexOf("ui:build")).toBeGreaterThan(labels.indexOf(lastTsdown));
expect(labels.indexOf("ui:build")).toBeGreaterThan(labels.indexOf("runtime-postbuild-stamp"));
// ui:build must run before write-build-info so the build manifest can
// see the final dist/control-ui assets.
expect(labels.indexOf("ui:build")).toBeLessThan(labels.indexOf("write-build-info"));
}
});
it("keeps ui:build out of minimal backend-only profiles", () => {
for (const profile of ["gatewayWatch", "qaRuntime", "sourcePerformance", "cliStartup"]) {
const labels = resolveBuildAllSteps(profile).map((step) => step.label);
expect(labels).not.toContain("ui:build");
}
});
it("does not cache ui:build because Vite reads package.json, git HEAD, and env metadata", () => {
// ui/vite.config.ts derives the Control UI build ID from package.json,
// git HEAD, and OPENCLAW_CONTROL_UI_BUILD_ID env, so a file-input
// signature cannot exactly invalidate generated assets. Leaving this
// step uncached avoids restoring stale service-worker/app cache
// metadata after `tsdown` clears `dist`.
const step = getBuildAllStep("ui:build");
expect(step.kind).toBe("pnpm");
expect(step.pnpmArgs).toEqual(["ui:build"]);
expect(step.cache).toBeUndefined();
});
it("caches plugin-sdk entry declarations without restoring compiled JS", () => {
const step = getBuildAllStep("write-plugin-sdk-entry-dts");
expect(step.env).toEqual({ OPENCLAW_PLUGIN_SDK_CANONICAL_DTS: "1" });
expect(step.cache?.env).toEqual([
"OPENCLAW_BUILD_PRIVATE_QA",
"OPENCLAW_PLUGIN_SDK_CANONICAL_DTS",
]);
expect(step.cache?.inputs).toEqual(
expect.arrayContaining([
"scripts/write-plugin-sdk-entry-dts.ts",
"scripts/lib/plugin-sdk-entrypoints.json",
{ path: "dist/plugin-sdk", extensions: [".d.ts"], recursive: false },
]),
);
expect(step.cache?.inputs).not.toContain("src/plugin-sdk");
expect(step.cache?.outputs).toEqual(
expect.arrayContaining([
"dist/plugin-sdk/.boundary-entry-shims.stamp",
"packages/plugin-sdk/dist/src/plugin-sdk/provider-entry.d.ts",
]),
);
expect(step.cache?.outputs).not.toContainEqual(
expect.objectContaining({ path: "dist/plugin-sdk" }),
);
expect(step.cache?.restore).toBe("always");
});
it("does not cache hook metadata over compiled hook handlers", () => {
const step = getBuildAllStep("copy-hook-metadata");
expect(step.cache).toBeUndefined();
});
it("rejects unknown build profiles", () => {
expect(() => resolveBuildAllSteps("wat")).toThrow("Unknown build profile: wat");
});
});
describe("build-all timing output", () => {
it("formats short and long phase durations compactly", () => {
expect(formatBuildAllDuration(42.4)).toBe("42ms");
expect(formatBuildAllDuration(1234)).toBe("1.23s");
expect(formatBuildAllDuration(12345)).toBe("12.3s");
});
it("summarizes phases slowest first with total time and status", () => {
expect(
formatBuildAllTimingSummary([
{ label: "tsdown", status: "ran", durationMs: 99000 },
{ label: "plugins:assets:copy", status: "cached", durationMs: 12 },
{ label: "write-plugin-sdk-entry-dts", status: "ran", durationMs: 34567 },
]),
).toBe(
"[build-all] phase timings: total 2m 13.6s; slowest tsdown 1m 39s; write-plugin-sdk-entry-dts 34.6s; plugins:assets:copy (cached) 12ms",
);
});
});
describe("resolveBuildAllStepCacheState", () => {
it("shares content-addressed outputs across checkout roots", () => {
const cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shared-build-cache-"));
const firstRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-cache-source-"));
const secondRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-cache-target-"));
const step = {
label: "cached",
cache: {
inputs: ["src"],
outputs: ["dist"],
restore: "always" as const,
},
};
const env = { BUILD_ALL_CACHE_ROOT: cacheRoot };
try {
for (const rootDir of [firstRoot, secondRoot]) {
fs.mkdirSync(path.join(rootDir, "src"), { recursive: true });
fs.writeFileSync(path.join(rootDir, "src/input.ts"), "same input");
}
fs.mkdirSync(path.join(firstRoot, "dist"), { recursive: true });
fs.writeFileSync(path.join(firstRoot, "dist/output.js"), "cached output");
const sourceState = resolveBuildAllStepCacheState(step, { rootDir: firstRoot, env });
writeBuildAllStepCacheStamp(
step,
resolveBuildAllStepCacheStampState(step, sourceState, { rootDir: firstRoot }),
{ rootDir: firstRoot },
);
const targetState = resolveBuildAllStepCacheState(step, { rootDir: secondRoot, env });
expect(targetState).toMatchObject({ fresh: true, restorable: true });
expect(targetState.outputRoot).toBe(path.join(cacheRoot, "cached", "outputs"));
expect(restoreBuildAllStepCacheOutputs(targetState, { rootDir: secondRoot })).toBe(true);
expect(fs.readFileSync(path.join(secondRoot, "dist/output.js"), "utf8")).toBe(
"cached output",
);
} finally {
fs.rmSync(cacheRoot, { force: true, recursive: true });
fs.rmSync(firstRoot, { force: true, recursive: true });
fs.rmSync(secondRoot, { force: true, recursive: true });
}
});
it("invalidates only declaration groups that depend on the changed module", () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tsdown-group-cache-"));
const ai = getBuildAllStep("tsdown-ai");
const packages = getBuildAllStep("tsdown-packages");
const unified = getBuildAllStep("tsdown-unified");
const sourcePath = path.join(rootDir, "src/index.ts");
const aiSourcePath = path.join(rootDir, "packages/ai/src/index.ts");
const packageSourcePath = path.join(rootDir, "packages/net-policy/src/index.ts");
const fixtures = [
["package.json", "{}"],
["src/index.ts", "export const core = 1;"],
["extensions/example/index.ts", "export const extension = 1;"],
["packages/ai/src/index.ts", "export const ai = 1;"],
["packages/net-policy/src/index.ts", "export const net = 1;"],
["packages/ai/dist/index.d.ts", "export declare const ai = 1;"],
["packages/net-policy/dist/index.d.ts", "export declare const net = 1;"],
["dist/index.d.ts", "export declare const core = 1;"],
] as const;
try {
for (const [relativePath, contents] of fixtures) {
const filePath = path.join(rootDir, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, contents);
}
for (const step of [ai, packages, unified]) {
const state = resolveBuildAllStepCacheState(step, { rootDir });
writeBuildAllStepCacheStamp(
step,
resolveBuildAllStepCacheStampState(step, state, { rootDir }),
{ rootDir },
);
}
fs.writeFileSync(sourcePath, "export const core = 2;");
expect(resolveBuildAllStepCacheState(ai, { rootDir }).fresh).toBe(true);
expect(resolveBuildAllStepCacheState(packages, { rootDir }).fresh).toBe(true);
expect(resolveBuildAllStepCacheState(unified, { rootDir }).fresh).toBe(false);
fs.writeFileSync(sourcePath, "export const core = 1;");
fs.writeFileSync(packageSourcePath, "export const net = 2;");
expect(resolveBuildAllStepCacheState(ai, { rootDir }).fresh).toBe(false);
expect(resolveBuildAllStepCacheState(packages, { rootDir }).fresh).toBe(false);
expect(resolveBuildAllStepCacheState(unified, { rootDir }).fresh).toBe(false);
fs.writeFileSync(packageSourcePath, "export const net = 1;");
fs.writeFileSync(aiSourcePath, "export const ai = 2;");
expect(resolveBuildAllStepCacheState(ai, { rootDir }).fresh).toBe(false);
expect(resolveBuildAllStepCacheState(packages, { rootDir }).fresh).toBe(false);
expect(resolveBuildAllStepCacheState(unified, { rootDir }).fresh).toBe(false);
} finally {
fs.rmSync(rootDir, { force: true, recursive: true });
}
});
it("marks cacheable steps fresh when the input signature matches", () => {
withBuildCacheFixture(({ rootDir, step }) => {
const cacheState = resolveBuildAllStepCacheState(step, { rootDir });
writeBuildAllStepCacheStamp(step, cacheState, { rootDir });
const fresh = resolveBuildAllStepCacheState(step, { rootDir });
expect(fresh.cacheable).toBe(true);
expect(fresh.fresh).toBe(true);
expect(fresh.reason).toBe("fresh");
expect(fresh.inputFiles).toBe(1);
expect(fresh.outputFiles).toBe(1);
expect(fresh.restorable).toBe(false);
expect(fresh.relativeOutputFiles).toEqual(["dist/output.js"]);
expect(fresh.stampedOutputs).toEqual(["dist/output.js"]);
expect(typeof fresh.signature).toBe("string");
expect(fresh.signature).toHaveLength(64);
expect(fresh.outputRoot).toBe(
path.join(rootDir, ".artifacts/build-all-cache/cached/outputs"),
);
expect(fresh.stampPath).toBe(
path.join(rootDir, ".artifacts/build-all-cache/cached/stamp.json"),
);
expect(fresh).toEqual({
cacheable: true,
fresh: true,
inputFiles: 1,
outputFiles: 1,
outputRoot: fresh.outputRoot,
reason: "fresh",
relativeOutputFiles: ["dist/output.js"],
restorable: false,
signature: fresh.signature,
stampedOutputs: ["dist/output.js"],
stampPath: fresh.stampPath,
});
});
});
it("rejects a matching legacy stamp that omits a required output", () => {
withBuildCacheFixture(({ rootDir, step }) => {
const legacyState = resolveBuildAllStepCacheState(step, { rootDir });
writeBuildAllStepCacheStamp(step, legacyState, { rootDir });
const legacyStamp = JSON.parse(fs.readFileSync(legacyState.stampPath!, "utf8"));
expect(legacyStamp).toMatchObject({
version: 4,
signature: legacyState.signature,
outputs: ["dist/output.js"],
});
fs.rmSync(path.join(rootDir, "dist"), { force: true, recursive: true });
const completeStep = {
...step,
cache: {
...step.cache,
requiredOutputs: ["dist/output.js", "dist/plugin-sdk/core.d.ts"],
restore: "always" as const,
},
};
const stale = resolveBuildAllStepCacheState(completeStep, { rootDir });
expect(stale).toMatchObject({
fresh: false,
reason: "stale",
restorable: false,
signature: legacyState.signature,
stampedOutputs: ["dist/output.js"],
});
expect(restoreBuildAllStepCacheOutputs(stale, { rootDir })).toBe(false);
});
});
it("does not replace a cache stamp from incomplete current outputs", () => {
withBuildCacheFixture(({ rootDir, outputPath, step }) => {
const initialState = resolveBuildAllStepCacheState(step, { rootDir });
writeBuildAllStepCacheStamp(step, initialState, { rootDir });
const stampBefore = fs.readFileSync(initialState.stampPath!, "utf8");
const cachedOutputPath = path.join(initialState.outputRoot!, "dist/output.js");
expect(fs.readFileSync(cachedOutputPath, "utf8")).toBe("output");
fs.writeFileSync(outputPath, "incomplete refresh");
const completeStep = {
...step,
cache: {
...step.cache,
requiredOutputs: ["dist/output.js", "dist/plugin-sdk/core.d.ts"],
},
};
const incompleteState = resolveBuildAllStepCacheState(completeStep, { rootDir });
expect(finalizeBuildAllStepCache(completeStep, incompleteState, { rootDir })).toBe(true);
expect(fs.readFileSync(initialState.stampPath!, "utf8")).toBe(stampBefore);
expect(fs.readFileSync(cachedOutputPath, "utf8")).toBe("output");
expect(resolveBuildAllStepCacheState(completeStep, { rootDir })).toMatchObject({
fresh: false,
reason: "stale",
});
});
});
it("marks cacheable steps stale when an input changes", () => {
withBuildCacheFixture(({ rootDir, inputPath, step }) => {
const cacheState = resolveBuildAllStepCacheState(step, { rootDir });
writeBuildAllStepCacheStamp(step, cacheState, { rootDir });
fs.writeFileSync(inputPath, "changed");
const stale = resolveBuildAllStepCacheState(step, { rootDir });
expect(stale.cacheable).toBe(true);
expect(stale.fresh).toBe(false);
expect(stale.reason).toBe("stale");
expect(stale.inputFiles).toBe(1);
expect(stale.outputFiles).toBe(1);
expect(stale.restorable).toBe(false);
expect(stale.relativeOutputFiles).toEqual(["dist/output.js"]);
expect(stale.stampedOutputs).toEqual(["dist/output.js"]);
expect(typeof stale.signature).toBe("string");
expect(stale.signature).toHaveLength(64);
expect(stale.outputRoot).toBe(
path.join(rootDir, ".artifacts/build-all-cache/cached/outputs"),
);
expect(stale.stampPath).toBe(
path.join(rootDir, ".artifacts/build-all-cache/cached/stamp.json"),
);
expect(stale).toEqual({
cacheable: true,
fresh: false,
inputFiles: 1,
outputFiles: 1,
outputRoot: stale.outputRoot,
reason: "stale",
relativeOutputFiles: ["dist/output.js"],
restorable: false,
signature: stale.signature,
stampedOutputs: ["dist/output.js"],
stampPath: stale.stampPath,
});
});
});
it("ignores generated and installed directories in broad cache inputs", () => {
withBuildCacheFixture(({ rootDir, step }) => {
const ignoredDist = path.join(rootDir, "src/nested/dist/generated.ts");
const ignoredModules = path.join(rootDir, "src/node_modules/dependency.ts");
fs.mkdirSync(path.dirname(ignoredDist), { recursive: true });
fs.mkdirSync(path.dirname(ignoredModules), { recursive: true });
fs.writeFileSync(ignoredDist, "generated");
fs.writeFileSync(ignoredModules, "dependency");
const broadStep = {
...step,
cache: {
...step.cache,
inputs: [
{
path: "src",
excludeDirectories: ["dist", "node_modules"],
extensions: [".ts"],
},
],
},
};
const cacheState = resolveBuildAllStepCacheState(broadStep, { rootDir });
writeBuildAllStepCacheStamp(broadStep, cacheState, { rootDir });
fs.writeFileSync(ignoredDist, "changed generated output");
fs.writeFileSync(ignoredModules, "changed installed dependency");
const fresh = resolveBuildAllStepCacheState(broadStep, { rootDir });
expect(fresh.fresh).toBe(true);
expect(fresh.inputFiles).toBe(1);
});
});
it("reuses the pre-run input signature when stamping successful cacheable steps", () => {
withBuildCacheFixture(({ rootDir, step }) => {
const cacheState = resolveBuildAllStepCacheState(step, { rootDir });
const readSpy = vi.spyOn(fs, "readFileSync");
try {
const stampState = resolveBuildAllStepCacheStampState(step, cacheState, { rootDir });
writeBuildAllStepCacheStamp(step, stampState, { rootDir });
expect(readSpy).not.toHaveBeenCalled();
expect(stampState.signature).toBe(cacheState.signature);
expect(stampState.relativeOutputFiles).toEqual(["dist/output.js"]);
} finally {
readSpy.mockRestore();
}
});
});
it("marks cacheable steps stale when a tracked env input changes", () => {
withBuildCacheFixture(({ rootDir, step }) => {
const envStep = {
...step,
cache: {
...step.cache,
env: ["OPENCLAW_BUILD_PRIVATE_QA"],
},
};
const cacheState = resolveBuildAllStepCacheState(envStep, {
rootDir,
env: { OPENCLAW_BUILD_PRIVATE_QA: "0" },
});
writeBuildAllStepCacheStamp(envStep, cacheState, { rootDir });
const stale = resolveBuildAllStepCacheState(envStep, {
rootDir,
env: { OPENCLAW_BUILD_PRIVATE_QA: "1" },
});
expect(stale.cacheable).toBe(true);
expect(stale.fresh).toBe(false);
expect(stale.reason).toBe("stale");
});
});
it("restores cached outputs when generated files were removed", () => {
withBuildCacheFixture(({ rootDir, outputPath, step }) => {
const cacheState = resolveBuildAllStepCacheState(step, { rootDir });
writeBuildAllStepCacheStamp(step, cacheState, { rootDir });
fs.rmSync(path.join(rootDir, "dist"), { force: true, recursive: true });
const restorable = resolveBuildAllStepCacheState(step, { rootDir });
expect(restorable.cacheable).toBe(true);
expect(restorable.fresh).toBe(true);
expect(restorable.reason).toBe("fresh-cache");
expect(restorable.inputFiles).toBe(1);
expect(restorable.outputFiles).toBe(0);
expect(restorable.restorable).toBe(true);
expect(restorable.relativeOutputFiles).toEqual([]);
expect(restorable.stampedOutputs).toEqual(["dist/output.js"]);
expect(typeof restorable.signature).toBe("string");
expect(restorable.signature).toHaveLength(64);
expect(restorable.outputRoot).toBe(
path.join(rootDir, ".artifacts/build-all-cache/cached/outputs"),
);
expect(restorable.stampPath).toBe(
path.join(rootDir, ".artifacts/build-all-cache/cached/stamp.json"),
);
expect(restorable).toEqual({
cacheable: true,
fresh: true,
inputFiles: 1,
outputFiles: 0,
outputRoot: restorable.outputRoot,
reason: "fresh-cache",
relativeOutputFiles: [],
restorable: true,
signature: restorable.signature,
stampedOutputs: ["dist/output.js"],
stampPath: restorable.stampPath,
});
expect(restoreBuildAllStepCacheOutputs(restorable, { rootDir })).toBe(true);
expect(fs.readFileSync(outputPath, "utf8")).toBe("output");
});
});
it("restores cached outputs over existing outputs for always-restore steps", () => {
withBuildCacheFixture(({ rootDir, outputPath, step }) => {
const alwaysRestoreStep = {
...step,
cache: {
...step.cache,
restore: "always" as const,
},
};
const cacheState = resolveBuildAllStepCacheState(alwaysRestoreStep, { rootDir });
writeBuildAllStepCacheStamp(alwaysRestoreStep, cacheState, { rootDir });
fs.writeFileSync(outputPath, "overwritten by earlier build step");
const restorable = resolveBuildAllStepCacheState(alwaysRestoreStep, { rootDir });
expect(restorable.cacheable).toBe(true);
expect(restorable.fresh).toBe(true);
expect(restorable.reason).toBe("fresh-cache");
expect(restorable.outputFiles).toBe(1);
expect(restorable.restorable).toBe(true);
expect(restorable.relativeOutputFiles).toEqual(["dist/output.js"]);
expect(restorable.stampedOutputs).toEqual(["dist/output.js"]);
expect(restoreBuildAllStepCacheOutputs(restorable, { rootDir })).toBe(true);
expect(fs.readFileSync(outputPath, "utf8")).toBe("output");
});
});
it("restores always-restore outputs after a cache-hit command cleans them", () => {
withBuildCacheFixture(({ rootDir, outputPath, step }) => {
const alwaysRestoreStep = {
...step,
cache: {
...step.cache,
restore: "always" as const,
},
};
const cacheState = resolveBuildAllStepCacheState(alwaysRestoreStep, { rootDir });
writeBuildAllStepCacheStamp(alwaysRestoreStep, cacheState, { rootDir });
const restorable = resolveBuildAllStepCacheState(alwaysRestoreStep, { rootDir });
fs.rmSync(outputPath);
expect(
finalizeBuildAllStepCache(alwaysRestoreStep, restorable, { rootDir, reusedCache: true }),
).toBe(true);
expect(fs.readFileSync(outputPath, "utf8")).toBe("output");
});
});
it("refreshes validator cache outputs after a cache-hit command updates them", () => {
withBuildCacheFixture(({ rootDir, outputPath, step }) => {
const refreshStep = {
...step,
cache: {
...step.cache,
restore: "always" as const,
runOnHit: { finalize: "refresh" as const },
},
};
const cacheState = resolveBuildAllStepCacheState(refreshStep, { rootDir });
writeBuildAllStepCacheStamp(refreshStep, cacheState, { rootDir });
const restorable = resolveBuildAllStepCacheState(refreshStep, { rootDir });
expect(restoreBuildAllStepCacheOutputs(restorable, { rootDir })).toBe(true);
fs.writeFileSync(outputPath, "validated refresh");
expect(
finalizeBuildAllStepCache(refreshStep, restorable, { rootDir, reusedCache: true }),
).toBe(true);
fs.rmSync(outputPath);
const refreshed = resolveBuildAllStepCacheState(refreshStep, { rootDir });
expect(restoreBuildAllStepCacheOutputs(refreshed, { rootDir })).toBe(true);
expect(fs.readFileSync(outputPath, "utf8")).toBe("validated refresh");
});
});
it("can cache only direct directory files for generated flat outputs", () => {
withBuildCacheFixture(({ rootDir, step }) => {
const nestedPath = path.join(rootDir, "dist/nested/output.d.ts");
fs.mkdirSync(path.dirname(nestedPath), { recursive: true });
fs.writeFileSync(path.join(rootDir, "dist/output.js"), "ignored");
fs.writeFileSync(path.join(rootDir, "dist/output.d.ts"), "flat");
fs.writeFileSync(nestedPath, "nested");
const flatOnlyStep = {
...step,
cache: {
...step.cache,
outputs: [{ path: "dist", extensions: [".d.ts"], recursive: false }],
},
};
const cacheState = resolveBuildAllStepCacheState(flatOnlyStep, { rootDir });
expect(cacheState.relativeOutputFiles).toEqual(["dist/output.d.ts"]);
});
});
});