openclaw / src /cli /nodes-cli /rpc.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw
History Blame Contribute Delete
7.08 kB
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();
});
});