Files
2026-07-13 13:36:53 +08:00

511 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { join } from "path";
const {
TEST_HOME,
TEST_REPO,
connModeRef,
healthStatuses,
aliveGatewayPids,
restartScript,
hermesCliArgsSpy,
} = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require("path");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require("os");
const script =
"const fs=require('fs'),path=require('path');" +
"fs.writeFileSync(path.join(process.env.HERMES_HOME,'restart-env.json')," +
"JSON.stringify({api:process.env.API_SERVER_ENABLED,key:process.env.TEST_PROFILE_KEY}))";
return {
TEST_HOME: path.join(os.tmpdir(), `hermes-gateway-restart-${Date.now()}`),
TEST_REPO: path.join(os.tmpdir(), `hermes-gateway-repo-${Date.now()}`),
connModeRef: { mode: "local" as "local" | "remote" | "ssh" },
healthStatuses: [] as number[],
aliveGatewayPids: new Set<number>(),
restartScript: script,
hermesCliArgsSpy: vi.fn(),
};
});
vi.mock("../src/main/installer", () => ({
HERMES_HOME: TEST_HOME,
HERMES_PYTHON: process.execPath,
HERMES_REPO: TEST_REPO,
hermesCliArgs: hermesCliArgsSpy,
getEnhancedPath: () => process.env.PATH || "",
}));
vi.mock("../src/main/config", () => ({
getModelConfig: () => ({ model: "test-model", provider: "openrouter" }),
getApiServerKey: () => "",
readEnv: (profile?: string) => ({ TEST_PROFILE_KEY: profile || "default" }),
getConnectionConfig: () => ({ mode: connModeRef.mode }),
getConfigValue: () => "",
setConfigValue: vi.fn(),
}));
vi.mock("../src/main/ssh-tunnel", () => ({
getSshTunnelUrl: () => null,
isSshTunnelActive: () => false,
isSshTunnelHealthy: () => Promise.resolve(false),
startSshTunnel: () => Promise.resolve(),
}));
vi.mock("../src/main/utils", () => ({
stripAnsi: (s: string) => s,
pidIsAliveAs: (pid: number) => aliveGatewayPids.has(pid),
getActiveProfileNameSync: () => "default",
normalizeProfileName: (profile?: string) =>
!profile || profile === "default" ? undefined : profile,
profileHome: (profile?: string) =>
profile ? join(TEST_HOME, "profiles", profile) : TEST_HOME,
profilePaths: (profile?: string) => {
const home = profile ? join(TEST_HOME, "profiles", profile) : TEST_HOME;
return {
home,
configFile: join(home, "config.yaml"),
envFile: join(home, ".env"),
authFile: join(home, "auth.json"),
};
},
}));
vi.mock("../src/main/models", () => ({
readModels: () => [],
}));
vi.mock("../src/main/process-options", () => ({
HIDDEN_SUBPROCESS_OPTIONS: {},
}));
vi.mock("http", () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { EventEmitter } = require("events");
const request = vi.fn(
(
_url: string,
_options: Record<string, unknown>,
callback: (res: { statusCode: number; resume: () => void }) => void,
) => {
const req = new EventEmitter() as InstanceType<typeof EventEmitter> & {
destroy: () => void;
end: () => void;
};
req.destroy = vi.fn();
req.end = (): void => {
queueMicrotask(() => {
callback({
statusCode: healthStatuses.shift() ?? 503,
resume: vi.fn(),
});
});
};
return req;
},
);
return { default: { request }, request };
});
import {
isGatewayHealthy,
isGatewayRunning,
restartGateway,
restartGatewayViaCli,
startGateway,
startGatewayWithRecovery,
stopGateway,
stopHealthPolling,
} from "../src/main/hermes";
function profilePidFile(profile = "work"): string {
return join(TEST_HOME, "profiles", profile, "gateway.pid");
}
async function waitForProcessExit(
pid: number,
timeoutMs = 1000,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
process.kill(pid, 0);
} catch {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
return false;
}
async function waitForFile(
filePath: string,
timeoutMs = 1000,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (existsSync(filePath)) return true;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return false;
}
describe("restartGatewayViaCli", () => {
beforeEach(() => {
stopGateway(true);
stopGateway("work", true);
stopGateway("personal", true);
stopHealthPolling();
mkdirSync(TEST_HOME, { recursive: true });
mkdirSync(join(TEST_HOME, "profiles", "work"), { recursive: true });
mkdirSync(join(TEST_HOME, "profiles", "personal"), { recursive: true });
mkdirSync(TEST_REPO, { recursive: true });
connModeRef.mode = "local";
healthStatuses.length = 0;
aliveGatewayPids.clear();
hermesCliArgsSpy.mockReset();
hermesCliArgsSpy.mockImplementation(() => ["-e", restartScript]);
});
afterEach(async () => {
stopGateway(true);
stopGateway("work", true);
stopGateway("personal", true);
stopHealthPolling();
await new Promise((resolve) => setTimeout(resolve, 50));
rmSync(TEST_HOME, { recursive: true, force: true });
rmSync(TEST_REPO, { recursive: true, force: true });
});
it("uses the hermes gateway restart command with the profile env", async () => {
healthStatuses.push(503, ...Array(20).fill(200));
await expect(restartGatewayViaCli("work", 50, 1)).resolves.toBe(true);
expect(hermesCliArgsSpy).toHaveBeenCalledWith([
"--profile",
"work",
"gateway",
"restart",
]);
expect(await waitForFile(join(TEST_HOME, "restart-env.json"))).toBe(true);
expect(
JSON.parse(readFileSync(join(TEST_HOME, "restart-env.json"), "utf-8")),
).toEqual({
api: "true",
key: "work",
});
});
it("treats a long-running restart process as success once health is ready", async () => {
const pidFile = join(TEST_HOME, "long-running-restart.pid");
const longRunningRestartScript =
"const fs=require('fs');" +
`fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));` +
"setInterval(() => {}, 1000);";
hermesCliArgsSpy.mockImplementation(() => ["-e", longRunningRestartScript]);
healthStatuses.push(200, 503, 200);
await expect(restartGatewayViaCli("work", 50, 1)).resolves.toBe(true);
expect(isGatewayRunning("work")).toBe(true);
expect(hermesCliArgsSpy).toHaveBeenCalledWith([
"--profile",
"work",
"gateway",
"restart",
]);
expect(await waitForFile(pidFile)).toBe(true);
const spawnedPid = Number(readFileSync(pidFile, "utf-8"));
stopGateway("work", true);
expect(await waitForProcessExit(spawnedPid, 3000)).toBe(true);
});
it("times out and stops a long-running restart process when health stays down", async () => {
const pidFile = join(TEST_HOME, "unhealthy-restart.pid");
const unhealthyRestartScript =
"const fs=require('fs');" +
`fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));` +
"setInterval(() => {}, 1000);";
hermesCliArgsSpy.mockImplementation(() => ["-e", unhealthyRestartScript]);
const restart = restartGatewayViaCli("work", 500, 10);
expect(await waitForFile(pidFile)).toBe(true);
await expect(restart).resolves.toBe(false);
const spawnedPid = Number(readFileSync(pidFile, "utf-8"));
expect(await waitForProcessExit(spawnedPid, 3000)).toBe(true);
expect(hermesCliArgsSpy).toHaveBeenCalledWith([
"--profile",
"work",
"gateway",
"restart",
]);
});
it("does not report success when the restart command exits but health stays down", async () => {
await expect(restartGatewayViaCli("work", 5, 1)).resolves.toBe(false);
expect(hermesCliArgsSpy).toHaveBeenCalledWith([
"--profile",
"work",
"gateway",
"restart",
]);
});
it("resolves false instead of rejecting when restart setup throws", async () => {
hermesCliArgsSpy.mockImplementation(() => {
throw new Error("boom");
});
await expect(restartGatewayViaCli("work", 5, 1)).resolves.toBe(false);
});
it("treats a throwing health probe as unhealthy", async () => {
connModeRef.mode = "ssh";
await expect(isGatewayHealthy()).resolves.toBe(false);
});
it("deduplicates concurrent restart requests", async () => {
healthStatuses.push(503, 200, 503, 503, 503, 200, 200, 200);
const first = restartGatewayViaCli("work", 50, 1);
const second = restartGatewayViaCli("work", 50, 1);
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
expect(hermesCliArgsSpy).toHaveBeenCalledTimes(1);
});
it("uses the native restart path only after the old gateway stops", async () => {
hermesCliArgsSpy.mockImplementation(() => ["-e", "process.exit(0)"]);
healthStatuses.push(503, 200);
await expect(restartGateway("work", 50, 1)).resolves.toBe(true);
expect(hermesCliArgsSpy).toHaveBeenCalledTimes(1);
expect(hermesCliArgsSpy).toHaveBeenCalledWith([
"--profile",
"work",
"gateway",
]);
});
it("does not report native restart success when the old gateway never stops", async () => {
const gatewayPid = 424242;
aliveGatewayPids.add(gatewayPid);
writeFileSync(profilePidFile(), String(gatewayPid), "utf-8");
hermesCliArgsSpy.mockImplementation(() => ["-e", "process.exit(0)"]);
healthStatuses.push(...Array(100).fill(200));
await expect(restartGateway("work", 25, 1, 25)).resolves.toBe(false);
expect(hermesCliArgsSpy).not.toHaveBeenCalled();
expect(isGatewayRunning("work")).toBe(true);
expect(readFileSync(profilePidFile(), "utf-8")).toBe(String(gatewayPid));
});
it("serializes restart requests for different profiles instead of reusing the first result", async () => {
const first = restartGatewayViaCli("work", 5, 1);
const second = restartGatewayViaCli("personal", 5, 1);
await expect(Promise.all([first, second])).resolves.toEqual([false, false]);
expect(hermesCliArgsSpy).toHaveBeenCalledTimes(2);
});
it("deduplicates queued restarts for the same profile", async () => {
hermesCliArgsSpy
.mockImplementationOnce(() => {
throw new Error("first failed");
})
.mockImplementation(() => ["-e", "process.exit(0)"]);
healthStatuses.push(503, 200, 503, 503, 503, 200, 200, 200);
const first = restartGatewayViaCli("work", 50, 1);
const second = restartGatewayViaCli("personal", 50, 1);
const third = restartGatewayViaCli("personal", 50, 1);
await expect(Promise.all([first, second, third])).resolves.toEqual([
false,
true,
true,
]);
expect(hermesCliArgsSpy).toHaveBeenCalledTimes(2);
});
it("still runs a queued different-profile restart after the in-flight restart setup fails", async () => {
hermesCliArgsSpy
.mockImplementationOnce(() => {
throw new Error("first failed");
})
.mockImplementation(() => ["-e", "process.exit(0)"]);
healthStatuses.push(503, 200, 503, 503, 503, 200, 200, 200);
const first = restartGatewayViaCli("work", 5, 1);
const second = restartGatewayViaCli("personal", 50, 1);
await expect(Promise.all([first, second])).resolves.toEqual([false, true]);
expect(hermesCliArgsSpy).toHaveBeenCalledTimes(2);
});
it("keeps an existing tracked gateway when CLI restart setup fails", async () => {
const pidFile = join(TEST_HOME, "tracked-gateway.pid");
const startScript =
"const fs=require('fs');" +
`fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));` +
"setInterval(() => {}, 1000);";
hermesCliArgsSpy
.mockImplementationOnce(() => ["-e", startScript])
.mockImplementationOnce(() => {
throw new Error("restart unavailable");
});
expect(startGateway("work")).toBe(true);
expect(isGatewayRunning("work")).toBe(true);
expect(await waitForFile(pidFile)).toBe(true);
await expect(restartGatewayViaCli("work", 5, 1)).resolves.toBe(false);
expect(isGatewayRunning("work")).toBe(true);
stopGateway("work", true);
stopGateway("work", true);
const spawnedPid = Number(readFileSync(pidFile, "utf-8"));
expect(await waitForProcessExit(spawnedPid, 3000)).toBe(true);
expect(hermesCliArgsSpy).toHaveBeenNthCalledWith(1, [
"--profile",
"work",
"gateway",
]);
expect(hermesCliArgsSpy).toHaveBeenNthCalledWith(2, [
"--profile",
"work",
"gateway",
"restart",
]);
});
it("does not restore a tracked gateway that exited during a failed CLI restart", async () => {
const pidFile = join(TEST_HOME, "tracked-gateway-exit.pid");
const startScript =
"const fs=require('fs');" +
`fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));` +
"setInterval(() => {}, 1000);";
const restartScript =
"const fs=require('fs');" +
`const pid=Number(fs.readFileSync(${JSON.stringify(pidFile)},'utf-8'));` +
"try{process.kill(pid,'SIGTERM')}catch{};" +
"const done=Date.now()+1000;" +
"function wait(){try{process.kill(pid,0);if(Date.now()<done)return setTimeout(wait,25)}catch{};process.exit(1)};" +
"wait();";
hermesCliArgsSpy
.mockImplementationOnce(() => ["-e", startScript])
.mockImplementationOnce(() => ["-e", restartScript]);
expect(startGateway("work")).toBe(true);
expect(await waitForFile(pidFile)).toBe(true);
const spawnedPid = Number(readFileSync(pidFile, "utf-8"));
await expect(restartGatewayViaCli("work", 2000, 25)).resolves.toBe(false);
expect(await waitForProcessExit(spawnedPid, 3000)).toBe(true);
expect(isGatewayRunning("work")).toBe(false);
expect(hermesCliArgsSpy).toHaveBeenNthCalledWith(1, [
"--profile",
"work",
"gateway",
]);
expect(hermesCliArgsSpy).toHaveBeenNthCalledWith(2, [
"--profile",
"work",
"gateway",
"restart",
]);
});
it("falls back to a native restart when a normal start does not become healthy", async () => {
hermesCliArgsSpy.mockImplementation(() => ["-e", "process.exit(0)"]);
healthStatuses.push(503, 503, 200);
await expect(
startGatewayWithRecovery("work", 5, 50, 15000, 250),
).resolves.toBe(true);
expect(hermesCliArgsSpy).toHaveBeenNthCalledWith(1, [
"--profile",
"work",
"gateway",
]);
expect(hermesCliArgsSpy).toHaveBeenNthCalledWith(2, [
"--profile",
"work",
"gateway",
]);
});
it("preserves the tracked PID entry when recovery cannot stop the gateway", async () => {
const gatewayPid = 2147483647;
aliveGatewayPids.add(gatewayPid);
writeFileSync(profilePidFile(), String(gatewayPid), "utf-8");
healthStatuses.push(503, ...Array(100).fill(200));
await expect(
startGatewayWithRecovery("work", 50, 75, 15000, 25, 25),
).resolves.toBe(false);
expect(isGatewayRunning("work")).toBe(true);
expect(readFileSync(profilePidFile(), "utf-8")).toBe(String(gatewayPid));
expect(hermesCliArgsSpy).not.toHaveBeenCalled();
});
it("stops a spawned gateway before native restart recovery", async () => {
const pidFile = join(TEST_HOME, "spawned-gateway.pid");
const startScript =
"const fs=require('fs');" +
`fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));` +
"setInterval(() => {}, 1000);";
hermesCliArgsSpy
.mockImplementationOnce(() => ["-e", startScript])
.mockImplementationOnce(() => {
throw new Error("restart unavailable");
});
await expect(startGatewayWithRecovery("work", 1000, 25)).resolves.toBe(
false,
);
const spawnedPid = Number(readFileSync(pidFile, "utf-8"));
const exited = await waitForProcessExit(spawnedPid);
if (!exited) {
try {
process.kill(spawnedPid, "SIGTERM");
} catch {
// already gone
}
}
expect(exited).toBe(true);
expect(hermesCliArgsSpy).toHaveBeenNthCalledWith(1, [
"--profile",
"work",
"gateway",
]);
expect(hermesCliArgsSpy).toHaveBeenNthCalledWith(2, [
"--profile",
"work",
"gateway",
]);
});
});