UNPKG

wsmini

Version:

Minimalist WebSocket client and server for real-time applications with RPC, PubSub, Rooms and Game state synchronization.

751 lines (631 loc) 24.2 kB
import sinon from 'sinon'; import { expect } from 'chai'; import { JSDOM } from 'jsdom'; import { TextEncoder, TextDecoder } from 'util'; // Setup DOM environment for browser-like behavior const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://example.com' }); global.window = dom.window; global.document = dom.window.document; global.location = dom.window.location; // Add TextEncoder/TextDecoder for string encoding (Node.js built-ins) global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder; // Add btoa/atob for base64 encoding global.btoa = function(str) { return Buffer.from(str, 'binary').toString('base64'); }; global.atob = function(base64) { return Buffer.from(base64, 'base64').toString('binary'); }; // Mock WebSocket class MockWebSocket { constructor(url, protocols) { this.url = url; this.protocols = protocols; this.readyState = 1; // OPEN this.addEventListener = sinon.spy(); this.send = sinon.spy(); this.close = sinon.spy(); this.onMessage = null; this.onError = null; this.onClose = null; } // Simulate receiving a message simulateMessage(data) { const event = { data: JSON.stringify(data) }; if (this.onMessage) { this.onMessage(event); } // Also trigger event listeners this.addEventListener.getCalls() .filter(call => call.args[0] === 'message') .forEach(call => call.args[1](event)); } // Simulate connection error simulateError() { this.addEventListener.getCalls() .filter(call => call.args[0] === 'error') .forEach(call => call.args[1]()); } // Simulate connection close simulateClose() { this.addEventListener.getCalls() .filter(call => call.args[0] === 'close') .forEach(call => call.args[1]()); } } // Mock WebSocket globally global.WebSocket = MockWebSocket; import WSClientRoom from '../../src/websocket/WSClientRoom.js'; describe('WSClientRoom', () => { let wsClientRoom; let sandbox; beforeEach(() => { sandbox = sinon.createSandbox(); wsClientRoom = new WSClientRoom('ws://localhost:8001'); }); afterEach(() => { if (wsClientRoom) { wsClientRoom.close(); } sandbox.restore(); }); describe('Constructor', () => { it('should inherit from WSClient', () => { expect(wsClientRoom).to.be.instanceOf(WSClientRoom); expect(wsClientRoom.url).to.equal('ws://localhost:8001'); expect(wsClientRoom.prefix).to.equal('__room-'); expect(wsClientRoom.unregisterCmdListener).to.be.instanceOf(Map); }); it('should initialize with default prefix', () => { expect(wsClientRoom.prefix).to.equal('__room-'); }); it('should initialize unregisterCmdListener as empty Map', () => { expect(wsClientRoom.unregisterCmdListener.size).to.equal(0); }); }); describe('Room Actions', () => { beforeEach(async () => { const connectPromise = wsClientRoom.connect(); wsClientRoom.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; }); describe('roomCreateOrJoin', () => { it('should create or join room with name', async () => { const roomPromise = wsClientRoom.roomCreateOrJoin('test-room', { welcome: 'message' }); // Verify RPC call was made expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'rpc', name: '__room-createOrJoin', data: { name: 'test-room', msg: { welcome: 'message' } }, id: 0 }); // Simulate successful response wsClientRoom.wsClient.simulateMessage({ action: 'rpc', name: '__room-createOrJoin', response: { name: 'test-room', meta: { capacity: 10 }, clients: ['client1', 'client2'] }, type: 'success', id: 0 }); const room = await roomPromise; expect(room.name).to.equal('test-room'); expect(room.meta).to.deep.equal({ capacity: 10 }); expect(room.clients).to.deep.equal(['client1', 'client2']); }); it('should create or join room without name', async () => { const roomPromise = wsClientRoom.roomCreateOrJoin(); // Verify RPC call was made expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'rpc', name: '__room-createOrJoin', data: { name: null, msg: {} }, id: 0 }); // Simulate successful response wsClientRoom.wsClient.simulateMessage({ action: 'rpc', name: '__room-createOrJoin', response: { name: 'generated-room-123', meta: { capacity: 10 }, clients: [] }, type: 'success', id: 0 }); const room = await roomPromise; expect(room.name).to.equal('generated-room-123'); expect(room.meta).to.deep.equal({ capacity: 10 }); expect(room.clients).to.deep.equal([]); }); }); describe('roomCreate', () => { it('should create room with name', async () => { const roomPromise = wsClientRoom.roomCreate('new-room', { type: 'game' }); // Verify RPC call was made expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'rpc', name: '__room-create', data: { name: 'new-room', msg: { type: 'game' } }, id: 0 }); // Simulate successful response wsClientRoom.wsClient.simulateMessage({ action: 'rpc', name: '__room-create', response: { name: 'new-room', meta: { type: 'game' }, clients: [] }, type: 'success', id: 0 }); const room = await roomPromise; expect(room.name).to.equal('new-room'); expect(room.meta).to.deep.equal({ type: 'game' }); }); }); describe('roomJoin', () => { it('should join existing room', async () => { const roomPromise = wsClientRoom.roomJoin('existing-room', { userId: 123 }); // Verify RPC call was made expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'rpc', name: '__room-join', data: { name: 'existing-room', msg: { userId: 123 } }, id: 0 }); // Simulate successful response wsClientRoom.wsClient.simulateMessage({ action: 'rpc', name: '__room-join', response: { name: 'existing-room', meta: { capacity: 5 }, clients: ['client1', 'client2'] }, type: 'success', id: 0 }); const room = await roomPromise; expect(room.name).to.equal('existing-room'); expect(room.meta).to.deep.equal({ capacity: 5 }); expect(room.clients).to.deep.equal(['client1', 'client2']); }); }); describe('roomLeave', () => { it('should leave room and clean up listeners', async () => { // First join a room to set up listeners const roomPromise = wsClientRoom.roomCreateOrJoin('test-room'); wsClientRoom.wsClient.simulateMessage({ action: 'rpc', name: '__room-createOrJoin', response: { name: 'test-room', meta: {}, clients: [] }, type: 'success', id: 0 }); await roomPromise; // Add some command listeners const callback1 = sinon.spy(); const callback2 = sinon.spy(); wsClientRoom.roomOnCmd('test-room', 'move', callback1); wsClientRoom.roomOnCmd('test-room', 'attack', callback2); // Reset send history wsClientRoom.wsClient.send.resetHistory(); // Leave the room const leavePromise = wsClientRoom.roomLeave('test-room'); // Verify RPC call was made expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'rpc', name: '__room-leave', data: { name: 'test-room' }, id: 1 }); // Simulate successful response wsClientRoom.wsClient.simulateMessage({ action: 'rpc', name: '__room-leave', response: 'left', type: 'success', id: 1 }); const result = await leavePromise; expect(result).to.equal('left'); // Verify listeners were cleaned up expect(wsClientRoom.unregisterCmdListener.has('test-room')).to.be.false; }); }); }); describe('Room Communication', () => { beforeEach(async () => { const connectPromise = wsClientRoom.connect(); wsClientRoom.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; }); describe('roomSend', () => { it('should send message to room', () => { wsClientRoom.roomSend('game-room', { action: 'move', x: 10, y: 20 }); expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'pub-room', room: 'game-room', msg: { action: 'move', x: 10, y: 20 } }); }); it('should send message with empty data', () => { wsClientRoom.roomSend('game-room'); expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'pub-room', room: 'game-room', msg: {} }); }); }); describe('roomSendCmd', () => { it('should send command to room', () => { wsClientRoom.roomSendCmd('game-room', 'move', { x: 10, y: 20 }); expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'pub-room-cmd', cmd: 'move', room: 'game-room', msg: { x: 10, y: 20 } }); }); it('should send command with empty data', () => { wsClientRoom.roomSendCmd('game-room', 'ping'); expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'pub-room-cmd', cmd: 'ping', room: 'game-room', msg: {} }); }); }); }); describe('Room Event Listeners', () => { beforeEach(async () => { const connectPromise = wsClientRoom.connect(); wsClientRoom.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; }); describe('roomOnMessage', () => { it('should register message listener for room', () => { const callback = sinon.spy(); const unregister = wsClientRoom.roomOnMessage('game-room', callback); expect(typeof unregister).to.equal('function'); // Simulate room message wsClientRoom.wsClient.simulateMessage({ action: 'pub', chan: '__room-game-room', msg: { content: 'Hello from room' } }); expect(callback.calledWith({ content: 'Hello from room' })).to.be.true; }); }); describe('roomOnCmd', () => { it('should register command listener for room', () => { const callback = sinon.spy(); const unregister = wsClientRoom.roomOnCmd('game-room', 'move', callback); expect(typeof unregister).to.equal('function'); // Simulate room command wsClientRoom.wsClient.simulateMessage({ action: 'pub-cmd', chan: '__room-game-room', msg: { cmd: 'move', data: { x: 10, y: 20 } } }); expect(callback.calledWith({ x: 10, y: 20 })).to.be.true; // Verify listener was registered for cleanup expect(wsClientRoom.unregisterCmdListener.has('game-room')).to.be.true; expect(wsClientRoom.unregisterCmdListener.get('game-room')).to.have.lengthOf(1); }); it('should manage multiple command listeners for same room', () => { const callback1 = sinon.spy(); const callback2 = sinon.spy(); wsClientRoom.roomOnCmd('game-room', 'move', callback1); wsClientRoom.roomOnCmd('game-room', 'attack', callback2); expect(wsClientRoom.unregisterCmdListener.get('game-room')).to.have.lengthOf(2); }); }); describe('roomOnRooms', () => { it('should get room list and subscribe to updates', async () => { const callback = sinon.spy(); const roomsPromise = wsClientRoom.roomOnRooms(callback); // Verify RPC call was made expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'rpc', name: '__room-list', data: {}, id: 0 }); // Verify subscription was also made expect(wsClientRoom.wsClient.send.calledTwice).to.be.true; const subMessage = JSON.parse(wsClientRoom.wsClient.send.secondCall.args[0]); expect(subMessage).to.deep.equal({ action: 'sub', chan: '__room-list', id: 0 }); // Simulate successful RPC response wsClientRoom.wsClient.simulateMessage({ action: 'rpc', name: '__room-list', response: ['room1', 'room2', 'room3'], type: 'success', id: 0 }); // Simulate subscription response wsClientRoom.wsClient.simulateMessage({ action: 'sub', chan: '__room-list', response: 'subscribed', type: 'success', id: 0 }); await roomsPromise; expect(callback.calledWith(['room1', 'room2', 'room3'])).to.be.true; }); }); describe('roomOnClients', () => { it('should register client list listener for room', () => { const callback = sinon.spy(); const unregister = wsClientRoom.roomOnClients('game-room', callback); expect(typeof unregister).to.equal('function'); // Simulate clients update wsClientRoom.wsClient.simulateMessage({ action: 'pub', chan: '__room-game-room-clients', msg: ['client1', 'client2', 'client3'] }); expect(callback.calledWith(['client1', 'client2', 'client3'])).to.be.true; }); }); }); describe('Room Class', () => { let room; let mockClient; beforeEach(async () => { const connectPromise = wsClientRoom.connect(); wsClientRoom.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; // Create a room const roomPromise = wsClientRoom.roomCreateOrJoin('test-room'); wsClientRoom.wsClient.simulateMessage({ action: 'rpc', name: '__room-createOrJoin', response: { name: 'test-room', meta: { capacity: 10 }, clients: ['client1', 'client2'] }, type: 'success', id: 0 }); room = await roomPromise; }); describe('Constructor', () => { it('should create room with correct properties', () => { expect(room.name).to.equal('test-room'); expect(room.meta).to.deep.equal({ capacity: 10 }); expect(room.clients).to.deep.equal(['client1', 'client2']); expect(room.wsClient).to.equal(wsClientRoom); }); it('should register close listener', () => { const roomOffSpy = sinon.spy(wsClientRoom, '_roomOff'); // Simulate client close wsClientRoom.emit('close'); expect(roomOffSpy.calledWith('test-room')).to.be.true; }); }); describe('send', () => { it('should send message through wsClient', () => { wsClientRoom.wsClient.send.resetHistory(); room.send({ action: 'move', x: 10, y: 20 }); expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'pub-room', room: 'test-room', msg: { action: 'move', x: 10, y: 20 } }); }); }); describe('sendCmd', () => { it('should send command through wsClient', () => { wsClientRoom.wsClient.send.resetHistory(); room.sendCmd('move', { x: 10, y: 20 }); expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'pub-room-cmd', cmd: 'move', room: 'test-room', msg: { x: 10, y: 20 } }); }); it('should send command with empty data', () => { wsClientRoom.wsClient.send.resetHistory(); room.sendCmd('ping'); expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'pub-room-cmd', cmd: 'ping', room: 'test-room', msg: {} }); }); }); describe('leave', () => { it('should leave room through wsClient', () => { wsClientRoom.wsClient.send.resetHistory(); room.leave(); expect(wsClientRoom.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClientRoom.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'rpc', name: '__room-leave', data: { name: 'test-room' }, id: 1 }); }); }); describe('onMessage', () => { it('should register message listener through wsClient', () => { const callback = sinon.spy(); const unregister = room.onMessage(callback); expect(typeof unregister).to.equal('function'); // Simulate room message wsClientRoom.wsClient.simulateMessage({ action: 'pub', chan: '__room-test-room', msg: { content: 'Hello' } }); expect(callback.calledWith({ content: 'Hello' })).to.be.true; }); }); describe('onCmd', () => { it('should register command listener through wsClient', () => { const callback = sinon.spy(); const unregister = room.onCmd('move', callback); expect(typeof unregister).to.equal('function'); // Simulate room command wsClientRoom.wsClient.simulateMessage({ action: 'pub-cmd', chan: '__room-test-room', msg: { cmd: 'move', data: { x: 10, y: 20 } } }); expect(callback.calledWith({ x: 10, y: 20 })).to.be.true; }); }); describe('onClients', () => { it('should call callback with current clients and register listener', () => { const callback = sinon.spy(); const unregister = room.onClients(callback); expect(typeof unregister).to.equal('function'); expect(callback.calledWith(['client1', 'client2'])).to.be.true; // Simulate clients update wsClientRoom.wsClient.simulateMessage({ action: 'pub', chan: '__room-test-room-clients', msg: ['client1', 'client2', 'client3'] }); expect(callback.calledWith(['client1', 'client2', 'client3'])).to.be.true; }); }); }); describe('Cleanup', () => { beforeEach(async () => { const connectPromise = wsClientRoom.connect(); wsClientRoom.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; }); describe('_roomOff', () => { it('should clean up all listeners for room', () => { // Set up some listeners const callback1 = sinon.spy(); const callback2 = sinon.spy(); const unregister1 = wsClientRoom.roomOnCmd('test-room', 'move', callback1); const unregister2 = wsClientRoom.roomOnCmd('test-room', 'attack', callback2); // Verify listeners were registered expect(wsClientRoom.unregisterCmdListener.has('test-room')).to.be.true; expect(wsClientRoom.unregisterCmdListener.get('test-room')).to.have.lengthOf(2); // Clean up wsClientRoom._roomOff('test-room'); // Verify listeners were cleaned up expect(wsClientRoom.unregisterCmdListener.has('test-room')).to.be.false; }); it('should handle cleanup when no listeners exist', () => { expect(() => wsClientRoom._roomOff('nonexistent-room')).to.not.throw; }); }); }); describe('Error Handling', () => { beforeEach(async () => { const connectPromise = wsClientRoom.connect(); wsClientRoom.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; }); it('should handle room creation errors', async () => { const roomPromise = wsClientRoom.roomCreate('existing-room'); // Simulate error response wsClientRoom.wsClient.simulateMessage({ action: 'rpc', name: '__room-create', response: 'Room already exists', type: 'error', id: 0 }); try { await roomPromise; expect.fail('Should have rejected on error response'); } catch (error) { expect(error.message).to.equal('Room already exists'); } }); it('should handle room join errors', async () => { const roomPromise = wsClientRoom.roomJoin('nonexistent-room'); // Simulate error response wsClientRoom.wsClient.simulateMessage({ action: 'rpc', name: '__room-join', response: 'Room not found', type: 'error', id: 0 }); try { await roomPromise; expect.fail('Should have rejected on error response'); } catch (error) { expect(error.message).to.equal('Room not found'); } }); it('should handle room leave errors', async () => { const leavePromise = wsClientRoom.roomLeave('nonexistent-room'); // Simulate error response wsClientRoom.wsClient.simulateMessage({ action: 'rpc', name: '__room-leave', response: 'Room not found', type: 'error', id: 0 }); try { await leavePromise; expect.fail('Should have rejected on error response'); } catch (error) { expect(error.message).to.equal('Room not found'); } }); }); });