fix(canvas): emit successful control results in JSON mode (#117297)

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-08-01 01:28:41 -07:00
committed by GitHub
parent 338a7ccb2f
commit b0b708d919
2 changed files with 121 additions and 29 deletions
+91
View File
@@ -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."],
+30 -29
View File
@@ -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 <ms>", "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 <ms>", "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 <ms>", "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");
});
}),
);