Files
openclaw/extensions/canvas/src/cli.test.ts
Rohit e55cebf4c2 fix(canvas): validate CLI numeric options
Validate Canvas CLI numeric arguments before node invocation.

- Reject malformed `--invoke-timeout` values through the shared Canvas invoke path before resolving a node.
- Keep snapshot `--quality` bounded to the existing Canvas tool schema range of 0..1.
- Add focused CLI regressions for timeout validation, quality bounds, and accepted boundary values.

Verification:
- `node scripts/run-vitest.mjs extensions/canvas/src/cli.test.ts --maxWorkers=1`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main ...`
- AWS Crabbox `cbx_b7838c58daba`, run `run_bbb58ca536f4`: `check:changed`
- Exact PR head `a970ce594ea6e7284ace519352fc258b1b81cb80`: GitHub checks green
2026-06-14 07:39:17 +08:00

257 lines
8.0 KiB
TypeScript

// Canvas tests cover cli plugin behavior.
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
import {
createDefaultCanvasCliDependencies,
registerNodesCanvasCommands,
type CanvasCliDependencies,
} from "./cli.js";
function createCanvasCliDeps() {
const writtenFiles: Array<{ filePath: string; base64: string }> = [];
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit ${code}`);
}),
writeJson: vi.fn(),
};
const deps: CanvasCliDependencies = {
defaultRuntime: runtime,
nodesCallOpts: (cmd) =>
cmd
.option("--url <url>", "Gateway WebSocket URL")
.option("--token <token>", "Gateway token")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--json", "Output JSON", false),
runNodesCommand: async (_label, action) => {
await action();
},
getNodesTheme: () => ({ ok: (value) => value }),
parseTimeoutMs: (raw) => (typeof raw === "string" ? Number.parseInt(raw, 10) : undefined),
resolveNodeId: async (opts) => opts.node ?? "ios-node",
buildNodeInvokeParams: ({ nodeId, command, params, timeoutMs }) => ({
nodeId,
command,
params,
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
}),
callGatewayCli: vi.fn(async () => ({
payload: {
format: "png",
base64: "aGk=",
},
})),
writeBase64ToFile: async (filePath, base64) => {
writtenFiles.push({ filePath, base64 });
},
shortenHomePath: (filePath) => filePath,
};
return { deps, runtime, writtenFiles };
}
function createCanvasCliDepsWithDefaultParsers() {
const baseDeps = createDefaultCanvasCliDependencies();
const harness = createCanvasCliDeps();
return {
...harness,
deps: {
...baseDeps,
defaultRuntime: harness.runtime,
nodesCallOpts: harness.deps.nodesCallOpts,
runNodesCommand: harness.deps.runNodesCommand,
getNodesTheme: harness.deps.getNodesTheme,
resolveNodeId: harness.deps.resolveNodeId,
buildNodeInvokeParams: harness.deps.buildNodeInvokeParams,
callGatewayCli: harness.deps.callGatewayCli,
writeBase64ToFile: harness.deps.writeBase64ToFile,
shortenHomePath: harness.deps.shortenHomePath,
},
};
}
describe("canvas CLI", () => {
it("registers under nodes and captures a snapshot media path", async () => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps, runtime, writtenFiles } = createCanvasCliDeps();
registerNodesCanvasCommands(nodes, deps);
await program.parseAsync(["nodes", "canvas", "snapshot", "--node", "ios-node"], {
from: "user",
});
expect(deps.callGatewayCli).toHaveBeenCalledTimes(1);
expect(deps.callGatewayCli).toHaveBeenCalledWith(
"node.invoke",
{
node: "ios-node",
format: "jpg",
timeout: "10000",
json: false,
invokeTimeout: "20000",
},
{
nodeId: "ios-node",
command: "canvas.snapshot",
params: {
format: "jpeg",
maxWidth: undefined,
quality: undefined,
},
timeoutMs: 20000,
},
);
expect(writtenFiles).toHaveLength(1);
const [writtenFile] = writtenFiles;
if (!writtenFile) {
throw new Error("Expected canvas snapshot file");
}
expect(writtenFile.filePath).toMatch(/openclaw-canvas-snapshot-.*\.png$/);
expect(writtenFile.base64).toBe("aGk=");
expect(runtime.log).toHaveBeenCalledTimes(1);
const savedPath = runtime.log.mock.calls[0]?.[0];
expect(savedPath?.startsWith("MEDIA:")).toBe(false);
expect(savedPath?.endsWith(".png")).toBe(true);
});
it("rejects node-controlled snapshot formats before writing", async () => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps, writtenFiles } = createCanvasCliDeps();
vi.mocked(deps.callGatewayCli).mockResolvedValueOnce({
payload: {
format: "/../../target.sh",
base64: "aGk=",
},
});
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(["nodes", "canvas", "snapshot", "--node", "ios-node"], {
from: "user",
}),
).rejects.toThrow(/invalid canvas\.snapshot payload/i);
expect(writtenFiles).toHaveLength(0);
});
it("rejects unsupported snapshot formats before invoking the node", async () => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps, writtenFiles } = createCanvasCliDeps();
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "gif"], {
from: "user",
}),
).rejects.toThrow(/invalid format: gif/i);
expect(deps.callGatewayCli).not.toHaveBeenCalled();
expect(writtenFiles).toHaveLength(0);
});
it.each([
["--max-width", "640px", "--max-width must be a positive integer."],
["--quality", "0.8x", "--quality must be a number."],
["--quality", "-0.1", "--quality must be between 0 and 1."],
["--quality", "5", "--quality must be between 0 and 1."],
])("rejects partial numeric snapshot %s values", async (flag, value, message) => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps } = createCanvasCliDeps();
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(["nodes", "canvas", "snapshot", "--node", "ios-node", flag, value], {
from: "user",
}),
).rejects.toThrow(message);
expect(deps.callGatewayCli).not.toHaveBeenCalled();
});
it.each(["0", "1"])("accepts snapshot --quality boundary value %s", async (quality) => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps } = createCanvasCliDeps();
registerNodesCanvasCommands(nodes, deps);
await program.parseAsync(
["nodes", "canvas", "snapshot", "--node", "ios-node", "--quality", quality],
{
from: "user",
},
);
expect(deps.callGatewayCli).toHaveBeenCalledWith(
"node.invoke",
expect.any(Object),
expect.objectContaining({
params: expect.objectContaining({
quality: Number(quality),
}),
}),
);
});
it.each([
["snapshot"],
["present"],
["hide"],
["navigate", "https://example.com"],
["eval", "1 + 1"],
["a2ui", "push", "--text", "hello"],
["a2ui", "reset"],
])("rejects invalid %s invoke timeouts before invoking the node", async (...args) => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps } = createCanvasCliDepsWithDefaultParsers();
deps.resolveNodeId = vi.fn(async () => {
throw new Error("resolveNodeId should not be called");
});
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(
["nodes", "canvas", ...args, "--node", "ios-node", "--invoke-timeout", "20ms"],
{
from: "user",
},
),
).rejects.toThrow("--invoke-timeout must be a positive integer.");
expect(deps.resolveNodeId).not.toHaveBeenCalled();
expect(deps.callGatewayCli).not.toHaveBeenCalled();
});
it.each([
["--x", "1x"],
["--y", "2px"],
["--width", "800wide"],
["--height", "600tall"],
])("rejects partial numeric present %s values", async (flag, value) => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps } = createCanvasCliDeps();
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(["nodes", "canvas", "present", "--node", "ios-node", flag, value], {
from: "user",
}),
).rejects.toThrow(`${flag} must be a number.`);
expect(deps.callGatewayCli).not.toHaveBeenCalled();
});
});