Skip to content

Commit fa42455

Browse files
authored
test(integration): add WebSocket + OfflineQueue integration test (#88)
1 parent 38f2abc commit fa42455

1 file changed

Lines changed: 290 additions & 0 deletions

File tree

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { WebSocketManager } from '../../src/websocket/index.js';
3+
4+
/**
5+
* Integration: WebSocket + OfflineQueue
6+
*
7+
* Tests the combined flow of WebSocketManager's built-in message queue
8+
* and reconnection logic:
9+
* - Messages queued while disconnected
10+
* - Queued messages flushed on reconnect
11+
* - Disconnect mid-flush preserves remaining messages
12+
* - Concurrent reconnect + queue operations
13+
*/
14+
15+
type MockWsInstance = {
16+
readyState: number;
17+
binaryType: string;
18+
close: ReturnType<typeof vi.fn>;
19+
send: ReturnType<typeof vi.fn>;
20+
onopen: (() => void) | null;
21+
onclose: ((e: { code: number; reason: string }) => void) | null;
22+
onerror: ((e: Event) => void) | null;
23+
onmessage: ((e: { data: string | ArrayBuffer | Blob }) => void) | null;
24+
};
25+
26+
describe('WebSocket + OfflineQueue', () => {
27+
let mockWsInstance: MockWsInstance;
28+
let originalWs: typeof WebSocket;
29+
30+
beforeEach(() => {
31+
vi.useFakeTimers();
32+
33+
const MockWebSocket = vi.fn().mockImplementation(function () {
34+
const instance = {
35+
readyState: 0,
36+
binaryType: 'blob',
37+
close: vi.fn(),
38+
send: vi.fn(),
39+
onopen: null as (() => void) | null,
40+
onclose: null as unknown as MockWsInstance['onclose'],
41+
onerror: null as unknown as MockWsInstance['onerror'],
42+
onmessage: null as unknown as MockWsInstance['onmessage'],
43+
};
44+
mockWsInstance = instance;
45+
return instance;
46+
});
47+
// @ts-expect-error - Mock WebSocket constants
48+
MockWebSocket.CONNECTING = 0;
49+
// @ts-expect-error - Mock WebSocket constants
50+
MockWebSocket.OPEN = 1;
51+
// @ts-expect-error - Mock WebSocket constants
52+
MockWebSocket.CLOSING = 2;
53+
// @ts-expect-error - Mock WebSocket constants
54+
MockWebSocket.CLOSED = 3;
55+
56+
originalWs = globalThis.WebSocket;
57+
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
58+
});
59+
60+
afterEach(() => {
61+
vi.useRealTimers();
62+
globalThis.WebSocket = originalWs;
63+
});
64+
65+
function simulateOpen(instance: MockWsInstance = mockWsInstance): void {
66+
instance.readyState = 1;
67+
instance.onopen?.();
68+
}
69+
70+
function simulateClose(
71+
instance: MockWsInstance = mockWsInstance,
72+
code = 1006,
73+
reason = ''
74+
): void {
75+
instance.readyState = 3;
76+
instance.onclose?.({ code, reason });
77+
}
78+
79+
it('should queue messages while WebSocket is disconnected', () => {
80+
const ws = WebSocketManager.create({
81+
url: 'wss://example.com',
82+
queueMessages: true,
83+
maxQueueSize: 50,
84+
reconnect: false,
85+
});
86+
87+
expect(ws.send({ type: 'msg', id: 1 })).toBe(true);
88+
expect(ws.send({ type: 'msg', id: 2 })).toBe(true);
89+
expect(ws.send({ type: 'msg', id: 3 })).toBe(true);
90+
91+
// Verify messages were queued by connecting and checking flush
92+
ws.connect();
93+
simulateOpen();
94+
expect(mockWsInstance.send).toHaveBeenCalledTimes(3);
95+
});
96+
97+
it('should flush queued messages on reconnect in FIFO order', () => {
98+
const ws = WebSocketManager.create({
99+
url: 'wss://example.com',
100+
queueMessages: true,
101+
reconnect: false,
102+
});
103+
104+
ws.send({ type: 'first' });
105+
ws.send({ type: 'second' });
106+
ws.send({ type: 'third' });
107+
108+
ws.connect();
109+
simulateOpen();
110+
111+
expect(mockWsInstance.send).toHaveBeenCalledTimes(3);
112+
expect(mockWsInstance.send).toHaveBeenNthCalledWith(1, '{"type":"first"}');
113+
expect(mockWsInstance.send).toHaveBeenNthCalledWith(2, '{"type":"second"}');
114+
expect(mockWsInstance.send).toHaveBeenNthCalledWith(3, '{"type":"third"}');
115+
});
116+
117+
it('should reconnect and flush queued messages after unexpected disconnect', async () => {
118+
const ws = WebSocketManager.create({
119+
url: 'wss://example.com',
120+
reconnect: true,
121+
reconnectDelay: 100,
122+
maxReconnectAttempts: 3,
123+
queueMessages: true,
124+
});
125+
126+
ws.connect();
127+
simulateOpen();
128+
expect(ws.state).toBe('connected');
129+
130+
simulateClose();
131+
132+
ws.send({ type: 'queued-during-reconnect', id: 1 });
133+
ws.send({ type: 'queued-during-reconnect', id: 2 });
134+
expect(ws.state).toBe('reconnecting');
135+
136+
await vi.advanceTimersByTimeAsync(100);
137+
simulateOpen();
138+
139+
expect(ws.state).toBe('connected');
140+
expect(mockWsInstance.send).toHaveBeenCalledWith('{"type":"queued-during-reconnect","id":1}');
141+
expect(mockWsInstance.send).toHaveBeenCalledWith('{"type":"queued-during-reconnect","id":2}');
142+
});
143+
144+
it('should stop flushing and preserve remaining messages on disconnect mid-flush', () => {
145+
const ws = WebSocketManager.create({
146+
url: 'wss://example.com',
147+
queueMessages: true,
148+
reconnect: false,
149+
});
150+
151+
ws.send({ id: 1 });
152+
ws.send({ id: 2 });
153+
ws.send({ id: 3 });
154+
155+
ws.connect();
156+
157+
// Mock send to fail on second call, simulating connection drop during flush
158+
let sendCount = 0;
159+
mockWsInstance.send.mockImplementation(() => {
160+
sendCount++;
161+
if (sendCount >= 2) {
162+
mockWsInstance.readyState = 3;
163+
throw new Error('Connection closed');
164+
}
165+
});
166+
167+
simulateOpen();
168+
169+
expect(sendCount).toBe(2);
170+
171+
mockWsInstance.send.mockReset();
172+
mockWsInstance.send.mockImplementation(() => {});
173+
174+
// Reconnect — remaining messages should be flushed
175+
ws.connect();
176+
simulateOpen();
177+
178+
const sentMessages = mockWsInstance.send.mock.calls.map((call: unknown[]) => call[0] as string);
179+
expect(sentMessages).toContain('{"id":2}');
180+
expect(sentMessages).toContain('{"id":3}');
181+
});
182+
183+
it('should handle concurrent queue operations during reconnect cycle', async () => {
184+
const stateChanges: string[] = [];
185+
186+
const ws = WebSocketManager.create({
187+
url: 'wss://example.com',
188+
reconnect: true,
189+
reconnectDelay: 50,
190+
maxReconnectAttempts: 5,
191+
queueMessages: true,
192+
maxQueueSize: 100,
193+
});
194+
195+
ws.onStateChange((s) => stateChanges.push(s));
196+
197+
ws.connect();
198+
simulateOpen();
199+
200+
ws.send({ phase: 'connected', id: 1 });
201+
202+
simulateClose();
203+
204+
ws.send({ phase: 'reconnecting', id: 2 });
205+
ws.send({ phase: 'reconnecting', id: 3 });
206+
207+
await vi.advanceTimersByTimeAsync(50);
208+
209+
ws.send({ phase: 'connecting', id: 4 });
210+
211+
simulateOpen();
212+
213+
const sentMessages = mockWsInstance.send.mock.calls.map((call: unknown[]) => call[0] as string);
214+
expect(sentMessages).toContain('{"phase":"reconnecting","id":2}');
215+
expect(sentMessages).toContain('{"phase":"reconnecting","id":3}');
216+
expect(sentMessages).toContain('{"phase":"connecting","id":4}');
217+
218+
expect(stateChanges).toContain('connected');
219+
expect(stateChanges).toContain('reconnecting');
220+
});
221+
222+
it('should respect maxQueueSize during offline period', () => {
223+
const ws = WebSocketManager.create({
224+
url: 'wss://example.com',
225+
queueMessages: true,
226+
maxQueueSize: 3,
227+
reconnect: false,
228+
});
229+
230+
expect(ws.send({ id: 1 })).toBe(true);
231+
expect(ws.send({ id: 2 })).toBe(true);
232+
expect(ws.send({ id: 3 })).toBe(true);
233+
expect(ws.send({ id: 4 })).toBe(false);
234+
});
235+
236+
it('should not queue messages when queueMessages is disabled', () => {
237+
const ws = WebSocketManager.create({
238+
url: 'wss://example.com',
239+
queueMessages: false,
240+
reconnect: false,
241+
});
242+
243+
expect(ws.send({ id: 1 })).toBe(false);
244+
});
245+
246+
it('should reset reconnect attempts on successful reconnection', async () => {
247+
const ws = WebSocketManager.create({
248+
url: 'wss://example.com',
249+
reconnect: true,
250+
reconnectDelay: 50,
251+
maxReconnectAttempts: 10,
252+
});
253+
254+
ws.connect();
255+
simulateOpen();
256+
expect(ws.reconnectAttempts).toBe(0);
257+
258+
simulateClose();
259+
expect(ws.state).toBe('reconnecting');
260+
expect(ws.reconnectAttempts).toBe(1);
261+
262+
await vi.advanceTimersByTimeAsync(50);
263+
simulateOpen();
264+
265+
expect(ws.state).toBe('connected');
266+
expect(ws.reconnectAttempts).toBe(0);
267+
});
268+
269+
it('should give up reconnecting after max attempts', async () => {
270+
const ws = WebSocketManager.create({
271+
url: 'wss://example.com',
272+
reconnect: true,
273+
reconnectDelay: 10,
274+
reconnectMultiplier: 1,
275+
maxReconnectAttempts: 2,
276+
});
277+
278+
ws.connect();
279+
simulateOpen();
280+
simulateClose();
281+
282+
await vi.advanceTimersByTimeAsync(10);
283+
simulateClose();
284+
285+
await vi.advanceTimersByTimeAsync(10);
286+
simulateClose();
287+
288+
expect(ws.state).toBe('disconnected');
289+
});
290+
});

0 commit comments

Comments
 (0)