import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { defaultRuntime, resetLifecycleRuntimeLogs, resetLifecycleServiceMocks, runtimeLogs, service, stubEmptyGatewayEnv, } from "./test-helpers/lifecycle-core-harness.js"; const loadConfig = vi.fn(() => ({ gateway: { auth: { token: "config-token", }, }, })); vi.mock("../../config/config.js", () => ({ loadConfig: () => loadConfig(), readBestEffortConfig: async () => loadConfig(), })); vi.mock("../../runtime.js", () => ({ defaultRuntime, })); let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart; let runServiceStart: typeof import("./lifecycle-core.js").runServiceStart; let runServiceStop: typeof import("./lifecycle-core.js").runServiceStop; function readJsonLog() { const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); return JSON.parse(jsonLine ?? "{}") as T; } function createServiceRunArgs(checkTokenDrift?: boolean) { return { serviceNoun: "Gateway", service, renderStartHints: () => [], opts: { json: true as const }, ...(checkTokenDrift ? { checkTokenDrift } : {}), }; } describe("runServiceRestart token drift", () => { beforeAll(async () => { ({ runServiceRestart, runServiceStart, runServiceStop } = await import("./lifecycle-core.js")); }); beforeEach(() => { resetLifecycleRuntimeLogs(); loadConfig.mockReset(); loadConfig.mockReturnValue({ gateway: { auth: { token: "config-token", }, }, }); resetLifecycleServiceMocks(); service.readCommand.mockResolvedValue({ programArguments: [], environment: { OPENCLAW_GATEWAY_TOKEN: "service-token" }, }); stubEmptyGatewayEnv(); }); it("prints the container restart hint when restart is requested for a not-loaded service", async () => { service.isLoaded.mockResolvedValue(false); vi.stubEnv("OPENCLAW_CONTAINER_HINT", "openclaw-demo-container"); await runServiceRestart({ serviceNoun: "Gateway", service, renderStartHints: () => [ "Restart the container or the service that manages it for openclaw-demo-container.", "openclaw gateway install", ], opts: { json: false }, }); expect(runtimeLogs).toContain("Gateway service not loaded."); expect(runtimeLogs).toContain( "Start with: Restart the container or the service that manages it for openclaw-demo-container.", ); }); it("emits drift warning when enabled", async () => { await runServiceRestart(createServiceRunArgs(true)); expect(loadConfig).toHaveBeenCalledTimes(1); const payload = readJsonLog<{ warnings?: string[] }>(); expect(payload.warnings).toEqual( expect.arrayContaining([expect.stringContaining("gateway install --force")]), ); }); it("compares restart drift against config token even when caller env is set", async () => { loadConfig.mockReturnValue({ gateway: { auth: { token: "config-token", }, }, }); service.readCommand.mockResolvedValue({ programArguments: [], environment: { OPENCLAW_GATEWAY_TOKEN: "env-token" }, }); vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "env-token"); await runServiceRestart(createServiceRunArgs(true)); const payload = readJsonLog<{ warnings?: string[] }>(); expect(payload.warnings).toEqual( expect.arrayContaining([expect.stringContaining("gateway install --force")]), ); }); it("skips drift warning when disabled", async () => { await runServiceRestart({ serviceNoun: "Node", service, renderStartHints: () => [], opts: { json: true }, }); expect(loadConfig).not.toHaveBeenCalled(); expect(service.readCommand).not.toHaveBeenCalled(); const payload = readJsonLog<{ warnings?: string[] }>(); expect(payload.warnings).toBeUndefined(); }); it("emits stopped when an unmanaged process handles stop", async () => { service.isLoaded.mockResolvedValue(false); await runServiceStop({ serviceNoun: "Gateway", service, opts: { json: true }, onNotLoaded: async () => ({ result: "stopped", message: "Gateway stop signal sent to unmanaged process on port 18789: 4200.", }), }); const payload = readJsonLog<{ result?: string; message?: string }>(); expect(payload.result).toBe("stopped"); expect(payload.message).toContain("unmanaged process"); expect(service.stop).not.toHaveBeenCalled(); }); it("runs restart health checks after an unmanaged restart signal", async () => { const postRestartCheck = vi.fn(async () => {}); service.isLoaded.mockResolvedValue(false); await runServiceRestart({ serviceNoun: "Gateway", service, renderStartHints: () => [], opts: { json: true }, onNotLoaded: async () => ({ result: "restarted", message: "Gateway restart signal sent to unmanaged process on port 18789: 4200.", }), postRestartCheck, }); expect(postRestartCheck).toHaveBeenCalledTimes(1); expect(service.restart).not.toHaveBeenCalled(); expect(service.readCommand).not.toHaveBeenCalled(); const payload = readJsonLog<{ result?: string; message?: string }>(); expect(payload.result).toBe("restarted"); expect(payload.message).toContain("unmanaged process"); }); it("skips restart health checks when restart is only scheduled", async () => { const postRestartCheck = vi.fn(async () => {}); service.restart.mockResolvedValue({ outcome: "scheduled" }); const result = await runServiceRestart({ serviceNoun: "Gateway", service, renderStartHints: () => [], opts: { json: true }, postRestartCheck, }); expect(result).toBe(true); expect(postRestartCheck).not.toHaveBeenCalled(); const payload = readJsonLog<{ result?: string; message?: string }>(); expect(payload.result).toBe("scheduled"); expect(payload.message).toBe("restart scheduled, gateway will restart momentarily"); }); it("emits scheduled when service start routes through a scheduled restart", async () => { service.restart.mockResolvedValue({ outcome: "scheduled" }); await runServiceStart({ serviceNoun: "Gateway", service, renderStartHints: () => [], opts: { json: true }, }); expect(service.isLoaded).toHaveBeenCalled(); const payload = readJsonLog<{ result?: string; message?: string }>(); expect(payload.result).toBe("scheduled"); expect(payload.message).toBe("restart scheduled, gateway will restart momentarily"); }); it("fails start when restarting a stopped installed service errors", async () => { service.isLoaded.mockResolvedValue(false); service.restart.mockRejectedValue(new Error("launchctl kickstart failed: permission denied")); await expect(runServiceStart(createServiceRunArgs())).rejects.toThrow("__exit__:1"); const payload = readJsonLog<{ ok?: boolean; error?: string }>(); expect(payload.ok).toBe(false); expect(payload.error).toContain("launchctl kickstart failed: permission denied"); }); it("falls back to not-loaded hints when start finds no install artifacts", async () => { service.isLoaded.mockResolvedValue(false); service.readCommand.mockResolvedValue(null); await runServiceStart({ serviceNoun: "Gateway", service, renderStartHints: () => ["openclaw gateway install"], opts: { json: true }, }); const payload = readJsonLog<{ ok?: boolean; result?: string; hints?: string[]; hintItems?: Array<{ kind: string; text: string }>; }>(); expect(payload.ok).toBe(true); expect(payload.result).toBe("not-loaded"); expect(payload.hints).toEqual(expect.arrayContaining(["openclaw gateway install"])); expect(payload.hintItems).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "install", text: "openclaw gateway install", }), ]), ); expect(service.restart).not.toHaveBeenCalled(); }); });