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

Merged via squash.

Prepared head SHA: edd8ed825469128bbe85f86e2e1341f6c57687d7
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/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. - 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/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 ## 2026.3.13

View File

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

View File

@ -40,11 +40,17 @@ pnpm gateway:watch
This maps to: This maps to:
```bash ```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 The watcher restarts on build-relevant files under `src/`, extension source files,
on each restart. 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) ## Dev profile + dev gateway (--dev)

View File

@ -96,7 +96,8 @@ pnpm install
pnpm gateway:watch 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 ### 2) Point the macOS app at your running Gateway

View File

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

View File

@ -8,7 +8,63 @@ import { pathToFileURL } from "node:url";
const compiler = "tsdown"; const compiler = "tsdown";
const compilerArgs = ["exec", compiler, "--no-clean"]; 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) => { const statMtime = (filePath, fsImpl = fs) => {
try { try {
@ -18,16 +74,12 @@ const statMtime = (filePath, fsImpl = fs) => {
} }
}; };
const isExcludedSource = (filePath, srcRoot) => { const isExcludedSource = (filePath, sourceRoot, sourceRootName) => {
const relativePath = path.relative(srcRoot, filePath); const relativePath = normalizePath(path.relative(sourceRoot, filePath));
if (relativePath.startsWith("..")) { if (relativePath.startsWith("..")) {
return false; return false;
} }
return ( return !isBuildRelevantRunNodePath(path.posix.join(sourceRootName, relativePath));
relativePath.endsWith(".test.ts") ||
relativePath.endsWith(".test.tsx") ||
relativePath.endsWith(`test-helpers.ts`)
);
}; };
const findLatestMtime = (dirPath, shouldSkip, deps) => { const findLatestMtime = (dirPath, shouldSkip, deps) => {
@ -89,15 +141,39 @@ const resolveGitHead = (deps) => {
return head || null; 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 hasDirtySourceTree = (deps) => {
const output = runGit( const output = readGitStatus(deps);
["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths],
deps,
);
if (output === null) { if (output === null) {
return null; return null;
} }
return output.length > 0; return parseGitStatusPaths(output).some((repoPath) => isBuildRelevantRunNodePath(repoPath));
}; };
const readBuildStamp = (deps) => { const readBuildStamp = (deps) => {
@ -119,12 +195,18 @@ const readBuildStamp = (deps) => {
}; };
const hasSourceMtimeChanged = (stampMtime, deps) => { const hasSourceMtimeChanged = (stampMtime, deps) => {
const srcMtime = findLatestMtime( let latestSourceMtime = null;
deps.srcRoot, for (const sourceRoot of deps.sourceRoots) {
(candidate) => isExcludedSource(candidate, deps.srcRoot), const sourceMtime = findLatestMtime(
deps, sourceRoot.path,
); (candidate) => isExcludedSource(candidate, sourceRoot.path, sourceRoot.name),
return srcMtime != null && srcMtime > stampMtime; deps,
);
if (sourceMtime != null && (latestSourceMtime == null || sourceMtime > latestSourceMtime)) {
latestSourceMtime = sourceMtime;
}
}
return latestSourceMtime != null && latestSourceMtime > stampMtime;
}; };
const shouldBuild = (deps) => { const shouldBuild = (deps) => {
@ -223,8 +305,11 @@ export async function runNodeMain(params = {}) {
deps.distRoot = path.join(deps.cwd, "dist"); deps.distRoot = path.join(deps.cwd, "dist");
deps.distEntry = path.join(deps.distRoot, "/entry.js"); deps.distEntry = path.join(deps.distRoot, "/entry.js");
deps.buildStampPath = path.join(deps.distRoot, ".buildstamp"); deps.buildStampPath = path.join(deps.distRoot, ".buildstamp");
deps.srcRoot = path.join(deps.cwd, "src"); deps.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({
deps.configFiles = [path.join(deps.cwd, "tsconfig.json"), path.join(deps.cwd, "package.json")]; name: sourceRoot,
path: path.join(deps.cwd, sourceRoot),
}));
deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath));
if (!shouldBuild(deps)) { if (!shouldBuild(deps)) {
return await runOpenClaw(deps); return await runOpenClaw(deps);

View File

@ -1,26 +1,32 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process"; import process from "node:process";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import chokidar from "chokidar"; 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_NODE_RUNNER = "scripts/run-node.mjs";
const WATCH_RESTART_SIGNAL = "SIGTERM"; const WATCH_RESTART_SIGNAL = "SIGTERM";
const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args]; const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args];
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); const normalizePath = (filePath) =>
String(filePath ?? "")
.replaceAll("\\", "/")
.replace(/^\.\/+/, "");
const isIgnoredWatchPath = (filePath) => { const resolveRepoPath = (filePath, cwd) => {
const normalizedPath = normalizePath(filePath); const rawPath = String(filePath ?? "");
return ( if (path.isAbsolute(rawPath)) {
normalizedPath.endsWith(".test.ts") || return normalizePath(path.relative(cwd, rawPath));
normalizedPath.endsWith(".test.tsx") || }
normalizedPath.endsWith("test-helpers.ts") return normalizePath(rawPath);
);
}; };
const isIgnoredWatchPath = (filePath, cwd) =>
!isRestartRelevantRunNodePath(resolveRepoPath(filePath, cwd));
export async function runWatchMain(params = {}) { export async function runWatchMain(params = {}) {
const deps = { const deps = {
spawn: params.spawn ?? spawn, spawn: params.spawn ?? spawn,
@ -52,7 +58,7 @@ export async function runWatchMain(params = {}) {
const watcher = deps.createWatcher(deps.watchPaths, { const watcher = deps.createWatcher(deps.watchPaths, {
ignoreInitial: true, ignoreInitial: true,
ignored: (watchPath) => isIgnoredWatchPath(watchPath), ignored: (watchPath) => isIgnoredWatchPath(watchPath, deps.cwd),
}); });
const settle = (code) => { const settle = (code) => {
@ -89,7 +95,7 @@ export async function runWatchMain(params = {}) {
}; };
const requestRestart = (changedPath) => { const requestRestart = (changedPath) => {
if (shuttingDown || isIgnoredWatchPath(changedPath)) { if (shuttingDown || isIgnoredWatchPath(changedPath, deps.cwd)) {
return; return;
} }
if (!watchProcess) { 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", () => { describe("run-node script", () => {
it.runIf(process.platform !== "win32")( it.runIf(process.platform !== "win32")(
"preserves control-ui assets by building with tsdown --no-clean", "preserves control-ui assets by building with tsdown --no-clean",
@ -161,4 +167,360 @@ describe("run-node script", () => {
expect(exitCode).toBe(23); 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 }, { ignoreInitial: boolean; ignored: (watchPath: string) => boolean },
]; ];
expect(watchPaths).toEqual(runNodeWatchedPaths); expect(watchPaths).toEqual(runNodeWatchedPaths);
expect(watchPaths).toContain("extensions");
expect(watchPaths).toContain("tsdown.config.ts");
expect(watchOptions.ignoreInitial).toBe(true); expect(watchOptions.ignoreInitial).toBe(true);
expect(watchOptions.ignored("src/infra/watch-node.test.ts")).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.tsx")).toBe(true);
expect(watchOptions.ignored("src/infra/watch-node-test-helpers.ts")).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("src/infra/watch-node.ts")).toBe(false);
expect(watchOptions.ignored("tsconfig.json")).toBe(false); expect(watchOptions.ignored("tsconfig.json")).toBe(false);
@ -120,9 +127,24 @@ describe("watch-node script", () => {
}), }),
}); });
const childB = Object.assign(new EventEmitter(), { 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(() => {}), 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(), { const watcher = Object.assign(new EventEmitter(), {
close: vi.fn(async () => {}), close: vi.fn(async () => {}),
}); });
@ -151,11 +173,26 @@ describe("watch-node script", () => {
expect(spawn).toHaveBeenCalledTimes(1); expect(spawn).toHaveBeenCalledTimes(1);
expect(childA.kill).not.toHaveBeenCalled(); 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)); await new Promise((resolve) => setImmediate(resolve));
expect(childA.kill).toHaveBeenCalledWith("SIGTERM"); expect(childA.kill).toHaveBeenCalledWith("SIGTERM");
expect(spawn).toHaveBeenCalledTimes(2); 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"); fakeProcess.emit("SIGINT");
const exitCode = await runPromise; const exitCode = await runPromise;
expect(exitCode).toBe(130); expect(exitCode).toBe(130);