/** * Unit tests for WebSocket client functionality * Tests connection, reconnection, authentication, error handling, and message dispatch */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Mock WebSocket class class MockWebSocket { constructor(url) { this.url = url; this.readyState = MockWebSocket.CONNECTING; this.CONNECTING = 0; this.OPEN = 1; this.CLOSING = 2; this.CLOSED = 3; // Event handlers this.onopen = null; this.onclose = null; this.onerror = null; this.onmessage = null; // Store instance for testing MockWebSocket._lastInstance = this; // Auto-connect after a tick setTimeout(() => { if (this.readyState === MockWebSocket.CONNECTING) { this.readyState = MockWebSocket.OPEN; if (this.onopen) this.onopen({ type: 'open' }); } }, 0); } send(data) { if (this.readyState !== MockWebSocket.OPEN) { throw new Error('WebSocket is not open'); } this._lastSent = data; } close(code, reason) { this.readyState = MockWebSocket.CLOSING; setTimeout(() => { this.readyState = MockWebSocket.CLOSED; if (this.onclose) { this.onclose({ type: 'close', code: code || 1000, reason: reason || '', wasClean: code === 1000 }); } }, 0); } // Test helper: simulate message received _simulateMessage(data) { if (this.onmessage) { this.onmessage({ type: 'message', data: typeof data === 'string' ? data : JSON.stringify(data) }); } } // Test helper: simulate error _simulateError(error) { if (this.onerror) { this.onerror({ type: 'error', error: error || new Error('WebSocket error') }); } } // Test helper: simulate connection close _simulateClose(code = 1006, reason = 'Connection lost') { this.readyState = MockWebSocket.CLOSED; if (this.onclose) { this.onclose({ type: 'close', code, reason, wasClean: false }); } } } // Static properties MockWebSocket.CONNECTING = 0; MockWebSocket.OPEN = 1; MockWebSocket.CLOSING = 2; MockWebSocket.CLOSED = 3; // Import WebSocket client code (we'll need to evaluate it) // For testing, we'll load the actual file let WebSocketClient; describe('WebSocket Client - Initialization', () => { beforeEach(() => { // Mock global WebSocket global.WebSocket = MockWebSocket; // Clear any timers vi.useFakeTimers(); // Load WebSocketClient class by evaluating the source // In a real setup, this would be imported const sourceCode = ` class WebSocketClient { constructor(url, options = {}) { this.url = url; this.ws = null; this.isConnected = false; this.reconnectAttempts = 0; this.maxReconnectAttempts = options.maxReconnectAttempts || 5; this.reconnectDelay = options.reconnectDelay || 1000; this.autoReconnect = options.autoReconnect !== false; this.eventHandlers = new Map(); this.messageQueue = []; this.rooms = new Set(); } getWebSocketUrl() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; return \`\${protocol}//\${host}\${this.url}\`; } connect() { try { const wsUrl = this.getWebSocketUrl(); this.ws = new WebSocket(wsUrl); this.ws.onopen = (event) => { this.isConnected = true; this.reconnectAttempts = 0; this.emit('connect'); this.rejoinRooms(); this.processMessageQueue(); }; this.ws.onmessage = (event) => { this.handleMessage(event); }; this.ws.onerror = (event) => { console.error('WebSocket error:', event); this.emit('error', event.error || new Error('WebSocket error')); }; this.ws.onclose = (event) => { this.isConnected = false; this.emit('disconnect', event.reason); if (this.autoReconnect && !event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; const delay = this.reconnectDelay * this.reconnectAttempts; console.log(\`Reconnecting in \${delay}ms (attempt \${this.reconnectAttempts}/\${this.maxReconnectAttempts})...\`); setTimeout(() => this.connect(), delay); } else if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.emit('reconnect_failed'); } }; } catch (error) { console.error('Failed to create WebSocket:', error); this.emit('error', error); } } disconnect() { if (this.ws) { this.autoReconnect = false; this.ws.close(1000, 'Client disconnect'); } } handleMessage(event) { try { const message = JSON.parse(event.data); const { type, ...data } = message; if (type) { this.emit(type, data); } } catch (error) { console.error('Failed to parse message:', error); this.emit('error', error); } } on(event, handler) { if (!this.eventHandlers.has(event)) { this.eventHandlers.set(event, []); } this.eventHandlers.get(event).push(handler); } off(event, handler) { if (this.eventHandlers.has(event)) { const handlers = this.eventHandlers.get(event); const index = handlers.indexOf(handler); if (index !== -1) { handlers.splice(index, 1); } } } emit(event, data) { if (this.eventHandlers.has(event)) { this.eventHandlers.get(event).forEach(handler => { try { handler(data); } catch (error) { console.error(\`Error in event handler for '\${event}':\`, error); } }); } } send(action, data) { const message = JSON.stringify({ action, ...data }); if (this.connected()) { this.ws.send(message); } else { this.messageQueue.push(message); } } join(room) { this.rooms.add(room); if (this.connected()) { this.send('join', { room }); } } leave(room) { this.rooms.delete(room); if (this.connected()) { this.send('leave', { room }); } } rejoinRooms() { this.rooms.forEach(room => { this.send('join', { room }); }); } processMessageQueue() { while (this.messageQueue.length > 0 && this.connected()) { const message = this.messageQueue.shift(); this.ws.send(message); } } connected() { return this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN; } } function io(url) { const client = new WebSocketClient(url); client.connect(); return client; } globalThis.WebSocketClient = WebSocketClient; globalThis.io = io; `; eval(sourceCode); WebSocketClient = globalThis.WebSocketClient; }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); delete globalThis.WebSocketClient; delete globalThis.io; }); it('should create WebSocket client with default options', () => { const client = new WebSocketClient('/ws'); expect(client.url).toBe('/ws'); expect(client.maxReconnectAttempts).toBe(5); expect(client.reconnectDelay).toBe(1000); expect(client.autoReconnect).toBe(true); expect(client.isConnected).toBe(false); expect(client.reconnectAttempts).toBe(0); }); it('should create WebSocket client with custom options', () => { const client = new WebSocketClient('/ws', { maxReconnectAttempts: 10, reconnectDelay: 2000, autoReconnect: false }); expect(client.maxReconnectAttempts).toBe(10); expect(client.reconnectDelay).toBe(2000); expect(client.autoReconnect).toBe(false); }); it('should initialize empty event handlers map', () => { const client = new WebSocketClient('/ws'); expect(client.eventHandlers).toBeInstanceOf(Map); expect(client.eventHandlers.size).toBe(0); }); it('should initialize empty message queue', () => { const client = new WebSocketClient('/ws'); expect(client.messageQueue).toEqual([]); }); it('should initialize empty rooms set', () => { const client = new WebSocketClient('/ws'); expect(client.rooms).toBeInstanceOf(Set); expect(client.rooms.size).toBe(0); }); }); describe('WebSocket Client - Connection', () => { beforeEach(() => { global.WebSocket = MockWebSocket; vi.useFakeTimers(); // Mock window.location global.window = { location: { protocol: 'http:', host: 'localhost:8000' } }; const sourceCode = `${/* Same source as above */}`; eval(sourceCode); WebSocketClient = globalThis.WebSocketClient; }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); delete global.window; }); it('should generate correct WebSocket URL with http protocol', () => { const client = new WebSocketClient('/ws/updates'); expect(client.getWebSocketUrl()).toBe('ws://localhost:8000/ws/updates'); }); it('should generate correct WebSocket URL with https protocol', () => { global.window.location.protocol = 'https:'; const client = new WebSocketClient('/ws/updates'); expect(client.getWebSocketUrl()).toBe('wss://localhost:8000/ws/updates'); }); it('should create WebSocket connection on connect()', async () => { const client = new WebSocketClient('/ws'); client.connect(); expect(MockWebSocket._lastInstance).toBeDefined(); expect(MockWebSocket._lastInstance.url).toBe('ws://localhost:8000/ws'); }); it('should emit connect event when connection opens', async () => { const client = new WebSocketClient('/ws'); const connectHandler = vi.fn(); client.on('connect', connectHandler); client.connect(); await vi.runAllTimersAsync(); expect(connectHandler).toHaveBeenCalledTimes(1); expect(client.isConnected).toBe(true); }); it('should reset reconnect attempts on successful connection', async () => { const client = new WebSocketClient('/ws'); client.reconnectAttempts = 3; client.connect(); await vi.runAllTimersAsync(); expect(client.reconnectAttempts).toBe(0); }); }); describe('WebSocket Client - Reconnection Logic', () => { beforeEach(() => { global.WebSocket = MockWebSocket; vi.useFakeTimers(); global.window = { location: { protocol: 'http:', host: 'localhost:8000' } }; }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); delete global.window; }); it('should attempt reconnection after unclean close', async () => { const client = new WebSocketClient('/ws'); client.connect(); await vi.runAllTimersAsync(); // Simulate connection loss const ws = MockWebSocket._lastInstance; ws._simulateClose(1006, 'Connection lost'); await vi.runAllTimersAsync(); // Should trigger reconnection after delay expect(client.reconnectAttempts).toBe(1); }); it('should use exponential backoff for reconnection delays', async () => { const client = new WebSocketClient('/ws', { reconnectDelay: 1000 }); client.connect(); await vi.runAllTimersAsync(); // First reconnection attempt: 1000ms delay let ws = MockWebSocket._lastInstance; ws._simulateClose(1006); vi.advanceTimersByTime(999); expect(client.reconnectAttempts).toBe(1); vi.advanceTimersByTime(1); await vi.runAllTimersAsync(); // Second reconnection attempt: 2000ms delay ws = MockWebSocket._lastInstance; ws._simulateClose(1006); vi.advanceTimersByTime(1999); expect(client.reconnectAttempts).toBe(2); vi.advanceTimersByTime(1); await vi.runAllTimersAsync(); // Third reconnection attempt: 3000ms delay expect(client.reconnectAttempts).toBe(3); }); it('should stop reconnecting after max attempts', async () => { const client = new WebSocketClient('/ws', { maxReconnectAttempts: 3, reconnectDelay: 100 }); const reconnectFailedHandler = vi.fn(); client.on('reconnect_failed', reconnectFailedHandler); client.connect(); await vi.runAllTimersAsync(); // Simulate 3 connection failures for (let i = 0; i < 3; i++) { const ws = MockWebSocket._lastInstance; ws._simulateClose(1006); await vi.runAllTimersAsync(); } expect(client.reconnectAttempts).toBe(3); expect(reconnectFailedHandler).toHaveBeenCalledTimes(1); // Should not attempt another reconnection const attemptsBefore = client.reconnectAttempts; await vi.advanceTimersByTimeAsync(5000); expect(client.reconnectAttempts).toBe(attemptsBefore); }); it('should not reconnect after clean disconnect', async () => { const client = new WebSocketClient('/ws'); client.connect(); await vi.runAllTimersAsync(); const attemptsBefore = client.reconnectAttempts; client.disconnect(); await vi.runAllTimersAsync(); expect(client.reconnectAttempts).toBe(attemptsBefore); expect(client.autoReconnect).toBe(false); }); it('should not reconnect when autoReconnect is disabled', async () => { const client = new WebSocketClient('/ws', { autoReconnect: false }); client.connect(); await vi.runAllTimersAsync(); const ws = MockWebSocket._lastInstance; ws._simulateClose(1006); await vi.runAllTimersAsync(); expect(client.reconnectAttempts).toBe(0); }); }); describe('WebSocket Client - Event Handling', () => { beforeEach(() => { global.WebSocket = MockWebSocket; vi.useFakeTimers(); global.window = { location: { protocol: 'http:', host: 'localhost:8000' } }; }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it('should register event handlers', () => { const client = new WebSocketClient('/ws'); const handler = vi.fn(); client.on('test_event', handler); expect(client.eventHandlers.has('test_event')).toBe(true); expect(client.eventHandlers.get('test_event')).toContain(handler); }); it('should register multiple handlers for same event', () => { const client = new WebSocketClient('/ws'); const handler1 = vi.fn(); const handler2 = vi.fn(); client.on('test_event', handler1); client.on('test_event', handler2); expect(client.eventHandlers.get('test_event').length).toBe(2); }); it('should emit events to registered handlers', () => { const client = new WebSocketClient('/ws'); const handler = vi.fn(); client.on('test_event', handler); client.emit('test_event', { message: 'test' }); expect(handler).toHaveBeenCalledWith({ message: 'test' }); }); it('should remove event handlers with off()', () => { const client = new WebSocketClient('/ws'); const handler = vi.fn(); client.on('test_event', handler); client.off('test_event', handler); client.emit('test_event', { message: 'test' }); expect(handler).not.toHaveBeenCalled(); }); it('should handle errors in event handlers gracefully', () => { const client = new WebSocketClient('/ws'); const errorHandler = vi.fn(() => { throw new Error('Handler error'); }); const normalHandler = vi.fn(); client.on('test_event', errorHandler); client.on('test_event', normalHandler); // Should not throw, should continue to next handler expect(() => client.emit('test_event', {})).not.toThrow(); expect(errorHandler).toHaveBeenCalled(); expect(normalHandler).toHaveBeenCalled(); }); }); describe('WebSocket Client - Message Handling', () => { beforeEach(() => { global.WebSocket = MockWebSocket; vi.useFakeTimers(); global.window = { location: { protocol: 'http:', host: 'localhost:8000' } }; }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it('should parse and emit JSON messages', async () => { const client = new WebSocketClient('/ws'); const handler = vi.fn(); client.on('download_progress', handler); client.connect(); await vi.runAllTimersAsync(); const ws = MockWebSocket._lastInstance; ws._simulateMessage({ type: 'download_progress', episode_id: '123', progress: 50 }); expect(handler).toHaveBeenCalledWith({ episode_id: '123', progress: 50 }); }); it('should handle malformed JSON messages', async () => { const client = new WebSocketClient('/ws'); const errorHandler = vi.fn(); client.on('error', errorHandler); client.connect(); await vi.runAllTimersAsync(); const ws = MockWebSocket._lastInstance; ws._simulateMessage('not valid json{'); expect(errorHandler).toHaveBeenCalled(); }); it('should emit error for messages without type', async () => { const client = new WebSocketClient('/ws'); const testHandler = vi.fn(); client.on('test', testHandler); client.connect(); await vi.runAllTimersAsync(); const ws = MockWebSocket._lastInstance; ws._simulateMessage({ data: 'no type field' }); // Should not emit to any handler without type expect(testHandler).not.toHaveBeenCalled(); }); }); describe('WebSocket Client - Message Queueing', () => { beforeEach(() => { global.WebSocket = MockWebSocket; vi.useFakeTimers(); global.window = { location: { protocol: 'http:', host: 'localhost:8000' } }; }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it('should queue messages when disconnected', () => { const client = new WebSocketClient('/ws'); client.send('test_action', { data: 'test' }); expect(client.messageQueue.length).toBe(1); expect(client.messageQueue[0]).toContain('test_action'); }); it('should send messages immediately when connected', async () => { const client = new WebSocketClient('/ws'); client.connect(); await vi.runAllTimersAsync(); const ws = MockWebSocket._lastInstance; client.send('test_action', { data: 'test' }); expect(ws._lastSent).toContain('test_action'); expect(client.messageQueue.length).toBe(0); }); it('should process queued messages on reconnection', async () => { const client = new WebSocketClient('/ws'); // Queue messages while disconnected client.send('action1', { data: '1' }); client.send('action2', { data: '2' }); expect(client.messageQueue.length).toBe(2); // Connect and process queue client.connect(); await vi.runAllTimersAsync(); expect(client.messageQueue.length).toBe(0); }); }); describe('WebSocket Client - Room Management', () => { beforeEach(() => { global.WebSocket = MockWebSocket; vi.useFakeTimers(); global.window = { location: { protocol: 'http:', host: 'localhost:8000' } }; }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it('should add room to rooms set on join', () => { const client = new WebSocketClient('/ws'); client.join('downloads'); expect(client.rooms.has('downloads')).toBe(true); }); it('should send join message when connected', async () => { const client = new WebSocketClient('/ws'); client.connect(); await vi.runAllTimersAsync(); const ws = MockWebSocket._lastInstance; client.join('downloads'); expect(ws._lastSent).toContain('join'); expect(ws._lastSent).toContain('downloads'); }); it('should remove room from rooms set on leave', async () => { const client = new WebSocketClient('/ws'); client.join('downloads'); client.leave('downloads'); expect(client.rooms.has('downloads')).toBe(false); }); it('should rejoin all rooms on reconnection', async () => { const client = new WebSocketClient('/ws', { reconnectDelay: 100 }); client.join('downloads'); client.join('progress'); client.connect(); await vi.runAllTimersAsync(); // Simulate disconnect and reconnect let ws = MockWebSocket._lastInstance; ws._simulateClose(1006); await vi.runAllTimersAsync(); // Check that join messages were sent for both rooms ws = MockWebSocket._lastInstance; const sentMessages = []; const originalSend = ws.send.bind(ws); ws.send = (data) => { sentMessages.push(data); return originalSend(data); }; // Trigger rejoin client.rejoinRooms(); expect(sentMessages.some(msg => msg.includes('downloads'))).toBe(true); expect(sentMessages.some(msg => msg.includes('progress'))).toBe(true); }); }); describe('WebSocket Client - Error Handling', () => { beforeEach(() => { global.WebSocket = MockWebSocket; vi.useFakeTimers(); global.window = { location: { protocol: 'http:', host: 'localhost:8000' } }; }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it('should emit error event on WebSocket error', async () => { const client = new WebSocketClient('/ws'); const errorHandler = vi.fn(); client.on('error', errorHandler); client.connect(); await vi.runAllTimersAsync(); const ws = MockWebSocket._lastInstance; ws._simulateError(new Error('Connection failed')); expect(errorHandler).toHaveBeenCalled(); }); it('should emit disconnect event on connection close', async () => { const client = new WebSocketClient('/ws'); const disconnectHandler = vi.fn(); client.on('disconnect', disconnectHandler); client.connect(); await vi.runAllTimersAsync(); const ws = MockWebSocket._lastInstance; ws._simulateClose(1006, 'Connection lost'); expect(disconnectHandler).toHaveBeenCalledWith('Connection lost'); }); it('should set isConnected to false on close', async () => { const client = new WebSocketClient('/ws'); client.connect(); await vi.runAllTimersAsync(); expect(client.isConnected).toBe(true); const ws = MockWebSocket._lastInstance; ws._simulateClose(1000); await vi.runAllTimersAsync(); expect(client.isConnected).toBe(false); }); }); describe('WebSocket Client - Connection State', () => { beforeEach(() => { global.WebSocket = MockWebSocket; vi.useFakeTimers(); global.window = { location: { protocol: 'http:', host: 'localhost:8000' } }; }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it('should return false when not connected', () => { const client = new WebSocketClient('/ws'); expect(client.connected()).toBe(false); }); it('should return true when connected', async () => { const client = new WebSocketClient('/ws'); client.connect(); await vi.runAllTimersAsync(); expect(client.connected()).toBe(true); }); it('should return false after disconnection', async () => { const client = new WebSocketClient('/ws'); client.connect(); await vi.runAllTimersAsync(); client.disconnect(); await vi.runAllTimersAsync(); expect(client.connected()).toBe(false); }); }); describe('WebSocket Client - Socket.IO Compatibility', () => { beforeEach(() => { global.WebSocket = MockWebSocket; vi.useFakeTimers(); global.window = { location: { protocol: 'http:', host: 'localhost:8000' } }; }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it('should create and connect client using io() function', async () => { const client = globalThis.io('/ws'); await vi.runAllTimersAsync(); expect(client).toBeInstanceOf(WebSocketClient); expect(client.isConnected).toBe(true); }); it('should support Socket.IO-like event interface', async () => { const client = globalThis.io('/ws'); const handler = vi.fn(); client.on('test', handler); await vi.runAllTimersAsync(); client.emit('test', { data: 'value' }); expect(handler).toHaveBeenCalledWith({ data: 'value' }); }); });