Files
clawdbot/src/gateway/server.reload.test.ts
2026-01-04 14:38:51 +00:00

318 lines
8.8 KiB
TypeScript

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<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
}> = [];
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,
},
slack: {
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 () => {}),
startSlackProvider: vi.fn(async () => {}),
stopSlackProvider: 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<void>)
| 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.CLAWDBOT_SKIP_PROVIDERS;
prevSkipGmail = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
process.env.CLAWDBOT_SKIP_PROVIDERS = "0";
delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
});
afterEach(() => {
if (prevSkipProviders === undefined) {
delete process.env.CLAWDBOT_SKIP_PROVIDERS;
} else {
process.env.CLAWDBOT_SKIP_PROVIDERS = prevSkipProviders;
}
if (prevSkipGmail === undefined) {
delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
} else {
process.env.CLAWDBOT_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();
});
});