Files
moltbot/src/cli/program.nodes-basic.e2e.test.ts
2026-04-27 13:20:52 +01:00

492 lines
14 KiB
TypeScript

import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createIosNodeListResponse } from "./program.nodes-test-helpers.js";
import { callGateway, installBaseProgramMocks, runtime } from "./program.test-mocks.js";
installBaseProgramMocks();
let registerNodesCli: typeof import("./nodes-cli.js").registerNodesCli;
function formatRuntimeLogCallArg(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (value == null) {
return "";
}
try {
return JSON.stringify(value);
} catch {
return "[unserializable]";
}
}
describe("cli program (nodes basics)", () => {
let program: Command;
function createProgram() {
const next = new Command();
next.exitOverride();
registerNodesCli(next);
return next;
}
async function runProgram(argv: string[]) {
runtime.log.mockClear();
await program.parseAsync(argv, { from: "user" });
}
function getRuntimeOutput() {
return runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
}
function mockGatewayWithIosNodeListAnd(method: "node.describe" | "node.invoke", result: unknown) {
callGateway.mockImplementation(async (...args: unknown[]) => {
const opts = (args[0] ?? {}) as { method?: string };
if (opts.method === "node.list") {
return createIosNodeListResponse();
}
if (opts.method === method) {
return result;
}
return { ok: true };
});
}
beforeEach(async () => {
vi.clearAllMocks();
({ registerNodesCli } = await import("./nodes-cli.js"));
program = createProgram();
});
it("runs nodes list with the effective paired node view while preserving paired metadata", async () => {
const now = Date.now();
callGateway.mockImplementation(async (...args: unknown[]) => {
const opts = (args[0] ?? {}) as { method?: string };
if (opts.method === "node.pair.list") {
return {
pending: [{ requestId: "r1", nodeId: "pending-node", ts: now - 10_000 }],
paired: [
{
nodeId: "paired-store",
displayName: "Stale paired name",
remoteIp: "10.0.0.1",
token: "paired-token",
lastConnectedAtMs: now - 5_000,
},
{
nodeId: "pair-only",
displayName: "Pair Only",
token: "pair-only-token",
},
],
};
}
if (opts.method === "node.list") {
return {
nodes: [
{
nodeId: "paired-store",
displayName: "Effective paired name",
remoteIp: "10.0.0.2",
connected: true,
connectedAtMs: now - 1_000,
},
{
nodeId: "catalog-only",
displayName: "Catalog Only",
remoteIp: "10.0.0.3",
paired: true,
connected: false,
},
{
nodeId: "effective-only-unknown",
displayName: "Effective Only Unknown",
connected: true,
},
{
nodeId: "unpaired-live",
displayName: "Unpaired Live",
paired: false,
connected: true,
},
],
};
}
return { ok: true };
});
await runProgram(["nodes", "list", "--json"]);
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" }));
expect(runtime.writeJson).toHaveBeenCalledWith({
pending: [{ requestId: "r1", nodeId: "pending-node", ts: now - 10_000 }],
paired: [
expect.objectContaining({
nodeId: "paired-store",
displayName: "Effective paired name",
remoteIp: "10.0.0.2",
lastConnectedAtMs: now - 5_000,
connected: true,
}),
expect.objectContaining({
nodeId: "catalog-only",
displayName: "Catalog Only",
paired: true,
}),
expect.objectContaining({
nodeId: "pair-only",
displayName: "Pair Only",
}),
],
});
expect(JSON.stringify(runtime.writeJson.mock.calls[0]?.[0])).not.toContain("paired-token");
expect(JSON.stringify(runtime.writeJson.mock.calls[0]?.[0])).not.toContain("pair-only-token");
const output = getRuntimeOutput();
expect(output).toContain("Pending: 1 · Paired: 3");
expect(output).not.toContain("Effective Only Unknown");
expect(output).not.toContain("unpaired-live");
});
it("runs unfiltered nodes list with pairing data when node.list is unavailable", async () => {
callGateway.mockImplementation(async (...args: unknown[]) => {
const opts = (args[0] ?? {}) as { method?: string };
if (opts.method === "node.pair.list") {
return {
pending: [],
paired: [
{
nodeId: "pairing-scoped",
displayName: "Pairing Scoped",
remoteIp: "10.0.0.9",
},
],
};
}
if (opts.method === "node.list") {
throw new Error("unauthorized");
}
return { ok: true };
});
await runProgram(["nodes", "list"]);
const output = getRuntimeOutput();
expect(output).toContain("Pending: 0 · Paired: 1");
expect(output).toContain("Pairing Scoped");
});
it("sanitizes untrusted nodes list table fields while preserving JSON values", async () => {
const now = Date.now();
callGateway.mockImplementation(async (...args: unknown[]) => {
const opts = (args[0] ?? {}) as { method?: string };
if (opts.method === "node.pair.list") {
return {
pending: [
{
requestId: "request\u001b[2K-1",
nodeId: "pending-node",
displayName: "Pending\u001b[1A\nNode",
remoteIp: "10.0.0.4\rrewritten",
ts: now - 1_000,
},
],
paired: [
{
nodeId: "paired-node",
displayName: "Paired\u001b[2K\nNode",
remoteIp: "10.0.0.5\rrewritten",
},
],
};
}
if (opts.method === "node.list") {
throw new Error("older gateway");
}
return { ok: true };
});
await runProgram(["nodes", "list"]);
const output = getRuntimeOutput();
expect(output).not.toContain("\u001b");
expect(output).not.toContain("[2K");
expect(output).toContain("Pending\\nNode");
expect(output).toContain("Paired\\nNode");
expect(output).toContain("10.0.0.5\\rrewritten");
runtime.log.mockClear();
await runProgram(["nodes", "list", "--json"]);
expect(runtime.writeJson).toHaveBeenCalledWith({
pending: [
expect.objectContaining({
requestId: "request\u001b[2K-1",
displayName: "Pending\u001b[1A\nNode",
}),
],
paired: [
expect.objectContaining({
nodeId: "paired-node",
displayName: "Paired\u001b[2K\nNode",
remoteIp: "10.0.0.5\rrewritten",
}),
],
});
});
it("runs nodes list --connected and filters to connected nodes", async () => {
const now = Date.now();
callGateway.mockImplementation(async (...args: unknown[]) => {
const opts = (args[0] ?? {}) as { method?: string };
if (opts.method === "node.pair.list") {
return {
pending: [],
paired: [
{
nodeId: "n1",
displayName: "One",
remoteIp: "10.0.0.1",
lastConnectedAtMs: now - 1_000,
},
{
nodeId: "n2",
displayName: "Two",
remoteIp: "10.0.0.2",
lastConnectedAtMs: now - 1_000,
},
],
};
}
if (opts.method === "node.list") {
return {
nodes: [
{ nodeId: "n1", connected: true },
{ nodeId: "n2", connected: false },
],
};
}
return { ok: true };
});
await runProgram(["nodes", "list", "--connected"]);
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" }));
const output = getRuntimeOutput();
expect(output).toContain("One");
expect(output).not.toContain("Two");
});
it("runs nodes status --last-connected and filters by age", async () => {
const now = Date.now();
callGateway.mockImplementation(async (...args: unknown[]) => {
const opts = (args[0] ?? {}) as { method?: string };
if (opts.method === "node.list") {
return {
ts: now,
nodes: [
{ nodeId: "n1", displayName: "One", connected: false },
{ nodeId: "n2", displayName: "Two", connected: false },
],
};
}
if (opts.method === "node.pair.list") {
return {
pending: [],
paired: [
{ nodeId: "n1", lastConnectedAtMs: now - 1_000 },
{ nodeId: "n2", lastConnectedAtMs: now - 2 * 24 * 60 * 60 * 1000 },
],
};
}
return { ok: true };
});
await runProgram(["nodes", "status", "--last-connected", "24h"]);
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
const output = getRuntimeOutput();
expect(output).toContain("One");
expect(output).not.toContain("Two");
});
it.each([
{
label: "paired node details",
node: {
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas", "camera"],
paired: true,
connected: true,
},
expectedOutput: [
"Known: 1 · Paired: 1 · Connected: 1",
"iOS Node",
"Detail",
"device: iPad",
"hw: iPad16,6",
"Status",
"paired",
"Caps",
"camera",
"canvas",
],
},
{
label: "unpaired node details",
node: {
nodeId: "android-node",
displayName: "Peter's Tab S10 Ultra",
remoteIp: "192.168.0.99",
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
caps: ["canvas", "camera"],
paired: false,
connected: true,
},
expectedOutput: [
"Known: 1 · Paired: 0 · Connected: 1",
"Peter's Tab",
"S10 Ultra",
"Detail",
"device: Android",
"hw: samsung",
"SM-X926B",
"Status",
"unpaired",
"connected",
"Caps",
"camera",
"canvas",
],
},
])("runs nodes status and renders $label", async ({ node, expectedOutput }) => {
callGateway.mockResolvedValue({
ts: Date.now(),
nodes: [node],
});
await runProgram(["nodes", "status"]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "node.list", params: {} }),
);
const output = getRuntimeOutput();
for (const expected of expectedOutput) {
expect(output).toContain(expected);
}
});
it("runs nodes describe and calls node.describe", async () => {
mockGatewayWithIosNodeListAnd("node.describe", {
ts: Date.now(),
nodeId: "ios-node",
displayName: "iOS Node",
caps: ["canvas", "camera"],
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
connected: true,
});
await runProgram(["nodes", "describe", "--node", "ios-node"]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "node.list", params: {} }),
);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.describe",
params: { nodeId: "ios-node" },
}),
);
const out = getRuntimeOutput();
expect(out).toContain("Commands");
expect(out).toContain("canvas.eval");
});
it("runs nodes approve and calls node.pair.approve", async () => {
callGateway.mockResolvedValue({
requestId: "r1",
node: { nodeId: "n1", token: "t1" },
});
await runProgram(["nodes", "approve", "r1"]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.pair.approve",
params: { requestId: "r1" },
}),
);
});
it("runs nodes remove and calls node.pair.remove", async () => {
callGateway.mockImplementation(async (...args: unknown[]) => {
const opts = (args[0] ?? {}) as { method?: string };
if (opts.method === "node.list") {
return {
nodes: [{ nodeId: "ios-node", displayName: "iOS Node", paired: true }],
};
}
if (opts.method === "node.pair.list") {
return {
pending: [],
paired: [{ nodeId: "ios-node", displayName: "iOS Node" }],
};
}
if (opts.method === "node.pair.remove") {
return { nodeId: "ios-node" };
}
return { ok: true };
});
await runProgram(["nodes", "remove", "--node", "iOS Node"]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.pair.remove",
params: { nodeId: "ios-node" },
}),
);
});
it("runs nodes invoke and calls node.invoke", async () => {
mockGatewayWithIosNodeListAnd("node.invoke", {
ok: true,
nodeId: "ios-node",
command: "canvas.eval",
payload: { result: "ok" },
});
await runProgram([
"nodes",
"invoke",
"--node",
"ios-node",
"--command",
"canvas.eval",
"--params",
'{"javaScript":"1+1"}',
]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "node.list", params: {} }),
);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.invoke",
params: {
nodeId: "ios-node",
command: "canvas.eval",
params: { javaScript: "1+1" },
timeoutMs: 15000,
idempotencyKey: "idem-test",
},
}),
);
});
});