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 });