From b0b708d919420d8aebc8aa1cf98576cd9679aacc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 01:28:41 -0700 Subject: [PATCH] fix(canvas): emit successful control results in JSON mode (#117297) Co-authored-by: Peter Steinberger --- extensions/canvas/src/cli.test.ts | 91 +++++++++++++++++++++++++++++++ extensions/canvas/src/cli.ts | 59 ++++++++++---------- 2 files changed, 121 insertions(+), 29 deletions(-) diff --git a/extensions/canvas/src/cli.test.ts b/extensions/canvas/src/cli.test.ts index bbfbc5e0bcc4..360fbaf63060 100644 --- a/extensions/canvas/src/cli.test.ts +++ b/extensions/canvas/src/cli.test.ts @@ -71,6 +71,34 @@ function createCanvasCliDepsWithDefaultParsers() { }; } +const canvasAcknowledgementCommands = [ + { + args: ["present"], + command: "canvas.present", + message: "canvas present ok", + }, + { + args: ["hide"], + command: "canvas.hide", + message: "canvas hide ok", + }, + { + args: ["navigate", "https://example.com"], + command: "canvas.navigate", + message: "canvas navigate ok", + }, + { + args: ["a2ui", "push", "--text", "hello"], + command: "canvas.a2ui.pushJSONL", + message: "canvas a2ui push ok (v0.8, 2 messages)", + }, + { + args: ["a2ui", "reset"], + command: "canvas.a2ui.reset", + message: "canvas a2ui reset ok", + }, +] as const; + describe("canvas CLI", () => { it("registers under nodes and captures a snapshot media path", async () => { const program = new Command(); @@ -194,6 +222,69 @@ describe("canvas CLI", () => { expect(runtime.log).not.toHaveBeenCalledWith("canvas eval ok"); }); + it.each(canvasAcknowledgementCommands)( + "prints the full successful $command Gateway response with --json", + async ({ args, command }) => { + const program = new Command(); + program.exitOverride(); + const nodes = program.command("nodes"); + const { deps, runtime } = createCanvasCliDeps(); + const response = { + ok: true, + nodeId: "ios-node", + command, + payload: { acknowledged: true }, + payloadJSON: '{"acknowledged":true}', + }; + vi.mocked(deps.callGatewayCli).mockResolvedValueOnce(response); + + registerNodesCanvasCommands(nodes, deps); + await program.parseAsync(["nodes", "canvas", ...args, "--node", "ios-node", "--json"], { + from: "user", + }); + + expect(deps.callGatewayCli).toHaveBeenCalledTimes(1); + expect(runtime.writeJson).toHaveBeenCalledExactlyOnceWith(response); + expect(runtime.log).not.toHaveBeenCalled(); + }, + ); + + it.each(canvasAcknowledgementCommands)( + "preserves the human-readable $command acknowledgement", + async ({ args, message }) => { + const program = new Command(); + program.exitOverride(); + const nodes = program.command("nodes"); + const { deps, runtime } = createCanvasCliDeps(); + + registerNodesCanvasCommands(nodes, deps); + await program.parseAsync(["nodes", "canvas", ...args, "--node", "ios-node"], { + from: "user", + }); + + expect(runtime.log).toHaveBeenCalledExactlyOnceWith(message); + expect(runtime.writeJson).not.toHaveBeenCalled(); + }, + ); + + it("does not print a machine-readable success response when a Canvas invocation fails", async () => { + const program = new Command(); + program.exitOverride(); + const nodes = program.command("nodes"); + const { deps, runtime } = createCanvasCliDeps(); + vi.mocked(deps.callGatewayCli).mockRejectedValueOnce(new Error("node disconnected")); + + registerNodesCanvasCommands(nodes, deps); + + await expect( + program.parseAsync(["nodes", "canvas", "present", "--node", "ios-node", "--json"], { + from: "user", + }), + ).rejects.toThrow("node disconnected"); + expect(runtime.writeJson).not.toHaveBeenCalled(); + expect(runtime.log).not.toHaveBeenCalled(); + }); + it.each([ ["--max-width", "640px", "--max-width must be a positive integer."], ["--quality", "0.8x", "--quality must be a number."], diff --git a/extensions/canvas/src/cli.ts b/extensions/canvas/src/cli.ts index 2bb0bd2e70fc..85df740e5503 100644 --- a/extensions/canvas/src/cli.ts +++ b/extensions/canvas/src/cli.ts @@ -271,6 +271,21 @@ async function invokeCanvas( ); } +/** Prints the complete invocation response for machines or the existing human acknowledgement. */ +function writeCanvasInvokeResult( + deps: CanvasCliDependencies, + opts: CanvasNodesRpcOpts, + result: unknown, + message: string, +): void { + if (opts.json) { + deps.defaultRuntime.writeJson(result); + return; + } + const { ok } = deps.getNodesTheme(); + deps.defaultRuntime.log(ok(message)); +} + /** Registers Canvas subcommands under the nodes CLI command group. */ export function registerNodesCanvasCommands(nodes: Command, deps: CanvasCliDependencies) { const canvas = nodes @@ -344,11 +359,8 @@ export function registerNodesCanvasCommands(nodes: Command, deps: CanvasCliDepen ) { params.placement = placement; } - await invokeCanvas(deps, opts, "canvas.present", params); - if (!opts.json) { - const { ok } = deps.getNodesTheme(); - deps.defaultRuntime.log(ok("canvas present ok")); - } + const result = await invokeCanvas(deps, opts, "canvas.present", params); + writeCanvasInvokeResult(deps, opts, result, "canvas present ok"); }); }), ); @@ -361,11 +373,8 @@ export function registerNodesCanvasCommands(nodes: Command, deps: CanvasCliDepen .option("--invoke-timeout ", "Node invoke timeout in ms") .action(async (opts: CanvasNodesRpcOpts) => { await deps.runNodesCommand("canvas hide", async () => { - await invokeCanvas(deps, opts, "canvas.hide", undefined); - if (!opts.json) { - const { ok } = deps.getNodesTheme(); - deps.defaultRuntime.log(ok("canvas hide ok")); - } + const result = await invokeCanvas(deps, opts, "canvas.hide", undefined); + writeCanvasInvokeResult(deps, opts, result, "canvas hide ok"); }); }), ); @@ -379,11 +388,8 @@ export function registerNodesCanvasCommands(nodes: Command, deps: CanvasCliDepen .option("--invoke-timeout ", "Node invoke timeout in ms") .action(async (url: string, opts: CanvasNodesRpcOpts) => { await deps.runNodesCommand("canvas navigate", async () => { - await invokeCanvas(deps, opts, "canvas.navigate", { url }); - if (!opts.json) { - const { ok } = deps.getNodesTheme(); - deps.defaultRuntime.log(ok("canvas navigate ok")); - } + const result = await invokeCanvas(deps, opts, "canvas.navigate", { url }); + writeCanvasInvokeResult(deps, opts, result, "canvas navigate ok"); }); }), ); @@ -445,15 +451,13 @@ export function registerNodesCanvasCommands(nodes: Command, deps: CanvasCliDepen ? buildA2UITextJsonl(opts.text ?? "") : await fs.readFile(String(opts.jsonl), "utf8"); const { messageCount } = validateSupportedA2UIJsonl(jsonl); - await invokeCanvas(deps, opts, "canvas.a2ui.pushJSONL", { jsonl }); - if (!opts.json) { - const { ok } = deps.getNodesTheme(); - deps.defaultRuntime.log( - ok( - `canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`, - ), - ); - } + const result = await invokeCanvas(deps, opts, "canvas.a2ui.pushJSONL", { jsonl }); + writeCanvasInvokeResult( + deps, + opts, + result, + `canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`, + ); }); }), ); @@ -466,11 +470,8 @@ export function registerNodesCanvasCommands(nodes: Command, deps: CanvasCliDepen .option("--invoke-timeout ", "Node invoke timeout in ms") .action(async (opts: CanvasNodesRpcOpts) => { await deps.runNodesCommand("canvas a2ui reset", async () => { - await invokeCanvas(deps, opts, "canvas.a2ui.reset", undefined); - if (!opts.json) { - const { ok } = deps.getNodesTheme(); - deps.defaultRuntime.log(ok("canvas a2ui reset ok")); - } + const result = await invokeCanvas(deps, opts, "canvas.a2ui.reset", undefined); + writeCanvasInvokeResult(deps, opts, result, "canvas a2ui reset ok"); }); }), );