428 lines
13 KiB
TypeScript
428 lines
13 KiB
TypeScript
import { describe, expect, test, vi } from "vitest";
|
|
import { WebSocket } from "ws";
|
|
import { AddressInfo, createServer } from "node:net";
|
|
import { startGatewayServer } from "./server.js";
|
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
|
|
|
vi.mock("../commands/health.js", () => ({
|
|
getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
|
|
}));
|
|
vi.mock("../commands/status.js", () => ({
|
|
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
|
|
}));
|
|
vi.mock("../web/outbound.js", () => ({
|
|
sendMessageWhatsApp: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
|
}));
|
|
vi.mock("../commands/agent.js", () => ({
|
|
agentCommand: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
async function getFreePort(): Promise<number> {
|
|
return await new Promise((resolve, reject) => {
|
|
const server = createServer();
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const port = (server.address() as AddressInfo).port;
|
|
server.close((err) => (err ? reject(err) : resolve(port)));
|
|
});
|
|
});
|
|
}
|
|
|
|
function onceMessage<T = any>(ws: WebSocket, filter: (obj: any) => boolean, timeoutMs = 3000) {
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
|
const closeHandler = (code: number, reason: Buffer) => {
|
|
clearTimeout(timer);
|
|
ws.off("message", handler);
|
|
reject(new Error(`closed ${code}: ${reason.toString()}`));
|
|
};
|
|
const handler = (data: WebSocket.RawData) => {
|
|
const obj = JSON.parse(String(data));
|
|
if (filter(obj)) {
|
|
clearTimeout(timer);
|
|
ws.off("message", handler);
|
|
ws.off("close", closeHandler);
|
|
resolve(obj as T);
|
|
}
|
|
};
|
|
ws.on("message", handler);
|
|
ws.once("close", closeHandler);
|
|
});
|
|
}
|
|
|
|
async function startServerWithClient(token?: string) {
|
|
const port = await getFreePort();
|
|
const prev = process.env.CLAWDIS_GATEWAY_TOKEN;
|
|
if (token === undefined) {
|
|
delete process.env.CLAWDIS_GATEWAY_TOKEN;
|
|
} else {
|
|
process.env.CLAWDIS_GATEWAY_TOKEN = token;
|
|
}
|
|
const server = await startGatewayServer(port);
|
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
|
return { server, ws, port, prevToken: prev };
|
|
}
|
|
|
|
describe("gateway server", () => {
|
|
test("rejects protocol mismatch", async () => {
|
|
const { server, ws } = await startServerWithClient();
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "hello",
|
|
minProtocol: 2,
|
|
maxProtocol: 3,
|
|
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
|
caps: [],
|
|
}),
|
|
);
|
|
const res = await onceMessage(ws, () => true);
|
|
expect(res.type).toBe("hello-error");
|
|
expect(res.reason).toContain("protocol mismatch");
|
|
ws.close();
|
|
await server.close();
|
|
});
|
|
|
|
test("rejects invalid token", async () => {
|
|
const { server, ws, prevToken } = await startServerWithClient("secret");
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "hello",
|
|
minProtocol: 1,
|
|
maxProtocol: 1,
|
|
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
|
caps: [],
|
|
auth: { token: "wrong" },
|
|
}),
|
|
);
|
|
const res = await onceMessage(ws, () => true);
|
|
expect(res.type).toBe("hello-error");
|
|
expect(res.reason).toContain("unauthorized");
|
|
ws.close();
|
|
await server.close();
|
|
process.env.CLAWDIS_GATEWAY_TOKEN = prevToken;
|
|
});
|
|
|
|
test("closes silent handshakes after timeout", async () => {
|
|
const { server, ws } = await startServerWithClient();
|
|
const closed = await new Promise<boolean>((resolve) => {
|
|
const timer = setTimeout(() => resolve(false), 4000);
|
|
ws.once("close", () => {
|
|
clearTimeout(timer);
|
|
resolve(true);
|
|
});
|
|
});
|
|
expect(closed).toBe(true);
|
|
await server.close();
|
|
});
|
|
|
|
test("hello + health + presence + status succeed", { timeout: 8000 }, async () => {
|
|
const { server, ws } = await startServerWithClient();
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "hello",
|
|
minProtocol: 1,
|
|
maxProtocol: 1,
|
|
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
|
caps: [],
|
|
}),
|
|
);
|
|
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
|
|
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
|
|
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
|
|
const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "presence1");
|
|
|
|
const sendReq = (id: string, method: string) =>
|
|
ws.send(JSON.stringify({ type: "req", id, method }));
|
|
sendReq("health1", "health");
|
|
sendReq("status1", "status");
|
|
sendReq("presence1", "system-presence");
|
|
|
|
const health = await healthP;
|
|
const status = await statusP;
|
|
const presence = await presenceP;
|
|
expect(health.ok).toBe(true);
|
|
expect(status.ok).toBe(true);
|
|
expect(presence.ok).toBe(true);
|
|
expect(Array.isArray(presence.payload)).toBe(true);
|
|
|
|
ws.close();
|
|
await server.close();
|
|
});
|
|
|
|
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
|
|
const { server, ws } = await startServerWithClient();
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "hello",
|
|
minProtocol: 1,
|
|
maxProtocol: 1,
|
|
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
|
caps: [],
|
|
}),
|
|
);
|
|
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
|
|
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "req",
|
|
id: "evt-1",
|
|
method: "system-event",
|
|
params: { text: "note from test" },
|
|
}),
|
|
);
|
|
|
|
const evt = await presenceEventP;
|
|
expect(typeof evt.seq).toBe("number");
|
|
expect(evt.stateVersion?.presence).toBeGreaterThan(0);
|
|
expect(Array.isArray(evt.payload?.presence)).toBe(true);
|
|
|
|
ws.close();
|
|
await server.close();
|
|
});
|
|
|
|
test("agent events stream with seq", { timeout: 8000 }, async () => {
|
|
const { server, ws } = await startServerWithClient();
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "hello",
|
|
minProtocol: 1,
|
|
maxProtocol: 1,
|
|
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
|
caps: [],
|
|
}),
|
|
);
|
|
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
|
|
// Emit a fake agent event directly through the shared emitter.
|
|
const evtPromise = onceMessage(ws, (o) => o.type === "event" && o.event === "agent");
|
|
emitAgentEvent({ runId: "run-1", stream: "job", data: { msg: "hi" } });
|
|
const evt = await evtPromise;
|
|
expect(evt.payload.runId).toBe("run-1");
|
|
expect(typeof evt.seq).toBe("number");
|
|
expect(evt.payload.data.msg).toBe("hi");
|
|
|
|
ws.close();
|
|
await server.close();
|
|
});
|
|
|
|
test("agent ack then final response", { timeout: 8000 }, async () => {
|
|
const { server, ws } = await startServerWithClient();
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "hello",
|
|
minProtocol: 1,
|
|
maxProtocol: 1,
|
|
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
|
caps: [],
|
|
}),
|
|
);
|
|
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
|
|
const ackP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status === "accepted");
|
|
const finalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted");
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "req",
|
|
id: "ag1",
|
|
method: "agent",
|
|
params: { message: "hi", idempotencyKey: "idem-ag" },
|
|
}),
|
|
);
|
|
|
|
const ack = await ackP;
|
|
const final = await finalP;
|
|
expect(ack.payload.runId).toBeDefined();
|
|
expect(final.payload.runId).toBe(ack.payload.runId);
|
|
expect(final.payload.status).toBe("ok");
|
|
|
|
ws.close();
|
|
await server.close();
|
|
});
|
|
|
|
test("agent dedupes by idempotencyKey after completion", { timeout: 8000 }, async () => {
|
|
const { server, ws } = await startServerWithClient();
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "hello",
|
|
minProtocol: 1,
|
|
maxProtocol: 1,
|
|
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
|
caps: [],
|
|
}),
|
|
);
|
|
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
|
|
const firstFinalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted");
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "req",
|
|
id: "ag1",
|
|
method: "agent",
|
|
params: { message: "hi", idempotencyKey: "same-agent" },
|
|
}),
|
|
);
|
|
const firstFinal = await firstFinalP;
|
|
|
|
const secondP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag2");
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "req",
|
|
id: "ag2",
|
|
method: "agent",
|
|
params: { message: "hi again", idempotencyKey: "same-agent" },
|
|
}),
|
|
);
|
|
const second = await secondP;
|
|
expect(second.payload).toEqual(firstFinal.payload);
|
|
|
|
ws.close();
|
|
await server.close();
|
|
});
|
|
|
|
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
|
|
const { server, ws } = await startServerWithClient();
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "hello",
|
|
minProtocol: 1,
|
|
maxProtocol: 1,
|
|
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
|
caps: [],
|
|
}),
|
|
);
|
|
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
|
|
const shutdownP = onceMessage(ws, (o) => o.type === "event" && o.event === "shutdown", 5000);
|
|
await server.close();
|
|
const evt = await shutdownP;
|
|
expect(evt.payload?.reason).toBeDefined();
|
|
});
|
|
|
|
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port);
|
|
const mkClient = async () => {
|
|
const c = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
await new Promise<void>((resolve) => c.once("open", resolve));
|
|
c.send(
|
|
JSON.stringify({
|
|
type: "hello",
|
|
minProtocol: 1,
|
|
maxProtocol: 1,
|
|
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
|
caps: [],
|
|
}),
|
|
);
|
|
await onceMessage(c, (o) => o.type === "hello-ok");
|
|
return c;
|
|
};
|
|
|
|
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
|
|
const waits = clients.map((c) => onceMessage(c, (o) => o.type === "event" && o.event === "presence"));
|
|
clients[0].send(
|
|
JSON.stringify({
|
|
type: "req",
|
|
id: "broadcast",
|
|
method: "system-event",
|
|
params: { text: "fanout" },
|
|
}),
|
|
);
|
|
const events = await Promise.all(waits);
|
|
for (const evt of events) {
|
|
expect(evt.payload?.presence?.length).toBeGreaterThan(0);
|
|
expect(typeof evt.seq).toBe("number");
|
|
}
|
|
for (const c of clients) c.close();
|
|
await server.close();
|
|
});
|
|
|
|
test("send dedupes by idempotencyKey", { timeout: 8000 }, async () => {
|
|
const { server, ws } = await startServerWithClient();
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "hello",
|
|
minProtocol: 1,
|
|
maxProtocol: 1,
|
|
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
|
caps: [],
|
|
}),
|
|
);
|
|
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
|
|
const idem = "same-key";
|
|
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
|
|
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
|
|
const sendReq = (id: string) =>
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "req",
|
|
id,
|
|
method: "send",
|
|
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
|
|
}),
|
|
);
|
|
sendReq("a1");
|
|
sendReq("a2");
|
|
|
|
const res1 = await res1P;
|
|
const res2 = await res2P;
|
|
expect(res1.ok).toBe(true);
|
|
expect(res2.ok).toBe(true);
|
|
expect(res1.payload).toEqual(res2.payload);
|
|
ws.close();
|
|
await server.close();
|
|
});
|
|
|
|
test("agent dedupe survives reconnect", { timeout: 15000 }, async () => {
|
|
const port = await getFreePort();
|
|
const server = await startGatewayServer(port);
|
|
|
|
const dial = async () => {
|
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "hello",
|
|
minProtocol: 1,
|
|
maxProtocol: 1,
|
|
client: { name: "test", version: "1.0.0", platform: "test", mode: "test" },
|
|
caps: [],
|
|
}),
|
|
);
|
|
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
return ws;
|
|
};
|
|
|
|
const idem = "reconnect-agent";
|
|
const ws1 = await dial();
|
|
const final1P = onceMessage(ws1, (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", 6000);
|
|
ws1.send(
|
|
JSON.stringify({
|
|
type: "req",
|
|
id: "ag1",
|
|
method: "agent",
|
|
params: { message: "hi", idempotencyKey: idem },
|
|
}),
|
|
);
|
|
const final1 = await final1P;
|
|
ws1.close();
|
|
|
|
const ws2 = await dial();
|
|
const final2P = onceMessage(ws2, (o) => o.type === "res" && o.id === "ag2", 6000);
|
|
ws2.send(
|
|
JSON.stringify({
|
|
type: "req",
|
|
id: "ag2",
|
|
method: "agent",
|
|
params: { message: "hi again", idempotencyKey: idem },
|
|
}),
|
|
);
|
|
const res = await final2P;
|
|
expect(res.payload).toEqual(final1.payload);
|
|
ws2.close();
|
|
await server.close();
|
|
});
|
|
});
|