923 lines
29 KiB
JavaScript
923 lines
29 KiB
JavaScript
/**
|
|
* 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' });
|
|
});
|
|
});
|