refactor(scripts): make run-node main testable

This commit is contained in:
Peter Steinberger
2026-02-14 15:20:30 +00:00
parent ebc68861a6
commit 9fb48f4dff
2 changed files with 138 additions and 116 deletions
+105 -79
View File
@@ -3,29 +3,22 @@ import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import process from "node:process"; import process from "node:process";
import { pathToFileURL } from "node:url";
const args = process.argv.slice(2);
const env = { ...process.env };
const cwd = process.cwd();
const compiler = "tsdown"; const compiler = "tsdown";
const compilerArgs = ["exec", compiler, "--no-clean"]; const compilerArgs = ["exec", compiler, "--no-clean"];
const distRoot = path.join(cwd, "dist");
const distEntry = path.join(distRoot, "/entry.js");
const buildStampPath = path.join(distRoot, ".buildstamp");
const srcRoot = path.join(cwd, "src");
const configFiles = [path.join(cwd, "tsconfig.json"), path.join(cwd, "package.json")];
const gitWatchedPaths = ["src", "tsconfig.json", "package.json"]; const gitWatchedPaths = ["src", "tsconfig.json", "package.json"];
const statMtime = (filePath) => { const statMtime = (filePath, fsImpl = fs) => {
try { try {
return fs.statSync(filePath).mtimeMs; return fsImpl.statSync(filePath).mtimeMs;
} catch { } catch {
return null; return null;
} }
}; };
const isExcludedSource = (filePath) => { const isExcludedSource = (filePath, srcRoot) => {
const relativePath = path.relative(srcRoot, filePath); const relativePath = path.relative(srcRoot, filePath);
if (relativePath.startsWith("..")) { if (relativePath.startsWith("..")) {
return false; return false;
@@ -37,7 +30,7 @@ const isExcludedSource = (filePath) => {
); );
}; };
const findLatestMtime = (dirPath, shouldSkip) => { const findLatestMtime = (dirPath, shouldSkip, deps) => {
let latest = null; let latest = null;
const queue = [dirPath]; const queue = [dirPath];
while (queue.length > 0) { while (queue.length > 0) {
@@ -47,7 +40,7 @@ const findLatestMtime = (dirPath, shouldSkip) => {
} }
let entries = []; let entries = [];
try { try {
entries = fs.readdirSync(current, { withFileTypes: true }); entries = deps.fs.readdirSync(current, { withFileTypes: true });
} catch { } catch {
continue; continue;
} }
@@ -63,7 +56,7 @@ const findLatestMtime = (dirPath, shouldSkip) => {
if (shouldSkip?.(fullPath)) { if (shouldSkip?.(fullPath)) {
continue; continue;
} }
const mtime = statMtime(fullPath); const mtime = statMtime(fullPath, deps.fs);
if (mtime == null) { if (mtime == null) {
continue; continue;
} }
@@ -75,10 +68,10 @@ const findLatestMtime = (dirPath, shouldSkip) => {
return latest; return latest;
}; };
const runGit = (args) => { const runGit = (gitArgs, deps) => {
try { try {
const result = spawnSync("git", args, { const result = deps.spawnSync("git", gitArgs, {
cwd, cwd: deps.cwd,
encoding: "utf8", encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"], stdio: ["ignore", "pipe", "ignore"],
}); });
@@ -91,32 +84,29 @@ const runGit = (args) => {
} }
}; };
const resolveGitHead = () => { const resolveGitHead = (deps) => {
const head = runGit(["rev-parse", "HEAD"]); const head = runGit(["rev-parse", "HEAD"], deps);
return head || null; return head || null;
}; };
const hasDirtySourceTree = () => { const hasDirtySourceTree = (deps) => {
const output = runGit([ const output = runGit(
"status", ["status", "--porcelain", "--untracked-files=normal", "--", ...gitWatchedPaths],
"--porcelain", deps,
"--untracked-files=normal", );
"--",
...gitWatchedPaths,
]);
if (output === null) { if (output === null) {
return null; return null;
} }
return output.length > 0; return output.length > 0;
}; };
const readBuildStamp = () => { const readBuildStamp = (deps) => {
const mtime = statMtime(buildStampPath); const mtime = statMtime(deps.buildStampPath, deps.fs);
if (mtime == null) { if (mtime == null) {
return { mtime: null, head: null }; return { mtime: null, head: null };
} }
try { try {
const raw = fs.readFileSync(buildStampPath, "utf8").trim(); const raw = deps.fs.readFileSync(deps.buildStampPath, "utf8").trim();
if (!raw.startsWith("{")) { if (!raw.startsWith("{")) {
return { mtime, head: null }; return { mtime, head: null };
} }
@@ -128,39 +118,43 @@ const readBuildStamp = () => {
} }
}; };
const hasSourceMtimeChanged = (stampMtime) => { const hasSourceMtimeChanged = (stampMtime, deps) => {
const srcMtime = findLatestMtime(srcRoot, isExcludedSource); const srcMtime = findLatestMtime(
deps.srcRoot,
(candidate) => isExcludedSource(candidate, deps.srcRoot),
deps,
);
return srcMtime != null && srcMtime > stampMtime; return srcMtime != null && srcMtime > stampMtime;
}; };
const shouldBuild = () => { const shouldBuild = (deps) => {
if (env.OPENCLAW_FORCE_BUILD === "1") { if (deps.env.OPENCLAW_FORCE_BUILD === "1") {
return true; return true;
} }
const stamp = readBuildStamp(); const stamp = readBuildStamp(deps);
if (stamp.mtime == null) { if (stamp.mtime == null) {
return true; return true;
} }
if (statMtime(distEntry) == null) { if (statMtime(deps.distEntry, deps.fs) == null) {
return true; return true;
} }
for (const filePath of configFiles) { for (const filePath of deps.configFiles) {
const mtime = statMtime(filePath); const mtime = statMtime(filePath, deps.fs);
if (mtime != null && mtime > stamp.mtime) { if (mtime != null && mtime > stamp.mtime) {
return true; return true;
} }
} }
const currentHead = resolveGitHead(); const currentHead = resolveGitHead(deps);
if (currentHead && !stamp.head) { if (currentHead && !stamp.head) {
return hasSourceMtimeChanged(stamp.mtime); return hasSourceMtimeChanged(stamp.mtime, deps);
} }
if (currentHead && stamp.head && currentHead !== stamp.head) { if (currentHead && stamp.head && currentHead !== stamp.head) {
return hasSourceMtimeChanged(stamp.mtime); return hasSourceMtimeChanged(stamp.mtime, deps);
} }
if (currentHead) { if (currentHead) {
const dirty = hasDirtySourceTree(); const dirty = hasDirtySourceTree(deps);
if (dirty === true) { if (dirty === true) {
return true; return true;
} }
@@ -169,69 +163,101 @@ const shouldBuild = () => {
} }
} }
if (hasSourceMtimeChanged(stamp.mtime)) { if (hasSourceMtimeChanged(stamp.mtime, deps)) {
return true; return true;
} }
return false; return false;
}; };
const logRunner = (message) => { const logRunner = (message, deps) => {
if (env.OPENCLAW_RUNNER_LOG === "0") { if (deps.env.OPENCLAW_RUNNER_LOG === "0") {
return; return;
} }
process.stderr.write(`[openclaw] ${message}\n`); deps.stderr.write(`[openclaw] ${message}\n`);
}; };
const runNode = () => { const runOpenClaw = async (deps) => {
const nodeProcess = spawn(process.execPath, ["openclaw.mjs", ...args], { const nodeProcess = deps.spawn(deps.execPath, ["openclaw.mjs", ...deps.args], {
cwd, cwd: deps.cwd,
env, env: deps.env,
stdio: "inherit", stdio: "inherit",
}); });
const res = await new Promise((resolve) => {
nodeProcess.on("exit", (exitCode, exitSignal) => { nodeProcess.on("exit", (exitCode, exitSignal) => {
if (exitSignal) { resolve({ exitCode, exitSignal });
process.exit(1); });
}
process.exit(exitCode ?? 1);
}); });
if (res.exitSignal) {
return 1;
}
return res.exitCode ?? 1;
}; };
const writeBuildStamp = () => { const writeBuildStamp = (deps) => {
try { try {
fs.mkdirSync(distRoot, { recursive: true }); deps.fs.mkdirSync(deps.distRoot, { recursive: true });
const stamp = { const stamp = {
builtAt: Date.now(), builtAt: Date.now(),
head: resolveGitHead(), head: resolveGitHead(deps),
}; };
fs.writeFileSync(buildStampPath, `${JSON.stringify(stamp)}\n`); deps.fs.writeFileSync(deps.buildStampPath, `${JSON.stringify(stamp)}\n`);
} catch (error) { } catch (error) {
// Best-effort stamp; still allow the runner to start. // Best-effort stamp; still allow the runner to start.
logRunner(`Failed to write build stamp: ${error?.message ?? "unknown error"}`); logRunner(`Failed to write build stamp: ${error?.message ?? "unknown error"}`, deps);
} }
}; };
if (!shouldBuild()) { export async function runNodeMain(params = {}) {
runNode(); const deps = {
} else { spawn: params.spawn ?? spawn,
logRunner("Building TypeScript (dist is stale)."); spawnSync: params.spawnSync ?? spawnSync,
const buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm"; fs: params.fs ?? fs,
stderr: params.stderr ?? process.stderr,
execPath: params.execPath ?? process.execPath,
cwd: params.cwd ?? process.cwd(),
args: params.args ?? process.argv.slice(2),
env: params.env ? { ...params.env } : { ...process.env },
platform: params.platform ?? process.platform,
};
deps.distRoot = path.join(deps.cwd, "dist");
deps.distEntry = path.join(deps.distRoot, "/entry.js");
deps.buildStampPath = path.join(deps.distRoot, ".buildstamp");
deps.srcRoot = path.join(deps.cwd, "src");
deps.configFiles = [path.join(deps.cwd, "tsconfig.json"), path.join(deps.cwd, "package.json")];
if (!shouldBuild(deps)) {
return await runOpenClaw(deps);
}
logRunner("Building TypeScript (dist is stale).", deps);
const buildCmd = deps.platform === "win32" ? "cmd.exe" : "pnpm";
const buildArgs = const buildArgs =
process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...compilerArgs] : compilerArgs; deps.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...compilerArgs] : compilerArgs;
const build = spawn(buildCmd, buildArgs, { const build = deps.spawn(buildCmd, buildArgs, {
cwd, cwd: deps.cwd,
env, env: deps.env,
stdio: "inherit", stdio: "inherit",
}); });
build.on("exit", (code, signal) => { const buildRes = await new Promise((resolve) => {
if (signal) { build.on("exit", (exitCode, exitSignal) => resolve({ exitCode, exitSignal }));
process.exit(1);
}
if (code !== 0 && code !== null) {
process.exit(code);
}
writeBuildStamp();
runNode();
}); });
if (buildRes.exitSignal) {
return 1;
}
if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) {
return buildRes.exitCode;
}
writeBuildStamp(deps);
return await runOpenClaw(deps);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
void runNodeMain()
.then((code) => process.exit(code))
.catch((err) => {
console.error(err);
process.exit(1);
});
} }
+33 -37
View File
@@ -1,4 +1,3 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -18,54 +17,51 @@ describe("run-node script", () => {
"preserves control-ui assets by building with tsdown --no-clean", "preserves control-ui assets by building with tsdown --no-clean",
async () => { async () => {
await withTempDir(async (tmp) => { await withTempDir(async (tmp) => {
const runNodeScript = path.join(process.cwd(), "scripts", "run-node.mjs");
const fakeBinDir = path.join(tmp, ".fake-bin");
const fakePnpmPath = path.join(fakeBinDir, "pnpm");
const argsPath = path.join(tmp, ".pnpm-args.txt"); const argsPath = path.join(tmp, ".pnpm-args.txt");
const indexPath = path.join(tmp, "dist", "control-ui", "index.html"); const indexPath = path.join(tmp, "dist", "control-ui", "index.html");
await fs.mkdir(fakeBinDir, { recursive: true });
await fs.mkdir(path.dirname(indexPath), { recursive: true }); await fs.mkdir(path.dirname(indexPath), { recursive: true });
await fs.writeFile(indexPath, "<html>sentinel</html>\n", "utf-8"); await fs.writeFile(indexPath, "<html>sentinel</html>\n", "utf-8");
await fs.writeFile( const nodeCalls: string[][] = [];
path.join(tmp, "openclaw.mjs"), const spawn = (cmd: string, args: string[]) => {
"#!/usr/bin/env node\nif (process.argv.includes('--version')) console.log('9.9.9-test');\n", if (cmd === "pnpm") {
"utf-8", void fs.writeFile(argsPath, args.join(" "), "utf-8");
); if (!args.includes("--no-clean")) {
await fs.chmod(path.join(tmp, "openclaw.mjs"), 0o755); void fs.rm(path.join(tmp, "dist", "control-ui"), { recursive: true, force: true });
}
const fakePnpm = `#!/usr/bin/env node }
const fs = require("node:fs"); if (cmd === process.execPath) {
const path = require("node:path"); nodeCalls.push([cmd, ...args]);
const args = process.argv.slice(2); }
const cwd = process.cwd(); return {
fs.writeFileSync(path.join(cwd, ".pnpm-args.txt"), args.join(" "), "utf-8"); on: (event: string, cb: (code: number | null, signal: string | null) => void) => {
if (!args.includes("--no-clean")) { if (event === "exit") {
fs.rmSync(path.join(cwd, "dist", "control-ui"), { recursive: true, force: true }); queueMicrotask(() => cb(0, null));
} }
fs.mkdirSync(path.join(cwd, "dist"), { recursive: true }); return undefined;
fs.writeFileSync(path.join(cwd, "dist", "entry.js"), "export {}\\n", "utf-8"); },
`; };
await fs.writeFile(fakePnpmPath, fakePnpm, "utf-8");
await fs.chmod(fakePnpmPath, 0o755);
const env = {
...process.env,
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
OPENCLAW_FORCE_BUILD: "1",
OPENCLAW_RUNNER_LOG: "0",
}; };
const result = spawnSync(process.execPath, [runNodeScript, "--version"], {
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp, cwd: tmp,
env, args: ["--version"],
encoding: "utf-8", env: {
...process.env,
OPENCLAW_FORCE_BUILD: "1",
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
execPath: process.execPath,
platform: process.platform,
}); });
expect(result.status).toBe(0); expect(exitCode).toBe(0);
expect(result.stdout).toContain("9.9.9-test");
await expect(fs.readFile(argsPath, "utf-8")).resolves.toContain("exec tsdown --no-clean"); await expect(fs.readFile(argsPath, "utf-8")).resolves.toContain("exec tsdown --no-clean");
await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel"); await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel");
expect(nodeCalls).toEqual([[process.execPath, "openclaw.mjs", "--version"]]);
}); });
}, },
); );