// Node camera command tests cover help text and RPC handling for optional values. import fs from "node:fs/promises"; import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { writeCameraPayloadToFile } from "../nodes-camera.js"; import { registerNodesCameraCommands } from "./register.camera.js"; import * as rpc from "./rpc.js"; const capturedInvokeParams: Record[] = []; vi.mock("./cli-utils.js", async () => { const actual = await vi.importActual("./cli-utils.js"); return { ...actual, runNodesCommand: async (_label: string, action: () => Promise) => action(), }; }); vi.mock("../nodes-camera.js", async () => { const actual = await vi.importActual("../nodes-camera.js"); return { ...actual, writeCameraPayloadToFile: vi.fn(async () => {}), writeCameraClipPayloadToFile: vi.fn(async () => {}), }; }); vi.mock("../../runtime.js", () => ({ defaultRuntime: { log: vi.fn(), error: vi.fn(), writeJson: vi.fn(), exit: vi.fn(), }, })); vi.mock("./rpc.js", async () => { const actual = await vi.importActual("./rpc.js"); return { ...actual, resolveCliNode: vi.fn(async () => ({ nodeId: "node-abc123", platform: "ios", remoteIp: "198.51.100.42", })), callNodesGatewayCli: vi.fn(async (_method, _opts, invokeParams) => { capturedInvokeParams.push(invokeParams as Record); return { payload: { format: "jpg", base64: "cmVkYWN0ZWQtYmFzZTY0", width: 1600, height: 1200, }, }; }), }; }); function buildRootCommand(): Command { const nodes = new Command("nodes"); registerNodesCameraCommands(nodes); return nodes.exitOverride().configureOutput({ writeErr: () => {}, writeOut: () => {} }); } function cameraSnapArgs(extra: string[]): string[] { // Commander.parseAsync expects argv[0]/argv[1] to be the node/script names. return ["node", "nodes", "camera", "snap", ...extra]; } describe("nodes camera snap CLI option forwarding", () => { beforeEach(() => { capturedInvokeParams.length = 0; vi.clearAllMocks(); }); it("describes node-owned camera defaults in help", () => { const nodes = buildRootCommand(); const camera = nodes.commands.find((command) => command.name() === "camera"); const snap = camera?.commands.find((command) => command.name() === "snap"); if (!snap) { throw new Error("expected camera snap command"); } expect(snap.options.find((option) => option.long === "--facing")?.defaultValue).toBeUndefined(); expect(snap.options.find((option) => option.long === "--quality")?.description).toBe( "JPEG quality (optional; platform-specific default)", ); expect(snap.options.find((option) => option.long === "--delay-ms")?.description).toBe( "Delay before capture in ms (optional; platform-specific default)", ); }); it("makes one facing-less request and forwards deviceId when --facing is omitted", async () => { const nodes = buildRootCommand(); await nodes.parseAsync(cameraSnapArgs(["--node", "test-node", "--device-id", "camera-device"])); expect(rpc.callNodesGatewayCli).toHaveBeenCalledTimes(1); expect(capturedInvokeParams).toHaveLength(1); expect(capturedInvokeParams[0]).toMatchObject({ command: "camera.snap", nodeId: "node-abc123", }); const forwardedParams = capturedInvokeParams[0]?.params as Record; expect(forwardedParams).not.toHaveProperty("facing"); expect(forwardedParams.deviceId).toBe("camera-device"); expect(forwardedParams.quality).toBeUndefined(); expect(forwardedParams.delayMs).toBeUndefined(); }); it("forwards explicit --quality and --delay-ms values in RPC params", async () => { const nodes = buildRootCommand(); await nodes.parseAsync( cameraSnapArgs(["--node", "test-node", "--quality", "0.7", "--delay-ms", "500"]), ); const firstInvokeParams = capturedInvokeParams[0]; if (!firstInvokeParams) { throw new Error("expected at least one camera.snap node.invoke call"); } const forwardedParams = firstInvokeParams.params as Record; expect(forwardedParams.quality).toBe(0.7); expect(forwardedParams.delayMs).toBe(500); }); it.each([ { name: "malformed image data", payload: { format: "jpg", base64: "not-base64!", width: 1, height: 1 }, }, { name: "an unsafe image extension", payload: { format: "../evil", base64: "aGk=", width: 1, height: 1 }, }, ])( "does not publish the front camera when the back camera returns $name", async ({ payload }) => { vi.mocked(rpc.callNodesGatewayCli) .mockResolvedValueOnce({ payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, }) .mockResolvedValueOnce({ payload }); const actualCamera = await vi.importActual("../nodes-camera.js"); const writer = vi.mocked(writeCameraPayloadToFile); writer.mockImplementation(actualCamera.writeCameraPayloadToFile); const rename = vi.spyOn(fs, "rename"); try { await expect( buildRootCommand().parseAsync( cameraSnapArgs(["--node", "test-node", "--facing", "both"]), ), ).rejects.toThrow(/invalid base64|invalid media format/i); expect(rename).not.toHaveBeenCalled(); expect(writer).not.toHaveBeenCalled(); } finally { const publishedPaths = rename.mock.calls.map(([, destination]) => String(destination)); rename.mockRestore(); writer.mockImplementation(async () => {}); await Promise.all( publishedPaths.map(async (filePath) => fs.unlink(filePath).catch(() => {})), ); } }, ); it("does not activate the back camera after the front returns an unsafe extension", async () => { vi.mocked(rpc.callNodesGatewayCli).mockResolvedValueOnce({ payload: { format: "../evil", base64: "aGk=", width: 1, height: 1 }, }); await expect( buildRootCommand().parseAsync(cameraSnapArgs(["--node", "test-node", "--facing", "both"])), ).rejects.toThrow(/invalid media format/i); expect(rpc.callNodesGatewayCli).toHaveBeenCalledTimes(1); expect(writeCameraPayloadToFile).not.toHaveBeenCalled(); }); it("rejects out-of-range --quality", async () => { const nodes = buildRootCommand(); await expect( nodes.parseAsync(cameraSnapArgs(["--node", "test-node", "--quality", "1.5"])), ).rejects.toThrow("--quality must be at most 1"); }); it("rejects negative --delay-ms", async () => { const nodes = buildRootCommand(); await expect( nodes.parseAsync(cameraSnapArgs(["--node", "test-node", "--delay-ms", "-1"])), ).rejects.toThrow("--delay-ms must be a non-negative integer"); }); });