import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { WebSocketOTClient } from "../src/websocket"; class MockWebSocket { static instances: MockWebSocket[] = []; url: string; onopen: ((e: Event) => void) | null = null; onclose: ((e: CloseEvent) => void) | null = null; onerror: ((e: Event) => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; send = vi.fn(); close = vi.fn(); constructor(url: string) { this.url = url; MockWebSocket.instances.push(this); } triggerOpen() { this.onopen?.(new Event("open")); } triggerClose() { this.onclose?.(new CloseEvent("close")); } triggerError() { this.onerror?.(new Event("error")); } triggerMessage(data: object) { this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(data) })); } } describe("WebSocketOTClient", () => { beforeEach(() => { MockWebSocket.instances = []; vi.stubGlobal("WebSocket", MockWebSocket); vi.useFakeTimers(); }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); it("reconnects on close", () => { const client = new WebSocketOTClient("ws://example.com/ws", "c1"); client.connect(); const ws1 = MockWebSocket.instances[0]!; ws1.triggerOpen(); ws1.triggerClose(); expect(MockWebSocket.instances).toHaveLength(1); vi.runAllTimers(); expect(MockWebSocket.instances).toHaveLength(2); }); it("uses exponential backoff across reconnect attempts", () => { const client = new WebSocketOTClient("ws://example.com/ws", "c1"); client.connect(); // Fail 3 times without opening (onopen never fires, so reconnectAttempt increments) for (let i = 0; i < 3; i++) { MockWebSocket.instances[i]!.triggerClose(); vi.runAllTimers(); } expect(MockWebSocket.instances).toHaveLength(4); }); it("resets backoff counter after successful onopen", () => { const client = new WebSocketOTClient("ws://example.com/ws", "c1"); client.connect(); // Fail twice without opening to increment reconnectAttempt MockWebSocket.instances[0]!.triggerClose(); vi.runAllTimers(); MockWebSocket.instances[1]!.triggerClose(); vi.runAllTimers(); // Open successfully — resets reconnectAttempt to 0 MockWebSocket.instances[2]!.triggerOpen(); MockWebSocket.instances[2]!.triggerClose(); // reconnectAttempt was reset so base delay is ~500ms (not ~2000ms) vi.runAllTimers(); expect(MockWebSocket.instances).toHaveLength(4); }); it("does not reconnect after fatal onerror", () => { const client = new WebSocketOTClient("ws://example.com/ws", "c1"); client.connect(); MockWebSocket.instances[0]!.triggerOpen(); MockWebSocket.instances[0]!.triggerError(); vi.runAllTimers(); expect(MockWebSocket.instances).toHaveLength(1); expect(() => client.pollServerOperations()).toThrow("WebSocket error"); }); it("does not reconnect after server error message", () => { const client = new WebSocketOTClient("ws://example.com/ws", "c1"); client.connect(); MockWebSocket.instances[0]!.triggerOpen(); MockWebSocket.instances[0]!.triggerMessage({ type: "error", message: "bad state" }); vi.runAllTimers(); expect(MockWebSocket.instances).toHaveLength(1); expect(() => client.pollServerOperations()).toThrow("bad state"); }); it("buffers local operations while disconnected and flushes on reconnect", () => { const client = new WebSocketOTClient("ws://example.com/ws", "c1"); client.connect(); const ws1 = MockWebSocket.instances[0]!; ws1.triggerOpen(); // Receive initial server message to set seq >= 0 ws1.triggerMessage({ type: "operation", commands: [], seq: 0 }); ws1.triggerClose(); // Send local op while disconnected client.sendLocalOperations(["createPile", 0]); expect(ws1.send).not.toHaveBeenCalledWith(expect.stringContaining("createPile")); // Reconnect vi.runAllTimers(); const ws2 = MockWebSocket.instances[1]!; ws2.triggerOpen(); expect(ws2.send).toHaveBeenCalledTimes(1); const sent = JSON.parse(ws2.send.mock.calls[0]?.[0]); expect(sent.type).toBe("operation"); expect(sent.commands).toEqual([["createPile", 0]]); }); it("stale timer does not open a new connection when already reconnected", () => { const client = new WebSocketOTClient("ws://example.com/ws", "c1"); client.connect(); MockWebSocket.instances[0]!.triggerOpen(); MockWebSocket.instances[0]!.triggerClose(); vi.runAllTimers(); MockWebSocket.instances[1]!.triggerOpen(); // Running timers again (simulating any stale timers) should not create a 3rd socket vi.runAllTimers(); expect(MockWebSocket.instances).toHaveLength(2); }); it("includes correct fromSeq in reconnect URL", () => { const client = new WebSocketOTClient("ws://example.com/ws", "c1"); client.connect(); const ws1 = MockWebSocket.instances[0]!; ws1.triggerOpen(); ws1.triggerMessage({ type: "operation", commands: [], seq: 5 }); ws1.triggerClose(); vi.runAllTimers(); expect(MockWebSocket.instances[1]!.url).toContain("fromSeq=6"); }); });