Files
openclaw/src/gateway/gateway-misc.test.ts
2026-02-17 15:50:07 +09:00

267 lines
8.9 KiB
TypeScript

import { describe, expect, it, test, vi } from "vitest";
import { defaultVoiceWakeTriggers } from "../infra/voicewake.js";
import { GatewayClient } from "./client.js";
import {
DEFAULT_DANGEROUS_NODE_COMMANDS,
resolveNodeCommandAllowlist,
} from "./node-command-policy.js";
import type { RequestFrame } from "./protocol/index.js";
import { createGatewayBroadcaster } from "./server-broadcast.js";
import { createChatRunRegistry } from "./server-chat.js";
import { handleNodeInvokeResult } from "./server-methods/nodes.handlers.invoke-result.js";
import type { GatewayClient as GatewayMethodClient } from "./server-methods/types.js";
import type { GatewayRequestContext, RespondFn } from "./server-methods/types.js";
import { createNodeSubscriptionManager } from "./server-node-subscriptions.js";
import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js";
import type { GatewayWsClient } from "./server/ws-types.js";
const wsMockState = vi.hoisted(() => ({
last: null as { url: unknown; opts: unknown } | null,
}));
vi.mock("ws", () => ({
WebSocket: class MockWebSocket {
on = vi.fn();
close = vi.fn();
send = vi.fn();
constructor(url: unknown, opts: unknown) {
wsMockState.last = { url, opts };
}
},
}));
describe("GatewayClient", () => {
test("uses a large maxPayload for node snapshots", () => {
wsMockState.last = null;
const client = new GatewayClient({ url: "ws://127.0.0.1:1" });
client.start();
const last = wsMockState.last as { url: unknown; opts: unknown } | null;
expect(last?.url).toBe("ws://127.0.0.1:1");
expect(last?.opts).toEqual(expect.objectContaining({ maxPayload: 25 * 1024 * 1024 }));
});
});
type TestSocket = {
bufferedAmount: number;
send: (payload: string) => void;
close: (code: number, reason: string) => void;
};
describe("gateway broadcaster", () => {
it("filters approval and pairing events by scope", () => {
const approvalsSocket: TestSocket = {
bufferedAmount: 0,
send: vi.fn(),
close: vi.fn(),
};
const pairingSocket: TestSocket = {
bufferedAmount: 0,
send: vi.fn(),
close: vi.fn(),
};
const readSocket: TestSocket = {
bufferedAmount: 0,
send: vi.fn(),
close: vi.fn(),
};
const clients = new Set<GatewayWsClient>([
{
socket: approvalsSocket as unknown as GatewayWsClient["socket"],
connect: { role: "operator", scopes: ["operator.approvals"] } as GatewayWsClient["connect"],
connId: "c-approvals",
},
{
socket: pairingSocket as unknown as GatewayWsClient["socket"],
connect: { role: "operator", scopes: ["operator.pairing"] } as GatewayWsClient["connect"],
connId: "c-pairing",
},
{
socket: readSocket as unknown as GatewayWsClient["socket"],
connect: { role: "operator", scopes: ["operator.read"] } as GatewayWsClient["connect"],
connId: "c-read",
},
]);
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
broadcast("exec.approval.requested", { id: "1" });
broadcast("device.pair.requested", { requestId: "r1" });
expect(approvalsSocket.send).toHaveBeenCalledTimes(1);
expect(pairingSocket.send).toHaveBeenCalledTimes(1);
expect(readSocket.send).toHaveBeenCalledTimes(0);
broadcastToConnIds("tick", { ts: 1 }, new Set(["c-read"]));
expect(readSocket.send).toHaveBeenCalledTimes(1);
expect(approvalsSocket.send).toHaveBeenCalledTimes(1);
expect(pairingSocket.send).toHaveBeenCalledTimes(1);
});
});
describe("chat run registry", () => {
test("queues and removes runs per session", () => {
const registry = createChatRunRegistry();
registry.add("s1", { sessionKey: "main", clientRunId: "c1" });
registry.add("s1", { sessionKey: "main", clientRunId: "c2" });
expect(registry.peek("s1")?.clientRunId).toBe("c1");
expect(registry.shift("s1")?.clientRunId).toBe("c1");
expect(registry.peek("s1")?.clientRunId).toBe("c2");
expect(registry.remove("s1", "c2")?.clientRunId).toBe("c2");
expect(registry.peek("s1")).toBeUndefined();
});
});
describe("late-arriving invoke results", () => {
test("returns success for unknown invoke ids for both success and error payloads", async () => {
const nodeId = "node-123";
const cases = [
{
id: "unknown-invoke-id-12345",
ok: true,
payloadJSON: JSON.stringify({ result: "late" }),
},
{
id: "another-unknown-invoke-id",
ok: false,
error: { code: "FAILED", message: "test error" },
},
] as const;
for (const params of cases) {
const respond = vi.fn<RespondFn>();
const context = {
nodeRegistry: { handleInvokeResult: () => false },
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
const client = {
connect: { device: { id: nodeId } },
} as unknown as GatewayMethodClient;
await handleNodeInvokeResult({
req: { method: "node.invoke.result" } as unknown as RequestFrame,
params: { ...params, nodeId } as unknown as Record<string, unknown>,
client,
isWebchatConnect: () => false,
respond,
context,
});
const [ok, rawPayload, error] = respond.mock.lastCall ?? [];
const payload = rawPayload as { ok?: boolean; ignored?: boolean } | undefined;
// Late-arriving results return success instead of error to reduce log noise.
expect(ok).toBe(true);
expect(error).toBeUndefined();
expect(payload?.ok).toBe(true);
expect(payload?.ignored).toBe(true);
}
});
});
describe("node subscription manager", () => {
test("routes events to subscribed nodes", () => {
const manager = createNodeSubscriptionManager();
const sent: Array<{
nodeId: string;
event: string;
payloadJSON?: string | null;
}> = [];
const sendEvent = (evt: { nodeId: string; event: string; payloadJSON?: string | null }) =>
sent.push(evt);
manager.subscribe("node-a", "main");
manager.subscribe("node-b", "main");
manager.sendToSession("main", "chat", { ok: true }, sendEvent);
expect(sent).toHaveLength(2);
expect(sent.map((s) => s.nodeId).toSorted()).toEqual(["node-a", "node-b"]);
expect(sent[0].event).toBe("chat");
});
test("unsubscribeAll clears session mappings", () => {
const manager = createNodeSubscriptionManager();
const sent: string[] = [];
const sendEvent = (evt: { nodeId: string; event: string }) =>
sent.push(`${evt.nodeId}:${evt.event}`);
manager.subscribe("node-a", "main");
manager.subscribe("node-a", "secondary");
manager.unsubscribeAll("node-a");
manager.sendToSession("main", "tick", {}, sendEvent);
manager.sendToSession("secondary", "tick", {}, sendEvent);
expect(sent).toEqual([]);
});
});
describe("resolveNodeCommandAllowlist", () => {
it("includes iOS service commands by default", () => {
const allow = resolveNodeCommandAllowlist(
{},
{
platform: "ios 26.0",
deviceFamily: "iPhone",
},
);
expect(allow.has("device.info")).toBe(true);
expect(allow.has("device.status")).toBe(true);
expect(allow.has("system.notify")).toBe(true);
expect(allow.has("contacts.search")).toBe(true);
expect(allow.has("calendar.events")).toBe(true);
expect(allow.has("reminders.list")).toBe(true);
expect(allow.has("photos.latest")).toBe(true);
expect(allow.has("motion.activity")).toBe(true);
for (const cmd of DEFAULT_DANGEROUS_NODE_COMMANDS) {
expect(allow.has(cmd)).toBe(false);
}
});
it("can explicitly allow dangerous commands via allowCommands", () => {
const allow = resolveNodeCommandAllowlist(
{
gateway: {
nodes: {
allowCommands: ["camera.snap", "screen.record"],
},
},
},
{ platform: "ios", deviceFamily: "iPhone" },
);
expect(allow.has("camera.snap")).toBe(true);
expect(allow.has("screen.record")).toBe(true);
expect(allow.has("camera.clip")).toBe(false);
});
});
describe("normalizeVoiceWakeTriggers", () => {
test("returns defaults when input is empty", () => {
expect(normalizeVoiceWakeTriggers([])).toEqual(defaultVoiceWakeTriggers());
expect(normalizeVoiceWakeTriggers(null)).toEqual(defaultVoiceWakeTriggers());
});
test("trims and limits entries", () => {
const result = normalizeVoiceWakeTriggers([" hello ", "", "world"]);
expect(result).toEqual(["hello", "world"]);
});
});
describe("formatError", () => {
test("prefers message for Error", () => {
expect(formatError(new Error("boom"))).toBe("boom");
});
test("handles status/code", () => {
expect(formatError({ status: 500, code: "EPIPE" })).toBe("status=500 code=EPIPE");
expect(formatError({ status: 404 })).toBe("status=404 code=unknown");
expect(formatError({ code: "ENOENT" })).toBe("status=unknown code=ENOENT");
});
});