import { describe, expect, it, vi } from "vitest"; import type { WebSocketLike } from "./index"; import { createSurfaceSocket } from "./index"; interface FakeSocket extends WebSocketLike { sent: string[]; resolveOpen(): void; invokeMessage(data: string): void; invokeClose(): void; } function fakeSocket(): FakeSocket { let onopen: (() => void) | null = null; let onmessage: ((ev: { data: string }) => void) | null = null; let onclose: ((ev: { code: number; reason: string }) => void) | null = null; const sent: string[] = []; const ws: FakeSocket = { send(data: string) { sent.push(data); }, close() {}, get onopen() { return onopen; }, set onopen(fn) { onopen = fn; }, get onmessage() { return onmessage; }, set onmessage(fn) { onmessage = fn; }, get onclose() { return onclose; }, set onclose(fn) { onclose = fn; }, resolveOpen() { onopen?.(); }, invokeMessage(data: string) { onmessage?.({ data }); }, invokeClose() { onclose?.({ code: 1000, reason: "" }); }, sent, }; return ws; } describe("createSurfaceSocket", () => { it("sends queued messages once socket opens", () => { const ws = fakeSocket(); const onMessage = vi.fn(); const handle = createSurfaceSocket({ url: "ws://test", onMessage, socketFactory: () => ws, }); handle.send({ type: "subscribe", surfaceId: "s1" }); handle.send({ type: "subscribe", surfaceId: "s2" }); expect(ws.sent).toHaveLength(0); ws.resolveOpen(); expect(ws.sent).toHaveLength(2); expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "s1" }); expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "s2" }); }); it("sends immediately when socket is already open", () => { const ws = fakeSocket(); const handle = createSurfaceSocket({ url: "ws://test", onMessage: vi.fn(), socketFactory: () => ws, }); ws.resolveOpen(); ws.sent.length = 0; handle.send({ type: "subscribe", surfaceId: "s1" }); expect(ws.sent).toHaveLength(1); }); it("routes inbound messages to onMessage via parseServerMessage", () => { const ws = fakeSocket(); const onMessage = vi.fn(); createSurfaceSocket({ url: "ws://test", onMessage, socketFactory: () => ws, }); ws.resolveOpen(); ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); expect(onMessage).toHaveBeenCalledOnce(); expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); }); it("drops malformed inbound messages silently", () => { const ws = fakeSocket(); const onMessage = vi.fn(); createSurfaceSocket({ url: "ws://test", onMessage, socketFactory: () => ws, }); ws.resolveOpen(); ws.invokeMessage("not json"); expect(onMessage).not.toHaveBeenCalled(); }); it("auto-reconnects on close and fires onReopen after successful reconnect", () => { vi.useFakeTimers(); try { const sockets: ReturnType[] = []; const onMessage = vi.fn(); const onReopen = vi.fn(); createSurfaceSocket({ url: "ws://test", onMessage, onReopen, socketFactory: () => { const ws = fakeSocket(); sockets.push(ws); return ws; }, }); expect(sockets).toHaveLength(1); sockets[0]?.resolveOpen(); // Simulate close sockets[0]?.invokeClose(); // Fast-forward past the backoff delay vi.advanceTimersByTime(600); expect(sockets).toHaveLength(2); // onReopen should NOT have fired yet (socket not open) expect(onReopen).not.toHaveBeenCalled(); sockets[1]?.resolveOpen(); expect(onReopen).toHaveBeenCalledOnce(); } finally { vi.useRealTimers(); } }); it("does not fire onReopen on initial connect", () => { const ws = fakeSocket(); const onReopen = vi.fn(); createSurfaceSocket({ url: "ws://test", onMessage: vi.fn(), onReopen, socketFactory: () => ws, }); ws.resolveOpen(); expect(onReopen).not.toHaveBeenCalled(); }); it("close() prevents further reconnects", () => { vi.useFakeTimers(); try { const sockets: ReturnType[] = []; const handle = createSurfaceSocket({ url: "ws://test", onMessage: vi.fn(), socketFactory: () => { const ws = fakeSocket(); sockets.push(ws); return ws; }, }); sockets[0]?.resolveOpen(); sockets[0]?.invokeClose(); handle.close(); vi.advanceTimersByTime(10_000); expect(sockets).toHaveLength(1); } finally { vi.useRealTimers(); } }); it("close() prevents further sends", () => { const ws = fakeSocket(); const handle = createSurfaceSocket({ url: "ws://test", onMessage: vi.fn(), socketFactory: () => ws, }); ws.resolveOpen(); ws.sent.length = 0; handle.close(); handle.send({ type: "subscribe", surfaceId: "s1" }); expect(ws.sent).toHaveLength(0); }); it("queues multiple sends before open and flushes in order", () => { const ws = fakeSocket(); const handle = createSurfaceSocket({ url: "ws://test", onMessage: vi.fn(), socketFactory: () => ws, }); handle.send({ type: "subscribe", surfaceId: "a" }); handle.send({ type: "subscribe", surfaceId: "b" }); handle.send({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1 }); ws.resolveOpen(); expect(ws.sent).toHaveLength(3); expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "a" }); expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "b" }); expect(JSON.parse(ws.sent[2] ?? "")).toEqual({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1, }); }); it("routes chat.delta to onChat", () => { const ws = fakeSocket(); const onMessage = vi.fn(); const onChat = vi.fn(); createSurfaceSocket({ url: "ws://test", onMessage, onChat, socketFactory: () => ws, }); ws.resolveOpen(); const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" }; ws.invokeMessage(JSON.stringify({ type: "chat.delta", event })); expect(onChat).toHaveBeenCalledOnce(); expect(onChat).toHaveBeenCalledWith({ type: "chat.delta", event }); expect(onMessage).not.toHaveBeenCalled(); }); it("routes chat.error to onChat", () => { const ws = fakeSocket(); const onMessage = vi.fn(); const onChat = vi.fn(); createSurfaceSocket({ url: "ws://test", onMessage, onChat, socketFactory: () => ws, }); ws.resolveOpen(); ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "bad request" })); expect(onChat).toHaveBeenCalledOnce(); expect(onChat).toHaveBeenCalledWith({ type: "chat.error", message: "bad request" }); expect(onMessage).not.toHaveBeenCalled(); }); it("still routes surface catalog/surface to onMessage", () => { const ws = fakeSocket(); const onMessage = vi.fn(); const onChat = vi.fn(); createSurfaceSocket({ url: "ws://test", onMessage, onChat, socketFactory: () => ws, }); ws.resolveOpen(); ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); expect(onMessage).toHaveBeenCalledOnce(); expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); expect(onChat).not.toHaveBeenCalled(); ws.invokeMessage( JSON.stringify({ type: "surface", spec: { id: "s1", region: "r", title: "S", fields: [] } }), ); expect(onMessage).toHaveBeenCalledTimes(2); }); it("send accepts and serializes a chat.send message", () => { const ws = fakeSocket(); const handle = createSurfaceSocket({ url: "ws://test", onMessage: vi.fn(), socketFactory: () => ws, }); ws.resolveOpen(); handle.send({ type: "chat.send", message: "hello" }); expect(ws.sent).toHaveLength(1); expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "hello" }); }); it("onChat absent is safe (surface-only usage does not throw)", () => { const ws = fakeSocket(); const onMessage = vi.fn(); createSurfaceSocket({ url: "ws://test", onMessage, socketFactory: () => ws, }); ws.resolveOpen(); expect(() => { ws.invokeMessage( JSON.stringify({ type: "chat.delta", event: { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "x" }, }), ); ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "boom" })); }).not.toThrow(); expect(onMessage).not.toHaveBeenCalled(); }); it("chat send is queued until open then flushed", () => { const ws = fakeSocket(); const handle = createSurfaceSocket({ url: "ws://test", onMessage: vi.fn(), socketFactory: () => ws, }); handle.send({ type: "chat.send", message: "queued" }); expect(ws.sent).toHaveLength(0); ws.resolveOpen(); expect(ws.sent).toHaveLength(1); expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" }); }); });