12.6 KB383 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8/* eslint-disable @typescript-eslint/no-explicit-any */
9/* eslint-disable @typescript-eslint/no-unused-vars */
10/* eslint-disable @typescript-eslint/no-this-alias */
11
12import type {Writable} from 'shared/typeUtils';
13import type {LocalWebSocketEventBus as LocalWebSocketEventBusType} from '../LocalWebSocketEventBus';
14import type {PlatformName} from '../types';
15
16const LocalWebSocketEventBus =
17 // eslint-disable-next-line @typescript-eslint/consistent-type-imports
18 (jest.requireActual('../LocalWebSocketEventBus') as typeof import('../LocalWebSocketEventBus'))
19 .LocalWebSocketEventBus;
20
21let globalMockWs: MockWebSocketImpl;
22class MockWebSocketImpl extends EventTarget implements WebSocket {
23 constructor(
24 public url: string,
25 _protocols?: string | string[] | undefined,
26 ) {
27 super();
28 globalMockWs = this as unknown as MockWebSocketImpl; // keep track of each new instance as a global to use in tests
29 }
30
31 binaryType = 'blob' as const;
32 bufferedAmount = 0;
33 extensions = '';
34
35 onclose = null;
36 onerror = null;
37 onmessage = null;
38 onopen = null;
39 protocol = '';
40 readyState = 0;
41
42 readonly OPEN = 1 as const;
43 readonly CONNECTING = 0 as const;
44 readonly CLOSED = 3 as const;
45 readonly CLOSING = 2 as const;
46
47 send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
48 this.sentMessages.push(data as string);
49 }
50
51 // eslint-disable-next-line @typescript-eslint/no-empty-function
52 close(_code?: number, _reason?: string): void {}
53
54 // -------- Additional APIs for testing --------
55
56 simulateIncomingMessage(message: string) {
57 const e = new Event('message');
58 (e as Writable<MessageEvent<string>>).data = message;
59 this.dispatchEvent(e);
60 }
61 simulateServerConnected() {
62 this.dispatchEvent(new Event('open'));
63 }
64 simulateServerDisconnected() {
65 this.dispatchEvent(new Event('close'));
66 }
67
68 public sentMessages: Array<string> = [];
69}
70const MockWebSocket = MockWebSocketImpl as unknown as typeof WebSocket;
71
72const DEFAULT_HOST = 'localhost:8080';
73
74function createMessageBus(): LocalWebSocketEventBusType {
75 return new LocalWebSocketEventBus(DEFAULT_HOST, MockWebSocket, {
76 token: '1234',
77 platformName: 'test' as string as PlatformName,
78 });
79}
80
81describe('LocalWebsocketEventBus', () => {
82 it('opens and sends messages', () => {
83 const bus = createMessageBus();
84 globalMockWs.simulateServerConnected();
85 bus.postMessage('my message');
86 expect(globalMockWs.sentMessages).toEqual(['my message']);
87 });
88
89 it('queues messages while connecting', () => {
90 const bus = createMessageBus();
91 bus.postMessage('first');
92 bus.postMessage('second');
93 expect(globalMockWs.sentMessages).toEqual([]);
94 globalMockWs.simulateServerConnected();
95 bus.postMessage('third');
96 expect(globalMockWs.sentMessages).toEqual(['first', 'second', 'third']);
97 });
98
99 it('handles incoming messages', () => {
100 const bus = createMessageBus();
101 const onMessage1 = jest.fn();
102 const onMessage2 = jest.fn();
103 bus.onMessage(onMessage1);
104 bus.onMessage(onMessage2);
105
106 globalMockWs.simulateServerConnected();
107 globalMockWs.simulateIncomingMessage('incoming message');
108
109 expect(onMessage1).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
110 expect(onMessage2).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
111 });
112
113 it('notifies about status', () => {
114 const bus = createMessageBus();
115 const changeStatus = jest.fn();
116 bus.onChangeStatus(changeStatus);
117
118 expect(changeStatus).toHaveBeenCalledWith({type: 'initializing'});
119 globalMockWs.simulateServerConnected();
120
121 expect(changeStatus).toHaveBeenCalledWith({type: 'open'});
122 expect(changeStatus).toHaveBeenCalledTimes(2);
123 changeStatus.mockClear();
124
125 globalMockWs.simulateServerDisconnected();
126 expect(changeStatus).toHaveBeenCalledWith({type: 'reconnecting'});
127 expect(changeStatus).not.toHaveBeenCalledWith({type: 'open'});
128
129 globalMockWs.simulateServerConnected();
130 expect(changeStatus).toHaveBeenCalledWith({type: 'open'});
131 });
132
133 it('disposes status handlers properly', () => {
134 const bus = createMessageBus();
135
136 const changeStatus1 = jest.fn();
137 bus.onChangeStatus(changeStatus1);
138
139 const changeStatus2 = jest.fn();
140 const disposable2 = bus.onChangeStatus(changeStatus2);
141
142 const changeStatus3 = jest.fn();
143 bus.onChangeStatus(changeStatus3);
144
145 expect(changeStatus1).toHaveBeenCalledWith({type: 'initializing'});
146 expect(changeStatus2).toHaveBeenCalledWith({type: 'initializing'});
147 expect(changeStatus3).toHaveBeenCalledWith({type: 'initializing'});
148
149 disposable2.dispose();
150
151 globalMockWs.simulateServerConnected();
152
153 expect(changeStatus1).toHaveBeenCalledWith({type: 'open'});
154 expect(changeStatus2).not.toHaveBeenCalledWith({type: 'open'});
155 expect(changeStatus3).toHaveBeenCalledWith({type: 'open'});
156 });
157
158 it('queues up messages while disconnected', () => {
159 const bus = createMessageBus();
160 globalMockWs.simulateServerConnected();
161
162 expect(globalMockWs.sentMessages).toEqual([]);
163
164 globalMockWs.simulateServerDisconnected();
165
166 bus.postMessage('hi');
167 expect(globalMockWs.sentMessages).toEqual([]);
168 globalMockWs.simulateServerConnected();
169 expect(globalMockWs.sentMessages).toEqual(['hi']);
170 });
171
172 it('previous onMessage handlers exist after reconnection', () => {
173 const bus = createMessageBus();
174
175 const onMessage = jest.fn();
176 bus.onMessage(onMessage);
177
178 globalMockWs.simulateServerConnected();
179
180 globalMockWs.simulateIncomingMessage('one');
181 expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({data: 'one'}));
182
183 globalMockWs.simulateServerDisconnected();
184 globalMockWs.simulateServerConnected();
185
186 globalMockWs.simulateIncomingMessage('two');
187 expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({data: 'two'}));
188 });
189
190 it('clears queued messages after sending them', () => {
191 const bus = createMessageBus();
192 globalMockWs.simulateServerConnected();
193
194 expect(globalMockWs.sentMessages).toEqual([]);
195
196 globalMockWs.simulateServerDisconnected();
197
198 bus.postMessage('hi');
199 expect(globalMockWs.sentMessages).toEqual([]);
200 globalMockWs.simulateServerConnected();
201 expect(globalMockWs.sentMessages).toEqual(['hi']);
202
203 globalMockWs.simulateServerDisconnected();
204 globalMockWs.simulateServerConnected();
205
206 expect(globalMockWs.sentMessages).toEqual(['hi']);
207 });
208
209 it('disposes handlers properly', () => {
210 const bus = createMessageBus();
211 globalMockWs.simulateServerConnected();
212
213 const onMessage = jest.fn();
214 const disposable = bus.onMessage(onMessage);
215
216 globalMockWs.simulateServerConnected();
217 globalMockWs.simulateIncomingMessage('incoming message');
218
219 expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
220 disposable.dispose();
221 globalMockWs.simulateIncomingMessage('another after dispose');
222 expect(onMessage).not.toHaveBeenCalledWith(
223 expect.objectContaining({data: 'another after dispose'}),
224 );
225 });
226
227 it('disposes only one handler at a time', () => {
228 const bus = createMessageBus();
229 globalMockWs.simulateServerConnected();
230
231 const onMessage1 = jest.fn();
232 bus.onMessage(onMessage1);
233
234 const onMessage2 = jest.fn();
235 const disposable2 = bus.onMessage(onMessage2);
236
237 const onMessage3 = jest.fn();
238 bus.onMessage(onMessage3);
239
240 globalMockWs.simulateServerConnected();
241 globalMockWs.simulateIncomingMessage('incoming message');
242
243 expect(onMessage1).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
244 expect(onMessage2).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
245 expect(onMessage3).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
246
247 disposable2.dispose();
248
249 globalMockWs.simulateIncomingMessage('another after dispose');
250 expect(onMessage2).not.toHaveBeenCalledWith(
251 expect.objectContaining({data: 'another after dispose'}),
252 );
253 // the other handlers still active
254 expect(onMessage1).toHaveBeenCalledWith(
255 expect.objectContaining({data: 'another after dispose'}),
256 );
257 expect(onMessage3).toHaveBeenCalledWith(
258 expect.objectContaining({data: 'another after dispose'}),
259 );
260 });
261
262 it('can send messages as soon as connection is created', () => {
263 const bus = createMessageBus();
264 bus.onChangeStatus(newStatus => {
265 if (newStatus.type === 'open') {
266 bus.postMessage('message once connected');
267 }
268 });
269 globalMockWs.simulateServerConnected();
270
271 expect(globalMockWs.sentMessages).toEqual(['message once connected']);
272 });
273
274 it('includes token from initialState', () => {
275 createMessageBus();
276 expect(globalMockWs.url).toEqual(`ws://${DEFAULT_HOST}/ws?token=1234&platform=test`);
277 });
278
279 describe('reconnect timing', () => {
280 beforeEach(() => {
281 jest.useFakeTimers();
282 });
283 afterEach(() => {
284 jest.useRealTimers();
285 });
286
287 it('reconnects after a delay', () => {
288 createMessageBus();
289
290 const initialWs = globalMockWs;
291
292 globalMockWs.simulateServerConnected();
293 globalMockWs.simulateServerDisconnected();
294 expect(initialWs).toBe(globalMockWs);
295 jest.runAllTimers();
296 // we have a new WebSocket instance which will re-try to connect
297 expect(initialWs).not.toBe(globalMockWs);
298 });
299
300 it("doesn't reconnect after disposing", () => {
301 const bus = createMessageBus();
302
303 const previousWs = globalMockWs;
304 globalMockWs.simulateServerConnected();
305 bus.dispose();
306 globalMockWs.simulateServerDisconnected();
307 expect(previousWs).toBe(globalMockWs);
308 jest.runAllTimers();
309 expect(previousWs).toBe(globalMockWs); // we haven't made a new WebSocket, because we didn't try to reconnect
310 });
311
312 it('reconnects with exponential backoff', () => {
313 createMessageBus();
314
315 const initialWs = globalMockWs;
316
317 globalMockWs.simulateServerConnected();
318 globalMockWs.simulateServerDisconnected();
319 expect(initialWs).toBe(globalMockWs);
320 jest.advanceTimersByTime(LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
321 expect(initialWs).not.toBe(globalMockWs);
322
323 const nextWs = globalMockWs;
324
325 // we failed to connect again
326 globalMockWs.simulateServerDisconnected();
327 // with exponential backoff, we should need to wait another tick
328 jest.advanceTimersByTime(LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
329 expect(nextWs).toBe(globalMockWs);
330 // but after another round, we're past the doubled time
331 jest.advanceTimersByTime(LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
332 expect(nextWs).not.toBe(globalMockWs);
333 });
334
335 it('resets exponential backoff after a successful connection', () => {
336 createMessageBus();
337
338 globalMockWs.simulateServerConnected();
339
340 // simulate 2 disconnects, which doubles backoff time
341 globalMockWs.simulateServerDisconnected();
342 jest.advanceTimersByTime(LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
343 globalMockWs.simulateServerDisconnected();
344 jest.advanceTimersByTime(2 * LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
345
346 // now reconnect should reset backoff time
347 globalMockWs.simulateServerConnected();
348
349 const initialWs = globalMockWs;
350 globalMockWs.simulateServerDisconnected();
351 expect(initialWs).toBe(globalMockWs);
352 // advancing by initial reconnect time creates a new ws
353 jest.advanceTimersByTime(LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
354 expect(initialWs).not.toBe(globalMockWs);
355 });
356
357 it('caps out exponential backoff at maximum', () => {
358 createMessageBus();
359
360 globalMockWs.simulateServerConnected();
361
362 // simulate a bunch of unsuccessful reconnects over time
363 for (let i = 0; i < 100; i++) {
364 globalMockWs.simulateServerDisconnected();
365 jest.advanceTimersByTime(LocalWebSocketEventBus.MAX_RECONNECT_CHECK_TIME_MS);
366 }
367
368 // now backoff time should have stopped doubling
369
370 const initialWs = globalMockWs;
371 globalMockWs.simulateServerDisconnected();
372 expect(initialWs).toBe(globalMockWs);
373 // advancing by anything less than cap of reconnect time doesn't reconnect yet
374 jest.advanceTimersByTime(LocalWebSocketEventBus.MAX_RECONNECT_CHECK_TIME_MS - 10);
375 expect(initialWs).toBe(globalMockWs);
376
377 // but just a little further pushes us over the edge and we reconnect
378 jest.advanceTimersByTime(20);
379 expect(initialWs).not.toBe(globalMockWs);
380 });
381 });
382});
383