| import { Command } from "commander"; |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|
|
| const callGateway = vi.fn(async () => ({ ok: true })); |
| const resolveGatewayProgramArguments = vi.fn(async () => ({ |
| programArguments: ["/bin/node", "cli", "gateway", "--port", "7860"], |
| })); |
| const serviceInstall = vi.fn().mockResolvedValue(undefined); |
| const serviceUninstall = vi.fn().mockResolvedValue(undefined); |
| const serviceStop = vi.fn().mockResolvedValue(undefined); |
| const serviceRestart = vi.fn().mockResolvedValue(undefined); |
| const serviceIsLoaded = vi.fn().mockResolvedValue(false); |
| const serviceReadCommand = vi.fn().mockResolvedValue(null); |
| const serviceReadRuntime = vi.fn().mockResolvedValue({ status: "running" }); |
| const findExtraGatewayServices = vi.fn(async () => []); |
| const inspectPortUsage = vi.fn(async (port: number) => ({ |
| port, |
| status: "free", |
| listeners: [], |
| hints: [], |
| })); |
|
|
| const runtimeLogs: string[] = []; |
| const runtimeErrors: string[] = []; |
| const defaultRuntime = { |
| log: (msg: string) => runtimeLogs.push(msg), |
| error: (msg: string) => runtimeErrors.push(msg), |
| exit: (code: number) => { |
| throw new Error(`__exit__:${code}`); |
| }, |
| }; |
|
|
| vi.mock("../gateway/call.js", () => ({ |
| callGateway: (opts: unknown) => callGateway(opts), |
| })); |
|
|
| vi.mock("../daemon/program-args.js", () => ({ |
| resolveGatewayProgramArguments: (opts: unknown) => resolveGatewayProgramArguments(opts), |
| })); |
|
|
| vi.mock("../daemon/service.js", () => ({ |
| resolveGatewayService: () => ({ |
| label: "LaunchAgent", |
| loadedText: "loaded", |
| notLoadedText: "not loaded", |
| install: serviceInstall, |
| uninstall: serviceUninstall, |
| stop: serviceStop, |
| restart: serviceRestart, |
| isLoaded: serviceIsLoaded, |
| readCommand: serviceReadCommand, |
| readRuntime: serviceReadRuntime, |
| }), |
| })); |
|
|
| vi.mock("../daemon/legacy.js", () => ({ |
| findLegacyGatewayServices: async () => [], |
| })); |
|
|
| vi.mock("../daemon/inspect.js", () => ({ |
| findExtraGatewayServices: (env: unknown, opts?: unknown) => findExtraGatewayServices(env, opts), |
| renderGatewayServiceCleanupHints: () => [], |
| })); |
|
|
| vi.mock("../infra/ports.js", () => ({ |
| inspectPortUsage: (port: number) => inspectPortUsage(port), |
| formatPortDiagnostics: () => ["Port 7860 is already in use."], |
| })); |
|
|
| vi.mock("../runtime.js", () => ({ |
| defaultRuntime, |
| })); |
|
|
| vi.mock("./deps.js", () => ({ |
| createDefaultDeps: () => {}, |
| })); |
|
|
| vi.mock("./progress.js", () => ({ |
| withProgress: async (_opts: unknown, fn: () => Promise<unknown>) => await fn(), |
| })); |
|
|
| describe("daemon-cli coverage", () => { |
| const originalEnv = { |
| OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, |
| OPENCLAW_CONFIG_PATH: process.env.OPENCLAW_CONFIG_PATH, |
| OPENCLAW_GATEWAY_PORT: process.env.OPENCLAW_GATEWAY_PORT, |
| OPENCLAW_PROFILE: process.env.OPENCLAW_PROFILE, |
| }; |
|
|
| beforeEach(() => { |
| process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-cli-state"; |
| process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-cli-state/openclaw.json"; |
| delete process.env.OPENCLAW_GATEWAY_PORT; |
| delete process.env.OPENCLAW_PROFILE; |
| serviceReadCommand.mockResolvedValue(null); |
| }); |
|
|
| afterEach(() => { |
| if (originalEnv.OPENCLAW_STATE_DIR !== undefined) { |
| process.env.OPENCLAW_STATE_DIR = originalEnv.OPENCLAW_STATE_DIR; |
| } else { |
| delete process.env.OPENCLAW_STATE_DIR; |
| } |
|
|
| if (originalEnv.OPENCLAW_CONFIG_PATH !== undefined) { |
| process.env.OPENCLAW_CONFIG_PATH = originalEnv.OPENCLAW_CONFIG_PATH; |
| } else { |
| delete process.env.OPENCLAW_CONFIG_PATH; |
| } |
|
|
| if (originalEnv.OPENCLAW_GATEWAY_PORT !== undefined) { |
| process.env.OPENCLAW_GATEWAY_PORT = originalEnv.OPENCLAW_GATEWAY_PORT; |
| } else { |
| delete process.env.OPENCLAW_GATEWAY_PORT; |
| } |
|
|
| if (originalEnv.OPENCLAW_PROFILE !== undefined) { |
| process.env.OPENCLAW_PROFILE = originalEnv.OPENCLAW_PROFILE; |
| } else { |
| delete process.env.OPENCLAW_PROFILE; |
| } |
| }); |
|
|
| it("probes gateway status by default", async () => { |
| runtimeLogs.length = 0; |
| runtimeErrors.length = 0; |
| callGateway.mockClear(); |
|
|
| const { registerDaemonCli } = await import("./daemon-cli.js"); |
| const program = new Command(); |
| program.exitOverride(); |
| registerDaemonCli(program); |
|
|
| await program.parseAsync(["daemon", "status"], { from: "user" }); |
|
|
| expect(callGateway).toHaveBeenCalledTimes(1); |
| expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "status" })); |
| expect(findExtraGatewayServices).toHaveBeenCalled(); |
| expect(inspectPortUsage).toHaveBeenCalled(); |
| }, 20_000); |
|
|
| it("derives probe URL from service args + env (json)", async () => { |
| runtimeLogs.length = 0; |
| runtimeErrors.length = 0; |
| callGateway.mockClear(); |
| inspectPortUsage.mockClear(); |
|
|
| serviceReadCommand.mockResolvedValueOnce({ |
| programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"], |
| environment: { |
| OPENCLAW_PROFILE: "dev", |
| OPENCLAW_STATE_DIR: "/tmp/openclaw-daemon-state", |
| OPENCLAW_CONFIG_PATH: "/tmp/openclaw-daemon-state/openclaw.json", |
| OPENCLAW_GATEWAY_PORT: "19001", |
| }, |
| sourcePath: "/tmp/bot.molt.gateway.plist", |
| }); |
|
|
| const { registerDaemonCli } = await import("./daemon-cli.js"); |
| const program = new Command(); |
| program.exitOverride(); |
| registerDaemonCli(program); |
|
|
| await program.parseAsync(["daemon", "status", "--json"], { from: "user" }); |
|
|
| expect(callGateway).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| url: "ws://127.0.0.1:19001", |
| method: "status", |
| }), |
| ); |
| expect(inspectPortUsage).toHaveBeenCalledWith(19001); |
|
|
| const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); |
| const parsed = JSON.parse(jsonLine ?? "{}") as { |
| gateway?: { port?: number; portSource?: string; probeUrl?: string }; |
| config?: { mismatch?: boolean }; |
| rpc?: { url?: string; ok?: boolean }; |
| }; |
| expect(parsed.gateway?.port).toBe(19001); |
| expect(parsed.gateway?.portSource).toBe("service args"); |
| expect(parsed.gateway?.probeUrl).toBe("ws://127.0.0.1:19001"); |
| expect(parsed.config?.mismatch).toBe(true); |
| expect(parsed.rpc?.url).toBe("ws://127.0.0.1:19001"); |
| expect(parsed.rpc?.ok).toBe(true); |
| }, 20_000); |
|
|
| it("passes deep scan flag for daemon status", async () => { |
| findExtraGatewayServices.mockClear(); |
|
|
| const { registerDaemonCli } = await import("./daemon-cli.js"); |
| const program = new Command(); |
| program.exitOverride(); |
| registerDaemonCli(program); |
|
|
| await program.parseAsync(["daemon", "status", "--deep"], { from: "user" }); |
|
|
| expect(findExtraGatewayServices).toHaveBeenCalledWith( |
| expect.anything(), |
| expect.objectContaining({ deep: true }), |
| ); |
| }); |
|
|
| it("installs the daemon when requested", async () => { |
| serviceIsLoaded.mockResolvedValueOnce(false); |
| serviceInstall.mockClear(); |
|
|
| const { registerDaemonCli } = await import("./daemon-cli.js"); |
| const program = new Command(); |
| program.exitOverride(); |
| registerDaemonCli(program); |
|
|
| await program.parseAsync(["daemon", "install", "--port", "7860"], { |
| from: "user", |
| }); |
|
|
| expect(serviceInstall).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("installs the daemon with json output", async () => { |
| runtimeLogs.length = 0; |
| runtimeErrors.length = 0; |
| serviceIsLoaded.mockResolvedValueOnce(false); |
| serviceInstall.mockClear(); |
|
|
| const { registerDaemonCli } = await import("./daemon-cli.js"); |
| const program = new Command(); |
| program.exitOverride(); |
| registerDaemonCli(program); |
|
|
| await program.parseAsync(["daemon", "install", "--port", "7860", "--json"], { |
| from: "user", |
| }); |
|
|
| const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); |
| const parsed = JSON.parse(jsonLine ?? "{}") as { |
| ok?: boolean; |
| action?: string; |
| result?: string; |
| }; |
| expect(parsed.ok).toBe(true); |
| expect(parsed.action).toBe("install"); |
| expect(parsed.result).toBe("installed"); |
| }); |
|
|
| it("starts and stops the daemon via service helpers", async () => { |
| serviceRestart.mockClear(); |
| serviceStop.mockClear(); |
| serviceIsLoaded.mockResolvedValue(true); |
|
|
| const { registerDaemonCli } = await import("./daemon-cli.js"); |
| const program = new Command(); |
| program.exitOverride(); |
| registerDaemonCli(program); |
|
|
| await program.parseAsync(["daemon", "start"], { from: "user" }); |
| await program.parseAsync(["daemon", "stop"], { from: "user" }); |
|
|
| expect(serviceRestart).toHaveBeenCalledTimes(1); |
| expect(serviceStop).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("emits json for daemon start/stop", async () => { |
| runtimeLogs.length = 0; |
| runtimeErrors.length = 0; |
| serviceRestart.mockClear(); |
| serviceStop.mockClear(); |
| serviceIsLoaded.mockResolvedValue(true); |
|
|
| const { registerDaemonCli } = await import("./daemon-cli.js"); |
| const program = new Command(); |
| program.exitOverride(); |
| registerDaemonCli(program); |
|
|
| await program.parseAsync(["daemon", "start", "--json"], { from: "user" }); |
| await program.parseAsync(["daemon", "stop", "--json"], { from: "user" }); |
|
|
| const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{")); |
| const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean }); |
| expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true); |
| expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true); |
| }); |
| }); |
|
|