diff --git a/src/cli/system-cli.test.ts b/src/cli/system-cli.test.ts new file mode 100644 index 000000000..3b0cfeb84 --- /dev/null +++ b/src/cli/system-cli.test.ts @@ -0,0 +1,91 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +const callGatewayFromCli = vi.fn(); +const addGatewayClientOptions = vi.fn((command: Command) => command); + +const { runtimeLogs, runtimeErrors, defaultRuntime, resetRuntimeCapture } = + createCliRuntimeCapture(); + +vi.mock("./gateway-rpc.js", () => ({ + addGatewayClientOptions, + callGatewayFromCli, +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +const { registerSystemCli } = await import("./system-cli.js"); + +describe("system-cli", () => { + async function runCli(args: string[]) { + const program = new Command(); + registerSystemCli(program); + try { + await program.parseAsync(args, { from: "user" }); + } catch (err) { + if (!(err instanceof Error && err.message.startsWith("__exit__:"))) { + throw err; + } + } + } + + beforeEach(() => { + vi.clearAllMocks(); + resetRuntimeCapture(); + callGatewayFromCli.mockResolvedValue({ ok: true }); + }); + + it("runs system event with default wake mode and text output", async () => { + await runCli(["system", "event", "--text", " hello world "]); + + expect(callGatewayFromCli).toHaveBeenCalledWith( + "wake", + expect.objectContaining({ text: " hello world " }), + { mode: "next-heartbeat", text: "hello world" }, + { expectFinal: false }, + ); + expect(runtimeLogs).toEqual(["ok"]); + }); + + it("prints JSON for event when --json is enabled", async () => { + callGatewayFromCli.mockResolvedValueOnce({ id: "wake-1" }); + + await runCli(["system", "event", "--text", "hello", "--json"]); + + expect(runtimeLogs).toEqual([JSON.stringify({ id: "wake-1" }, null, 2)]); + }); + + it("handles invalid wake mode as runtime error", async () => { + await runCli(["system", "event", "--text", "hello", "--mode", "later"]); + + expect(callGatewayFromCli).not.toHaveBeenCalled(); + expect(runtimeErrors[0]).toContain("--mode must be now or next-heartbeat"); + }); + + it.each([ + { args: ["system", "heartbeat", "last"], method: "last-heartbeat", params: undefined }, + { + args: ["system", "heartbeat", "enable"], + method: "set-heartbeats", + params: { enabled: true }, + }, + { + args: ["system", "heartbeat", "disable"], + method: "set-heartbeats", + params: { enabled: false }, + }, + { args: ["system", "presence"], method: "system-presence", params: undefined }, + ])("routes $args to gateway", async ({ args, method, params }) => { + callGatewayFromCli.mockResolvedValueOnce({ method }); + + await runCli(args); + + expect(callGatewayFromCli).toHaveBeenCalledWith(method, expect.any(Object), params, { + expectFinal: false, + }); + expect(runtimeLogs).toEqual([JSON.stringify({ method }, null, 2)]); + }); +}); diff --git a/src/cli/system-cli.ts b/src/cli/system-cli.ts index 653d842b7..ae5b2033c 100644 --- a/src/cli/system-cli.ts +++ b/src/cli/system-cli.ts @@ -7,6 +7,7 @@ import type { GatewayRpcOpts } from "./gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; type SystemEventOpts = GatewayRpcOpts & { text?: string; mode?: string; json?: boolean }; +type SystemGatewayOpts = GatewayRpcOpts & { json?: boolean }; const normalizeWakeMode = (raw: unknown) => { const mode = typeof raw === "string" ? raw.trim() : ""; @@ -19,6 +20,24 @@ const normalizeWakeMode = (raw: unknown) => { throw new Error("--mode must be now or next-heartbeat"); }; +async function runSystemGatewayCommand( + opts: SystemGatewayOpts, + action: () => Promise, + successText?: string, +): Promise { + try { + const result = await action(); + if (opts.json || successText === undefined) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + } else { + defaultRuntime.log(successText); + } + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + } +} + export function registerSystemCli(program: Command) { const system = program .command("system") @@ -37,22 +56,18 @@ export function registerSystemCli(program: Command) { .option("--mode ", "Wake mode (now|next-heartbeat)", "next-heartbeat") .option("--json", "Output JSON", false), ).action(async (opts: SystemEventOpts) => { - try { - const text = typeof opts.text === "string" ? opts.text.trim() : ""; - if (!text) { - throw new Error("--text is required"); - } - const mode = normalizeWakeMode(opts.mode); - const result = await callGatewayFromCli("wake", opts, { mode, text }, { expectFinal: false }); - if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - } else { - defaultRuntime.log("ok"); - } - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } + await runSystemGatewayCommand( + opts, + async () => { + const text = typeof opts.text === "string" ? opts.text.trim() : ""; + if (!text) { + throw new Error("--text is required"); + } + const mode = normalizeWakeMode(opts.mode); + return await callGatewayFromCli("wake", opts, { mode, text }, { expectFinal: false }); + }, + "ok", + ); }); const heartbeat = system.command("heartbeat").description("Heartbeat controls"); @@ -62,16 +77,12 @@ export function registerSystemCli(program: Command) { .command("last") .description("Show the last heartbeat event") .option("--json", "Output JSON", false), - ).action(async (opts: GatewayRpcOpts & { json?: boolean }) => { - try { - const result = await callGatewayFromCli("last-heartbeat", opts, undefined, { + ).action(async (opts: SystemGatewayOpts) => { + await runSystemGatewayCommand(opts, async () => { + return await callGatewayFromCli("last-heartbeat", opts, undefined, { expectFinal: false, }); - defaultRuntime.log(JSON.stringify(result, null, 2)); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } + }); }); addGatewayClientOptions( @@ -79,19 +90,15 @@ export function registerSystemCli(program: Command) { .command("enable") .description("Enable heartbeats") .option("--json", "Output JSON", false), - ).action(async (opts: GatewayRpcOpts & { json?: boolean }) => { - try { - const result = await callGatewayFromCli( + ).action(async (opts: SystemGatewayOpts) => { + await runSystemGatewayCommand(opts, async () => { + return await callGatewayFromCli( "set-heartbeats", opts, { enabled: true }, { expectFinal: false }, ); - defaultRuntime.log(JSON.stringify(result, null, 2)); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } + }); }); addGatewayClientOptions( @@ -99,19 +106,15 @@ export function registerSystemCli(program: Command) { .command("disable") .description("Disable heartbeats") .option("--json", "Output JSON", false), - ).action(async (opts: GatewayRpcOpts & { json?: boolean }) => { - try { - const result = await callGatewayFromCli( + ).action(async (opts: SystemGatewayOpts) => { + await runSystemGatewayCommand(opts, async () => { + return await callGatewayFromCli( "set-heartbeats", opts, { enabled: false }, { expectFinal: false }, ); - defaultRuntime.log(JSON.stringify(result, null, 2)); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } + }); }); addGatewayClientOptions( @@ -119,15 +122,11 @@ export function registerSystemCli(program: Command) { .command("presence") .description("List system presence entries") .option("--json", "Output JSON", false), - ).action(async (opts: GatewayRpcOpts & { json?: boolean }) => { - try { - const result = await callGatewayFromCli("system-presence", opts, undefined, { + ).action(async (opts: SystemGatewayOpts) => { + await runSystemGatewayCommand(opts, async () => { + return await callGatewayFromCli("system-presence", opts, undefined, { expectFinal: false, }); - defaultRuntime.log(JSON.stringify(result, null, 2)); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } + }); }); }