import { FakeWebSocket } from '../../test-helpers/FakeWebSocket.js'; import { ResilientSocket } from './ResilientSocket.js'; describe('ResilientSocket', () => { let uninstall: () => void; beforeEach(() => { uninstall = FakeWebSocket.install(); vi.useFakeTimers(); vi.spyOn(console, 'debug').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); uninstall(); }); describe('connecting', () => { it('opens a socket to the given uri', () => { new ResilientSocket('ws://example.test/socket'); expect(FakeWebSocket.instances).toHaveLength(1); expect(FakeWebSocket.last.url).toBe('ws://example.test/socket'); }); it('is not connected until the socket opens', () => { const sut = new ResilientSocket('ws://x'); expect(sut.isConnected).toBe(false); FakeWebSocket.last.emitOpen(); expect(sut.isConnected).toBe(true); }); it('emits open on the first connection only', () => { const sut = new ResilientSocket('ws://x'); const onOpen = vi.fn(); sut.onOpen(onOpen); FakeWebSocket.last.emitOpen(); FakeWebSocket.last.emitOpen(); expect(onOpen).toHaveBeenCalledTimes(1); }); it('forwards messages', () => { const sut = new ResilientSocket('ws://x'); const onMessage = vi.fn(); sut.onMessage(onMessage); FakeWebSocket.last.emitOpen(); FakeWebSocket.last.emitMessage('hello'); expect(onMessage).toHaveBeenCalledTimes(1); expect(onMessage.mock.calls[0][0].data).toBe('hello'); }); }); describe('the message queue', () => { it('sends straight through once connected', () => { const sut = new ResilientSocket('ws://x'); FakeWebSocket.last.emitOpen(); sut.send('hello'); expect(FakeWebSocket.last.sent).toEqual(['hello']); }); it('holds messages sent before the socket opens', () => { const sut = new ResilientSocket('ws://x'); sut.send('hello'); expect(FakeWebSocket.last.sent).toEqual([]); }); // The regression: `for (const m in queue)` iterates *indices*, so this used // to flush the strings "0" and "1" instead of the messages. it('flushes the held messages, not their indices, on open', () => { const sut = new ResilientSocket('ws://x'); sut.send('first'); sut.send('second'); FakeWebSocket.last.emitOpen(); expect(FakeWebSocket.last.sent).toEqual(['first', 'second']); }); it('empties the queue once flushed', () => { const sut = new ResilientSocket('ws://x'); sut.send('once'); FakeWebSocket.last.emitOpen(); FakeWebSocket.last.emitClose(); vi.advanceTimersByTime(1000); FakeWebSocket.last.emitOpen(); expect(FakeWebSocket.last.sent).toEqual([]); }); }); describe('reconnection', () => { it('unbinds the dead socket before retrying', () => { new ResilientSocket('ws://x'); const first = FakeWebSocket.last; first.emitOpen(); first.emitClose(); expect(first.listenerCount('message')).toBe(0); expect(first.listenerCount('close')).toBe(0); }); it('waits the reconnection delay before opening a new socket', () => { new ResilientSocket('ws://x', { reconnectionDelay: 100 }); FakeWebSocket.last.emitOpen(); FakeWebSocket.last.emitClose(); expect(FakeWebSocket.instances).toHaveLength(1); vi.advanceTimersByTime(100); expect(FakeWebSocket.instances).toHaveLength(2); expect(FakeWebSocket.last.url).toBe('ws://x'); }); it('emits reconnect, not open, when the new socket opens', () => { const sut = new ResilientSocket('ws://x', { reconnectionDelay: 100 }); const onOpen = vi.fn(); const onReconnect = vi.fn(); sut.onOpen(onOpen); sut.onReconnect(onReconnect); FakeWebSocket.last.emitOpen(); FakeWebSocket.last.emitClose(); vi.advanceTimersByTime(100); FakeWebSocket.last.emitOpen(); expect(onOpen).toHaveBeenCalledTimes(1); expect(onReconnect).toHaveBeenCalledTimes(1); expect(onReconnect.mock.calls[0][0].disconnectedTime).toBeInstanceOf(Date); }); // The one that made the backoff dead code: `connectionLost` returned early // while `isReconnecting` was still true, so a failed retry never scheduled // another one. One attempt, then silence. it('keeps retrying when the retry itself fails', () => { new ResilientSocket('ws://x', { reconnectionDelay: 100 }); FakeWebSocket.last.emitOpen(); FakeWebSocket.last.emitClose(); vi.advanceTimersByTime(100); expect(FakeWebSocket.instances).toHaveLength(2); FakeWebSocket.last.emitClose(); vi.advanceTimersByTime(200); expect(FakeWebSocket.instances).toHaveLength(3); }); it('doubles the delay on each failed attempt', () => { new ResilientSocket('ws://x', { reconnectionDelay: 100 }); FakeWebSocket.last.emitOpen(); FakeWebSocket.last.emitClose(); vi.advanceTimersByTime(99); expect(FakeWebSocket.instances).toHaveLength(1); vi.advanceTimersByTime(1); expect(FakeWebSocket.instances).toHaveLength(2); FakeWebSocket.last.emitClose(); vi.advanceTimersByTime(199); expect(FakeWebSocket.instances).toHaveLength(2); vi.advanceTimersByTime(1); expect(FakeWebSocket.instances).toHaveLength(3); }); it('resets the delay after a successful reconnection', () => { new ResilientSocket('ws://x', { reconnectionDelay: 100 }); FakeWebSocket.last.emitOpen(); FakeWebSocket.last.emitClose(); vi.advanceTimersByTime(100); FakeWebSocket.last.emitOpen(); FakeWebSocket.last.emitClose(); vi.advanceTimersByTime(100); expect(FakeWebSocket.instances).toHaveLength(3); }); it('gives up after maxReconnectAttempts and stops opening sockets', () => { const sut = new ResilientSocket('ws://x', { reconnectionDelay: 100, maxReconnectAttempts: 2, }); const onError = vi.fn(); sut.onError(onError); FakeWebSocket.last.emitOpen(); // Fail every retry: 2 are allowed, the third must not happen. for (let i = 0; i < 6; i++) { FakeWebSocket.last.emitClose(); vi.advanceTimersByTime(10_000); } expect(FakeWebSocket.instances).toHaveLength(3); expect(onError).toHaveBeenCalled(); }); }); describe('close', () => { it('unbinds and closes the underlying socket', () => { const sut = new ResilientSocket('ws://x'); const socket = FakeWebSocket.last; socket.emitOpen(); sut.close(); expect(socket.closed).toBe(true); expect(socket.listenerCount('message')).toBe(0); }); it('does not reconnect after an explicit close', () => { const sut = new ResilientSocket('ws://x', { reconnectionDelay: 100 }); FakeWebSocket.last.emitOpen(); sut.close(); vi.advanceTimersByTime(10_000); expect(FakeWebSocket.instances).toHaveLength(1); }); }); });