language: TypeScript
5.11 KB / 167 lines / 133 loc
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");
});
});