addons/isl/src/__tests__/LocalWebsocketEventBus.test.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318/* eslint-disable @typescript-eslint/no-explicit-any */
b69ab319/* eslint-disable @typescript-eslint/no-unused-vars */
b69ab3110/* eslint-disable @typescript-eslint/no-this-alias */
b69ab3111
b69ab3112import type {Writable} from 'shared/typeUtils';
b69ab3113import type {LocalWebSocketEventBus as LocalWebSocketEventBusType} from '../LocalWebSocketEventBus';
b69ab3114import type {PlatformName} from '../types';
b69ab3115
b69ab3116const LocalWebSocketEventBus =
b69ab3117 // eslint-disable-next-line @typescript-eslint/consistent-type-imports
b69ab3118 (jest.requireActual('../LocalWebSocketEventBus') as typeof import('../LocalWebSocketEventBus'))
b69ab3119 .LocalWebSocketEventBus;
b69ab3120
b69ab3121let globalMockWs: MockWebSocketImpl;
b69ab3122class MockWebSocketImpl extends EventTarget implements WebSocket {
b69ab3123 constructor(
b69ab3124 public url: string,
b69ab3125 _protocols?: string | string[] | undefined,
b69ab3126 ) {
b69ab3127 super();
b69ab3128 globalMockWs = this as unknown as MockWebSocketImpl; // keep track of each new instance as a global to use in tests
b69ab3129 }
b69ab3130
b69ab3131 binaryType = 'blob' as const;
b69ab3132 bufferedAmount = 0;
b69ab3133 extensions = '';
b69ab3134
b69ab3135 onclose = null;
b69ab3136 onerror = null;
b69ab3137 onmessage = null;
b69ab3138 onopen = null;
b69ab3139 protocol = '';
b69ab3140 readyState = 0;
b69ab3141
b69ab3142 readonly OPEN = 1 as const;
b69ab3143 readonly CONNECTING = 0 as const;
b69ab3144 readonly CLOSED = 3 as const;
b69ab3145 readonly CLOSING = 2 as const;
b69ab3146
b69ab3147 send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
b69ab3148 this.sentMessages.push(data as string);
b69ab3149 }
b69ab3150
b69ab3151 // eslint-disable-next-line @typescript-eslint/no-empty-function
b69ab3152 close(_code?: number, _reason?: string): void {}
b69ab3153
b69ab3154 // -------- Additional APIs for testing --------
b69ab3155
b69ab3156 simulateIncomingMessage(message: string) {
b69ab3157 const e = new Event('message');
b69ab3158 (e as Writable<MessageEvent<string>>).data = message;
b69ab3159 this.dispatchEvent(e);
b69ab3160 }
b69ab3161 simulateServerConnected() {
b69ab3162 this.dispatchEvent(new Event('open'));
b69ab3163 }
b69ab3164 simulateServerDisconnected() {
b69ab3165 this.dispatchEvent(new Event('close'));
b69ab3166 }
b69ab3167
b69ab3168 public sentMessages: Array<string> = [];
b69ab3169}
b69ab3170const MockWebSocket = MockWebSocketImpl as unknown as typeof WebSocket;
b69ab3171
b69ab3172const DEFAULT_HOST = 'localhost:8080';
b69ab3173
b69ab3174function createMessageBus(): LocalWebSocketEventBusType {
b69ab3175 return new LocalWebSocketEventBus(DEFAULT_HOST, MockWebSocket, {
b69ab3176 token: '1234',
b69ab3177 platformName: 'test' as string as PlatformName,
b69ab3178 });
b69ab3179}
b69ab3180
b69ab3181describe('LocalWebsocketEventBus', () => {
b69ab3182 it('opens and sends messages', () => {
b69ab3183 const bus = createMessageBus();
b69ab3184 globalMockWs.simulateServerConnected();
b69ab3185 bus.postMessage('my message');
b69ab3186 expect(globalMockWs.sentMessages).toEqual(['my message']);
b69ab3187 });
b69ab3188
b69ab3189 it('queues messages while connecting', () => {
b69ab3190 const bus = createMessageBus();
b69ab3191 bus.postMessage('first');
b69ab3192 bus.postMessage('second');
b69ab3193 expect(globalMockWs.sentMessages).toEqual([]);
b69ab3194 globalMockWs.simulateServerConnected();
b69ab3195 bus.postMessage('third');
b69ab3196 expect(globalMockWs.sentMessages).toEqual(['first', 'second', 'third']);
b69ab3197 });
b69ab3198
b69ab3199 it('handles incoming messages', () => {
b69ab31100 const bus = createMessageBus();
b69ab31101 const onMessage1 = jest.fn();
b69ab31102 const onMessage2 = jest.fn();
b69ab31103 bus.onMessage(onMessage1);
b69ab31104 bus.onMessage(onMessage2);
b69ab31105
b69ab31106 globalMockWs.simulateServerConnected();
b69ab31107 globalMockWs.simulateIncomingMessage('incoming message');
b69ab31108
b69ab31109 expect(onMessage1).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
b69ab31110 expect(onMessage2).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
b69ab31111 });
b69ab31112
b69ab31113 it('notifies about status', () => {
b69ab31114 const bus = createMessageBus();
b69ab31115 const changeStatus = jest.fn();
b69ab31116 bus.onChangeStatus(changeStatus);
b69ab31117
b69ab31118 expect(changeStatus).toHaveBeenCalledWith({type: 'initializing'});
b69ab31119 globalMockWs.simulateServerConnected();
b69ab31120
b69ab31121 expect(changeStatus).toHaveBeenCalledWith({type: 'open'});
b69ab31122 expect(changeStatus).toHaveBeenCalledTimes(2);
b69ab31123 changeStatus.mockClear();
b69ab31124
b69ab31125 globalMockWs.simulateServerDisconnected();
b69ab31126 expect(changeStatus).toHaveBeenCalledWith({type: 'reconnecting'});
b69ab31127 expect(changeStatus).not.toHaveBeenCalledWith({type: 'open'});
b69ab31128
b69ab31129 globalMockWs.simulateServerConnected();
b69ab31130 expect(changeStatus).toHaveBeenCalledWith({type: 'open'});
b69ab31131 });
b69ab31132
b69ab31133 it('disposes status handlers properly', () => {
b69ab31134 const bus = createMessageBus();
b69ab31135
b69ab31136 const changeStatus1 = jest.fn();
b69ab31137 bus.onChangeStatus(changeStatus1);
b69ab31138
b69ab31139 const changeStatus2 = jest.fn();
b69ab31140 const disposable2 = bus.onChangeStatus(changeStatus2);
b69ab31141
b69ab31142 const changeStatus3 = jest.fn();
b69ab31143 bus.onChangeStatus(changeStatus3);
b69ab31144
b69ab31145 expect(changeStatus1).toHaveBeenCalledWith({type: 'initializing'});
b69ab31146 expect(changeStatus2).toHaveBeenCalledWith({type: 'initializing'});
b69ab31147 expect(changeStatus3).toHaveBeenCalledWith({type: 'initializing'});
b69ab31148
b69ab31149 disposable2.dispose();
b69ab31150
b69ab31151 globalMockWs.simulateServerConnected();
b69ab31152
b69ab31153 expect(changeStatus1).toHaveBeenCalledWith({type: 'open'});
b69ab31154 expect(changeStatus2).not.toHaveBeenCalledWith({type: 'open'});
b69ab31155 expect(changeStatus3).toHaveBeenCalledWith({type: 'open'});
b69ab31156 });
b69ab31157
b69ab31158 it('queues up messages while disconnected', () => {
b69ab31159 const bus = createMessageBus();
b69ab31160 globalMockWs.simulateServerConnected();
b69ab31161
b69ab31162 expect(globalMockWs.sentMessages).toEqual([]);
b69ab31163
b69ab31164 globalMockWs.simulateServerDisconnected();
b69ab31165
b69ab31166 bus.postMessage('hi');
b69ab31167 expect(globalMockWs.sentMessages).toEqual([]);
b69ab31168 globalMockWs.simulateServerConnected();
b69ab31169 expect(globalMockWs.sentMessages).toEqual(['hi']);
b69ab31170 });
b69ab31171
b69ab31172 it('previous onMessage handlers exist after reconnection', () => {
b69ab31173 const bus = createMessageBus();
b69ab31174
b69ab31175 const onMessage = jest.fn();
b69ab31176 bus.onMessage(onMessage);
b69ab31177
b69ab31178 globalMockWs.simulateServerConnected();
b69ab31179
b69ab31180 globalMockWs.simulateIncomingMessage('one');
b69ab31181 expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({data: 'one'}));
b69ab31182
b69ab31183 globalMockWs.simulateServerDisconnected();
b69ab31184 globalMockWs.simulateServerConnected();
b69ab31185
b69ab31186 globalMockWs.simulateIncomingMessage('two');
b69ab31187 expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({data: 'two'}));
b69ab31188 });
b69ab31189
b69ab31190 it('clears queued messages after sending them', () => {
b69ab31191 const bus = createMessageBus();
b69ab31192 globalMockWs.simulateServerConnected();
b69ab31193
b69ab31194 expect(globalMockWs.sentMessages).toEqual([]);
b69ab31195
b69ab31196 globalMockWs.simulateServerDisconnected();
b69ab31197
b69ab31198 bus.postMessage('hi');
b69ab31199 expect(globalMockWs.sentMessages).toEqual([]);
b69ab31200 globalMockWs.simulateServerConnected();
b69ab31201 expect(globalMockWs.sentMessages).toEqual(['hi']);
b69ab31202
b69ab31203 globalMockWs.simulateServerDisconnected();
b69ab31204 globalMockWs.simulateServerConnected();
b69ab31205
b69ab31206 expect(globalMockWs.sentMessages).toEqual(['hi']);
b69ab31207 });
b69ab31208
b69ab31209 it('disposes handlers properly', () => {
b69ab31210 const bus = createMessageBus();
b69ab31211 globalMockWs.simulateServerConnected();
b69ab31212
b69ab31213 const onMessage = jest.fn();
b69ab31214 const disposable = bus.onMessage(onMessage);
b69ab31215
b69ab31216 globalMockWs.simulateServerConnected();
b69ab31217 globalMockWs.simulateIncomingMessage('incoming message');
b69ab31218
b69ab31219 expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
b69ab31220 disposable.dispose();
b69ab31221 globalMockWs.simulateIncomingMessage('another after dispose');
b69ab31222 expect(onMessage).not.toHaveBeenCalledWith(
b69ab31223 expect.objectContaining({data: 'another after dispose'}),
b69ab31224 );
b69ab31225 });
b69ab31226
b69ab31227 it('disposes only one handler at a time', () => {
b69ab31228 const bus = createMessageBus();
b69ab31229 globalMockWs.simulateServerConnected();
b69ab31230
b69ab31231 const onMessage1 = jest.fn();
b69ab31232 bus.onMessage(onMessage1);
b69ab31233
b69ab31234 const onMessage2 = jest.fn();
b69ab31235 const disposable2 = bus.onMessage(onMessage2);
b69ab31236
b69ab31237 const onMessage3 = jest.fn();
b69ab31238 bus.onMessage(onMessage3);
b69ab31239
b69ab31240 globalMockWs.simulateServerConnected();
b69ab31241 globalMockWs.simulateIncomingMessage('incoming message');
b69ab31242
b69ab31243 expect(onMessage1).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
b69ab31244 expect(onMessage2).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
b69ab31245 expect(onMessage3).toHaveBeenCalledWith(expect.objectContaining({data: 'incoming message'}));
b69ab31246
b69ab31247 disposable2.dispose();
b69ab31248
b69ab31249 globalMockWs.simulateIncomingMessage('another after dispose');
b69ab31250 expect(onMessage2).not.toHaveBeenCalledWith(
b69ab31251 expect.objectContaining({data: 'another after dispose'}),
b69ab31252 );
b69ab31253 // the other handlers still active
b69ab31254 expect(onMessage1).toHaveBeenCalledWith(
b69ab31255 expect.objectContaining({data: 'another after dispose'}),
b69ab31256 );
b69ab31257 expect(onMessage3).toHaveBeenCalledWith(
b69ab31258 expect.objectContaining({data: 'another after dispose'}),
b69ab31259 );
b69ab31260 });
b69ab31261
b69ab31262 it('can send messages as soon as connection is created', () => {
b69ab31263 const bus = createMessageBus();
b69ab31264 bus.onChangeStatus(newStatus => {
b69ab31265 if (newStatus.type === 'open') {
b69ab31266 bus.postMessage('message once connected');
b69ab31267 }
b69ab31268 });
b69ab31269 globalMockWs.simulateServerConnected();
b69ab31270
b69ab31271 expect(globalMockWs.sentMessages).toEqual(['message once connected']);
b69ab31272 });
b69ab31273
b69ab31274 it('includes token from initialState', () => {
b69ab31275 createMessageBus();
b69ab31276 expect(globalMockWs.url).toEqual(`ws://${DEFAULT_HOST}/ws?token=1234&platform=test`);
b69ab31277 });
b69ab31278
b69ab31279 describe('reconnect timing', () => {
b69ab31280 beforeEach(() => {
b69ab31281 jest.useFakeTimers();
b69ab31282 });
b69ab31283 afterEach(() => {
b69ab31284 jest.useRealTimers();
b69ab31285 });
b69ab31286
b69ab31287 it('reconnects after a delay', () => {
b69ab31288 createMessageBus();
b69ab31289
b69ab31290 const initialWs = globalMockWs;
b69ab31291
b69ab31292 globalMockWs.simulateServerConnected();
b69ab31293 globalMockWs.simulateServerDisconnected();
b69ab31294 expect(initialWs).toBe(globalMockWs);
b69ab31295 jest.runAllTimers();
b69ab31296 // we have a new WebSocket instance which will re-try to connect
b69ab31297 expect(initialWs).not.toBe(globalMockWs);
b69ab31298 });
b69ab31299
b69ab31300 it("doesn't reconnect after disposing", () => {
b69ab31301 const bus = createMessageBus();
b69ab31302
b69ab31303 const previousWs = globalMockWs;
b69ab31304 globalMockWs.simulateServerConnected();
b69ab31305 bus.dispose();
b69ab31306 globalMockWs.simulateServerDisconnected();
b69ab31307 expect(previousWs).toBe(globalMockWs);
b69ab31308 jest.runAllTimers();
b69ab31309 expect(previousWs).toBe(globalMockWs); // we haven't made a new WebSocket, because we didn't try to reconnect
b69ab31310 });
b69ab31311
b69ab31312 it('reconnects with exponential backoff', () => {
b69ab31313 createMessageBus();
b69ab31314
b69ab31315 const initialWs = globalMockWs;
b69ab31316
b69ab31317 globalMockWs.simulateServerConnected();
b69ab31318 globalMockWs.simulateServerDisconnected();
b69ab31319 expect(initialWs).toBe(globalMockWs);
b69ab31320 jest.advanceTimersByTime(LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
b69ab31321 expect(initialWs).not.toBe(globalMockWs);
b69ab31322
b69ab31323 const nextWs = globalMockWs;
b69ab31324
b69ab31325 // we failed to connect again
b69ab31326 globalMockWs.simulateServerDisconnected();
b69ab31327 // with exponential backoff, we should need to wait another tick
b69ab31328 jest.advanceTimersByTime(LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
b69ab31329 expect(nextWs).toBe(globalMockWs);
b69ab31330 // but after another round, we're past the doubled time
b69ab31331 jest.advanceTimersByTime(LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
b69ab31332 expect(nextWs).not.toBe(globalMockWs);
b69ab31333 });
b69ab31334
b69ab31335 it('resets exponential backoff after a successful connection', () => {
b69ab31336 createMessageBus();
b69ab31337
b69ab31338 globalMockWs.simulateServerConnected();
b69ab31339
b69ab31340 // simulate 2 disconnects, which doubles backoff time
b69ab31341 globalMockWs.simulateServerDisconnected();
b69ab31342 jest.advanceTimersByTime(LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
b69ab31343 globalMockWs.simulateServerDisconnected();
b69ab31344 jest.advanceTimersByTime(2 * LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
b69ab31345
b69ab31346 // now reconnect should reset backoff time
b69ab31347 globalMockWs.simulateServerConnected();
b69ab31348
b69ab31349 const initialWs = globalMockWs;
b69ab31350 globalMockWs.simulateServerDisconnected();
b69ab31351 expect(initialWs).toBe(globalMockWs);
b69ab31352 // advancing by initial reconnect time creates a new ws
b69ab31353 jest.advanceTimersByTime(LocalWebSocketEventBus.DEFAULT_RECONNECT_CHECK_TIME_MS + 10);
b69ab31354 expect(initialWs).not.toBe(globalMockWs);
b69ab31355 });
b69ab31356
b69ab31357 it('caps out exponential backoff at maximum', () => {
b69ab31358 createMessageBus();
b69ab31359
b69ab31360 globalMockWs.simulateServerConnected();
b69ab31361
b69ab31362 // simulate a bunch of unsuccessful reconnects over time
b69ab31363 for (let i = 0; i < 100; i++) {
b69ab31364 globalMockWs.simulateServerDisconnected();
b69ab31365 jest.advanceTimersByTime(LocalWebSocketEventBus.MAX_RECONNECT_CHECK_TIME_MS);
b69ab31366 }
b69ab31367
b69ab31368 // now backoff time should have stopped doubling
b69ab31369
b69ab31370 const initialWs = globalMockWs;
b69ab31371 globalMockWs.simulateServerDisconnected();
b69ab31372 expect(initialWs).toBe(globalMockWs);
b69ab31373 // advancing by anything less than cap of reconnect time doesn't reconnect yet
b69ab31374 jest.advanceTimersByTime(LocalWebSocketEventBus.MAX_RECONNECT_CHECK_TIME_MS - 10);
b69ab31375 expect(initialWs).toBe(globalMockWs);
b69ab31376
b69ab31377 // but just a little further pushes us over the edge and we reconnect
b69ab31378 jest.advanceTimersByTime(20);
b69ab31379 expect(initialWs).not.toBe(globalMockWs);
b69ab31380 });
b69ab31381 });
b69ab31382});