diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts new file mode 100644 index 000000000..0446766ec --- /dev/null +++ b/src/gateway/server.reload.test.ts @@ -0,0 +1,309 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getFreePort, + installGatewayTestHooks, + startGatewayServer, +} from "./test-helpers.js"; + +const hoisted = vi.hoisted(() => { + const cronInstances: Array<{ + start: ReturnType; + stop: ReturnType; + }> = []; + + class CronServiceMock { + start = vi.fn(async () => {}); + stop = vi.fn(); + constructor() { + cronInstances.push(this); + } + } + + const browserStop = vi.fn(async () => {}); + const startBrowserControlServerIfEnabled = vi.fn(async () => ({ + stop: browserStop, + })); + + const heartbeatStop = vi.fn(); + const startHeartbeatRunner = vi.fn(() => ({ stop: heartbeatStop })); + + const startGmailWatcher = vi.fn(async () => ({ started: true })); + const stopGmailWatcher = vi.fn(async () => {}); + + const providerManager = { + getRuntimeSnapshot: vi.fn(() => ({ + whatsapp: { + running: false, + connected: false, + reconnectAttempts: 0, + lastConnectedAt: null, + lastDisconnect: null, + lastMessageAt: null, + lastEventAt: null, + lastError: null, + }, + telegram: { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + mode: null, + }, + discord: { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + signal: { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + baseUrl: null, + }, + imessage: { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + cliPath: null, + dbPath: null, + }, + })), + startProviders: vi.fn(async () => {}), + startWhatsAppProvider: vi.fn(async () => {}), + stopWhatsAppProvider: vi.fn(async () => {}), + startTelegramProvider: vi.fn(async () => {}), + stopTelegramProvider: vi.fn(async () => {}), + startDiscordProvider: vi.fn(async () => {}), + stopDiscordProvider: vi.fn(async () => {}), + startSignalProvider: vi.fn(async () => {}), + stopSignalProvider: vi.fn(async () => {}), + startIMessageProvider: vi.fn(async () => {}), + stopIMessageProvider: vi.fn(async () => {}), + markWhatsAppLoggedOut: vi.fn(), + }; + + const createProviderManager = vi.fn(() => providerManager); + + const reloaderStop = vi.fn(async () => {}); + let onHotReload: + | ((plan: unknown, nextConfig: unknown) => Promise) + | null = null; + let onRestart: ((plan: unknown, nextConfig: unknown) => void) | null = null; + + const startGatewayConfigReloader = vi.fn( + (opts: { + onHotReload: typeof onHotReload; + onRestart: typeof onRestart; + }) => { + onHotReload = opts.onHotReload as typeof onHotReload; + onRestart = opts.onRestart as typeof onRestart; + return { stop: reloaderStop }; + }, + ); + + return { + CronService: CronServiceMock, + cronInstances, + browserStop, + startBrowserControlServerIfEnabled, + heartbeatStop, + startHeartbeatRunner, + startGmailWatcher, + stopGmailWatcher, + providerManager, + createProviderManager, + startGatewayConfigReloader, + reloaderStop, + getOnHotReload: () => onHotReload, + getOnRestart: () => onRestart, + }; +}); + +vi.mock("../cron/service.js", () => ({ + CronService: hoisted.CronService, +})); + +vi.mock("./server-browser.js", () => ({ + startBrowserControlServerIfEnabled: + hoisted.startBrowserControlServerIfEnabled, +})); + +vi.mock("../infra/heartbeat-runner.js", () => ({ + startHeartbeatRunner: hoisted.startHeartbeatRunner, +})); + +vi.mock("../hooks/gmail-watcher.js", () => ({ + startGmailWatcher: hoisted.startGmailWatcher, + stopGmailWatcher: hoisted.stopGmailWatcher, +})); + +vi.mock("./server-providers.js", () => ({ + createProviderManager: hoisted.createProviderManager, +})); + +vi.mock("./config-reload.js", () => ({ + startGatewayConfigReloader: hoisted.startGatewayConfigReloader, +})); + +installGatewayTestHooks(); + +describe("gateway hot reload", () => { + let prevSkipProviders: string | undefined; + let prevSkipGmail: string | undefined; + + beforeEach(() => { + prevSkipProviders = process.env.CLAWDIS_SKIP_PROVIDERS; + prevSkipGmail = process.env.CLAWDIS_SKIP_GMAIL_WATCHER; + process.env.CLAWDIS_SKIP_PROVIDERS = "0"; + delete process.env.CLAWDIS_SKIP_GMAIL_WATCHER; + }); + + afterEach(() => { + if (prevSkipProviders === undefined) { + delete process.env.CLAWDIS_SKIP_PROVIDERS; + } else { + process.env.CLAWDIS_SKIP_PROVIDERS = prevSkipProviders; + } + if (prevSkipGmail === undefined) { + delete process.env.CLAWDIS_SKIP_GMAIL_WATCHER; + } else { + process.env.CLAWDIS_SKIP_GMAIL_WATCHER = prevSkipGmail; + } + }); + + it("applies hot reload actions for providers + services", async () => { + const port = await getFreePort(); + const server = await startGatewayServer(port); + + const onHotReload = hoisted.getOnHotReload(); + expect(onHotReload).toBeTypeOf("function"); + + const nextConfig = { + hooks: { + enabled: true, + token: "secret", + gmail: { account: "me@example.com" }, + }, + cron: { enabled: true, store: "/tmp/cron.json" }, + agent: { heartbeat: { every: "1m" }, maxConcurrent: 2 }, + browser: { enabled: true, controlUrl: "http://127.0.0.1:18791" }, + web: { enabled: true }, + telegram: { botToken: "token" }, + discord: { token: "token" }, + signal: { account: "+15550000000" }, + imessage: { enabled: true }, + }; + + await onHotReload?.( + { + changedPaths: [ + "hooks.gmail.account", + "cron.enabled", + "agent.heartbeat.every", + "browser.enabled", + "web.enabled", + "telegram.botToken", + "discord.token", + "signal.account", + "imessage.enabled", + ], + restartGateway: false, + restartReasons: [], + hotReasons: ["web.enabled"], + reloadHooks: true, + restartGmailWatcher: true, + restartBrowserControl: true, + restartCron: true, + restartHeartbeat: true, + restartProviders: new Set([ + "whatsapp", + "telegram", + "discord", + "signal", + "imessage", + ]), + noopPaths: [], + }, + nextConfig, + ); + + expect(hoisted.stopGmailWatcher).toHaveBeenCalled(); + expect(hoisted.startGmailWatcher).toHaveBeenCalledWith(nextConfig); + + expect(hoisted.browserStop).toHaveBeenCalledTimes(1); + expect(hoisted.startBrowserControlServerIfEnabled).toHaveBeenCalledTimes(2); + + expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(2); + expect(hoisted.heartbeatStop).toHaveBeenCalledTimes(1); + + expect(hoisted.cronInstances.length).toBe(2); + expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1); + expect(hoisted.cronInstances[1].start).toHaveBeenCalledTimes(1); + + expect(hoisted.providerManager.stopWhatsAppProvider).toHaveBeenCalledTimes( + 1, + ); + expect(hoisted.providerManager.startWhatsAppProvider).toHaveBeenCalledTimes( + 1, + ); + expect(hoisted.providerManager.stopTelegramProvider).toHaveBeenCalledTimes( + 1, + ); + expect(hoisted.providerManager.startTelegramProvider).toHaveBeenCalledTimes( + 1, + ); + expect(hoisted.providerManager.stopDiscordProvider).toHaveBeenCalledTimes( + 1, + ); + expect(hoisted.providerManager.startDiscordProvider).toHaveBeenCalledTimes( + 1, + ); + expect(hoisted.providerManager.stopSignalProvider).toHaveBeenCalledTimes(1); + expect(hoisted.providerManager.startSignalProvider).toHaveBeenCalledTimes( + 1, + ); + expect(hoisted.providerManager.stopIMessageProvider).toHaveBeenCalledTimes( + 1, + ); + expect(hoisted.providerManager.startIMessageProvider).toHaveBeenCalledTimes( + 1, + ); + + await server.close(); + }); + + it("emits SIGUSR1 on restart plan when listener exists", async () => { + const port = await getFreePort(); + const server = await startGatewayServer(port); + + const onRestart = hoisted.getOnRestart(); + expect(onRestart).toBeTypeOf("function"); + + const signalSpy = vi.fn(); + process.once("SIGUSR1", signalSpy); + + onRestart?.( + { + changedPaths: ["gateway.port"], + restartGateway: true, + restartReasons: ["gateway.port"], + hotReasons: [], + reloadHooks: false, + restartGmailWatcher: false, + restartBrowserControl: false, + restartCron: false, + restartHeartbeat: false, + restartProviders: new Set(), + noopPaths: [], + }, + {}, + ); + + expect(signalSpy).toHaveBeenCalledTimes(1); + + await server.close(); + }); +});