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); }); });