jmou/yakatak
git clone https://github.com/jmou/yakatak.git
git clone git@github.com:jmou/yakatak.git
Loading…
(top)/operation/test/websocket.test.ts
language: TypeScript
5.11 KB / 167 lines / 133 loc
History
View raw
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");
  });
});