theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

ws.test.ts (8999B)


      1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
      2 import { WsClient } from "./ws";
      3 
      4 // Mock WebSocket
      5 class MockWebSocket {
      6   static instances: MockWebSocket[] = [];
      7   url: string;
      8   onopen: (() => void) | null = null;
      9   onclose: (() => void) | null = null;
     10   onmessage: ((event: { data: string }) => void) | null = null;
     11   onerror: (() => void) | null = null;
     12   sent: string[] = [];
     13   readyState = 1; // OPEN
     14 
     15   constructor(url: string) {
     16     this.url = url;
     17     MockWebSocket.instances.push(this);
     18   }
     19 
     20   send(data: string) {
     21     this.sent.push(data);
     22   }
     23 
     24   close() {
     25     this.readyState = 3;
     26   }
     27 
     28   simulateMessage(data: string) {
     29     this.onmessage?.({ data });
     30   }
     31 
     32   simulateOpen() {
     33     this.onopen?.();
     34   }
     35 
     36   simulateClose() {
     37     this.onclose?.();
     38   }
     39 
     40   simulateError() {
     41     this.onerror?.();
     42   }
     43 }
     44 
     45 beforeEach(() => {
     46   MockWebSocket.instances = [];
     47   vi.stubGlobal("WebSocket", MockWebSocket);
     48   vi.useFakeTimers();
     49 });
     50 
     51 afterEach(() => {
     52   vi.useRealTimers();
     53   vi.restoreAllMocks();
     54 });
     55 
     56 describe("WsClient", () => {
     57   it("connect creates a WebSocket", () => {
     58     const client = new WsClient("ws://localhost:8080/ws");
     59     client.connect();
     60 
     61     expect(MockWebSocket.instances).toHaveLength(1);
     62     expect(MockWebSocket.instances[0].url).toBe("ws://localhost:8080/ws");
     63   });
     64 
     65   it("send serializes JSON", () => {
     66     const client = new WsClient("ws://localhost:8080/ws");
     67     client.connect();
     68 
     69     const ws = MockWebSocket.instances[0];
     70     ws.simulateOpen();
     71 
     72     client.send("chat.message", { text: "hello" });
     73 
     74     expect(ws.sent).toHaveLength(1);
     75     expect(JSON.parse(ws.sent[0])).toEqual({
     76       type: "chat.message",
     77       data: { text: "hello" },
     78     });
     79   });
     80 
     81   it("send without data sends type only", () => {
     82     const client = new WsClient("ws://localhost:8080/ws");
     83     client.connect();
     84 
     85     const ws = MockWebSocket.instances[0];
     86     ws.simulateOpen();
     87 
     88     client.send("ping");
     89 
     90     expect(ws.sent).toHaveLength(1);
     91     expect(JSON.parse(ws.sent[0])).toEqual({ type: "ping", data: undefined });
     92   });
     93 
     94   it("send does nothing when not connected (readyState != 1)", () => {
     95     const client = new WsClient("ws://localhost:8080/ws");
     96     client.connect();
     97 
     98     const ws = MockWebSocket.instances[0];
     99     // readyState is 1 by default, but let's test when it's not open
    100     ws.readyState = 0; // CONNECTING
    101 
    102     client.send("chat.message", { text: "hello" });
    103     expect(ws.sent).toHaveLength(0);
    104   });
    105 
    106   it("receive dispatches to handler", () => {
    107     const handler = vi.fn();
    108     const client = new WsClient("ws://localhost:8080/ws");
    109     client.onMessage(handler);
    110     client.connect();
    111 
    112     const ws = MockWebSocket.instances[0];
    113     ws.simulateOpen();
    114     ws.simulateMessage(
    115       JSON.stringify({ type: "assistant.delta", data: { text: "hi" } }),
    116     );
    117 
    118     expect(handler).toHaveBeenCalledWith({
    119       type: "assistant.delta",
    120       data: { text: "hi" },
    121     });
    122   });
    123 
    124   it("receive dispatches to multiple handlers", () => {
    125     const handler1 = vi.fn();
    126     const handler2 = vi.fn();
    127     const client = new WsClient("ws://localhost:8080/ws");
    128     client.onMessage(handler1);
    129     client.onMessage(handler2);
    130     client.connect();
    131 
    132     const ws = MockWebSocket.instances[0];
    133     ws.simulateMessage(JSON.stringify({ type: "test" }));
    134 
    135     expect(handler1).toHaveBeenCalled();
    136     expect(handler2).toHaveBeenCalled();
    137   });
    138 
    139   it("ignores malformed JSON messages", () => {
    140     const handler = vi.fn();
    141     const client = new WsClient("ws://localhost:8080/ws");
    142     client.onMessage(handler);
    143     client.connect();
    144 
    145     const ws = MockWebSocket.instances[0];
    146     ws.simulateMessage("not json {{{");
    147 
    148     expect(handler).not.toHaveBeenCalled();
    149   });
    150 
    151   it("reconnects with exponential backoff on disconnect", () => {
    152     const client = new WsClient("ws://localhost:8080/ws", {
    153       maxReconnectAttempts: 5,
    154     });
    155     client.connect();
    156 
    157     const ws1 = MockWebSocket.instances[0];
    158     ws1.simulateOpen();
    159     ws1.simulateClose();
    160 
    161     // No immediate reconnect
    162     expect(MockWebSocket.instances).toHaveLength(1);
    163 
    164     // First reconnect after 1s
    165     vi.advanceTimersByTime(1000);
    166     expect(MockWebSocket.instances).toHaveLength(2);
    167 
    168     // Second reconnect after 2s
    169     const ws2 = MockWebSocket.instances[1];
    170     ws2.simulateOpen();
    171     ws2.simulateClose();
    172     vi.advanceTimersByTime(2000);
    173     expect(MockWebSocket.instances).toHaveLength(3);
    174   });
    175 
    176   it("stops reconnecting after max attempts", () => {
    177     const client = new WsClient("ws://localhost:8080/ws", {
    178       maxReconnectAttempts: 2,
    179     });
    180     client.connect();
    181 
    182     // Connect attempt 1: open then close
    183     MockWebSocket.instances[0].simulateOpen();
    184     MockWebSocket.instances[0].simulateClose();
    185     vi.advanceTimersByTime(1000);
    186 
    187     // 1st reconnect: instance 2 created
    188     expect(MockWebSocket.instances).toHaveLength(2);
    189     MockWebSocket.instances[1].simulateClose();
    190     vi.advanceTimersByTime(2000);
    191 
    192     // 2nd reconnect: instance 3 created
    193     expect(MockWebSocket.instances).toHaveLength(3);
    194 
    195     // No more reconnects after max (2) reached
    196     MockWebSocket.instances[2].simulateClose();
    197     vi.advanceTimersByTime(4000);
    198     expect(MockWebSocket.instances).toHaveLength(3);
    199   });
    200 
    201   it("caps reconnect delay at maxReconnectDelay", () => {
    202     const client = new WsClient("ws://localhost:8080/ws", {
    203       maxReconnectAttempts: 10,
    204       baseReconnectDelay: 1000,
    205       maxReconnectDelay: 5000,
    206     });
    207     client.connect();
    208 
    209     // Simulate many reconnects to reach the cap
    210     for (let i = 0; i < 4; i++) {
    211       MockWebSocket.instances[i].simulateClose();
    212       vi.advanceTimersByTime(5000); // enough time for any delay
    213     }
    214 
    215     // Should have created instances: 1 (initial) + 4 reconnects = 5
    216     expect(MockWebSocket.instances).toHaveLength(5);
    217   });
    218 
    219   it("destroy stops reconnection", () => {
    220     const client = new WsClient("ws://localhost:8080/ws");
    221     client.connect();
    222 
    223     MockWebSocket.instances[0].simulateOpen();
    224     client.destroy();
    225 
    226     MockWebSocket.instances[0].simulateClose();
    227     vi.advanceTimersByTime(5000);
    228 
    229     // No reconnect after destroy
    230     expect(MockWebSocket.instances).toHaveLength(1);
    231   });
    232 
    233   it("destroy clears reconnect timer", () => {
    234     const client = new WsClient("ws://localhost:8080/ws");
    235     client.connect();
    236 
    237     // Close to schedule reconnect
    238     MockWebSocket.instances[0].simulateOpen();
    239     MockWebSocket.instances[0].simulateClose();
    240 
    241     // Destroy before timer fires
    242     client.destroy();
    243 
    244     // Wait past the reconnect delay
    245     vi.advanceTimersByTime(5000);
    246 
    247     // No new instance created
    248     expect(MockWebSocket.instances).toHaveLength(1);
    249   });
    250 
    251   it("destroy closes websocket", () => {
    252     const client = new WsClient("ws://localhost:8080/ws");
    253     client.connect();
    254 
    255     const ws = MockWebSocket.instances[0];
    256     client.destroy();
    257 
    258     // After destroy, the ws should have been closed (readyState = 3)
    259     expect(ws.readyState).toBe(3);
    260   });
    261 
    262   it("onopen resets reconnectAttempts", () => {
    263     const client = new WsClient("ws://localhost:8080/ws", {
    264       maxReconnectAttempts: 5,
    265     });
    266     client.connect();
    267 
    268     // Simulate disconnect and reconnect
    269     MockWebSocket.instances[0].simulateOpen();
    270     MockWebSocket.instances[0].simulateClose();
    271     vi.advanceTimersByTime(1000);
    272 
    273     // Second connection opens
    274     MockWebSocket.instances[1].simulateOpen();
    275 
    276     // Disconnect again
    277     MockWebSocket.instances[1].simulateClose();
    278 
    279     // Should use 1s delay (reset) not 2s
    280     vi.advanceTimersByTime(1000);
    281     expect(MockWebSocket.instances).toHaveLength(3);
    282   });
    283 
    284   it("does not reconnect when destroyed flag is set in scheduleReconnect", () => {
    285     const client = new WsClient("ws://localhost:8080/ws", {
    286       maxReconnectAttempts: 5,
    287     });
    288     client.connect();
    289 
    290     MockWebSocket.instances[0].simulateOpen();
    291     MockWebSocket.instances[0].simulateClose();
    292 
    293     // Schedule is pending
    294     client.destroy();
    295 
    296     vi.advanceTimersByTime(1000);
    297     // Timer fires but destroyed is true, so no new connect
    298     expect(MockWebSocket.instances).toHaveLength(1);
    299   });
    300 
    301   it("onerror handler exists but does nothing", () => {
    302     const client = new WsClient("ws://localhost:8080/ws");
    303     client.connect();
    304 
    305     const ws = MockWebSocket.instances[0];
    306     // onerror should not throw
    307     expect(() => ws.simulateError()).not.toThrow();
    308   });
    309 
    310   it("defaults constructor options", () => {
    311     const client = new WsClient("ws://localhost:8080/ws");
    312     client.connect();
    313 
    314     // Should use default maxReconnectAttempts of 10
    315     // Simulate 10 failed reconnects
    316     for (let i = 0; i < 10; i++) {
    317       MockWebSocket.instances[i].simulateClose();
    318       vi.advanceTimersByTime(30000); // maxReconnectDelay
    319     }
    320 
    321     // 11th attempt should not happen
    322     const countBefore = MockWebSocket.instances.length;
    323     MockWebSocket.instances[countBefore - 1].simulateClose();
    324     vi.advanceTimersByTime(30000);
    325     expect(MockWebSocket.instances).toHaveLength(countBefore);
    326   });
    327 });