mirror of https://github.com/openclaw/openclaw.git
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:
parent
a2080421a1
commit
594920f8cc
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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?: (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue