import { beforeEach, describe, expect, it, vi } from "vitest"; import { GatewayProtocolRequestTimeoutError } from "../../../packages/gateway-client/src/protocol-request.js"; import { GatewayClientRequestError } from "../../../packages/gateway-client/src/request-error.js"; const gatewayMocks = vi.hoisted(() => ({ callGatewayFromCliWithTransport: vi.fn(), })); vi.mock("../gateway-rpc.js", () => ({ callGatewayFromCliWithTransport: gatewayMocks.callGatewayFromCliWithTransport, })); import { buildNodeInvokeParams, parseOptionalNodeFiniteNumber, parseOptionalNodeNonNegativeInteger, parseOptionalNodePositiveInteger, resolveCliNode, resolveNodeDiagnosticsId, } from "./rpc.js"; function requestError(params: { code?: string; message?: string; retryable?: boolean; retryAfterMs?: number; }) { return new GatewayClientRequestError({ code: params.code ?? "INVALID_REQUEST", message: params.message ?? "unknown method: node.list", ...params, }); } describe("node inventory resolution", () => { beforeEach(() => { gatewayMocks.callGatewayFromCliWithTransport.mockReset(); }); it("uses paired records when an older Gateway rejects the exact node.list method", async () => { gatewayMocks.callGatewayFromCliWithTransport .mockRejectedValueOnce(requestError({})) .mockResolvedValueOnce({ pending: [], paired: [{ nodeId: "legacy-node", displayName: "Legacy Node", platform: "ios" }], }); await expect(resolveCliNode({}, "Legacy Node")).resolves.toMatchObject({ nodeId: "legacy-node", displayName: "Legacy Node", }); expect( gatewayMocks.callGatewayFromCliWithTransport.mock.calls.map(([method]) => method), ).toEqual(["node.list", "node.pair.list"]); }); it.each([ { label: "a local request timeout", error: new GatewayProtocolRequestTimeoutError({ method: "node.list", timeoutMs: 80, requestSent: true, }), }, { label: "an authorization rejection", error: requestError({ code: "UNAUTHORIZED", message: "operator authorization required" }), }, { label: "an INVALID_REQUEST authentication failure", error: requestError({ message: "invalid auth token" }), }, { label: "a retryable unknown-method rejection", error: requestError({ retryable: true }), }, { label: "an unknown-method rejection for another method", error: requestError({ message: "unknown method: node.list.extra" }), }, { label: "malformed request retry metadata", error: requestError({ retryAfterMs: -1 }), }, { label: "an embedded unknown-method message", error: requestError({ message: "request failed: unknown method: node.list" }), }, { 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 consulting stale paired nodes", async ({ error }) => { gatewayMocks.callGatewayFromCliWithTransport .mockRejectedValueOnce(error) .mockResolvedValueOnce({ pending: [], paired: [{ nodeId: "stale-node", displayName: "Stale Node" }], }); await expect(resolveCliNode({}, "Stale Node")).rejects.toBe(error); expect( gatewayMocks.callGatewayFromCliWithTransport.mock.calls.map(([method]) => method), ).toEqual(["node.list"]); }); it.each([ requestError({ retryable: true }), requestError({ message: "unknown method: node.list.extra" }), Object.assign(new Error("unknown method: node.list"), { name: "GatewayClientRequestError", gatewayCode: "INVALID_REQUEST", }), ])("keeps diagnostics on the same exact missing-method contract", async (error) => { gatewayMocks.callGatewayFromCliWithTransport.mockRejectedValueOnce(error); await expect(resolveNodeDiagnosticsId({}, "stale-node")).rejects.toBe(error); expect( gatewayMocks.callGatewayFromCliWithTransport.mock.calls.map(([method]) => method), ).toEqual(["node.list"]); }); it.each(["", " \t "])("rejects explicit blank --timeout %j before transport", async (timeout) => { await expect(resolveCliNode({ timeout }, "some-node")).rejects.toThrow(/Invalid --timeout/); expect(gatewayMocks.callGatewayFromCliWithTransport).not.toHaveBeenCalled(); }); }); describe("node invoke envelope", () => { it("preserves caller-owned params, idempotency key, descriptors, and key order", () => { const params = { url: "openclaw://widget/local" }; const result = buildNodeInvokeParams({ nodeId: "mac-1", command: "canvas.present", params, idempotencyKey: "caller-supplied-key", timeoutMs: 35_000, }); expect(Object.keys(result)).toEqual([ "nodeId", "command", "params", "idempotencyKey", "timeoutMs", ]); expect(result.params).toBe(params); expect(result.idempotencyKey).toBe("caller-supplied-key"); expect(Object.getOwnPropertyDescriptor(result, "params")).toEqual({ value: params, writable: true, enumerable: true, configurable: true, }); }); it("retains undefined params while generating an idempotency key", () => { const result = buildNodeInvokeParams({ nodeId: "mac-1", command: "canvas.hide" }); expect(Object.keys(result)).toEqual(["nodeId", "command", "params", "idempotencyKey"]); expect(Object.hasOwn(result, "params")).toBe(true); expect(result.params).toBeUndefined(); expect(result.idempotencyKey).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, ); }); }); describe("node numeric option parsing", () => { it.each(["", " \t "])("rejects explicit blank numeric values %j", (value) => { expect(() => parseOptionalNodePositiveInteger(value, "--positive")).toThrow( "--positive must be a positive integer", ); expect(() => parseOptionalNodeNonNegativeInteger(value, "--non-negative")).toThrow( "--non-negative must be a non-negative integer", ); expect(() => parseOptionalNodeFiniteNumber(value, "--finite")).toThrow( "--finite must be a finite number", ); }); it.each([undefined, null])("preserves omitted numeric values %s", (value) => { expect(parseOptionalNodePositiveInteger(value, "--positive")).toBeUndefined(); expect(parseOptionalNodeNonNegativeInteger(value, "--non-negative")).toBeUndefined(); expect(parseOptionalNodeFiniteNumber(value, "--finite")).toBeUndefined(); }); });