Files
openclaw/src/infra/watch-node.test.ts
Gustavo Madeira Santana 594920f8cc 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
2026-03-15 16:19:27 -04:00

219 lines
7.8 KiB
TypeScript

import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { runNodeWatchedPaths } from "../../scripts/run-node.mjs";
import { runWatchMain } from "../../scripts/watch-node.mjs";
const createFakeProcess = () =>
Object.assign(new EventEmitter(), {
pid: 4242,
execPath: "/usr/local/bin/node",
}) as unknown as NodeJS.Process;
const createWatchHarness = () => {
const child = Object.assign(new EventEmitter(), {
kill: vi.fn(() => {}),
});
const spawn = vi.fn(() => child);
const watcher = Object.assign(new EventEmitter(), {
close: vi.fn(async () => {}),
});
const createWatcher = vi.fn(() => watcher);
const fakeProcess = createFakeProcess();
return { child, spawn, watcher, createWatcher, fakeProcess };
};
describe("watch-node script", () => {
it("wires chokidar watch to run-node with watched source/config paths", async () => {
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
args: ["gateway", "--force"],
cwd: "/tmp/openclaw",
createWatcher,
env: { PATH: "/usr/bin" },
now: () => 1700000000000,
process: fakeProcess,
spawn,
});
expect(createWatcher).toHaveBeenCalledTimes(1);
const firstWatcherCall = createWatcher.mock.calls[0];
expect(firstWatcherCall).toBeDefined();
const [watchPaths, watchOptions] = firstWatcherCall as unknown as [
string[],
{ 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);
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith(
"/usr/local/bin/node",
["scripts/run-node.mjs", "gateway", "--force"],
expect.objectContaining({
cwd: "/tmp/openclaw",
stdio: "inherit",
env: expect.objectContaining({
PATH: "/usr/bin",
OPENCLAW_WATCH_MODE: "1",
OPENCLAW_WATCH_SESSION: "1700000000000-4242",
OPENCLAW_WATCH_COMMAND: "gateway --force",
}),
}),
);
fakeProcess.emit("SIGINT");
const exitCode = await runPromise;
expect(exitCode).toBe(130);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(watcher.close).toHaveBeenCalledTimes(1);
});
it("terminates child on SIGINT and returns shell interrupt code", async () => {
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
args: ["gateway", "--force"],
createWatcher,
process: fakeProcess,
spawn,
});
fakeProcess.emit("SIGINT");
const exitCode = await runPromise;
expect(exitCode).toBe(130);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(watcher.close).toHaveBeenCalledTimes(1);
expect(fakeProcess.listenerCount("SIGINT")).toBe(0);
expect(fakeProcess.listenerCount("SIGTERM")).toBe(0);
});
it("terminates child on SIGTERM and returns shell terminate code", async () => {
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
args: ["gateway", "--force"],
createWatcher,
process: fakeProcess,
spawn,
});
fakeProcess.emit("SIGTERM");
const exitCode = await runPromise;
expect(exitCode).toBe(143);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(watcher.close).toHaveBeenCalledTimes(1);
expect(fakeProcess.listenerCount("SIGINT")).toBe(0);
expect(fakeProcess.listenerCount("SIGTERM")).toBe(0);
});
it("ignores test-only changes and restarts on non-test source changes", async () => {
const childA = Object.assign(new EventEmitter(), {
kill: vi.fn(function () {
queueMicrotask(() => childA.emit("exit", 0, null));
}),
});
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)
.mockReturnValueOnce(childC)
.mockReturnValueOnce(childD);
const watcher = Object.assign(new EventEmitter(), {
close: vi.fn(async () => {}),
});
const createWatcher = vi.fn(() => watcher);
const fakeProcess = createFakeProcess();
const runPromise = runWatchMain({
args: ["gateway", "--force"],
createWatcher,
process: fakeProcess,
spawn,
});
watcher.emit("change", "src/infra/watch-node.test.ts");
await new Promise((resolve) => setImmediate(resolve));
expect(spawn).toHaveBeenCalledTimes(1);
expect(childA.kill).not.toHaveBeenCalled();
watcher.emit("change", "src/infra/watch-node.test.tsx");
await new Promise((resolve) => setImmediate(resolve));
expect(spawn).toHaveBeenCalledTimes(1);
expect(childA.kill).not.toHaveBeenCalled();
watcher.emit("change", "src/infra/watch-node-test-helpers.ts");
await new Promise((resolve) => setImmediate(resolve));
expect(spawn).toHaveBeenCalledTimes(1);
expect(childA.kill).not.toHaveBeenCalled();
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);
});
it("kills child and exits when watcher emits an error", async () => {
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
args: ["gateway", "--force"],
createWatcher,
process: fakeProcess,
spawn,
});
watcher.emit("error", new Error("watch failed"));
const exitCode = await runPromise;
expect(exitCode).toBe(1);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(watcher.close).toHaveBeenCalledTimes(1);
});
});