diff --git a/package.json b/package.json index 0fe2fa12..a72a8799 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mswjs/interceptors", "description": "Low-level HTTP/HTTPS/XHR/fetch request interception library.", - "version": "0.36.0", + "version": "0.36.3", "main": "./lib/node/index.js", "module": "./lib/node/index.mjs", "types": "./lib/node/index.d.ts", diff --git a/src/interceptors/WebSocket/WebSocketClientConnection.ts b/src/interceptors/WebSocket/WebSocketClientConnection.ts index 3f5e4b61..0e1749cf 100644 --- a/src/interceptors/WebSocket/WebSocketClientConnection.ts +++ b/src/interceptors/WebSocket/WebSocketClientConnection.ts @@ -86,18 +86,21 @@ export class WebSocketClientConnection listener: WebSocketEventListener, options?: AddEventListenerOptions | boolean ): void { - const boundListener = listener.bind(this.socket) - - // Store the bound listener on the original listener - // so the exact bound function can be accessed in "removeEventListener()". - Object.defineProperty(listener, kBoundListener, { - value: boundListener, - enumerable: false, - }) + if (!Reflect.has(listener, kBoundListener)) { + const boundListener = listener.bind(this.socket) + + // Store the bound listener on the original listener + // so the exact bound function can be accessed in "removeEventListener()". + Object.defineProperty(listener, kBoundListener, { + value: boundListener, + enumerable: false, + configurable: false, + }) + } this[kEmitter].addEventListener( type, - boundListener as EventListener, + Reflect.get(listener, kBoundListener) as EventListener, options ) } diff --git a/src/interceptors/WebSocket/WebSocketOverride.ts b/src/interceptors/WebSocket/WebSocketOverride.ts index 5ddf1393..8edb1728 100644 --- a/src/interceptors/WebSocket/WebSocketOverride.ts +++ b/src/interceptors/WebSocket/WebSocketOverride.ts @@ -58,8 +58,6 @@ export class WebSocketOverride extends EventTarget implements WebSocket { return } - this.readyState = this.OPEN - this.protocol = typeof protocols === 'string' ? protocols @@ -67,7 +65,15 @@ export class WebSocketOverride extends EventTarget implements WebSocket { ? protocols[0] : '' - this.dispatchEvent(bindEvent(this, new Event('open'))) + /** + * @note Check that nothing has prevented this connection + * (e.g. called `client.close()` in the connection listener). + * If the connection has been prevented, never dispatch the open event,. + */ + if (this.readyState === this.CONNECTING) { + this.readyState = this.OPEN + this.dispatchEvent(bindEvent(this, new Event('open'))) + } }) } diff --git a/src/interceptors/WebSocket/WebSocketServerConnection.ts b/src/interceptors/WebSocket/WebSocketServerConnection.ts index 0f90d9f6..e4c72c7a 100644 --- a/src/interceptors/WebSocket/WebSocketServerConnection.ts +++ b/src/interceptors/WebSocket/WebSocketServerConnection.ts @@ -169,18 +169,20 @@ export class WebSocketServerConnection { listener: WebSocketEventListener, options?: AddEventListenerOptions | boolean ): void { - const boundListener = listener.bind(this.client) - - // Store the bound listener on the original listener - // so the exact bound function can be accessed in "removeEventListener()". - Object.defineProperty(listener, kBoundListener, { - value: boundListener, - enumerable: false, - }) + if (!Reflect.has(listener, kBoundListener)) { + const boundListener = listener.bind(this.client) + + // Store the bound listener on the original listener + // so the exact bound function can be accessed in "removeEventListener()". + Object.defineProperty(listener, kBoundListener, { + value: boundListener, + enumerable: false, + }) + } this[kEmitter].addEventListener( event, - boundListener as EventListener, + Reflect.get(listener, kBoundListener) as EventListener, options ) } diff --git a/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts b/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts new file mode 100644 index 00000000..53fa4640 --- /dev/null +++ b/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment node-with-websocket +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { WebSocketServer } from 'ws' +import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' +import { getWsUrl } from '../utils/getWsUrl' + +const wsServer = new WebSocketServer({ + host: '127.0.0.1', + port: 0, +}) + +const interceptor = new WebSocketInterceptor() + +beforeAll(async () => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() + wsServer.clients.forEach((client) => client.close()) +}) + +afterAll(async () => { + interceptor.dispose() + wsServer.close() +}) + +it('allows reusing the same function for multiple client listeners', async () => { + const clientMessageListener = vi.fn() + + interceptor.on('connection', ({ client }) => { + client.addEventListener('message', clientMessageListener) + client.addEventListener('message', clientMessageListener) + client.addEventListener('message', clientMessageListener) + + /** + * @note Use `process.nextTick()` because `queueMicrotask()` has a + * higher priority. We need the connection to open, handle messages, + * and then close. + */ + process.nextTick(() => { + client.close() + }) + }) + + const socket = new WebSocket('wss://example.com') + socket.onopen = () => socket.send('hello world') + + await waitForWebSocketEvent('close', socket) + + /** + * @note The same event listner for the same event is deduped. + * It will only be called once. That is correct. + */ + expect(clientMessageListener).toHaveBeenCalledTimes(1) +}) + +it('allows reusing the same function for multiple server listeners', async () => { + wsServer.once('connection', (ws) => { + ws.send('hello from server') + queueMicrotask(() => ws.close()) + }) + + const serverMessageListener = vi.fn() + + interceptor.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + server.addEventListener('message', serverMessageListener) + server.addEventListener('message', serverMessageListener) + }) + + const socket = new WebSocket(getWsUrl(wsServer)) + + await waitForWebSocketEvent('close', socket) + + /** + * @note The same event listner for the same event is deduped. + * It will only be called once. That is correct. + */ + expect(serverMessageListener).toHaveBeenCalledTimes(1) +}) diff --git a/test/modules/WebSocket/exchange/websocket.readystate.test.ts b/test/modules/WebSocket/exchange/websocket.readystate.test.ts new file mode 100644 index 00000000..6e634f5b --- /dev/null +++ b/test/modules/WebSocket/exchange/websocket.readystate.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment node-with-websocket +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' +import { waitForNextTick } from '../utils/waitForNextTick' + +const interceptor = new WebSocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('dispatches the connection even when the client "readyState" is OPEN', async () => { + const readyStateListener = vi.fn<[number]>() + + interceptor.on('connection', ({ client }) => { + // CONNECTING. + readyStateListener(client.socket.readyState) + + /** + * @note Use `process.nextTick()` because it has lower priority + * than `queueMicrotask()`. If you queue a microtask here, it will + * run BEFORE a queued microtask that dispatches the open event. + */ + process.nextTick(() => { + readyStateListener(client.socket.readyState) + client.close() + }) + }) + + const socket = new WebSocket('wss://localhost') + await waitForWebSocketEvent('close', socket) + + // The client ready state must be OPEN when the connection listener is called. + expect(readyStateListener).toHaveBeenNthCalledWith(1, WebSocket.CONNECTING) + expect(readyStateListener).toHaveBeenNthCalledWith(2, WebSocket.OPEN) + expect(readyStateListener).toHaveBeenCalledTimes(2) +}) + +it('updates "readyState" correctly when closing the connection in the interceptor', async () => { + const readyStateListener = vi.fn<[number]>() + + interceptor.on('connection', ({ client }) => { + // CONNECTING. + readyStateListener(client.socket.readyState) + + client.close() + + // CLOSING. + readyStateListener(client.socket.readyState) + + process.nextTick(() => { + // CLOSED. + readyStateListener(client.socket.readyState) + }) + }) + + const openEventListener = vi.fn() + const socket = new WebSocket('wss://localhost') + socket.onopen = openEventListener + await waitForWebSocketEvent('close', socket) + + expect(readyStateListener).toHaveBeenNthCalledWith(1, WebSocket.CONNECTING) + // Must set ready state to CLOSING in the same frame as "client.close()". + expect(readyStateListener).toHaveBeenNthCalledWith(2, WebSocket.CLOSING) + + await waitForNextTick() + // Must set ready state to CLOSED in the next frame. + expect(readyStateListener).toHaveBeenNthCalledWith(3, WebSocket.CLOSED) + + expect(openEventListener).not.toHaveBeenCalled() +}) diff --git a/test/modules/WebSocket/utils/waitForNextTick.ts b/test/modules/WebSocket/utils/waitForNextTick.ts index fcedcd97..0e9e583a 100644 --- a/test/modules/WebSocket/utils/waitForNextTick.ts +++ b/test/modules/WebSocket/utils/waitForNextTick.ts @@ -1,5 +1,5 @@ export async function waitForNextTick(): Promise { return new Promise((resolve) => { - queueMicrotask(() => resolve()) + process.nextTick(() => resolve()) }) }