import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; import { bridgeInvoke, bridgeListConnected, connectOk, installGatewayTestHooks, onceMessage, rpcReq, startServerWithClient, } from "./test-helpers.js"; const decodeWsData = (data: unknown): string => { if (typeof data === "string") return data; if (Buffer.isBuffer(data)) return data.toString("utf-8"); if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8"); if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8"); if (ArrayBuffer.isView(data)) { return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf-8"); } return ""; }; async function _waitFor(condition: () => boolean, timeoutMs = 1500) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (condition()) return; await new Promise((r) => setTimeout(r, 5)); } throw new Error("timeout waiting for condition"); } installGatewayTestHooks(); describe("gateway server node/bridge", () => { test("supports gateway-owned node pairing methods and events", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; const { server, ws } = await startServerWithClient(); await connectOk(ws); const requestedP = new Promise<{ type: "event"; event: string; payload?: unknown; }>((resolve) => { ws.on("message", (data) => { const obj = JSON.parse(decodeWsData(data)) as { type?: string; event?: string; payload?: unknown; }; if (obj.type === "event" && obj.event === "node.pair.requested") { resolve(obj as never); } }); }); const res1 = await rpcReq(ws, "node.pair.request", { nodeId: "n1", displayName: "Node", }); expect(res1.ok).toBe(true); const req1 = (res1.payload as { request?: { requestId?: unknown } } | null)?.request; const requestId = typeof req1?.requestId === "string" ? req1.requestId : ""; expect(requestId.length).toBeGreaterThan(0); const evt1 = await requestedP; expect(evt1.event).toBe("node.pair.requested"); expect((evt1.payload as { requestId?: unknown } | null)?.requestId).toBe(requestId); const res2 = await rpcReq(ws, "node.pair.request", { nodeId: "n1", displayName: "Node", }); expect(res2.ok).toBe(true); await expect( onceMessage(ws, (o) => o.type === "event" && o.event === "node.pair.requested", 200), ).rejects.toThrow(); const resolvedP = new Promise<{ type: "event"; event: string; payload?: unknown; }>((resolve) => { ws.on("message", (data) => { const obj = JSON.parse(decodeWsData(data)) as { type?: string; event?: string; payload?: unknown; }; if (obj.type === "event" && obj.event === "node.pair.resolved") { resolve(obj as never); } }); }); const approveRes = await rpcReq(ws, "node.pair.approve", { requestId }); expect(approveRes.ok).toBe(true); const tokenValue = (approveRes.payload as { node?: { token?: unknown } } | null)?.node?.token; const token = typeof tokenValue === "string" ? tokenValue : ""; expect(token.length).toBeGreaterThan(0); const evt2 = await resolvedP; expect((evt2.payload as { requestId?: unknown } | null)?.requestId).toBe(requestId); expect((evt2.payload as { decision?: unknown } | null)?.decision).toBe("approved"); const verifyRes = await rpcReq(ws, "node.pair.verify", { nodeId: "n1", token, }); expect(verifyRes.ok).toBe(true); expect((verifyRes.payload as { ok?: unknown } | null)?.ok).toBe(true); const listRes = await rpcReq(ws, "node.pair.list", {}); expect(listRes.ok).toBe(true); const paired = (listRes.payload as { paired?: unknown } | null)?.paired; expect(Array.isArray(paired)).toBe(true); expect((paired as Array<{ nodeId?: unknown }>).some((n) => n.nodeId === "n1")).toBe(true); ws.close(); await server.close(); await fs.rm(homeDir, { recursive: true, force: true }); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } }); test("routes node.invoke to the node bridge", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; try { bridgeInvoke.mockResolvedValueOnce({ type: "invoke-res", id: "inv-1", ok: true, payloadJSON: JSON.stringify({ result: "4" }), error: null, }); const { server, ws } = await startServerWithClient(); try { await connectOk(ws); const res = await rpcReq(ws, "node.invoke", { nodeId: "ios-node", command: "canvas.eval", params: { javaScript: "2+2" }, timeoutMs: 123, idempotencyKey: "idem-1", }); expect(res.ok).toBe(true); expect(bridgeInvoke).toHaveBeenCalledWith( expect.objectContaining({ nodeId: "ios-node", command: "canvas.eval", paramsJSON: JSON.stringify({ javaScript: "2+2" }), timeoutMs: 123, }), ); } finally { ws.close(); await server.close(); } } finally { await fs.rm(homeDir, { recursive: true, force: true }); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } } }); test("routes camera.list invoke to the node bridge", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; try { bridgeInvoke.mockResolvedValueOnce({ type: "invoke-res", id: "inv-2", ok: true, payloadJSON: JSON.stringify({ devices: [] }), error: null, }); const { server, ws } = await startServerWithClient(); try { await connectOk(ws); const res = await rpcReq(ws, "node.invoke", { nodeId: "ios-node", command: "camera.list", params: {}, idempotencyKey: "idem-2", }); expect(res.ok).toBe(true); expect(bridgeInvoke).toHaveBeenCalledWith( expect.objectContaining({ nodeId: "ios-node", command: "camera.list", paramsJSON: JSON.stringify({}), }), ); } finally { ws.close(); await server.close(); } } finally { await fs.rm(homeDir, { recursive: true, force: true }); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } } }); test("node.describe returns supported invoke commands for paired nodes", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; try { const { server, ws } = await startServerWithClient(); try { await connectOk(ws); const reqRes = await rpcReq<{ status?: string; request?: { requestId?: string }; }>(ws, "node.pair.request", { nodeId: "n1", displayName: "iPad", platform: "iPadOS", version: "dev", deviceFamily: "iPad", modelIdentifier: "iPad16,6", caps: ["canvas", "camera"], commands: ["canvas.eval", "canvas.snapshot", "camera.snap"], remoteIp: "10.0.0.10", }); expect(reqRes.ok).toBe(true); const requestId = reqRes.payload?.request?.requestId; expect(typeof requestId).toBe("string"); const approveRes = await rpcReq(ws, "node.pair.approve", { requestId, }); expect(approveRes.ok).toBe(true); const describeRes = await rpcReq<{ commands?: string[] }>(ws, "node.describe", { nodeId: "n1", }); expect(describeRes.ok).toBe(true); expect(describeRes.payload?.commands).toEqual([ "camera.snap", "canvas.eval", "canvas.snapshot", ]); } finally { ws.close(); await server.close(); } } finally { await fs.rm(homeDir, { recursive: true, force: true }); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } } }); test("node.describe works for connected unpaired nodes (caps + commands)", async () => { const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-")); const prevHome = process.env.HOME; process.env.HOME = homeDir; try { const { server, ws } = await startServerWithClient(); try { await connectOk(ws); bridgeListConnected.mockReturnValueOnce([ { nodeId: "u1", displayName: "Unpaired Live", platform: "Android", version: "dev-live", remoteIp: "10.0.0.12", deviceFamily: "Android", modelIdentifier: "samsung SM-X926B", caps: ["canvas", "camera", "canvas"], commands: ["canvas.eval", "camera.snap", "canvas.eval"], }, ]); const describeRes = await rpcReq<{ paired?: boolean; connected?: boolean; caps?: string[]; commands?: string[]; deviceFamily?: string; modelIdentifier?: string; remoteIp?: string; }>(ws, "node.describe", { nodeId: "u1" }); expect(describeRes.ok).toBe(true); expect(describeRes.payload).toMatchObject({ paired: false, connected: true, deviceFamily: "Android", modelIdentifier: "samsung SM-X926B", remoteIp: "10.0.0.12", }); expect(describeRes.payload?.caps).toEqual(["camera", "canvas"]); expect(describeRes.payload?.commands).toEqual(["camera.snap", "canvas.eval"]); } finally { ws.close(); await server.close(); } } finally { await fs.rm(homeDir, { recursive: true, force: true }); if (prevHome === undefined) { delete process.env.HOME; } else { process.env.HOME = prevHome; } } }); });