mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 21:01:43 +03:00
refactor(scripts): make run-node main testable
This commit is contained in:
+105
-79
@@ -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
@@ -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"]]);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user