Files
openclaw/extensions/canvas/src/cli.test.ts
2026-08-20 02:06:09 -07:00

318 lines
9.1 KiB
TypeScript

import {
GatewayClientRequestError,
GatewayClientRequestTimeoutError,
} from "@openclaw/gateway-client";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const gatewayMocks = vi.hoisted(() => ({
callGatewayFromCli: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/gateway-runtime")>()),
callGatewayFromCli: gatewayMocks.callGatewayFromCli,
}));
import {
createDefaultCanvasCliDependencies,
registerNodesCanvasCommands,
type CanvasCliDependencies,
} from "./cli.js";
function createDeps() {
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
writeJson: vi.fn(),
};
const defaults = createDefaultCanvasCliDependencies();
const deps: CanvasCliDependencies = {
...defaults,
defaultRuntime: runtime,
runNodesCommand: (_label, action) => action(),
getNodesTheme: () => ({ ok: (value) => value }),
resolveNodeId: vi.fn(async () => "mac-1"),
callGatewayCli: vi.fn(async () => ({ ok: true })),
};
return { deps, runtime };
}
function createProgram(deps: CanvasCliDependencies) {
const program = new Command();
program.exitOverride();
registerNodesCanvasCommands(program.command("nodes"), deps);
return program;
}
describe("nodes canvas CLI", () => {
beforeEach(() => {
vi.clearAllMocks();
gatewayMocks.callGatewayFromCli.mockReset();
});
it("registers only presenter commands", () => {
const { deps } = createDeps();
const program = createProgram(deps);
const canvas = program.commands[0]?.commands.find((command) => command.name() === "canvas");
expect(canvas?.commands.map((command) => command.name())).toEqual([
"present",
"hide",
"navigate",
]);
});
it.each([
{
args: ["present"],
command: "canvas.present",
params: {},
message: "canvas present ok",
},
{
args: ["hide"],
command: "canvas.hide",
params: undefined,
message: "canvas hide ok",
},
{
args: ["navigate", "/__openclaw__/canvas/documents/cv_1/index.html"],
command: "canvas.navigate",
params: { url: "/__openclaw__/canvas/documents/cv_1/index.html" },
message: "canvas navigate ok",
},
])(
"invokes $command and prints its acknowledgement",
async ({ args, command, params, message }) => {
const { deps, runtime } = createDeps();
const program = createProgram(deps);
await program.parseAsync(["nodes", "canvas", ...args, "--node", "Studio"], {
from: "user",
});
expect(deps.resolveNodeId).toHaveBeenCalledWith(expect.any(Object), "Studio");
expect(deps.callGatewayCli).toHaveBeenCalledWith(
"node.invoke",
expect.any(Object),
{
nodeId: "mac-1",
command,
params,
timeoutMs: 30_000,
idempotencyKey: expect.any(String),
},
{ transportTimeoutMs: 40_000 },
);
expect(runtime.log).toHaveBeenCalledWith(message);
},
);
it("preserves present target and placement fields", async () => {
const { deps } = createDeps();
const program = createProgram(deps);
await program.parseAsync(
[
"nodes",
"canvas",
"present",
"--node",
"mac-1",
"--target",
"openclaw://widget/local",
"--x",
"10.5",
"--y",
"-2",
"--width",
"640",
"--height",
"480",
],
{ from: "user" },
);
expect(deps.callGatewayCli).toHaveBeenCalledWith(
"node.invoke",
expect.any(Object),
expect.objectContaining({
command: "canvas.present",
params: {
url: "openclaw://widget/local",
placement: { x: 10.5, y: -2, width: 640, height: 480 },
},
}),
expect.any(Object),
);
});
it("prints the full Gateway response in JSON mode", async () => {
const { deps, runtime } = createDeps();
const response = { ok: true, command: "canvas.hide", payload: { acknowledged: true } };
vi.mocked(deps.callGatewayCli).mockResolvedValue(response);
const program = createProgram(deps);
await program.parseAsync(["nodes", "canvas", "hide", "--node", "mac-1", "--json"], {
from: "user",
});
expect(runtime.writeJson).toHaveBeenCalledWith(response);
expect(runtime.log).not.toHaveBeenCalled();
});
it("keeps the Gateway deadline longer than an explicit node deadline", async () => {
const { deps } = createDeps();
const program = createProgram(deps);
await program.parseAsync(
["nodes", "canvas", "hide", "--node", "mac-1", "--invoke-timeout", "35000"],
{ from: "user" },
);
expect(deps.callGatewayCli).toHaveBeenCalledWith(
"node.invoke",
expect.any(Object),
expect.objectContaining({ timeoutMs: 35_000 }),
{ transportTimeoutMs: 45_000 },
);
});
it.each([
["--x", "1x", "--x must be a number."],
["--width", "640px", "--width must be a number."],
["--invoke-timeout", "20ms", "--invoke-timeout must be a positive integer."],
])("rejects invalid present %s values", async (flag, value, message) => {
const { deps } = createDeps();
const program = createProgram(deps);
await expect(
program.parseAsync(["nodes", "canvas", "present", "--node", "mac-1", flag, value], {
from: "user",
}),
).rejects.toThrow(message);
expect(deps.callGatewayCli).not.toHaveBeenCalled();
});
it("resolves and invokes a paired node when an older Gateway lacks node.list", async () => {
const { deps } = createDeps();
deps.resolveNodeId = createDefaultCanvasCliDependencies().resolveNodeId;
gatewayMocks.callGatewayFromCli
.mockRejectedValueOnce(
new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "unknown method: node.list",
}),
)
.mockResolvedValueOnce({
pending: [],
paired: [{ nodeId: "legacy-node", displayName: "Legacy Node" }],
});
await createProgram(deps).parseAsync(["nodes", "canvas", "hide", "--node", "Legacy Node"], {
from: "user",
});
expect(gatewayMocks.callGatewayFromCli.mock.calls.map(([method]) => method)).toEqual([
"node.list",
"node.pair.list",
]);
expect(deps.callGatewayCli).toHaveBeenCalledWith(
"node.invoke",
expect.any(Object),
expect.objectContaining({ nodeId: "legacy-node", command: "canvas.hide" }),
expect.any(Object),
);
});
it.each([
{
label: "a local request timeout",
error: new GatewayClientRequestTimeoutError({
method: "node.list",
timeoutMs: 80,
requestSent: true,
}),
},
{
label: "an authorization rejection",
error: new GatewayClientRequestError({
code: "FORBIDDEN",
message: "unknown method: node.list",
}),
},
{
label: "an INVALID_REQUEST authentication failure",
error: new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "unauthorized",
}),
},
{
label: "a retryable unknown-method rejection",
error: new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "unknown method: node.list",
retryable: true,
}),
},
{
label: "an unknown-method rejection for another method",
error: new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "unknown method: node.list.extra",
}),
},
{
label: "malformed request retry metadata",
error: new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "unknown method: node.list",
retryAfterMs: -1,
}),
},
{
label: "a network connection error",
error: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:18789"), {
code: "ECONNREFUSED",
}),
},
{
label: "a closed Gateway transport",
error: new Error("gateway closed (1006): connection lost"),
},
{
label: "a malformed request-error lookalike",
error: Object.assign(new Error("unknown method: node.list"), {
name: "GatewayClientRequestError",
gatewayCode: "INVALID_REQUEST",
}),
},
{
label: "a plain unknown-method error",
error: new Error("unknown method: node.list"),
},
])("preserves $label without resolving or invoking a stale node", async ({ error }) => {
const { deps, runtime } = createDeps();
deps.resolveNodeId = createDefaultCanvasCliDependencies().resolveNodeId;
gatewayMocks.callGatewayFromCli.mockRejectedValueOnce(error).mockResolvedValueOnce({
pending: [],
paired: [{ nodeId: "stale-node", displayName: "Stale Node" }],
});
await expect(
createProgram(deps).parseAsync(["nodes", "canvas", "hide", "--node", "Stale Node"], {
from: "user",
}),
).rejects.toBe(error);
expect(gatewayMocks.callGatewayFromCli.mock.calls.map(([method]) => method)).toEqual([
"node.list",
]);
expect(deps.callGatewayCli).not.toHaveBeenCalled();
expect(runtime.log).not.toHaveBeenCalled();
});
});