UNPKG

wsmini

Version:

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

874 lines (721 loc) 26.4 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 WSClient from '../../src/websocket/WSClient.js'; describe('WSClient', () => { let wsClient; let sandbox; beforeEach(() => { sandbox = sinon.createSandbox(); wsClient = new WSClient('ws://localhost:8001'); }); afterEach(() => { if (wsClient) { wsClient.close(); } sandbox.restore(); }); describe('Constructor', () => { it('should create WSClient with provided URL', () => { const client = new WSClient('ws://test.com:8080'); expect(client.url).to.equal('ws://test.com:8080'); expect(client.defaultTimeout).to.equal(5000); expect(client.wsClient).to.be.null; }); it('should create WSClient with custom timeout', () => { const client = new WSClient('ws://test.com:8080', 10000); expect(client.defaultTimeout).to.equal(10000); }); it('should initialize event system', () => { expect(wsClient.on).to.be.a('function'); expect(wsClient.off).to.be.a('function'); expect(wsClient.emit).to.be.a('function'); expect(wsClient.once).to.be.a('function'); }); it('should initialize ID counters', () => { expect(wsClient.rpcId).to.equal(0); expect(wsClient.pubId).to.equal(0); expect(wsClient.subId).to.equal(0); expect(wsClient.unsubId).to.equal(0); }); }); describe('Connection Management', () => { it('should connect without token', async () => { const connectPromise = wsClient.connect(); // Verify WebSocket was created with correct parameters expect(wsClient.wsClient.constructor.name).to.equal('MockWebSocket'); expect(wsClient.wsClient.url).to.equal('ws://localhost:8001'); expect(wsClient.wsClient.protocols).to.deep.equal(['ws.mini']); // Simulate successful authentication wsClient.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; }); it('should connect with token', async () => { const connectPromise = wsClient.connect('mytoken'); // Verify WebSocket was created expect(wsClient.wsClient.constructor.name).to.equal('MockWebSocket'); // Check that the protocols are correct expect(wsClient.wsClient.protocols).to.have.lengthOf(2); expect(wsClient.wsClient.protocols[0]).to.equal('ws.mini'); expect(wsClient.wsClient.protocols[1]).to.be.a('string'); // base64 encoded token // Simulate successful authentication wsClient.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; }); it('should reject connection with non-string token', async () => { try { await wsClient.connect(123); expect.fail('Should have rejected non-string token'); } catch (error) { expect(error.message).to.equal('The auth token must be a string.'); } }); it('should reject connection on auth failure', async () => { const connectPromise = wsClient.connect(); // Simulate auth failure wsClient.wsClient.simulateMessage({ action: 'auth-failed' }); try { await connectPromise; expect.fail('Should have rejected on auth failure'); } catch (error) { expect(error.message).to.equal('WS auth failed'); } }); it('should reject connection on WebSocket error', async () => { const connectPromise = wsClient.connect(); // Simulate WebSocket error wsClient.wsClient.simulateError(); try { await connectPromise; expect.fail('Should have rejected on WebSocket error'); } catch (error) { expect(error.message).to.equal('WS connection error'); } }); it('should reject connection on WebSocket close', async () => { const connectPromise = wsClient.connect(); // Simulate WebSocket close wsClient.wsClient.simulateClose(); try { await connectPromise; expect.fail('Should have rejected on WebSocket close'); } catch (error) { expect(error.message).to.equal('WS connection closed.'); } }); it('should close connection properly', async () => { const connectPromise = wsClient.connect(); wsClient.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; const closeSpy = sinon.spy(); wsClient.on('close', closeSpy); // Store reference to the mock before closing const mockWebSocket = wsClient.wsClient; wsClient.close(); expect(mockWebSocket.close.called).to.be.true; expect(wsClient.wsClient).to.be.null; expect(closeSpy.called).to.be.true; }); it('should handle close when not connected', () => { expect(() => wsClient.close()).to.not.throw; }); }); describe('Message Handling', () => { beforeEach(async () => { const connectPromise = wsClient.connect(); wsClient.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; }); it('should handle pub messages', () => { const callback = sinon.spy(); wsClient.on('ws:chan:test-channel', callback); wsClient.wsClient.simulateMessage({ action: 'pub', chan: 'test-channel', msg: { content: 'Hello World' } }); expect(callback.calledWith({ content: 'Hello World' })).to.be.true; }); it('should handle pub-cmd messages', () => { const callback = sinon.spy(); wsClient.on('ws:chan-cmd:move:game-room', callback); wsClient.wsClient.simulateMessage({ action: 'pub-cmd', chan: 'game-room', msg: { cmd: 'move', data: { x: 10, y: 20 } } }); expect(callback.calledWith({ x: 10, y: 20 })).to.be.true; }); it('should handle sub confirmation messages', () => { const callback = sinon.spy(); wsClient.on('ws:sub:test-channel', callback); wsClient.wsClient.simulateMessage({ action: 'sub', chan: 'test-channel', response: 'success', type: 'success', id: 1 }); expect(callback.calledWith({ response: 'success', type: 'success', id: 1 })).to.be.true; }); it('should handle unsub confirmation messages', () => { const callback = sinon.spy(); wsClient.on('ws:unsub:test-channel', callback); wsClient.wsClient.simulateMessage({ action: 'unsub', chan: 'test-channel', response: 'success', type: 'success', id: 1 }); expect(callback.calledWith({ response: 'success', type: 'success', id: 1 })).to.be.true; }); it('should handle pub-confirm messages', () => { const callback = sinon.spy(); wsClient.on('ws:pub:test-channel', callback); wsClient.wsClient.simulateMessage({ action: 'pub-confirm', chan: 'test-channel', response: 'success', type: 'success', id: 1 }); expect(callback.calledWith({ response: 'success', type: 'success', id: 1 })).to.be.true; }); it('should handle rpc messages', () => { const callback = sinon.spy(); wsClient.on('ws:rpc:test-method', callback); wsClient.wsClient.simulateMessage({ action: 'rpc', name: 'test-method', response: { result: 'success' }, type: 'success', id: 1 }); expect(callback.calledWith({ response: { result: 'success' }, type: 'success', id: 1 })).to.be.true; }); it('should handle error messages', () => { const callback = sinon.spy(); wsClient.on('ws:error', callback); wsClient.wsClient.simulateMessage({ action: 'error', msg: 'Something went wrong' }); expect(callback.calledWith('Something went wrong')).to.be.true; }); it('should handle cmd messages', () => { const callback = sinon.spy(); wsClient.on('ws:cmd:notification', callback); wsClient.wsClient.simulateMessage({ action: 'cmd', cmd: 'notification', data: { message: 'New notification' } }); expect(callback.calledWith({ message: 'New notification' })).to.be.true; }); it('should handle auth-failed messages', () => { const closeSpy = sinon.spy(wsClient, 'close'); const callback = sinon.spy(); wsClient.on('ws:auth:failed', callback); wsClient.wsClient.simulateMessage({ action: 'auth-failed' }); expect(callback.called).to.be.true; expect(closeSpy.called).to.be.true; }); it('should handle auth-success messages', () => { const callback = sinon.spy(); wsClient.on('ws:auth:success', callback); wsClient.wsClient.simulateMessage({ action: 'auth-success' }); expect(callback.called).to.be.true; }); }); describe('RPC Functionality', () => { beforeEach(async () => { const connectPromise = wsClient.connect(); wsClient.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; }); it('should make successful RPC call', async () => { const rpcPromise = wsClient.rpc('test-method', { param: 'value' }); // Verify message was sent expect(wsClient.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClient.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'rpc', name: 'test-method', data: { param: 'value' }, id: 0 }); // Simulate successful response wsClient.wsClient.simulateMessage({ action: 'rpc', name: 'test-method', response: { result: 'success' }, type: 'success', id: 0 }); const result = await rpcPromise; expect(result).to.deep.equal({ result: 'success' }); }); it('should handle RPC error response', async () => { const rpcPromise = wsClient.rpc('test-method', {}); // Simulate error response wsClient.wsClient.simulateMessage({ action: 'rpc', name: 'test-method', response: 'Method not found', type: 'error', id: 0 }); try { await rpcPromise; expect.fail('Should have rejected on error response'); } catch (error) { expect(error.message).to.equal('Method not found'); } }); it('should handle RPC timeout', async () => { const rpcPromise = wsClient.rpc('test-method', {}, 100); try { await rpcPromise; expect.fail('Should have rejected on timeout'); } catch (error) { expect(error.message).to.include('WS RPC Timeout'); } }); it('should ignore RPC responses with wrong ID', async () => { const rpcPromise = wsClient.rpc('test-method', {}); // Simulate response with wrong ID wsClient.wsClient.simulateMessage({ action: 'rpc', name: 'test-method', response: { result: 'wrong' }, type: 'success', id: 999 }); // Simulate correct response setTimeout(() => { wsClient.wsClient.simulateMessage({ action: 'rpc', name: 'test-method', response: { result: 'correct' }, type: 'success', id: 0 }); }, 10); const result = await rpcPromise; expect(result).to.deep.equal({ result: 'correct' }); }); it('should increment RPC ID', async () => { wsClient.rpc('method1', {}); wsClient.rpc('method2', {}); expect(wsClient.rpcId).to.equal(2); }); }); describe('Pub/Sub Functionality', () => { beforeEach(async () => { const connectPromise = wsClient.connect(); wsClient.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; }); describe('Publishing', () => { it('should publish message successfully', async () => { const pubPromise = wsClient.pub('test-channel', { content: 'Hello' }); // Verify message was sent expect(wsClient.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClient.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'pub', chan: 'test-channel', id: 0, msg: { content: 'Hello' } }); // Simulate successful response wsClient.wsClient.simulateMessage({ action: 'pub-confirm', chan: 'test-channel', response: 'published', type: 'success', id: 0 }); const result = await pubPromise; expect(result).to.equal('published'); }); it('should handle publish error', async () => { const pubPromise = wsClient.pub('test-channel', { content: 'Hello' }); // Simulate error response wsClient.wsClient.simulateMessage({ action: 'pub-confirm', chan: 'test-channel', response: 'Channel not found', type: 'error', id: 0 }); try { await pubPromise; expect.fail('Should have rejected on error response'); } catch (error) { expect(error.message).to.equal('Channel not found'); } }); it('should handle publish timeout', async () => { const pubPromise = wsClient.pub('test-channel', { content: 'Hello' }, 100); try { await pubPromise; expect.fail('Should have rejected on timeout'); } catch (error) { expect(error.message).to.include('WS Pub Timeout'); } }); it('should publish simple message', () => { wsClient.pubSimple('test-channel', { content: 'Hello' }); expect(wsClient.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClient.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'pub-simple', chan: 'test-channel', id: 0, msg: { content: 'Hello' } }); }); it('should increment pub ID', () => { wsClient.pub('channel1', {}); wsClient.pub('channel2', {}); expect(wsClient.pubId).to.equal(2); }); }); describe('Subscription', () => { it('should subscribe to channel successfully', async () => { const callback = sinon.spy(); const subPromise = wsClient.sub('test-channel', callback); // Verify message was sent expect(wsClient.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClient.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'sub', chan: 'test-channel', id: 0 }); // Simulate successful response wsClient.wsClient.simulateMessage({ action: 'sub', chan: 'test-channel', response: 'subscribed', type: 'success', id: 0 }); const result = await subPromise; expect(result).to.equal('subscribed'); }); it('should handle subscription error', async () => { const callback = sinon.spy(); const subPromise = wsClient.sub('test-channel', callback); // Simulate error response wsClient.wsClient.simulateMessage({ action: 'sub', chan: 'test-channel', response: 'Channel not found', type: 'error', id: 0 }); try { await subPromise; expect.fail('Should have rejected on error response'); } catch (error) { expect(error.message).to.equal('Channel not found'); } }); it('should handle subscription timeout', async () => { const callback = sinon.spy(); const subPromise = wsClient.sub('test-channel', callback, 100); try { await subPromise; expect.fail('Should have rejected on timeout'); } catch (error) { expect(error.message).to.include('WS Sub Timeout'); } }); it('should not send duplicate subscription', async () => { const callback1 = sinon.spy(); const callback2 = sinon.spy(); // First subscription const subPromise1 = wsClient.sub('test-channel', callback1); wsClient.wsClient.simulateMessage({ action: 'sub', chan: 'test-channel', response: 'subscribed', type: 'success', id: 0 }); await subPromise1; // Second subscription should not send another message wsClient.wsClient.send.resetHistory(); const result = await wsClient.sub('test-channel', callback2); expect(wsClient.wsClient.send.called).to.be.false; expect(result).to.equal('Subscribed'); }); }); describe('Unsubscription', () => { it('should unsubscribe from channel', async () => { const callback = sinon.spy(); // First subscribe const subPromise = wsClient.sub('test-channel', callback); wsClient.wsClient.simulateMessage({ action: 'sub', chan: 'test-channel', response: 'subscribed', type: 'success', id: 0 }); await subPromise; // Then unsubscribe wsClient.wsClient.send.resetHistory(); const unsubPromise = wsClient.unsub('test-channel'); // Verify message was sent expect(wsClient.wsClient.send.called).to.be.true; const sentMessage = JSON.parse(wsClient.wsClient.send.firstCall.args[0]); expect(sentMessage).to.deep.equal({ action: 'unsub', chan: 'test-channel', id: 0 }); // Simulate successful response wsClient.wsClient.simulateMessage({ action: 'unsub', chan: 'test-channel', response: 'unsubscribed', type: 'success', id: 0 }); const result = await unsubPromise; expect(result).to.equal('unsubscribed'); }); it('should unsubscribe specific callback', async () => { const callback1 = sinon.spy(); const callback2 = sinon.spy(); // Subscribe with multiple callbacks const subPromise1 = wsClient.sub('test-channel', callback1); wsClient.wsClient.simulateMessage({ action: 'sub', chan: 'test-channel', response: 'subscribed', type: 'success', id: 0 }); await subPromise1; const subPromise2 = wsClient.sub('test-channel', callback2); // This should not send another message, so no need to simulate response await subPromise2; // Unsubscribe specific callback wsClient.wsClient.send.resetHistory(); const result = await wsClient.unsub('test-channel', callback1); expect(wsClient.wsClient.send.called).to.be.false; expect(result).to.equal('Unsubscribed'); }); it('should handle unsubscribe error', async () => { const callback = sinon.spy(); // First subscribe const subPromise = wsClient.sub('test-channel', callback); wsClient.wsClient.simulateMessage({ action: 'sub', chan: 'test-channel', response: 'subscribed', type: 'success', id: 0 }); await subPromise; // Then try to unsubscribe const unsubPromise = wsClient.unsub('test-channel'); // Simulate error response wsClient.wsClient.simulateMessage({ action: 'unsub', chan: 'test-channel', response: 'Channel not found', type: 'error', id: 0 }); try { await unsubPromise; expect.fail('Should have rejected on error response'); } catch (error) { expect(error.message).to.equal('Channel not found'); } }); it('should handle unsubscribe timeout', async () => { const callback = sinon.spy(); // First subscribe const subPromise = wsClient.sub('test-channel', callback); wsClient.wsClient.simulateMessage({ action: 'sub', chan: 'test-channel', response: 'subscribed', type: 'success', id: 0 }); await subPromise; // Then try to unsubscribe with short timeout const unsubPromise = wsClient.unsub('test-channel', null, 100); try { await unsubPromise; expect.fail('Should have rejected on timeout'); } catch (error) { expect(error.message).to.include('WS Unsub Timeout'); } }); }); }); describe('Command Handling', () => { beforeEach(async () => { const connectPromise = wsClient.connect(); wsClient.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; }); it('should register command callback', () => { const callback = sinon.spy(); const unregister = wsClient.onCmd('notification', callback); expect(typeof unregister).to.equal('function'); // Simulate command message wsClient.wsClient.simulateMessage({ action: 'cmd', cmd: 'notification', data: { message: 'Hello' } }); expect(callback.calledWith({ message: 'Hello' })).to.be.true; }); it('should unregister command callback', () => { const callback = sinon.spy(); const unregister = wsClient.onCmd('notification', callback); // Unregister the callback unregister(); // Simulate command message wsClient.wsClient.simulateMessage({ action: 'cmd', cmd: 'notification', data: { message: 'Hello' } }); expect(callback.called).to.be.false; }); }); describe('Edge Cases', () => { it('should handle malformed JSON messages gracefully', async () => { const connectPromise = wsClient.connect(); wsClient.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; // This should not throw an error expect(() => { const event = { data: 'invalid json' }; wsClient.onMessage(event); }).to.throw(); }); it('should handle unknown message actions', async () => { const connectPromise = wsClient.connect(); wsClient.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; // This should not throw an error expect(() => { wsClient.wsClient.simulateMessage({ action: 'unknown-action', data: 'some data' }); }).to.not.throw(); }); it('should handle multiple rapid RPC calls', async () => { const connectPromise = wsClient.connect(); wsClient.wsClient.simulateMessage({ action: 'auth-success' }); await connectPromise; const promises = []; for (let i = 0; i < 5; i++) { promises.push(wsClient.rpc(`method${i}`, {})); } // Simulate responses with a slight delay to ensure all promises are set up setTimeout(() => { for (let i = 0; i < 5; i++) { wsClient.wsClient.simulateMessage({ action: 'rpc', name: `method${i}`, response: { result: i }, type: 'success', id: i }); } }, 100); const results = await Promise.all(promises); expect(results).to.have.lengthOf(5); results.forEach((result, index) => { expect(result.result).to.equal(index); }); }); }); });