From 4fab617266b94e1c84235e355bad1e86687c1850 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 1 Oct 2024 23:19:54 +0000 Subject: [PATCH 1/5] chore(release): v0.36.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 09efdd07..0aa2450c 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.1", "main": "./lib/node/index.js", "module": "./lib/node/index.mjs", "types": "./lib/node/index.d.ts", From 3c1cc28f3f2d7859f2117bbb0198feb06ba69935 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 3 Oct 2024 13:58:39 +0200 Subject: [PATCH 2/5] fix(WebSocket): allow reusing the same event listener (#653) --- .../WebSocket/WebSocketClientConnection.ts | 21 ++--- .../WebSocket/WebSocketServerConnection.ts | 20 ++--- .../websocket.reuse-listeners.test.ts | 76 +++++++++++++++++++ 3 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 test/modules/WebSocket/compliance/websocket.reuse-listeners.test.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/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..d833d0c7 --- /dev/null +++ b/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts @@ -0,0 +1,76 @@ +// @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) + + queueMicrotask(() => 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) +}) From be4b9ada24e04c7fcb6aabfc234792990f62708f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 3 Oct 2024 12:01:06 +0000 Subject: [PATCH 3/5] chore(release): v0.36.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0aa2450c..41fd3984 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.1", + "version": "0.36.2", "main": "./lib/node/index.js", "module": "./lib/node/index.mjs", "types": "./lib/node/index.d.ts", From 9988d7340049a8cc923a0dbe68659735ce463af8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 4 Oct 2024 17:09:27 +0200 Subject: [PATCH 4/5] fix(WebSocket): prevent dispatching "open" event if the connection was closed (#654) --- .../WebSocket/WebSocketOverride.ts | 12 ++- .../websocket.reuse-listeners.test.ts | 9 ++- .../exchange/websocket.readystate.test.ts | 80 +++++++++++++++++++ .../WebSocket/utils/waitForNextTick.ts | 2 +- 4 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 test/modules/WebSocket/exchange/websocket.readystate.test.ts 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/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts b/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts index d833d0c7..53fa4640 100644 --- a/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts +++ b/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts @@ -34,7 +34,14 @@ it('allows reusing the same function for multiple client listeners', async () => client.addEventListener('message', clientMessageListener) client.addEventListener('message', clientMessageListener) - queueMicrotask(() => client.close()) + /** + * @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') 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()) }) } From 39e58a2ec0360512628518fcaa5735a2c1224b33 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 4 Oct 2024 15:11:52 +0000 Subject: [PATCH 5/5] chore(release): v0.36.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 41fd3984..74637fa9 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.2", + "version": "0.36.3", "main": "./lib/node/index.js", "module": "./lib/node/index.mjs", "types": "./lib/node/index.d.ts",