Scripts: rebuild on extension and tsdown config changes (#47571)

Merged via squash.

Prepared head SHA: edd8ed8254
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana 2026-03-15 16:19:27 -04:00 committed by GitHub
parent a2080421a1
commit 594920f8cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 539 additions and 39 deletions

View File

@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc.
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse.
- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras.
## 2026.3.13

View File

@ -103,7 +103,7 @@ pnpm build
pnpm openclaw onboard --install-daemon
# Dev loop (auto-reload on TS changes)
# Dev loop (auto-reload on source/config changes)
pnpm gateway:watch
```

View File

@ -40,11 +40,17 @@ pnpm gateway:watch
This maps to:
```bash
node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force
node scripts/watch-node.mjs gateway --force
```
Add any gateway CLI flags after `gateway:watch` and they will be passed through
on each restart.
The watcher restarts on build-relevant files under `src/`, extension source files,
extension `package.json` and `openclaw.plugin.json` metadata, `tsconfig.json`,
`package.json`, and `tsdown.config.ts`. Extension metadata changes restart the
gateway without forcing a `tsdown` rebuild; source and config changes still
rebuild `dist` first.
Add any gateway CLI flags after `gateway:watch` and they will be passed through on
each restart.
## Dev profile + dev gateway (--dev)

View File

@ -96,7 +96,8 @@ pnpm install
pnpm gateway:watch
```
`gateway:watch` runs the gateway in watch mode and reloads on TypeScript changes.
`gateway:watch` runs the gateway in watch mode and reloads on relevant source,
config, and bundled-plugin metadata changes.
### 2) Point the macOS app at your running Gateway

View File

@ -1,4 +1,6 @@
export const runNodeWatchedPaths: string[];
export function isBuildRelevantRunNodePath(repoPath: string): boolean;
export function isRestartRelevantRunNodePath(repoPath: string): boolean;
export function runNodeMain(params?: {
spawn?: (

View File

@ -8,7 +8,63 @@ import { pathToFileURL } from "node:url";
const compiler = "tsdown";
const compilerArgs = ["exec", compiler, "--no-clean"];
export const runNodeWatchedPaths = ["src", "tsconfig.json", "package.json"];
const runNodeSourceRoots = ["src", "extensions"];
const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"];
export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles];
const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/;
const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]);
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
const isIgnoredSourcePath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
return (
normalizedPath.endsWith(".test.ts") ||
normalizedPath.endsWith(".test.tsx") ||
normalizedPath.endsWith("test-helpers.ts")
);
};
const isBuildRelevantSourcePath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath);
};
export const isBuildRelevantRunNodePath = (repoPath) => {
const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, "");
if (runNodeConfigFiles.includes(normalizedPath)) {
return true;
}
if (normalizedPath.startsWith("src/")) {
return !isIgnoredSourcePath(normalizedPath.slice("src/".length));
}
if (normalizedPath.startsWith("extensions/")) {
return isBuildRelevantSourcePath(normalizedPath.slice("extensions/".length));
}
return false;
};
const isRestartRelevantExtensionPath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) {
return true;
}
return isBuildRelevantSourcePath(normalizedPath);
};
export const isRestartRelevantRunNodePath = (repoPath) => {
const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, "");
if (runNodeConfigFiles.includes(normalizedPath)) {
return true;
}
if (normalizedPath.startsWith("src/")) {
return !isIgnoredSourcePath(normalizedPath.slice("src/".length));
}
if (normalizedPath.startsWith("extensions/")) {
return isRestartRelevantExtensionPath(normalizedPath.slice("extensions/".length));
}
return false;
};
const statMtime = (filePath, fsImpl = fs) => {
try {
@ -18,16 +74,12 @@ const statMtime = (filePath, fsImpl = fs) => {
}
};
const isExcludedSource = (filePath, srcRoot) => {
const relativePath = path.relative(srcRoot, filePath);
const isExcludedSource = (filePath, sourceRoot, sourceRootName) => {
const relativePath = normalizePath(path.relative(sourceRoot, filePath));
if (relativePath.startsWith("..")) {
return false;
}
return (
relativePath.endsWith(".test.ts") ||
relativePath.endsWith(".test.tsx") ||
relativePath.endsWith(`test-helpers.ts`)
);
return !isBuildRelevantRunNodePath(path.posix.join(sourceRootName, relativePath));
};
const findLatestMtime = (dirPath, shouldSkip, deps) => {
@ -89,15 +141,39 @@ const resolveGitHead = (deps) => {
return head || null;
};
const readGitStatus = (deps) => {
try {
const result = deps.spawnSync(
"git",
["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths],
{
cwd: deps.cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
},
);
if (result.status !== 0) {
return null;
}
return result.stdout ?? "";
} catch {
return null;
}
};
const parseGitStatusPaths = (output) =>
output
.split("\n")
.flatMap((line) => line.slice(3).split(" -> "))
.map((entry) => normalizePath(entry.trim()))
.filter(Boolean);
const hasDirtySourceTree = (deps) => {
const output = runGit(
["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths],
deps,
);
const output = readGitStatus(deps);
if (output === null) {
return null;
}
return output.length > 0;
return parseGitStatusPaths(output).some((repoPath) => isBuildRelevantRunNodePath(repoPath));
};
const readBuildStamp = (deps) => {
@ -119,12 +195,18 @@ const readBuildStamp = (deps) => {
};
const hasSourceMtimeChanged = (stampMtime, deps) => {
const srcMtime = findLatestMtime(
deps.srcRoot,
(candidate) => isExcludedSource(candidate, deps.srcRoot),
deps,
);
return srcMtime != null && srcMtime > stampMtime;
let latestSourceMtime = null;
for (const sourceRoot of deps.sourceRoots) {
const sourceMtime = findLatestMtime(
sourceRoot.path,
(candidate) => isExcludedSource(candidate, sourceRoot.path, sourceRoot.name),
deps,
);
if (sourceMtime != null && (latestSourceMtime == null || sourceMtime > latestSourceMtime)) {
latestSourceMtime = sourceMtime;
}
}
return latestSourceMtime != null && latestSourceMtime > stampMtime;
};
const shouldBuild = (deps) => {
@ -223,8 +305,11 @@ export async function runNodeMain(params = {}) {
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")];
deps.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({
name: sourceRoot,
path: path.join(deps.cwd, sourceRoot),
}));
deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath));
if (!shouldBuild(deps)) {
return await runOpenClaw(deps);

View File

@ -1,26 +1,32 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import chokidar from "chokidar";
import { runNodeWatchedPaths } from "./run-node.mjs";
import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node.mjs";
const WATCH_NODE_RUNNER = "scripts/run-node.mjs";
const WATCH_RESTART_SIGNAL = "SIGTERM";
const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args];
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
const normalizePath = (filePath) =>
String(filePath ?? "")
.replaceAll("\\", "/")
.replace(/^\.\/+/, "");
const isIgnoredWatchPath = (filePath) => {
const normalizedPath = normalizePath(filePath);
return (
normalizedPath.endsWith(".test.ts") ||
normalizedPath.endsWith(".test.tsx") ||
normalizedPath.endsWith("test-helpers.ts")
);
const resolveRepoPath = (filePath, cwd) => {
const rawPath = String(filePath ?? "");
if (path.isAbsolute(rawPath)) {
return normalizePath(path.relative(cwd, rawPath));
}
return normalizePath(rawPath);
};
const isIgnoredWatchPath = (filePath, cwd) =>
!isRestartRelevantRunNodePath(resolveRepoPath(filePath, cwd));
export async function runWatchMain(params = {}) {
const deps = {
spawn: params.spawn ?? spawn,
@ -52,7 +58,7 @@ export async function runWatchMain(params = {}) {
const watcher = deps.createWatcher(deps.watchPaths, {
ignoreInitial: true,
ignored: (watchPath) => isIgnoredWatchPath(watchPath),
ignored: (watchPath) => isIgnoredWatchPath(watchPath, deps.cwd),
});
const settle = (code) => {
@ -89,7 +95,7 @@ export async function runWatchMain(params = {}) {
};
const requestRestart = (changedPath) => {
if (shuttingDown || isIgnoredWatchPath(changedPath)) {
if (shuttingDown || isIgnoredWatchPath(changedPath, deps.cwd)) {
return;
}
if (!watchProcess) {

View File

@ -24,6 +24,12 @@ function createExitedProcess(code: number | null, signal: string | null = null)
};
}
function expectedBuildSpawn(platform: NodeJS.Platform = process.platform) {
return platform === "win32"
? ["cmd.exe", "/d", "/s", "/c", "pnpm", "exec", "tsdown", "--no-clean"]
: ["pnpm", "exec", "tsdown", "--no-clean"];
}
describe("run-node script", () => {
it.runIf(process.platform !== "win32")(
"preserves control-ui assets by building with tsdown --no-clean",
@ -161,4 +167,360 @@ describe("run-node script", () => {
expect(exitCode).toBe(23);
});
});
it("rebuilds when extension sources are newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const extensionPath = path.join(tmp, "extensions", "demo", "src", "index.ts");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
await fs.mkdir(path.dirname(extensionPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(extensionPath, "export const extensionValue = 1;\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(tsconfigPath, stampTime, stampTime);
await fs.utimes(packageJsonPath, stampTime, stampTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(extensionPath, newTime, newTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = () => ({ status: 1, stdout: "" });
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([
expectedBuildSpawn(),
[process.execPath, "openclaw.mjs", "status"],
]);
});
});
it("skips rebuilding when extension package metadata is newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const packagePath = path.join(tmp, "extensions", "demo", "package.json");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await fs.mkdir(path.dirname(packagePath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(
packagePath,
'{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n',
"utf-8",
);
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const oldTime = new Date("2026-03-13T10:00:00.000Z");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(tsconfigPath, oldTime, oldTime);
await fs.utimes(packageJsonPath, oldTime, oldTime);
await fs.utimes(tsdownConfigPath, oldTime, oldTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(packagePath, newTime, newTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = () => ({ status: 1, stdout: "" });
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
});
});
it("skips rebuilding for dirty non-source files under extensions", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const readmePath = path.join(tmp, "extensions", "demo", "README.md");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(readmePath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(readmePath, "# demo\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
await fs.utimes(srcPath, stampTime, stampTime);
await fs.utimes(readmePath, stampTime, stampTime);
await fs.utimes(tsconfigPath, stampTime, stampTime);
await fs.utimes(packageJsonPath, stampTime, stampTime);
await fs.utimes(tsdownConfigPath, stampTime, stampTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
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") {
return { status: 0, stdout: "abc123\n" };
}
if (cmd === "git" && args[0] === "status") {
return { status: 0, stdout: " M extensions/demo/README.md\n" };
}
return { status: 1, stdout: "" };
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
});
});
it("skips rebuilding for dirty extension manifests that only affect runtime reload", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(manifestPath, '{"id":"demo"}\n', "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
await fs.utimes(srcPath, stampTime, stampTime);
await fs.utimes(manifestPath, stampTime, stampTime);
await fs.utimes(tsconfigPath, stampTime, stampTime);
await fs.utimes(packageJsonPath, stampTime, stampTime);
await fs.utimes(tsdownConfigPath, stampTime, stampTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
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") {
return { status: 0, stdout: "abc123\n" };
}
if (cmd === "git" && args[0] === "status") {
return { status: 0, stdout: " M extensions/demo/openclaw.plugin.json\n" };
}
return { status: 1, stdout: "" };
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
});
});
it("skips rebuilding when only non-source extension files are newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const readmePath = path.join(tmp, "extensions", "demo", "README.md");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(readmePath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(readmePath, "# demo\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const oldTime = new Date("2026-03-13T10:00:00.000Z");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(srcPath, oldTime, oldTime);
await fs.utimes(tsconfigPath, oldTime, oldTime);
await fs.utimes(packageJsonPath, oldTime, oldTime);
await fs.utimes(tsdownConfigPath, oldTime, oldTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(readmePath, newTime, newTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = () => ({ status: 1, stdout: "" });
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
});
});
it("rebuilds when tsdown config is newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const oldTime = new Date("2026-03-13T10:00:00.000Z");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(srcPath, oldTime, oldTime);
await fs.utimes(tsconfigPath, oldTime, oldTime);
await fs.utimes(packageJsonPath, oldTime, oldTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(tsdownConfigPath, newTime, newTime);
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") {
return { status: 0, stdout: "abc123\n" };
}
if (cmd === "git" && args[0] === "status") {
return { status: 0, stdout: "" };
}
return { status: 1, stdout: "" };
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([
expectedBuildSpawn(),
[process.execPath, "openclaw.mjs", "status"],
]);
});
});
});

View File

@ -44,10 +44,17 @@ describe("watch-node script", () => {
{ ignoreInitial: boolean; ignored: (watchPath: string) => boolean },
];
expect(watchPaths).toEqual(runNodeWatchedPaths);
expect(watchPaths).toContain("extensions");
expect(watchPaths).toContain("tsdown.config.ts");
expect(watchOptions.ignoreInitial).toBe(true);
expect(watchOptions.ignored("src/infra/watch-node.test.ts")).toBe(true);
expect(watchOptions.ignored("src/infra/watch-node.test.tsx")).toBe(true);
expect(watchOptions.ignored("src/infra/watch-node-test-helpers.ts")).toBe(true);
expect(watchOptions.ignored("extensions/voice-call/README.md")).toBe(true);
expect(watchOptions.ignored("extensions/voice-call/openclaw.plugin.json")).toBe(false);
expect(watchOptions.ignored("extensions/voice-call/package.json")).toBe(false);
expect(watchOptions.ignored("extensions/voice-call/index.ts")).toBe(false);
expect(watchOptions.ignored("extensions/voice-call/src/runtime.ts")).toBe(false);
expect(watchOptions.ignored("src/infra/watch-node.ts")).toBe(false);
expect(watchOptions.ignored("tsconfig.json")).toBe(false);
@ -120,9 +127,24 @@ describe("watch-node script", () => {
}),
});
const childB = Object.assign(new EventEmitter(), {
kill: vi.fn(function () {
queueMicrotask(() => childB.emit("exit", 0, null));
}),
});
const childC = Object.assign(new EventEmitter(), {
kill: vi.fn(function () {
queueMicrotask(() => childC.emit("exit", 0, null));
}),
});
const childD = Object.assign(new EventEmitter(), {
kill: vi.fn(() => {}),
});
const spawn = vi.fn().mockReturnValueOnce(childA).mockReturnValueOnce(childB);
const spawn = vi
.fn()
.mockReturnValueOnce(childA)
.mockReturnValueOnce(childB)
.mockReturnValueOnce(childC)
.mockReturnValueOnce(childD);
const watcher = Object.assign(new EventEmitter(), {
close: vi.fn(async () => {}),
});
@ -151,11 +173,26 @@ describe("watch-node script", () => {
expect(spawn).toHaveBeenCalledTimes(1);
expect(childA.kill).not.toHaveBeenCalled();
watcher.emit("change", "src/infra/watch-node.ts");
watcher.emit("change", "extensions/voice-call/README.md");
await new Promise((resolve) => setImmediate(resolve));
expect(spawn).toHaveBeenCalledTimes(1);
expect(childA.kill).not.toHaveBeenCalled();
watcher.emit("change", "extensions/voice-call/openclaw.plugin.json");
await new Promise((resolve) => setImmediate(resolve));
expect(childA.kill).toHaveBeenCalledWith("SIGTERM");
expect(spawn).toHaveBeenCalledTimes(2);
watcher.emit("change", "extensions/voice-call/package.json");
await new Promise((resolve) => setImmediate(resolve));
expect(childB.kill).toHaveBeenCalledWith("SIGTERM");
expect(spawn).toHaveBeenCalledTimes(3);
watcher.emit("change", "src/infra/watch-node.ts");
await new Promise((resolve) => setImmediate(resolve));
expect(childC.kill).toHaveBeenCalledWith("SIGTERM");
expect(spawn).toHaveBeenCalledTimes(4);
fakeProcess.emit("SIGINT");
const exitCode = await runPromise;
expect(exitCode).toBe(130);