import * as fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; const messageCommand = vi.fn(); const statusCommand = vi.fn(); const configureCommand = vi.fn(); const configureCommandWithSections = vi.fn(); const setupCommand = vi.fn(); const onboardCommand = vi.fn(); const callGateway = vi.fn(); const runProviderLogin = vi.fn(); const runProviderLogout = vi.fn(); const runTui = vi.fn(); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn(() => { throw new Error("exit"); }), }; vi.mock("../commands/message.js", () => ({ messageCommand, })); vi.mock("../commands/status.js", () => ({ statusCommand })); vi.mock("../commands/configure.js", () => ({ CONFIGURE_WIZARD_SECTIONS: [ "workspace", "model", "gateway", "daemon", "providers", "skills", "health", ], configureCommand, configureCommandWithSections, })); vi.mock("../commands/setup.js", () => ({ setupCommand })); vi.mock("../commands/onboard.js", () => ({ onboardCommand })); vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); vi.mock("./provider-auth.js", () => ({ runProviderLogin, runProviderLogout, })); vi.mock("../tui/tui.js", () => ({ runTui, })); vi.mock("../gateway/call.js", () => ({ callGateway, randomIdempotencyKey: () => "idem-test", })); vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}), })); const { buildProgram } = await import("./program.js"); describe("cli program", () => { beforeEach(() => { vi.clearAllMocks(); runTui.mockResolvedValue(undefined); }); it("runs message with required options", async () => { const program = buildProgram(); await program.parseAsync( ["message", "send", "--to", "+1", "--message", "hi"], { from: "user", }, ); expect(messageCommand).toHaveBeenCalled(); }); it("runs status command", async () => { const program = buildProgram(); await program.parseAsync(["status"], { from: "user" }); expect(statusCommand).toHaveBeenCalled(); }); it("runs tui without overriding timeout", async () => { const program = buildProgram(); await program.parseAsync(["tui"], { from: "user" }); expect(runTui).toHaveBeenCalledWith( expect.objectContaining({ timeoutMs: undefined }), ); }); it("runs tui with explicit timeout override", async () => { const program = buildProgram(); await program.parseAsync(["tui", "--timeout-ms", "45000"], { from: "user", }); expect(runTui).toHaveBeenCalledWith( expect.objectContaining({ timeoutMs: 45000 }), ); }); it("runs config alias as configure", async () => { const program = buildProgram(); await program.parseAsync(["config"], { from: "user" }); expect(configureCommand).toHaveBeenCalled(); }); it("runs setup without wizard flags", async () => { const program = buildProgram(); await program.parseAsync(["setup"], { from: "user" }); expect(setupCommand).toHaveBeenCalled(); expect(onboardCommand).not.toHaveBeenCalled(); }); it("runs setup wizard when wizard flags are present", async () => { const program = buildProgram(); await program.parseAsync(["setup", "--remote-url", "ws://example"], { from: "user", }); expect(onboardCommand).toHaveBeenCalled(); expect(setupCommand).not.toHaveBeenCalled(); }); it("runs providers login", async () => { const program = buildProgram(); await program.parseAsync(["providers", "login", "--account", "work"], { from: "user", }); expect(runProviderLogin).toHaveBeenCalledWith( { provider: undefined, account: "work", verbose: false }, runtime, ); }); it("runs providers logout", async () => { const program = buildProgram(); await program.parseAsync(["providers", "logout", "--account", "work"], { from: "user", }); expect(runProviderLogout).toHaveBeenCalledWith( { provider: undefined, account: "work" }, runtime, ); }); it("runs hidden login alias", async () => { const program = buildProgram(); await program.parseAsync(["login", "--account", "work"], { from: "user" }); expect(runProviderLogin).toHaveBeenCalledWith( { provider: undefined, account: "work", verbose: false }, runtime, ); }); it("runs nodes list and calls node.pair.list", async () => { callGateway.mockResolvedValue({ pending: [], paired: [] }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync(["nodes", "list"], { from: "user" }); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.pair.list", }), ); expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0"); }); it("runs nodes status and calls node.list", async () => { callGateway.mockResolvedValue({ ts: Date.now(), nodes: [ { nodeId: "ios-node", displayName: "iOS Node", remoteIp: "192.168.0.88", deviceFamily: "iPad", modelIdentifier: "iPad16,6", caps: ["canvas", "camera"], paired: true, connected: true, }, ], }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync(["nodes", "status"], { from: "user" }); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.list", params: {} }), ); const output = runtime.log.mock.calls .map((c) => String(c[0] ?? "")) .join("\n"); expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1"); expect(output).toContain("iOS Node"); expect(output).toContain("device: iPad"); expect(output).toContain("hw: iPad16,6"); expect(output).toContain("paired"); expect(output).toContain("caps: [camera,canvas]"); }); it("runs nodes status and shows unpaired nodes", async () => { callGateway.mockResolvedValue({ ts: Date.now(), nodes: [ { nodeId: "android-node", displayName: "Peter's Tab S10 Ultra", remoteIp: "192.168.0.99", deviceFamily: "Android", modelIdentifier: "samsung SM-X926B", caps: ["canvas", "camera"], paired: false, connected: true, }, ], }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync(["nodes", "status"], { from: "user" }); const output = runtime.log.mock.calls .map((c) => String(c[0] ?? "")) .join("\n"); expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1"); expect(output).toContain("Peter's Tab S10 Ultra"); expect(output).toContain("device: Android"); expect(output).toContain("hw: samsung SM-X926B"); expect(output).toContain("unpaired"); expect(output).toContain("connected"); expect(output).toContain("caps: [camera,canvas]"); }); it("runs nodes describe and calls node.describe", async () => { callGateway .mockResolvedValueOnce({ ts: Date.now(), nodes: [ { nodeId: "ios-node", displayName: "iOS Node", remoteIp: "192.168.0.88", connected: true, }, ], }) .mockResolvedValueOnce({ ts: Date.now(), nodeId: "ios-node", displayName: "iOS Node", caps: ["canvas", "camera"], commands: ["canvas.eval", "canvas.snapshot", "camera.snap"], connected: true, }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync(["nodes", "describe", "--node", "ios-node"], { from: "user", }); expect(callGateway).toHaveBeenNthCalledWith( 1, expect.objectContaining({ method: "node.list", params: {} }), ); expect(callGateway).toHaveBeenNthCalledWith( 2, expect.objectContaining({ method: "node.describe", params: { nodeId: "ios-node" }, }), ); const out = runtime.log.mock.calls .map((c) => String(c[0] ?? "")) .join("\n"); expect(out).toContain("Commands:"); expect(out).toContain("canvas.eval"); }); it("runs nodes approve and calls node.pair.approve", async () => { callGateway.mockResolvedValue({ requestId: "r1", node: { nodeId: "n1", token: "t1" }, }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync(["nodes", "approve", "r1"], { from: "user" }); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.pair.approve", params: { requestId: "r1" }, }), ); expect(runtime.log).toHaveBeenCalled(); }); it("runs nodes invoke and calls node.invoke", async () => { callGateway .mockResolvedValueOnce({ ts: Date.now(), nodes: [ { nodeId: "ios-node", displayName: "iOS Node", remoteIp: "192.168.0.88", connected: true, }, ], }) .mockResolvedValueOnce({ ok: true, nodeId: "ios-node", command: "canvas.eval", payload: { result: "ok" }, }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync( [ "nodes", "invoke", "--node", "ios-node", "--command", "canvas.eval", "--params", '{"javaScript":"1+1"}', ], { from: "user" }, ); expect(callGateway).toHaveBeenNthCalledWith( 1, expect.objectContaining({ method: "node.list", params: {} }), ); expect(callGateway).toHaveBeenNthCalledWith( 2, expect.objectContaining({ method: "node.invoke", params: { nodeId: "ios-node", command: "canvas.eval", params: { javaScript: "1+1" }, timeoutMs: 15000, idempotencyKey: "idem-test", }, }), ); expect(runtime.log).toHaveBeenCalled(); }); it("runs nodes camera snap and prints two MEDIA paths", async () => { callGateway .mockResolvedValueOnce({ ts: Date.now(), nodes: [ { nodeId: "ios-node", displayName: "iOS Node", remoteIp: "192.168.0.88", connected: true, }, ], }) .mockResolvedValueOnce({ ok: true, nodeId: "ios-node", command: "camera.snap", payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, }) .mockResolvedValueOnce({ ok: true, nodeId: "ios-node", command: "camera.snap", payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync( ["nodes", "camera", "snap", "--node", "ios-node"], { from: "user", }, ); expect(callGateway).toHaveBeenNthCalledWith( 2, expect.objectContaining({ method: "node.invoke", params: expect.objectContaining({ nodeId: "ios-node", command: "camera.snap", timeoutMs: 20000, idempotencyKey: "idem-test", params: expect.objectContaining({ facing: "front", format: "jpg" }), }), }), ); expect(callGateway).toHaveBeenNthCalledWith( 3, expect.objectContaining({ method: "node.invoke", params: expect.objectContaining({ nodeId: "ios-node", command: "camera.snap", timeoutMs: 20000, idempotencyKey: "idem-test", params: expect.objectContaining({ facing: "back", format: "jpg" }), }), }), ); const out = String(runtime.log.mock.calls[0]?.[0] ?? ""); const mediaPaths = out .split("\n") .filter((l) => l.startsWith("MEDIA:")) .map((l) => l.replace(/^MEDIA:/, "")) .filter(Boolean); expect(mediaPaths).toHaveLength(2); try { for (const p of mediaPaths) { await expect(fs.readFile(p, "utf8")).resolves.toBe("hi"); } } finally { await Promise.all(mediaPaths.map((p) => fs.unlink(p).catch(() => {}))); } }); it("runs nodes camera clip and prints one MEDIA path", async () => { callGateway .mockResolvedValueOnce({ ts: Date.now(), nodes: [ { nodeId: "ios-node", displayName: "iOS Node", remoteIp: "192.168.0.88", connected: true, }, ], }) .mockResolvedValueOnce({ ok: true, nodeId: "ios-node", command: "camera.clip", payload: { format: "mp4", base64: "aGk=", durationMs: 3000, hasAudio: true, }, }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync( ["nodes", "camera", "clip", "--node", "ios-node", "--duration", "3000"], { from: "user" }, ); expect(callGateway).toHaveBeenNthCalledWith( 2, expect.objectContaining({ method: "node.invoke", params: expect.objectContaining({ nodeId: "ios-node", command: "camera.clip", timeoutMs: 90000, idempotencyKey: "idem-test", params: expect.objectContaining({ facing: "front", durationMs: 3000, includeAudio: true, format: "mp4", }), }), }), ); const out = String(runtime.log.mock.calls[0]?.[0] ?? ""); const mediaPath = out.replace(/^MEDIA:/, "").trim(); expect(mediaPath).toMatch(/clawdbot-camera-clip-front-.*\.mp4$/); try { await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi"); } finally { await fs.unlink(mediaPath).catch(() => {}); } }); it("runs nodes camera snap with facing front and passes params", async () => { callGateway .mockResolvedValueOnce({ ts: Date.now(), nodes: [ { nodeId: "ios-node", displayName: "iOS Node", remoteIp: "192.168.0.88", connected: true, }, ], }) .mockResolvedValueOnce({ ok: true, nodeId: "ios-node", command: "camera.snap", payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync( [ "nodes", "camera", "snap", "--node", "ios-node", "--facing", "front", "--max-width", "640", "--quality", "0.8", "--delay-ms", "2000", "--device-id", "cam-123", ], { from: "user" }, ); expect(callGateway).toHaveBeenNthCalledWith( 2, expect.objectContaining({ method: "node.invoke", params: expect.objectContaining({ nodeId: "ios-node", command: "camera.snap", timeoutMs: 20000, idempotencyKey: "idem-test", params: expect.objectContaining({ facing: "front", maxWidth: 640, quality: 0.8, delayMs: 2000, deviceId: "cam-123", }), }), }), ); const out = String(runtime.log.mock.calls[0]?.[0] ?? ""); const mediaPath = out.replace(/^MEDIA:/, "").trim(); try { await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi"); } finally { await fs.unlink(mediaPath).catch(() => {}); } }); it("runs nodes camera clip with --no-audio", async () => { callGateway .mockResolvedValueOnce({ ts: Date.now(), nodes: [ { nodeId: "ios-node", displayName: "iOS Node", remoteIp: "192.168.0.88", connected: true, }, ], }) .mockResolvedValueOnce({ ok: true, nodeId: "ios-node", command: "camera.clip", payload: { format: "mp4", base64: "aGk=", durationMs: 3000, hasAudio: false, }, }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync( [ "nodes", "camera", "clip", "--node", "ios-node", "--duration", "3000", "--no-audio", "--device-id", "cam-123", ], { from: "user" }, ); expect(callGateway).toHaveBeenNthCalledWith( 2, expect.objectContaining({ method: "node.invoke", params: expect.objectContaining({ nodeId: "ios-node", command: "camera.clip", timeoutMs: 90000, idempotencyKey: "idem-test", params: expect.objectContaining({ includeAudio: false, deviceId: "cam-123", }), }), }), ); const out = String(runtime.log.mock.calls[0]?.[0] ?? ""); const mediaPath = out.replace(/^MEDIA:/, "").trim(); try { await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi"); } finally { await fs.unlink(mediaPath).catch(() => {}); } }); it("runs nodes camera clip with human duration (10s)", async () => { callGateway .mockResolvedValueOnce({ ts: Date.now(), nodes: [ { nodeId: "ios-node", displayName: "iOS Node", remoteIp: "192.168.0.88", connected: true, }, ], }) .mockResolvedValueOnce({ ok: true, nodeId: "ios-node", command: "camera.clip", payload: { format: "mp4", base64: "aGk=", durationMs: 10_000, hasAudio: true, }, }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync( ["nodes", "camera", "clip", "--node", "ios-node", "--duration", "10s"], { from: "user" }, ); expect(callGateway).toHaveBeenNthCalledWith( 2, expect.objectContaining({ method: "node.invoke", params: expect.objectContaining({ nodeId: "ios-node", command: "camera.clip", params: expect.objectContaining({ durationMs: 10_000 }), }), }), ); }); it("runs nodes canvas snapshot and prints MEDIA path", async () => { callGateway .mockResolvedValueOnce({ ts: Date.now(), nodes: [ { nodeId: "ios-node", displayName: "iOS Node", remoteIp: "192.168.0.88", connected: true, }, ], }) .mockResolvedValueOnce({ ok: true, nodeId: "ios-node", command: "canvas.snapshot", payload: { format: "png", base64: "aGk=" }, }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync( ["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "png"], { from: "user" }, ); const out = String(runtime.log.mock.calls[0]?.[0] ?? ""); const mediaPath = out.replace(/^MEDIA:/, "").trim(); expect(mediaPath).toMatch(/clawdbot-canvas-snapshot-.*\.png$/); try { await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi"); } finally { await fs.unlink(mediaPath).catch(() => {}); } }); it("fails nodes camera snap on invalid facing", async () => { callGateway.mockResolvedValueOnce({ ts: Date.now(), nodes: [ { nodeId: "ios-node", displayName: "iOS Node", remoteIp: "192.168.0.88", connected: true, }, ], }); const program = buildProgram(); runtime.error.mockClear(); await expect( program.parseAsync( ["nodes", "camera", "snap", "--node", "ios-node", "--facing", "nope"], { from: "user" }, ), ).rejects.toThrow(/exit/i); expect(runtime.error).toHaveBeenCalledWith( expect.stringMatching(/invalid facing/i), ); }); });