mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 01:41:40 +00:00
521 lines
17 KiB
TypeScript
521 lines
17 KiB
TypeScript
import fsSync from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { runNodeMain } from "../../scripts/run-node.mjs";
|
|
|
|
const ROOT_SRC = "src/index.ts";
|
|
const ROOT_TSCONFIG = "tsconfig.json";
|
|
const ROOT_PACKAGE = "package.json";
|
|
const ROOT_TSDOWN = "tsdown.config.ts";
|
|
const DIST_ENTRY = "dist/entry.js";
|
|
const BUILD_STAMP = "dist/.buildstamp";
|
|
const EXTENSION_SRC = "extensions/demo/src/index.ts";
|
|
const EXTENSION_MANIFEST = "extensions/demo/openclaw.plugin.json";
|
|
const EXTENSION_PACKAGE = "extensions/demo/package.json";
|
|
const EXTENSION_README = "extensions/demo/README.md";
|
|
const DIST_EXTENSION_MANIFEST = "dist/extensions/demo/openclaw.plugin.json";
|
|
const DIST_EXTENSION_PACKAGE = "dist/extensions/demo/package.json";
|
|
|
|
const OLD_TIME = new Date("2026-03-13T10:00:00.000Z");
|
|
const BUILD_TIME = new Date("2026-03-13T12:00:00.000Z");
|
|
const NEW_TIME = new Date("2026-03-13T12:00:01.000Z");
|
|
|
|
const BASE_PROJECT_FILES = {
|
|
[ROOT_TSCONFIG]: "{}\n",
|
|
[ROOT_PACKAGE]: '{"name":"openclaw-test"}\n',
|
|
[DIST_ENTRY]: "console.log('built');\n",
|
|
[BUILD_STAMP]: '{"head":"abc123"}\n',
|
|
} as const;
|
|
|
|
async function withTempDir<T>(run: (dir: string) => Promise<T>): Promise<T> {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-run-node-"));
|
|
try {
|
|
return await run(dir);
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function createExitedProcess(code: number | null, signal: string | null = null) {
|
|
return {
|
|
on: (event: string, cb: (code: number | null, signal: string | null) => void) => {
|
|
if (event === "exit") {
|
|
queueMicrotask(() => cb(code, signal));
|
|
}
|
|
return undefined;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function writeRuntimePostBuildScaffold(tmp: string): Promise<void> {
|
|
const pluginSdkAliasPath = path.join(tmp, "src", "plugin-sdk", "root-alias.cjs");
|
|
await fs.mkdir(path.dirname(pluginSdkAliasPath), { recursive: true });
|
|
await fs.mkdir(path.join(tmp, "extensions"), { recursive: true });
|
|
await fs.writeFile(pluginSdkAliasPath, "module.exports = {};\n", "utf-8");
|
|
await fs.utimes(pluginSdkAliasPath, BUILD_TIME, BUILD_TIME);
|
|
}
|
|
|
|
function expectedBuildSpawn() {
|
|
return [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"];
|
|
}
|
|
|
|
function statusCommandSpawn() {
|
|
return [process.execPath, "openclaw.mjs", "status"];
|
|
}
|
|
|
|
function resolvePath(tmp: string, relativePath: string) {
|
|
return path.join(tmp, relativePath);
|
|
}
|
|
|
|
async function writeProjectFiles(tmp: string, files: Record<string, string>) {
|
|
for (const [relativePath, contents] of Object.entries(files)) {
|
|
const absolutePath = resolvePath(tmp, relativePath);
|
|
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
await fs.writeFile(absolutePath, contents, "utf-8");
|
|
}
|
|
}
|
|
|
|
async function touchProjectFiles(tmp: string, relativePaths: string[], time: Date) {
|
|
for (const relativePath of relativePaths) {
|
|
const absolutePath = resolvePath(tmp, relativePath);
|
|
await fs.utimes(absolutePath, time, time);
|
|
}
|
|
}
|
|
|
|
async function setupTrackedProject(
|
|
tmp: string,
|
|
options: {
|
|
files?: Record<string, string>;
|
|
oldPaths?: string[];
|
|
buildPaths?: string[];
|
|
newPaths?: string[];
|
|
} = {},
|
|
) {
|
|
await writeRuntimePostBuildScaffold(tmp);
|
|
await writeProjectFiles(tmp, {
|
|
...BASE_PROJECT_FILES,
|
|
...options.files,
|
|
});
|
|
await touchProjectFiles(tmp, options.oldPaths ?? [], OLD_TIME);
|
|
await touchProjectFiles(tmp, options.buildPaths ?? [], BUILD_TIME);
|
|
await touchProjectFiles(tmp, options.newPaths ?? [], NEW_TIME);
|
|
}
|
|
|
|
function createSpawnRecorder(
|
|
options: {
|
|
gitHead?: string;
|
|
gitStatus?: string;
|
|
} = {},
|
|
) {
|
|
const spawnCalls: string[][] = [];
|
|
const spawn = (cmd: string, args: string[]) => {
|
|
spawnCalls.push([cmd, ...args]);
|
|
return createExitedProcess(0);
|
|
};
|
|
const spawnSync = (cmd: string, args: string[]) => {
|
|
if (cmd === "git" && args[0] === "rev-parse" && options.gitHead !== undefined) {
|
|
return { status: 0, stdout: options.gitHead };
|
|
}
|
|
if (cmd === "git" && args[0] === "status" && options.gitStatus !== undefined) {
|
|
return { status: 0, stdout: options.gitStatus };
|
|
}
|
|
return { status: 1, stdout: "" };
|
|
};
|
|
return { spawnCalls, spawn, spawnSync };
|
|
}
|
|
|
|
async function runStatusCommand(params: {
|
|
tmp: string;
|
|
spawn: (cmd: string, args: string[]) => ReturnType<typeof createExitedProcess>;
|
|
spawnSync?: (cmd: string, args: string[]) => { status: number; stdout: string };
|
|
env?: Record<string, string>;
|
|
}) {
|
|
return await runNodeMain({
|
|
cwd: params.tmp,
|
|
args: ["status"],
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_RUNNER_LOG: "0",
|
|
...params.env,
|
|
},
|
|
spawn: params.spawn,
|
|
...(params.spawnSync ? { spawnSync: params.spawnSync } : {}),
|
|
execPath: process.execPath,
|
|
platform: process.platform,
|
|
});
|
|
}
|
|
|
|
async function expectManifestId(tmp: string, relativePath: string, id: string) {
|
|
await expect(
|
|
fs.readFile(resolvePath(tmp, relativePath), "utf-8").then((raw) => JSON.parse(raw)),
|
|
).resolves.toMatchObject({ id });
|
|
}
|
|
|
|
describe("run-node script", () => {
|
|
it.runIf(process.platform !== "win32")(
|
|
"preserves control-ui assets by building with tsdown --no-clean",
|
|
async () => {
|
|
await withTempDir(async (tmp) => {
|
|
const argsPath = resolvePath(tmp, ".build-args.txt");
|
|
const indexPath = resolvePath(tmp, "dist/control-ui/index.html");
|
|
|
|
await writeRuntimePostBuildScaffold(tmp);
|
|
await fs.mkdir(path.dirname(indexPath), { recursive: true });
|
|
await fs.writeFile(indexPath, "<html>sentinel</html>\n", "utf-8");
|
|
|
|
const nodeCalls: string[][] = [];
|
|
const spawn = (cmd: string, args: string[]) => {
|
|
if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") {
|
|
fsSync.writeFileSync(argsPath, args.join(" "), "utf-8");
|
|
if (!args.includes("--no-clean")) {
|
|
fsSync.rmSync(resolvePath(tmp, "dist/control-ui"), { recursive: true, force: true });
|
|
}
|
|
}
|
|
if (cmd === process.execPath) {
|
|
nodeCalls.push([cmd, ...args]);
|
|
}
|
|
return createExitedProcess(0);
|
|
};
|
|
|
|
const exitCode = await runNodeMain({
|
|
cwd: tmp,
|
|
args: ["--version"],
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_FORCE_BUILD: "1",
|
|
OPENCLAW_RUNNER_LOG: "0",
|
|
},
|
|
spawn,
|
|
execPath: process.execPath,
|
|
platform: process.platform,
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
await expect(fs.readFile(argsPath, "utf-8")).resolves.toContain(
|
|
"scripts/tsdown-build.mjs --no-clean",
|
|
);
|
|
await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel");
|
|
expect(nodeCalls).toEqual([
|
|
[process.execPath, "scripts/tsdown-build.mjs", "--no-clean"],
|
|
[process.execPath, "openclaw.mjs", "--version"],
|
|
]);
|
|
});
|
|
},
|
|
);
|
|
|
|
it("copies bundled plugin metadata after rebuilding from a clean dist", async () => {
|
|
await withTempDir(async (tmp) => {
|
|
await writeRuntimePostBuildScaffold(tmp);
|
|
await writeProjectFiles(tmp, {
|
|
[EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n',
|
|
[EXTENSION_PACKAGE]:
|
|
JSON.stringify(
|
|
{
|
|
name: "demo",
|
|
openclaw: {
|
|
extensions: ["./src/index.ts", "./nested/entry.mts"],
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
});
|
|
|
|
const spawnCalls: string[][] = [];
|
|
const spawn = (cmd: string, args: string[]) => {
|
|
spawnCalls.push([cmd, ...args]);
|
|
return createExitedProcess(0);
|
|
};
|
|
|
|
const exitCode = await runStatusCommand({
|
|
tmp,
|
|
spawn,
|
|
env: { OPENCLAW_FORCE_BUILD: "1" },
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]);
|
|
|
|
await expect(
|
|
fs.readFile(resolvePath(tmp, "dist/plugin-sdk/root-alias.cjs"), "utf-8"),
|
|
).resolves.toContain("module.exports = {};");
|
|
await expect(
|
|
fs
|
|
.readFile(resolvePath(tmp, DIST_EXTENSION_MANIFEST), "utf-8")
|
|
.then((raw) => JSON.parse(raw)),
|
|
).resolves.toMatchObject({ id: "demo" });
|
|
await expect(
|
|
fs.readFile(resolvePath(tmp, DIST_EXTENSION_PACKAGE), "utf-8"),
|
|
).resolves.toContain(
|
|
'"extensions": [\n "./src/index.js",\n "./nested/entry.js"\n ]',
|
|
);
|
|
});
|
|
});
|
|
|
|
it("skips rebuilding when dist is current and the source tree is clean", async () => {
|
|
await withTempDir(async (tmp) => {
|
|
await setupTrackedProject(tmp, {
|
|
files: {
|
|
[ROOT_SRC]: "export const value = 1;\n",
|
|
},
|
|
oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE],
|
|
buildPaths: [DIST_ENTRY, BUILD_STAMP],
|
|
});
|
|
|
|
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({
|
|
gitHead: "abc123\n",
|
|
gitStatus: "",
|
|
});
|
|
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(spawnCalls).toEqual([statusCommandSpawn()]);
|
|
});
|
|
});
|
|
|
|
it("returns the build exit code when the compiler step fails", async () => {
|
|
await withTempDir(async (tmp) => {
|
|
const spawn = (cmd: string, args: string[] = []) => {
|
|
if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") {
|
|
return createExitedProcess(23);
|
|
}
|
|
return createExitedProcess(0);
|
|
};
|
|
|
|
const exitCode = await runNodeMain({
|
|
cwd: tmp,
|
|
args: ["status"],
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_FORCE_BUILD: "1",
|
|
OPENCLAW_RUNNER_LOG: "0",
|
|
},
|
|
spawn,
|
|
execPath: process.execPath,
|
|
platform: process.platform,
|
|
});
|
|
|
|
expect(exitCode).toBe(23);
|
|
});
|
|
});
|
|
|
|
it("rebuilds when extension sources are newer than the build stamp", async () => {
|
|
await withTempDir(async (tmp) => {
|
|
await setupTrackedProject(tmp, {
|
|
files: {
|
|
[EXTENSION_SRC]: "export const extensionValue = 1;\n",
|
|
},
|
|
buildPaths: [ROOT_TSCONFIG, ROOT_PACKAGE, DIST_ENTRY, BUILD_STAMP],
|
|
newPaths: [EXTENSION_SRC],
|
|
});
|
|
|
|
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder();
|
|
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]);
|
|
});
|
|
});
|
|
|
|
it("skips rebuilding when extension package metadata is newer than the build stamp", async () => {
|
|
await withTempDir(async (tmp) => {
|
|
await setupTrackedProject(tmp, {
|
|
files: {
|
|
[EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n',
|
|
[EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n',
|
|
[ROOT_TSDOWN]: "export default {};\n",
|
|
[DIST_EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./stale.js"]}}\n',
|
|
},
|
|
oldPaths: [EXTENSION_MANIFEST, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN],
|
|
buildPaths: [DIST_ENTRY, BUILD_STAMP, DIST_EXTENSION_PACKAGE],
|
|
newPaths: [EXTENSION_PACKAGE],
|
|
});
|
|
|
|
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder();
|
|
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(spawnCalls).toEqual([statusCommandSpawn()]);
|
|
await expect(
|
|
fs.readFile(resolvePath(tmp, DIST_EXTENSION_PACKAGE), "utf-8"),
|
|
).resolves.toContain('"./index.js"');
|
|
});
|
|
});
|
|
|
|
it("skips rebuilding for dirty non-source files under extensions", async () => {
|
|
await withTempDir(async (tmp) => {
|
|
await setupTrackedProject(tmp, {
|
|
files: {
|
|
[ROOT_SRC]: "export const value = 1;\n",
|
|
[EXTENSION_README]: "# demo\n",
|
|
[ROOT_TSDOWN]: "export default {};\n",
|
|
},
|
|
buildPaths: [
|
|
ROOT_SRC,
|
|
EXTENSION_README,
|
|
ROOT_TSCONFIG,
|
|
ROOT_PACKAGE,
|
|
ROOT_TSDOWN,
|
|
DIST_ENTRY,
|
|
BUILD_STAMP,
|
|
],
|
|
});
|
|
|
|
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({
|
|
gitHead: "abc123\n",
|
|
gitStatus: " M extensions/demo/README.md\n",
|
|
});
|
|
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(spawnCalls).toEqual([statusCommandSpawn()]);
|
|
});
|
|
});
|
|
|
|
it("skips rebuilding for dirty extension manifests that only affect runtime reload", async () => {
|
|
await withTempDir(async (tmp) => {
|
|
await setupTrackedProject(tmp, {
|
|
files: {
|
|
[ROOT_SRC]: "export const value = 1;\n",
|
|
[EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n',
|
|
[ROOT_TSDOWN]: "export default {};\n",
|
|
[DIST_EXTENSION_MANIFEST]: '{"id":"stale","configSchema":{"type":"object"}}\n',
|
|
},
|
|
buildPaths: [
|
|
ROOT_SRC,
|
|
EXTENSION_MANIFEST,
|
|
ROOT_TSCONFIG,
|
|
ROOT_PACKAGE,
|
|
ROOT_TSDOWN,
|
|
DIST_ENTRY,
|
|
BUILD_STAMP,
|
|
DIST_EXTENSION_MANIFEST,
|
|
],
|
|
});
|
|
|
|
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({
|
|
gitHead: "abc123\n",
|
|
gitStatus: " M extensions/demo/openclaw.plugin.json\n",
|
|
});
|
|
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(spawnCalls).toEqual([statusCommandSpawn()]);
|
|
await expectManifestId(tmp, DIST_EXTENSION_MANIFEST, "demo");
|
|
});
|
|
});
|
|
|
|
it("repairs missing bundled plugin metadata without rerunning tsdown", async () => {
|
|
await withTempDir(async (tmp) => {
|
|
await setupTrackedProject(tmp, {
|
|
files: {
|
|
[ROOT_SRC]: "export const value = 1;\n",
|
|
[EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n',
|
|
[ROOT_TSDOWN]: "export default {};\n",
|
|
},
|
|
buildPaths: [
|
|
ROOT_SRC,
|
|
EXTENSION_MANIFEST,
|
|
ROOT_TSCONFIG,
|
|
ROOT_PACKAGE,
|
|
ROOT_TSDOWN,
|
|
DIST_ENTRY,
|
|
BUILD_STAMP,
|
|
],
|
|
});
|
|
|
|
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({
|
|
gitHead: "abc123\n",
|
|
gitStatus: "",
|
|
});
|
|
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(spawnCalls).toEqual([statusCommandSpawn()]);
|
|
await expectManifestId(tmp, DIST_EXTENSION_MANIFEST, "demo");
|
|
});
|
|
});
|
|
|
|
it("removes stale bundled plugin metadata when the source manifest is gone", async () => {
|
|
await withTempDir(async (tmp) => {
|
|
await setupTrackedProject(tmp, {
|
|
files: {
|
|
[ROOT_SRC]: "export const value = 1;\n",
|
|
[ROOT_TSDOWN]: "export default {};\n",
|
|
[DIST_EXTENSION_MANIFEST]: '{"id":"stale","configSchema":{"type":"object"}}\n',
|
|
[DIST_EXTENSION_PACKAGE]: '{"name":"stale"}\n',
|
|
},
|
|
buildPaths: [
|
|
ROOT_SRC,
|
|
ROOT_TSCONFIG,
|
|
ROOT_PACKAGE,
|
|
ROOT_TSDOWN,
|
|
DIST_ENTRY,
|
|
BUILD_STAMP,
|
|
DIST_EXTENSION_MANIFEST,
|
|
DIST_EXTENSION_PACKAGE,
|
|
],
|
|
});
|
|
|
|
await fs.mkdir(resolvePath(tmp, "extensions/demo"), { recursive: true });
|
|
|
|
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({
|
|
gitHead: "abc123\n",
|
|
gitStatus: "",
|
|
});
|
|
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(spawnCalls).toEqual([statusCommandSpawn()]);
|
|
await expect(fs.access(resolvePath(tmp, DIST_EXTENSION_MANIFEST))).rejects.toThrow();
|
|
await expect(fs.access(resolvePath(tmp, DIST_EXTENSION_PACKAGE))).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
it("skips rebuilding when only non-source extension files are newer than the build stamp", async () => {
|
|
await withTempDir(async (tmp) => {
|
|
await setupTrackedProject(tmp, {
|
|
files: {
|
|
[ROOT_SRC]: "export const value = 1;\n",
|
|
[EXTENSION_README]: "# demo\n",
|
|
[ROOT_TSDOWN]: "export default {};\n",
|
|
},
|
|
oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN],
|
|
buildPaths: [DIST_ENTRY, BUILD_STAMP],
|
|
newPaths: [EXTENSION_README],
|
|
});
|
|
|
|
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder();
|
|
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(spawnCalls).toEqual([statusCommandSpawn()]);
|
|
});
|
|
});
|
|
|
|
it("rebuilds when tsdown config is newer than the build stamp", async () => {
|
|
await withTempDir(async (tmp) => {
|
|
await setupTrackedProject(tmp, {
|
|
files: {
|
|
[ROOT_SRC]: "export const value = 1;\n",
|
|
[ROOT_TSDOWN]: "export default {};\n",
|
|
},
|
|
oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE],
|
|
buildPaths: [DIST_ENTRY, BUILD_STAMP],
|
|
newPaths: [ROOT_TSDOWN],
|
|
});
|
|
|
|
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({
|
|
gitHead: "abc123\n",
|
|
gitStatus: "",
|
|
});
|
|
const exitCode = await runStatusCommand({ tmp, spawn, spawnSync });
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]);
|
|
});
|
|
});
|
|
});
|