From a94fe816b222b2624e543e0113a317c92165ba9d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 3 Feb 2024 16:31:19 +0100 Subject: [PATCH 01/69] test(matchRequestUrl): add ws scheme tests --- .../utils/matching/matchRequestUrl.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/core/utils/matching/matchRequestUrl.test.ts b/src/core/utils/matching/matchRequestUrl.test.ts index 737c54594..4a5de24d6 100644 --- a/src/core/utils/matching/matchRequestUrl.test.ts +++ b/src/core/utils/matching/matchRequestUrl.test.ts @@ -61,6 +61,50 @@ describe('matchRequestUrl', () => { expect(match).toHaveProperty('matches', false) expect(match).toHaveProperty('params', {}) }) + + test('returns true for matching WebSocket URL', () => { + expect( + matchRequestUrl(new URL('ws://test.mswjs.io'), 'ws://test.mswjs.io'), + ).toEqual({ + matches: true, + params: {}, + }) + expect( + matchRequestUrl(new URL('wss://test.mswjs.io'), 'wss://test.mswjs.io'), + ).toEqual({ + matches: true, + params: {}, + }) + }) + + test('returns false for non-matching WebSocket URL', () => { + expect( + matchRequestUrl(new URL('ws://test.mswjs.io'), 'ws://foo.mswjs.io'), + ).toEqual({ + matches: false, + params: {}, + }) + expect( + matchRequestUrl(new URL('wss://test.mswjs.io'), 'wss://completely.diff'), + ).toEqual({ + matches: false, + params: {}, + }) + }) + + test('returns path parameters when matched a WebSocket URL', () => { + expect( + matchRequestUrl( + new URL('wss://test.mswjs.io'), + 'wss://:service.mswjs.io', + ), + ).toEqual({ + matches: true, + params: { + service: 'test', + }, + }) + }) }) describe('coercePath', () => { From f1a7f12a9a490aea0e0d8baa0ce4d5bf53fbd37c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 3 Feb 2024 16:31:27 +0100 Subject: [PATCH 02/69] chore(ws): draft public api --- pnpm-lock.yaml | 22 ++-------------- src/core/ws.ts | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 20 deletions(-) create mode 100644 src/core/ws.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f70a7215f..583ef3afe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/cookie': 2.0.0 '@bundled-es-modules/statuses': 1.0.1 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.25.15 + '@mswjs/interceptors': link:../interceptors '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1071,18 +1071,6 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.25.15: - resolution: {integrity: sha512-s4jdyxmq1eeftfDXJ7MUiK/jlvYaU8Sr75+42hHCVBrYez0k51RHbMitKIKdmsF92Q6gwhp8Sm1MmvdA9llpcg==} - engines: {node: '>=18'} - dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.2 - strict-event-emitter: 0.5.1 - dev: false - /@nodelib/fs.scandir/2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1106,13 +1094,7 @@ packages: /@open-draft/deferred-promise/2.2.0: resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - - /@open-draft/logger/0.3.0: - resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} - dependencies: - is-node-process: 1.2.0 - outvariant: 1.4.2 - dev: false + dev: true /@open-draft/test-server/0.4.2: resolution: {integrity: sha512-J9wbdQkPx5WKcDNtgfnXsx5ew4UJd6BZyGr89YlHeaUkOShkO2iO5QIyCCsG4qpjIvr2ZTkEYJA9ujOXXyO6Pg==} diff --git a/src/core/ws.ts b/src/core/ws.ts new file mode 100644 index 000000000..c164aefd7 --- /dev/null +++ b/src/core/ws.ts @@ -0,0 +1,71 @@ +import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' +import { matchRequestUrl, type Path } from './utils/matching/matchRequestUrl' + +/** + * Intercept outgoing WebSocket connections to the given URL. + * @param url WebSocket server URL + */ +function createWebSocketHandler(url: Path) { + /** + * @note I think the handler should initialize the interceptor. + * This way, no WebSocket class stubs will be applied unless + * you use a single "ws" handler. Interceptors are deduped. + */ + const interceptor = new WebSocketInterceptor() + interceptor.apply() + + /** + * @todo Should this maybe live in the "setup" function + * and then emit ONE intercepted connection to MANY "ws" + * handlers? That way: + * - The order of listeners still matters (consistency). + * - The `.use()` makes sense. + * + * The challenge: only apply the interceptor when at least + * ONE "ws.link()" has been created. + */ + interceptor.on('connection', (connection) => { + const match = matchRequestUrl(connection.client.url, url) + + // For WebSocket connections that don't match the given + // URL predicate, open them as-is and forward all messages. + if (!match.matches) { + connection.server.connect() + connection.client.on('message', (event) => { + connection.server.send(event.data) + }) + return + } + + /** @todo Those that match, route to the public API */ + }) + + /** @fixme Dispose of the interceptor. */ + + return { + /** + * @fixme Don't expose these directly. The exposed interface + * must be decoupled from the interceptor to support + * "removeAllEvents" and such. + */ + on: interceptor.on.bind(interceptor), + off: interceptor.off.bind(interceptor), + removeAllListeners: interceptor.removeAllListeners.bind(interceptor), + } +} + +export const ws = { + link: createWebSocketHandler, +} + +// +// + +const chat = ws.link('wss://*.service.com') + +chat.on('connection', ({ client }) => { + client.on('message', (event) => { + console.log(event.data) + client.send('Hello from server!') + }) +}) From d5a9b4c3ba02c63dcaa32e6735226949da03d558 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 7 Feb 2024 11:16:47 +0100 Subject: [PATCH 03/69] chore: design "ws.link" api --- src/core/ws.ts | 155 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 102 insertions(+), 53 deletions(-) diff --git a/src/core/ws.ts b/src/core/ws.ts index c164aefd7..58b0afe0e 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -1,71 +1,120 @@ -import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' -import { matchRequestUrl, type Path } from './utils/matching/matchRequestUrl' +import { Emitter } from 'strict-event-emitter' +import { + WebSocketInterceptor, + type WebSocketEventMap, + type WebSocketClientConnection, + type WebSocketServerConnection, +} from '@mswjs/interceptors/WebSocket' +import { + type Path, + matchRequestUrl, + PathParams, +} from './utils/matching/matchRequestUrl' /** - * Intercept outgoing WebSocket connections to the given URL. - * @param url WebSocket server URL + * @fixme Will each "ws" import create a NEW intereptor? + * Consider moving this away and reusing. */ -function createWebSocketHandler(url: Path) { - /** - * @note I think the handler should initialize the interceptor. - * This way, no WebSocket class stubs will be applied unless - * you use a single "ws" handler. Interceptors are deduped. - */ - const interceptor = new WebSocketInterceptor() - interceptor.apply() +const interceptor = new WebSocketInterceptor() +const emitter = new EventTarget() - /** - * @todo Should this maybe live in the "setup" function - * and then emit ONE intercepted connection to MANY "ws" - * handlers? That way: - * - The order of listeners still matters (consistency). - * - The `.use()` makes sense. - * - * The challenge: only apply the interceptor when at least - * ONE "ws.link()" has been created. - */ - interceptor.on('connection', (connection) => { - const match = matchRequestUrl(connection.client.url, url) - - // For WebSocket connections that don't match the given - // URL predicate, open them as-is and forward all messages. - if (!match.matches) { - connection.server.connect() - connection.client.on('message', (event) => { - connection.server.send(event.data) - }) - return - } - - /** @todo Those that match, route to the public API */ +interceptor.on('connection', (connection) => { + const connectionMessage = new MessageEvent('connection', { + data: connection, + cancelable: true, }) - /** @fixme Dispose of the interceptor. */ + emitter.dispatchEvent(connectionMessage) + + // If none of the "ws" handlers matched, + // establish the WebSocket connection as-is. + if (!connectionMessage.defaultPrevented) { + connection.server.connect() + connection.client.on('message', (event) => { + connection.server.send(event.data) + }) + } +}) + +/** + * Disposes of the WebSocket interceptor. + * The interceptor is a singleton instantiated in the + * "ws" API. + */ +export function disposeWebSocketInterceptor() { + interceptor.dispose() +} + +type WebSocketLinkHandlerEventMap = { + connection: [ + args: { + client: WebSocketClientConnection + server: WebSocketServerConnection + params: PathParams + }, + ] +} + +/** + * Intercepts outgoing WebSocket connections to the given URL. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * chat.on('connection', (connection) => {}) + */ +function createWebSocketLinkHandler(url: Path) { + const publicEmitter = new Emitter() + + // Apply the WebSocket interceptor. + // This defers the WebSocket class patching to the first + // "ws" event handler call. Repetitive calls to the "apply" + // method have no effect. + interceptor.apply() + + emitter.addEventListener( + 'connection', + (event: MessageEvent) => { + const { client, server } = event.data + const match = matchRequestUrl(client.url, url) + + if (match.matches) { + // Preventing the default on the connection event + // will prevent the WebSocket connection from being + // established. + event.preventDefault() + publicEmitter.emit('connection', { + client, + server, + params: match.params || {}, + }) + } + }, + ) + + const { on, off, removeAllListeners } = publicEmitter return { - /** - * @fixme Don't expose these directly. The exposed interface - * must be decoupled from the interceptor to support - * "removeAllEvents" and such. - */ - on: interceptor.on.bind(interceptor), - off: interceptor.off.bind(interceptor), - removeAllListeners: interceptor.removeAllListeners.bind(interceptor), + on, + off, + removeAllListeners, } } export const ws = { - link: createWebSocketHandler, + link: createWebSocketLinkHandler, } // +// Public usage. // -const chat = ws.link('wss://*.service.com') +const chat = ws.link('wss://:roomId.service.com') -chat.on('connection', ({ client }) => { - client.on('message', (event) => { - console.log(event.data) - client.send('Hello from server!') - }) -}) +export const handlers = [ + chat.on('connection', ({ client }) => { + client.on('message', (event) => { + console.log(event.data) + client.send('Hello from server!') + }) + }), +] From 47ca629aff3c499340060f401b4fc1dfa7b25e19 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 8 Feb 2024 17:44:04 +0100 Subject: [PATCH 04/69] chore: add agnostic "Handler" class --- src/core/handlers/Handler.ts | 43 +++++++++ src/core/handlers/WebSocketHandler.ts | 95 ++++++++++++++++++++ src/core/utils/handleWebSocketEvent.ts | 31 +++++++ src/core/ws.ts | 120 ------------------------- src/core/ws/webSocketInterceptor.ts | 3 + src/core/ws/ws.ts | 19 ++++ 6 files changed, 191 insertions(+), 120 deletions(-) create mode 100644 src/core/handlers/Handler.ts create mode 100644 src/core/handlers/WebSocketHandler.ts create mode 100644 src/core/utils/handleWebSocketEvent.ts delete mode 100644 src/core/ws.ts create mode 100644 src/core/ws/webSocketInterceptor.ts create mode 100644 src/core/ws/ws.ts diff --git a/src/core/handlers/Handler.ts b/src/core/handlers/Handler.ts new file mode 100644 index 000000000..6f2bea49d --- /dev/null +++ b/src/core/handlers/Handler.ts @@ -0,0 +1,43 @@ +export type HandlerOptions = { + once?: boolean +} + +export abstract class Handler { + public isUsed: boolean + + constructor(protected readonly options: HandlerOptions = {}) { + this.isUsed = false + } + + abstract parse(args: { input: Input }): unknown + abstract predicate(args: { input: Input; parsedResult: unknown }): boolean + protected abstract handle(args: { + input: Input + parsedResult: unknown + }): Promise + + public async run(input: Input): Promise { + if (this.options?.once && this.isUsed) { + return null + } + + const parsedResult = this.parse({ input }) + const shouldHandle = this.predicate({ + input, + parsedResult, + }) + + if (!shouldHandle) { + return null + } + + const result = await this.handle({ + input, + parsedResult, + }) + + this.isUsed = true + + return result + } +} diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts new file mode 100644 index 000000000..9a96abcc6 --- /dev/null +++ b/src/core/handlers/WebSocketHandler.ts @@ -0,0 +1,95 @@ +import { Emitter } from 'strict-event-emitter' +import type { + WebSocketClientConnection, + WebSocketServerConnection, +} from '@mswjs/interceptors/WebSocket' +import { + type Match, + type Path, + type PathParams, + matchRequestUrl, +} from '../utils/matching/matchRequestUrl' +import { Handler } from './Handler' + +type WebSocketHandlerParsedResult = { + match: Match +} + +type WebSocketHandlerEventMap = { + connection: [ + args: { + client: WebSocketClientConnection + server: WebSocketServerConnection + params: PathParams + }, + ] +} + +export class WebSocketHandler extends Handler> { + public on: ( + event: K, + listener: (...args: WebSocketHandlerEventMap[K]) => void, + ) => void + + public off: ( + event: K, + listener: (...args: WebSocketHandlerEventMap[K]) => void, + ) => void + + public removeAllListeners: ( + event?: K, + ) => void + + protected emitter: Emitter + + constructor(private readonly url: Path) { + super() + this.emitter = new Emitter() + + // Forward some of the emitter API to the public API + // of the event handler. + this.on = this.emitter.on.bind(this.emitter) + this.off = this.emitter.off.bind(this.emitter) + this.removeAllListeners = this.emitter.removeAllListeners.bind(this.emitter) + } + + public parse(args: { + input: MessageEvent + }): WebSocketHandlerParsedResult { + const connection = args.input.data + const match = matchRequestUrl(connection.client.url, this.url) + + return { + match, + } + } + + public predicate(args: { + input: MessageEvent + parsedResult: WebSocketHandlerParsedResult + }): boolean { + const { match } = args.parsedResult + return match.matches + } + + protected async handle(args: { + input: MessageEvent + parsedResult: WebSocketHandlerParsedResult + }): Promise { + const connectionEvent = args.input + + // At this point, the WebSocket connection URL has matched the handler. + // Prevent the default behavior of establishing the connection as-is. + connectionEvent.preventDefault() + + const connection = connectionEvent.data + + // Emit the connection event on the handler. + // This is what the developer adds listeners for. + this.emitter.emit('connection', { + client: connection.client, + server: connection.server, + params: args.parsedResult.match.params || {}, + }) + } +} diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts new file mode 100644 index 000000000..811dde2b1 --- /dev/null +++ b/src/core/utils/handleWebSocketEvent.ts @@ -0,0 +1,31 @@ +import { type Handler, WebSocketHandler } from '../handlers/WebSocketHandler' +import { webSocketInterceptor } from '../ws/webSocketInterceptor' + +export function handleWebSocketEvent(handlers: Array) { + webSocketInterceptor.on('connection', (connection) => { + const connectionEvent = new MessageEvent('connection', { + data: connection, + cancelable: true, + }) + + // Iterate over the handlers and forward the connection + // event to WebSocket event handlers. This is equivalent + // to dispatching that event onto multiple listeners. + for (const handler of handlers) { + if (handler instanceof WebSocketHandler) { + // Never await the run function because event handlers + // are side-effectful and don't block the event loop. + handler.run(connectionEvent) + } + } + + // If none of the "ws" handlers matched, + // establish the WebSocket connection as-is. + if (!connectionEvent.defaultPrevented) { + connection.server.connect() + connection.client.on('message', (event) => { + connection.server.send(event.data) + }) + } + }) +} diff --git a/src/core/ws.ts b/src/core/ws.ts deleted file mode 100644 index 58b0afe0e..000000000 --- a/src/core/ws.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Emitter } from 'strict-event-emitter' -import { - WebSocketInterceptor, - type WebSocketEventMap, - type WebSocketClientConnection, - type WebSocketServerConnection, -} from '@mswjs/interceptors/WebSocket' -import { - type Path, - matchRequestUrl, - PathParams, -} from './utils/matching/matchRequestUrl' - -/** - * @fixme Will each "ws" import create a NEW intereptor? - * Consider moving this away and reusing. - */ -const interceptor = new WebSocketInterceptor() -const emitter = new EventTarget() - -interceptor.on('connection', (connection) => { - const connectionMessage = new MessageEvent('connection', { - data: connection, - cancelable: true, - }) - - emitter.dispatchEvent(connectionMessage) - - // If none of the "ws" handlers matched, - // establish the WebSocket connection as-is. - if (!connectionMessage.defaultPrevented) { - connection.server.connect() - connection.client.on('message', (event) => { - connection.server.send(event.data) - }) - } -}) - -/** - * Disposes of the WebSocket interceptor. - * The interceptor is a singleton instantiated in the - * "ws" API. - */ -export function disposeWebSocketInterceptor() { - interceptor.dispose() -} - -type WebSocketLinkHandlerEventMap = { - connection: [ - args: { - client: WebSocketClientConnection - server: WebSocketServerConnection - params: PathParams - }, - ] -} - -/** - * Intercepts outgoing WebSocket connections to the given URL. - * - * @example - * const chat = ws.link('wss://chat.example.com') - * chat.on('connection', (connection) => {}) - */ -function createWebSocketLinkHandler(url: Path) { - const publicEmitter = new Emitter() - - // Apply the WebSocket interceptor. - // This defers the WebSocket class patching to the first - // "ws" event handler call. Repetitive calls to the "apply" - // method have no effect. - interceptor.apply() - - emitter.addEventListener( - 'connection', - (event: MessageEvent) => { - const { client, server } = event.data - const match = matchRequestUrl(client.url, url) - - if (match.matches) { - // Preventing the default on the connection event - // will prevent the WebSocket connection from being - // established. - event.preventDefault() - publicEmitter.emit('connection', { - client, - server, - params: match.params || {}, - }) - } - }, - ) - - const { on, off, removeAllListeners } = publicEmitter - - return { - on, - off, - removeAllListeners, - } -} - -export const ws = { - link: createWebSocketLinkHandler, -} - -// -// Public usage. -// - -const chat = ws.link('wss://:roomId.service.com') - -export const handlers = [ - chat.on('connection', ({ client }) => { - client.on('message', (event) => { - console.log(event.data) - client.send('Hello from server!') - }) - }), -] diff --git a/src/core/ws/webSocketInterceptor.ts b/src/core/ws/webSocketInterceptor.ts new file mode 100644 index 000000000..8a8b21f2d --- /dev/null +++ b/src/core/ws/webSocketInterceptor.ts @@ -0,0 +1,3 @@ +import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' + +export const webSocketInterceptor = new WebSocketInterceptor() diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts new file mode 100644 index 000000000..c44a3e469 --- /dev/null +++ b/src/core/ws/ws.ts @@ -0,0 +1,19 @@ +import { WebSocketHandler } from '../handlers/WebSocketHandler' +import type { Path } from '../utils/matching/matchRequestUrl' +import { webSocketInterceptor } from './webSocketInterceptor' + +/** + * Intercepts outgoing WebSocket connections to the given URL. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * chat.on('connection', (connection) => {}) + */ +function createWebSocketLinkHandler(url: Path) { + webSocketInterceptor.apply() + return new WebSocketHandler(url) +} + +export const ws = { + link: createWebSocketLinkHandler, +} From 4fe7989ba9463074dae5736e428f8f78fc2c53fc Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 8 Feb 2024 19:36:30 +0100 Subject: [PATCH 05/69] chore: drop non-extendable "Handler" --- src/core/handlers/Handler.ts | 43 -------------------------- src/core/handlers/WebSocketHandler.ts | 33 +++++++++++++------- src/core/utils/handleWebSocketEvent.ts | 11 ++++--- 3 files changed, 28 insertions(+), 59 deletions(-) delete mode 100644 src/core/handlers/Handler.ts diff --git a/src/core/handlers/Handler.ts b/src/core/handlers/Handler.ts deleted file mode 100644 index 6f2bea49d..000000000 --- a/src/core/handlers/Handler.ts +++ /dev/null @@ -1,43 +0,0 @@ -export type HandlerOptions = { - once?: boolean -} - -export abstract class Handler { - public isUsed: boolean - - constructor(protected readonly options: HandlerOptions = {}) { - this.isUsed = false - } - - abstract parse(args: { input: Input }): unknown - abstract predicate(args: { input: Input; parsedResult: unknown }): boolean - protected abstract handle(args: { - input: Input - parsedResult: unknown - }): Promise - - public async run(input: Input): Promise { - if (this.options?.once && this.isUsed) { - return null - } - - const parsedResult = this.parse({ input }) - const shouldHandle = this.predicate({ - input, - parsedResult, - }) - - if (!shouldHandle) { - return null - } - - const result = await this.handle({ - input, - parsedResult, - }) - - this.isUsed = true - - return result - } -} diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index 9a96abcc6..ffb398692 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -9,7 +9,6 @@ import { type PathParams, matchRequestUrl, } from '../utils/matching/matchRequestUrl' -import { Handler } from './Handler' type WebSocketHandlerParsedResult = { match: Match @@ -25,7 +24,14 @@ type WebSocketHandlerEventMap = { ] } -export class WebSocketHandler extends Handler> { +type WebSocketHandlerIncomingEvent = MessageEvent<{ + client: WebSocketClientConnection + server: WebSocketServerConnection +}> + +export const kRun = Symbol('run') + +export class WebSocketHandler { public on: ( event: K, listener: (...args: WebSocketHandlerEventMap[K]) => void, @@ -43,7 +49,6 @@ export class WebSocketHandler extends Handler> { protected emitter: Emitter constructor(private readonly url: Path) { - super() this.emitter = new Emitter() // Forward some of the emitter API to the public API @@ -54,9 +59,9 @@ export class WebSocketHandler extends Handler> { } public parse(args: { - input: MessageEvent + event: WebSocketHandlerIncomingEvent }): WebSocketHandlerParsedResult { - const connection = args.input.data + const connection = args.event.data const match = matchRequestUrl(connection.client.url, this.url) return { @@ -65,18 +70,22 @@ export class WebSocketHandler extends Handler> { } public predicate(args: { - input: MessageEvent + event: WebSocketHandlerIncomingEvent parsedResult: WebSocketHandlerParsedResult }): boolean { const { match } = args.parsedResult return match.matches } - protected async handle(args: { - input: MessageEvent - parsedResult: WebSocketHandlerParsedResult - }): Promise { - const connectionEvent = args.input + async [kRun](args: { event: MessageEvent }): Promise { + const parsedResult = this.parse({ event: args.event }) + const shouldIntercept = this.predicate({ event: args.event, parsedResult }) + + if (!shouldIntercept) { + return + } + + const connectionEvent = args.event // At this point, the WebSocket connection URL has matched the handler. // Prevent the default behavior of establishing the connection as-is. @@ -89,7 +98,7 @@ export class WebSocketHandler extends Handler> { this.emitter.emit('connection', { client: connection.client, server: connection.server, - params: args.parsedResult.match.params || {}, + params: parsedResult.match.params || {}, }) } } diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index 811dde2b1..2f30e31a8 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -1,7 +1,10 @@ -import { type Handler, WebSocketHandler } from '../handlers/WebSocketHandler' +import { RequestHandler } from '../handlers/RequestHandler' +import { WebSocketHandler, kRun } from '../handlers/WebSocketHandler' import { webSocketInterceptor } from '../ws/webSocketInterceptor' -export function handleWebSocketEvent(handlers: Array) { +export function handleWebSocketEvent( + handlers: Array, +) { webSocketInterceptor.on('connection', (connection) => { const connectionEvent = new MessageEvent('connection', { data: connection, @@ -15,7 +18,7 @@ export function handleWebSocketEvent(handlers: Array) { if (handler instanceof WebSocketHandler) { // Never await the run function because event handlers // are side-effectful and don't block the event loop. - handler.run(connectionEvent) + handler[kRun]({ event: connectionEvent }) } } @@ -23,7 +26,7 @@ export function handleWebSocketEvent(handlers: Array) { // establish the WebSocket connection as-is. if (!connectionEvent.defaultPrevented) { connection.server.connect() - connection.client.on('message', (event) => { + connection.client.addEventListener('message', (event) => { connection.server.send(event.data) }) } From 2f9070869ff877a590efa970e0fe4d22dbad2390 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 8 Feb 2024 19:52:28 +0100 Subject: [PATCH 06/69] fix: support "WebSocketHandler" in setup apis --- src/browser/setupWorker/glossary.ts | 20 ++++++++--------- src/browser/setupWorker/setupWorker.ts | 9 +++++--- src/core/SetupApi.ts | 30 ++++++++++++++------------ src/core/utils/executeHandlers.ts | 8 +++++-- src/core/utils/handleRequest.ts | 3 +-- src/node/SetupServerApi.ts | 3 ++- src/node/glossary.ts | 14 ++++++------ src/node/setupServer.ts | 8 +++---- 8 files changed, 51 insertions(+), 44 deletions(-) diff --git a/src/browser/setupWorker/glossary.ts b/src/browser/setupWorker/glossary.ts index 4a33edee7..3124d5a66 100644 --- a/src/browser/setupWorker/glossary.ts +++ b/src/browser/setupWorker/glossary.ts @@ -5,13 +5,11 @@ import { SharedOptions, } from '~/core/sharedOptions' import { ServiceWorkerMessage } from './start/utils/createMessageChannel' -import { - RequestHandler, - RequestHandlerDefaultInfo, -} from '~/core/handlers/RequestHandler' +import { RequestHandler } from '~/core/handlers/RequestHandler' import type { HttpRequestEventMap, Interceptor } from '@mswjs/interceptors' -import { Path } from '~/core/utils/matching/matchRequestUrl' -import { RequiredDeep } from '~/core/typeUtils' +import type { Path } from '~/core/utils/matching/matchRequestUrl' +import type { RequiredDeep } from '~/core/typeUtils' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' export type ResolvedPath = Path | URL @@ -102,7 +100,7 @@ export interface SetupWorkerInternalContext { startOptions: RequiredDeep worker: ServiceWorker | null registration: ServiceWorkerRegistration | null - requestHandlers: Array + requestHandlers: Array requests: Map emitter: Emitter keepAliveInterval?: number @@ -226,7 +224,7 @@ export interface SetupWorker { * * @see {@link https://mswjs.io/docs/api/setup-worker/use `worker.use()` API reference} */ - use: (...handlers: RequestHandler[]) => void + use: (...handlers: Array) => void /** * Marks all request handlers that respond using `res.once()` as unused. @@ -241,14 +239,16 @@ export interface SetupWorker { * * @see {@link https://mswjs.io/docs/api/setup-worker/reset-handlers `worker.resetHandlers()` API reference} */ - resetHandlers: (...nextHandlers: RequestHandler[]) => void + resetHandlers: ( + ...nextHandlers: Array + ) => void /** * Returns a readonly list of currently active request handlers. * * @see {@link https://mswjs.io/docs/api/setup-worker/list-handlers `worker.listHandlers()` API reference} */ - listHandlers(): ReadonlyArray> + listHandlers(): ReadonlyArray /** * Life-cycle events. diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index f99f7b5de..4cac5e8a0 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -18,7 +18,8 @@ import { createFallbackStop } from './stop/createFallbackStop' import { devUtils } from '~/core/utils/internal/devUtils' import { SetupApi } from '~/core/SetupApi' import { mergeRight } from '~/core/utils/internal/mergeRight' -import { LifeCycleEventsMap } from '~/core/sharedOptions' +import type { LifeCycleEventsMap } from '~/core/sharedOptions' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { SetupWorker } from './glossary' import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' @@ -37,7 +38,7 @@ export class SetupWorkerApi private stopHandler: StopHandler = null as any private listeners: Array - constructor(...handlers: Array) { + constructor(...handlers: Array) { super(...handlers) invariant( @@ -201,6 +202,8 @@ export class SetupWorkerApi * * @see {@link https://mswjs.io/docs/api/setup-worker `setupWorker()` API reference} */ -export function setupWorker(...handlers: Array): SetupWorker { +export function setupWorker( + ...handlers: Array +): SetupWorker { return new SetupWorkerApi(...handlers) } diff --git a/src/core/SetupApi.ts b/src/core/SetupApi.ts index 7dfbdb593..71c2c0c32 100644 --- a/src/core/SetupApi.ts +++ b/src/core/SetupApi.ts @@ -1,27 +1,25 @@ import { invariant } from 'outvariant' import { EventMap, Emitter } from 'strict-event-emitter' -import { - RequestHandler, - RequestHandlerDefaultInfo, -} from './handlers/RequestHandler' +import { RequestHandler } from './handlers/RequestHandler' import { LifeCycleEventEmitter } from './sharedOptions' import { devUtils } from './utils/internal/devUtils' import { pipeEvents } from './utils/internal/pipeEvents' import { toReadonlyArray } from './utils/internal/toReadonlyArray' import { Disposable } from './utils/internal/Disposable' +import type { WebSocketHandler } from './handlers/WebSocketHandler' /** * Generic class for the mock API setup. */ export abstract class SetupApi extends Disposable { - protected initialHandlers: ReadonlyArray - protected currentHandlers: Array + protected initialHandlers: ReadonlyArray + protected currentHandlers: Array protected readonly emitter: Emitter protected readonly publicEmitter: Emitter public readonly events: LifeCycleEventEmitter - constructor(...initialHandlers: Array) { + constructor(...initialHandlers: Array) { super() invariant( @@ -46,12 +44,14 @@ export abstract class SetupApi extends Disposable { }) } - private validateHandlers(handlers: ReadonlyArray): boolean { + private validateHandlers(handlers: ReadonlyArray): boolean { // Guard against incorrect call signature of the setup API. return handlers.every((handler) => !Array.isArray(handler)) } - public use(...runtimeHandlers: Array): void { + public use( + ...runtimeHandlers: Array + ): void { invariant( this.validateHandlers(runtimeHandlers), devUtils.formatMessage( @@ -64,18 +64,20 @@ export abstract class SetupApi extends Disposable { public restoreHandlers(): void { this.currentHandlers.forEach((handler) => { - handler.isUsed = false + if ('isUsed' in handler) { + handler.isUsed = false + } }) } - public resetHandlers(...nextHandlers: Array): void { + public resetHandlers( + ...nextHandlers: Array + ): void { this.currentHandlers = nextHandlers.length > 0 ? [...nextHandlers] : [...this.initialHandlers] } - public listHandlers(): ReadonlyArray< - RequestHandler - > { + public listHandlers(): ReadonlyArray { return toReadonlyArray(this.currentHandlers) } diff --git a/src/core/utils/executeHandlers.ts b/src/core/utils/executeHandlers.ts index 34e4e7894..3df00901e 100644 --- a/src/core/utils/executeHandlers.ts +++ b/src/core/utils/executeHandlers.ts @@ -1,6 +1,6 @@ import { RequestHandler, - RequestHandlerExecutionResult, + type RequestHandlerExecutionResult, } from '../handlers/RequestHandler' export interface HandlersExecutionResult { @@ -18,7 +18,7 @@ export interface ResponseResolutionContext { * Returns the execution result object containing any matching request * handler and any mocked response it returned. */ -export const executeHandlers = async >({ +export const executeHandlers = async >({ request, requestId, handlers, @@ -33,6 +33,10 @@ export const executeHandlers = async >({ let result: RequestHandlerExecutionResult | null = null for (const handler of handlers) { + if (!(handler instanceof RequestHandler)) { + continue + } + result = await handler.run({ request, requestId, resolutionContext }) // If the handler produces some result for this request, diff --git a/src/core/utils/handleRequest.ts b/src/core/utils/handleRequest.ts index 45f9ebe6f..f821410ba 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -1,6 +1,5 @@ import { until } from '@open-draft/until' import { Emitter } from 'strict-event-emitter' -import { RequestHandler } from '../handlers/RequestHandler' import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions' import { RequiredDeep } from '../typeUtils' import { HandlersExecutionResult, executeHandlers } from './executeHandlers' @@ -45,7 +44,7 @@ export interface HandleRequestOptions { export async function handleRequest( request: Request, requestId: string, - handlers: Array, + handlers: Array, options: RequiredDeep, emitter: Emitter, handleRequestOptions?: HandleRequestOptions, diff --git a/src/node/SetupServerApi.ts b/src/node/SetupServerApi.ts index d820a0200..6dd18e1e0 100644 --- a/src/node/SetupServerApi.ts +++ b/src/node/SetupServerApi.ts @@ -13,6 +13,7 @@ import { handleRequest } from '~/core/utils/handleRequest' import { devUtils } from '~/core/utils/internal/devUtils' import { mergeRight } from '~/core/utils/internal/mergeRight' import { SetupServer } from './glossary' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', @@ -32,7 +33,7 @@ export class SetupServerApi interceptors: Array<{ new (): Interceptor }>, - ...handlers: Array + ...handlers: Array ) { super(...handlers) diff --git a/src/node/glossary.ts b/src/node/glossary.ts index 0edda3ce8..ac6d3269e 100644 --- a/src/node/glossary.ts +++ b/src/node/glossary.ts @@ -1,9 +1,7 @@ import type { PartialDeep } from 'type-fest' -import { - RequestHandler, - RequestHandlerDefaultInfo, -} from '~/core/handlers/RequestHandler' -import { +import { RequestHandler } from '~/core/handlers/RequestHandler' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' +import type { LifeCycleEventEmitter, LifeCycleEventsMap, SharedOptions, @@ -29,7 +27,7 @@ export interface SetupServer { * * @see {@link https://mswjs.io/docs/api/setup-server/use `server.use()` API reference} */ - use(...handlers: Array): void + use(...handlers: Array): void /** * Marks all request handlers that respond using `res.once()` as unused. @@ -43,14 +41,14 @@ export interface SetupServer { * * @see {@link https://mswjs.io/docs/api/setup-server/reset-handlers `server.reset-handlers()` API reference} */ - resetHandlers(...nextHandlers: Array): void + resetHandlers(...nextHandlers: Array): void /** * Returns a readonly list of currently active request handlers. * * @see {@link https://mswjs.io/docs/api/setup-server/list-handlers `server.listHandlers()` API reference} */ - listHandlers(): ReadonlyArray> + listHandlers(): ReadonlyArray /** * Life-cycle events. diff --git a/src/node/setupServer.ts b/src/node/setupServer.ts index e72a449d3..180cd7cb8 100644 --- a/src/node/setupServer.ts +++ b/src/node/setupServer.ts @@ -1,9 +1,9 @@ import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { FetchInterceptor } from '@mswjs/interceptors/fetch' -import { RequestHandler } from '~/core/handlers/RequestHandler' +import type { RequestHandler } from '~/core/handlers/RequestHandler' import { SetupServerApi } from './SetupServerApi' -import { SetupServer } from './glossary' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' /** * Sets up a requests interception in Node.js with the given request handlers. @@ -12,8 +12,8 @@ import { SetupServer } from './glossary' * @see {@link https://mswjs.io/docs/api/setup-server `setupServer()` API reference} */ export const setupServer = ( - ...handlers: Array -): SetupServer => { + ...handlers: Array +) => { return new SetupServerApi( [ClientRequestInterceptor, XMLHttpRequestInterceptor, FetchInterceptor], ...handlers, From ad82a548f7c3e010290ed6a2e512ae48b871dc32 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 8 Feb 2024 19:56:05 +0100 Subject: [PATCH 07/69] feat: export "ws" from core --- src/core/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/index.ts b/src/core/index.ts index 6e8aa5ac9..5cfdb4b59 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -9,6 +9,9 @@ export { HttpHandler, HttpMethods } from './handlers/HttpHandler' export { graphql } from './graphql' export { GraphQLHandler } from './handlers/GraphQLHandler' +/* WebSocket */ +export { ws } from './ws/ws' + /* Utils */ export { matchRequestUrl } from './utils/matching/matchRequestUrl' export * from './utils/handleRequest' From 38f5d270491eb47dd23ceaee903c90a9ee639378 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 9 Feb 2024 12:11:45 +0100 Subject: [PATCH 08/69] fix(setupServer): invalid return type --- src/node/setupServer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/node/setupServer.ts b/src/node/setupServer.ts index 180cd7cb8..64a2104d1 100644 --- a/src/node/setupServer.ts +++ b/src/node/setupServer.ts @@ -2,8 +2,9 @@ import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { FetchInterceptor } from '@mswjs/interceptors/fetch' import type { RequestHandler } from '~/core/handlers/RequestHandler' -import { SetupServerApi } from './SetupServerApi' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' +import { SetupServerApi } from './SetupServerApi' +import type { SetupServer } from './glossary' /** * Sets up a requests interception in Node.js with the given request handlers. @@ -13,7 +14,7 @@ import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' */ export const setupServer = ( ...handlers: Array -) => { +): SetupServer => { return new SetupServerApi( [ClientRequestInterceptor, XMLHttpRequestInterceptor, FetchInterceptor], ...handlers, From e82d9a7137f8fedbbaab3a20b44c4a79881dc5c5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 9 Feb 2024 12:12:00 +0100 Subject: [PATCH 09/69] test(ws): add interception tests --- package.json | 5 +- src/core/handlers/WebSocketHandler.ts | 31 +---- src/core/ws/ws.ts | 24 +++- src/node/SetupServerApi.ts | 10 +- test/node/vitest.config.ts | 2 + test/node/ws-api/ws.intercept.test.ts | 109 ++++++++++++++++++ test/support/WebSocketServer.ts | 55 +++++++++ .../vitest-environment-node-websocket.ts | 20 ++++ 8 files changed, 225 insertions(+), 31 deletions(-) create mode 100644 test/node/ws-api/ws.intercept.test.ts create mode 100644 test/support/WebSocketServer.ts create mode 100644 test/support/environments/vitest-environment-node-websocket.ts diff --git a/package.json b/package.json index f23569e6f..a1b53f092 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "devDependencies": { "@commitlint/cli": "^18.4.4", "@commitlint/config-conventional": "^18.4.4", + "@fastify/websocket": "^8.3.1", "@open-draft/test-server": "^0.4.2", "@ossjs/release": "^0.8.0", "@playwright/test": "^1.40.1", @@ -143,6 +144,7 @@ "@types/glob": "^8.1.0", "@types/json-bigint": "^1.0.4", "@types/node": "18.x", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^5.11.0", "@typescript-eslint/parser": "^5.11.0", "@web/dev-server": "^0.1.38", @@ -158,6 +160,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "express": "^4.18.2", + "fastify": "^4.26.0", "fs-extra": "^11.2.0", "fs-teardown": "^0.3.0", "glob": "^10.3.10", @@ -174,7 +177,7 @@ "typescript": "^5.0.2", "undici": "^5.20.0", "url-loader": "^4.1.1", - "vitest": "^0.34.6", + "vitest": "^1.2.2", "vitest-environment-miniflare": "^2.14.1", "webpack": "^5.89.0", "webpack-http-server": "^0.5.0" diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index ffb398692..be0d4e136 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -14,7 +14,7 @@ type WebSocketHandlerParsedResult = { match: Match } -type WebSocketHandlerEventMap = { +export type WebSocketHandlerEventMap = { connection: [ args: { client: WebSocketClientConnection @@ -29,33 +29,14 @@ type WebSocketHandlerIncomingEvent = MessageEvent<{ server: WebSocketServerConnection }> -export const kRun = Symbol('run') +export const kEmitter = Symbol('kEmitter') +export const kRun = Symbol('kRun') export class WebSocketHandler { - public on: ( - event: K, - listener: (...args: WebSocketHandlerEventMap[K]) => void, - ) => void - - public off: ( - event: K, - listener: (...args: WebSocketHandlerEventMap[K]) => void, - ) => void - - public removeAllListeners: ( - event?: K, - ) => void - - protected emitter: Emitter + protected [kEmitter]: Emitter constructor(private readonly url: Path) { - this.emitter = new Emitter() - - // Forward some of the emitter API to the public API - // of the event handler. - this.on = this.emitter.on.bind(this.emitter) - this.off = this.emitter.off.bind(this.emitter) - this.removeAllListeners = this.emitter.removeAllListeners.bind(this.emitter) + this[kEmitter] = new Emitter() } public parse(args: { @@ -95,7 +76,7 @@ export class WebSocketHandler { // Emit the connection event on the handler. // This is what the developer adds listeners for. - this.emitter.emit('connection', { + this[kEmitter].emit('connection', { client: connection.client, server: connection.server, params: parsedResult.match.params || {}, diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index c44a3e469..516c87d16 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -1,4 +1,8 @@ -import { WebSocketHandler } from '../handlers/WebSocketHandler' +import { + WebSocketHandler, + kEmitter, + type WebSocketHandlerEventMap, +} from '../handlers/WebSocketHandler' import type { Path } from '../utils/matching/matchRequestUrl' import { webSocketInterceptor } from './webSocketInterceptor' @@ -11,7 +15,23 @@ import { webSocketInterceptor } from './webSocketInterceptor' */ function createWebSocketLinkHandler(url: Path) { webSocketInterceptor.apply() - return new WebSocketHandler(url) + + return { + on( + event: K, + listener: (...args: WebSocketHandlerEventMap[K]) => void, + ): WebSocketHandler { + const handler = new WebSocketHandler(url) + + // The "handleWebSocketEvent" function will invoke + // the "run()" method on the WebSocketHandler. + // If the handler matches, it will emit the "connection" + // event. Attach the user-defined listener to that event. + handler[kEmitter].on(event, listener) + + return handler + }, + } } export const ws = { diff --git a/src/node/SetupServerApi.ts b/src/node/SetupServerApi.ts index 6dd18e1e0..c632c1428 100644 --- a/src/node/SetupServerApi.ts +++ b/src/node/SetupServerApi.ts @@ -7,13 +7,14 @@ import { import { invariant } from 'outvariant' import { SetupApi } from '~/core/SetupApi' import { RequestHandler } from '~/core/handlers/RequestHandler' -import { LifeCycleEventsMap, SharedOptions } from '~/core/sharedOptions' -import { RequiredDeep } from '~/core/typeUtils' +import type { LifeCycleEventsMap, SharedOptions } from '~/core/sharedOptions' +import type { RequiredDeep } from '~/core/typeUtils' import { handleRequest } from '~/core/utils/handleRequest' import { devUtils } from '~/core/utils/internal/devUtils' import { mergeRight } from '~/core/utils/internal/mergeRight' -import { SetupServer } from './glossary' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' +import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' +import type { SetupServer } from './glossary' const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', @@ -79,6 +80,9 @@ export class SetupServerApi ) }, ) + + // Handle outgoing WebSocket connections. + handleWebSocketEvent(this.currentHandlers) } public listen(options: Partial = {}): void { diff --git a/test/node/vitest.config.ts b/test/node/vitest.config.ts index 801c1edeb..53f3e1525 100644 --- a/test/node/vitest.config.ts +++ b/test/node/vitest.config.ts @@ -11,6 +11,8 @@ export default defineConfig({ dir: './test/node', globals: true, alias: { + 'vitest-environment-node-websocket': + './test/support/environments/vitest-environment-node-websocket', 'msw/node': path.resolve(LIB_DIR, 'node/index.mjs'), 'msw/native': path.resolve(LIB_DIR, 'native/index.mjs'), 'msw/browser': path.resolve(LIB_DIR, 'browser/index.mjs'), diff --git a/test/node/ws-api/ws.intercept.test.ts b/test/node/ws-api/ws.intercept.test.ts new file mode 100644 index 000000000..70db69c7f --- /dev/null +++ b/test/node/ws-api/ws.intercept.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' +import { waitFor } from '../../support/waitFor' + +const server = setupServer() +const wsServer = new WebSocketServer() + +const service = ws.link('ws://*') + +beforeAll(async () => { + server.listen() + await wsServer.listen() +}) + +afterEach(() => { + wsServer.closeAllClients() + wsServer.removeAllListeners() +}) + +afterAll(async () => { + server.close() + await wsServer.close() +}) + +it('intercepts outgoing client text message', async () => { + const mockMessageListener = vi.fn() + const realConnectionListener = vi.fn() + + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const ws = new WebSocket(wsServer.url) + ws.onopen = () => ws.send('hello') + + await waitFor(() => { + // Must intercept the outgoing client message event. + expect(mockMessageListener).toHaveBeenCalledTimes(1) + + const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent + expect(messageEvent.type).toBe('message') + expect(messageEvent.data).toBe('hello') + expect(messageEvent.target).toBe(ws) + + // Must not connect to the actual server by default. + expect(realConnectionListener).not.toHaveBeenCalled() + }) +}) + +it('intercepts outgoing client Blob message', async () => { + const mockMessageListener = vi.fn() + const realConnectionListener = vi.fn() + + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const ws = new WebSocket(wsServer.url) + ws.onopen = () => ws.send(new Blob(['hello'])) + + await waitFor(() => { + expect(mockMessageListener).toHaveBeenCalledTimes(1) + + const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent + expect(messageEvent.type).toBe('message') + expect(messageEvent.data.size).toBe(5) + expect(messageEvent.target).toEqual(ws) + + // Must not connect to the actual server by default. + expect(realConnectionListener).not.toHaveBeenCalled() + }) +}) + +it('intercepts outgoing client ArrayBuffer message', async () => { + const mockMessageListener = vi.fn() + const realConnectionListener = vi.fn() + + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const ws = new WebSocket(wsServer.url) + ws.onopen = () => ws.send(new TextEncoder().encode('hello')) + + await waitFor(() => { + expect(mockMessageListener).toHaveBeenCalledTimes(1) + + const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent + expect(messageEvent.type).toBe('message') + expect(messageEvent.data).toEqual(new TextEncoder().encode('hello')) + expect(messageEvent.target).toEqual(ws) + + // Must not connect to the actual server by default. + expect(realConnectionListener).not.toHaveBeenCalled() + }) +}) diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts new file mode 100644 index 000000000..546c14168 --- /dev/null +++ b/test/support/WebSocketServer.ts @@ -0,0 +1,55 @@ +import { invariant } from 'outvariant' +import { Emitter } from 'strict-event-emitter' +import fastify, { FastifyInstance } from 'fastify' +import fastifyWebSocket, { SocketStream } from '@fastify/websocket' + +type FastifySocket = SocketStream['socket'] + +type WebSocketEventMap = { + connection: [client: FastifySocket] +} + +export class WebSocketServer extends Emitter { + private _url?: string + private app: FastifyInstance + private clients: Set + + constructor() { + super() + this.clients = new Set() + + this.app = fastify() + this.app.register(fastifyWebSocket) + this.app.register(async (fastify) => { + fastify.get('/', { websocket: true }, (connection) => { + this.clients.add(connection.socket) + this.emit('connection', connection.socket) + }) + }) + } + + get url(): string { + invariant( + this._url, + 'Failed to get "url" on WebSocketServer: server is not running. Did you forget to "await server.listen()"?', + ) + return this._url + } + + public async listen(): Promise { + const address = await this.app.listen({ port: 0 }) + const url = new URL(address) + url.protocol = url.protocol.replace(/^http/, 'ws') + this._url = url.href + } + + public closeAllClients(): void { + this.clients.forEach((client) => { + client.close() + }) + } + + public async close(): Promise { + return this.app.close() + } +} diff --git a/test/support/environments/vitest-environment-node-websocket.ts b/test/support/environments/vitest-environment-node-websocket.ts new file mode 100644 index 000000000..4fe1b93ad --- /dev/null +++ b/test/support/environments/vitest-environment-node-websocket.ts @@ -0,0 +1,20 @@ +/** + * Node.js environment superset that has a global WebSocket API. + */ +import type { Environment } from 'vitest' +import { builtinEnvironments } from 'vitest/environments' +import { WebSocket } from 'undici' + +export default { + name: 'node-with-websocket', + transformMode: 'ssr', + async setup(global, options) { + const { teardown } = await builtinEnvironments.jsdom.setup(global, options) + + Reflect.set(globalThis, 'WebSocket', WebSocket) + + return { + teardown, + } + }, +} From d795e159a7226e7de05f2012e33556112406bd9b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 14 Feb 2024 17:29:44 +0100 Subject: [PATCH 10/69] chore: update @mswjs/interceptors --- package.json | 2 +- pnpm-lock.yaml | 432 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 340 insertions(+), 94 deletions(-) diff --git a/package.json b/package.json index a1b53f092..4ad1cf7a9 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "@bundled-es-modules/cookie": "^2.0.0", "@bundled-es-modules/statuses": "^1.0.1", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.25.16", + "@mswjs/interceptors": "^0.26.1", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91b9528ae..97e72038b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,8 +8,9 @@ specifiers: '@bundled-es-modules/statuses': ^1.0.1 '@commitlint/cli': ^18.4.4 '@commitlint/config-conventional': ^18.4.4 + '@fastify/websocket': ^8.3.1 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.25.16 + '@mswjs/interceptors': ^0.26.1 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -22,6 +23,7 @@ specifiers: '@types/json-bigint': ^1.0.4 '@types/node': 18.x '@types/statuses': ^2.0.4 + '@types/ws': ^8.5.10 '@typescript-eslint/eslint-plugin': ^5.11.0 '@typescript-eslint/parser': ^5.11.0 '@web/dev-server': ^0.1.38 @@ -39,6 +41,7 @@ specifiers: eslint-config-prettier: ^9.1.0 eslint-plugin-prettier: ^5.1.3 express: ^4.18.2 + fastify: ^4.26.0 fs-extra: ^11.2.0 fs-teardown: ^0.3.0 glob: ^10.3.10 @@ -63,7 +66,7 @@ specifiers: typescript: ^5.0.2 undici: ^5.20.0 url-loader: ^4.1.1 - vitest: ^0.34.6 + vitest: ^1.2.2 vitest-environment-miniflare: ^2.14.1 webpack: ^5.89.0 webpack-http-server: ^0.5.0 @@ -73,7 +76,7 @@ dependencies: '@bundled-es-modules/cookie': 2.0.0 '@bundled-es-modules/statuses': 1.0.1 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.25.16 + '@mswjs/interceptors': 0.26.1 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -92,6 +95,7 @@ dependencies: devDependencies: '@commitlint/cli': 18.4.4_z5x4t2owsiiyng6cl4yau77uc4 '@commitlint/config-conventional': 18.4.4 + '@fastify/websocket': 8.3.1 '@open-draft/test-server': 0.4.2 '@ossjs/release': 0.8.0 '@playwright/test': 1.40.1 @@ -101,6 +105,7 @@ devDependencies: '@types/glob': 8.1.0 '@types/json-bigint': 1.0.4 '@types/node': 18.17.14 + '@types/ws': 8.5.10 '@typescript-eslint/eslint-plugin': 5.52.0_ct7kqyuhmchjrd4rut2lcwua2e '@typescript-eslint/parser': 5.52.0_ia2vohguagzyh4ngzoayyctqim '@web/dev-server': 0.1.38 @@ -116,6 +121,7 @@ devDependencies: eslint-config-prettier: 9.1.0_eslint@8.56.0 eslint-plugin-prettier: 5.1.3_dhjydligol7nv2ellgbhyihfk4 express: 4.18.2 + fastify: 4.26.0 fs-extra: 11.2.0 fs-teardown: 0.3.2 glob: 10.3.10 @@ -132,8 +138,8 @@ devDependencies: typescript: 5.0.2 undici: 5.23.0 url-loader: 4.1.1_webpack@5.89.0 - vitest: 0.34.6_jsdom@23.2.0 - vitest-environment-miniflare: 2.14.1_vitest@0.34.6 + vitest: 1.2.2_b4fzwn3atmkdkqrawvd5volizi + vitest-environment-miniflare: 2.14.1_vitest@1.2.2 webpack: 5.89.0_aeq7xbrtxnnsm7zl2u4axmljse webpack-http-server: 0.5.0_aeq7xbrtxnnsm7zl2u4axmljse @@ -811,6 +817,40 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@fastify/ajv-compiler/3.5.0: + resolution: {integrity: sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==} + dependencies: + ajv: 8.12.0 + ajv-formats: 2.1.1 + fast-uri: 2.3.0 + dev: true + + /@fastify/error/3.4.1: + resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} + dev: true + + /@fastify/fast-json-stringify-compiler/4.3.0: + resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} + dependencies: + fast-json-stringify: 5.12.0 + dev: true + + /@fastify/merge-json-schemas/0.1.1: + resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} + dependencies: + fast-deep-equal: 3.1.3 + dev: true + + /@fastify/websocket/8.3.1: + resolution: {integrity: sha512-hsQYHHJme/kvP3ZS4v/WMUznPBVeeQHHwAoMy1LiN6m/HuPfbdXq1MBJ4Nt8qX1YI+eVbog4MnOsU7MTozkwYA==} + dependencies: + fastify-plugin: 4.5.1 + ws: 8.16.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /@humanwhocodes/config-array/0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -1060,7 +1100,7 @@ packages: '@miniflare/core': 2.14.1 '@miniflare/shared': 2.14.1 undici: 5.20.0 - ws: 8.14.2 + ws: 8.16.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -1071,8 +1111,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.25.16: - resolution: {integrity: sha512-8QC8JyKztvoGAdPgyZy49c9vSHHAZjHagwl4RY9E8carULk8ym3iTaiawrT1YoLF/qb449h48f71XDPgkUSOUg==} + /@mswjs/interceptors/0.26.1: + resolution: {integrity: sha512-iK3hLdSp8153NQKU8BdnJQoa0V+tBdHZPNmmAZwLLG2GN/I64PpnbyEOT81SOPFDghpfdtTccfR1L0oEpfhxTA==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -1101,7 +1141,7 @@ packages: engines: {node: '>= 8'} dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.15.0 + fastq: 1.17.1 dev: true /@open-draft/deferred-promise/2.2.0: @@ -1480,16 +1520,6 @@ packages: '@types/node': 18.17.14 dev: true - /@types/chai-subset/1.3.4: - resolution: {integrity: sha512-CCWNXrJYSUIojZ1149ksLl3AN9cmZ5djf+yUoVVV+NuYrtydItQVlL2ZDqyC6M6O9LWRnVf8yYDxbXHO2TfQZg==} - dependencies: - '@types/chai': 4.3.9 - dev: true - - /@types/chai/4.3.9: - resolution: {integrity: sha512-69TtiDzu0bcmKQv3yg1Zx409/Kd7r0b5F1PfpYJfSHzLGtB53547V4u+9iqKYsTu/O2ai6KTb0TInNpvuQ3qmg==} - dev: true - /@types/command-line-args/5.2.1: resolution: {integrity: sha512-U2OcmS2tj36Yceu+mRuPyUV0ILfau/h5onStcSCzqTENsq0sBiAp2TmaXu1k8fY4McLcPKSYl9FRzn2hx5bI+w==} dev: true @@ -1737,6 +1767,12 @@ packages: '@types/node': 18.17.14 dev: true + /@types/ws/8.5.10: + resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} + dependencies: + '@types/node': 18.17.14 + dev: true + /@types/yargs-parser/21.0.0: resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} dev: true @@ -1881,40 +1917,41 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true - /@vitest/expect/0.34.6: - resolution: {integrity: sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==} + /@vitest/expect/1.2.2: + resolution: {integrity: sha512-3jpcdPAD7LwHUUiT2pZTj2U82I2Tcgg2oVPvKxhn6mDI2On6tfvPQTjAI4628GUGDZrCm4Zna9iQHm5cEexOAg==} dependencies: - '@vitest/spy': 0.34.6 - '@vitest/utils': 0.34.6 + '@vitest/spy': 1.2.2 + '@vitest/utils': 1.2.2 chai: 4.3.10 dev: true - /@vitest/runner/0.34.6: - resolution: {integrity: sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==} + /@vitest/runner/1.2.2: + resolution: {integrity: sha512-JctG7QZ4LSDXr5CsUweFgcpEvrcxOV1Gft7uHrvkQ+fsAVylmWQvnaAr/HDp3LAH1fztGMQZugIheTWjaGzYIg==} dependencies: - '@vitest/utils': 0.34.6 - p-limit: 4.0.0 + '@vitest/utils': 1.2.2 + p-limit: 5.0.0 pathe: 1.1.1 dev: true - /@vitest/snapshot/0.34.6: - resolution: {integrity: sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==} + /@vitest/snapshot/1.2.2: + resolution: {integrity: sha512-SmGY4saEw1+bwE1th6S/cZmPxz/Q4JWsl7LvbQIky2tKE35US4gd0Mjzqfr84/4OD0tikGWaWdMja/nWL5NIPA==} dependencies: magic-string: 0.30.5 pathe: 1.1.1 pretty-format: 29.7.0 dev: true - /@vitest/spy/0.34.6: - resolution: {integrity: sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==} + /@vitest/spy/1.2.2: + resolution: {integrity: sha512-k9Gcahssw8d7X3pSLq3e3XEu/0L78mUkCjivUqCQeXJm9clfXR/Td8+AP+VC1O6fKPIDLcHDTAmBOINVuv6+7g==} dependencies: tinyspy: 2.2.0 dev: true - /@vitest/utils/0.34.6: - resolution: {integrity: sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==} + /@vitest/utils/1.2.2: + resolution: {integrity: sha512-WKITBHLsBHlpjnDQahr+XK6RE7MiAsgrIkr0pGhQ9ygoxBfUeG0lUG5iLlzqjmKSlBv3+j5EGsriBzh+C3Tq9g==} dependencies: - diff-sequences: 29.4.3 + diff-sequences: 29.6.3 + estree-walker: 3.0.3 loupe: 2.3.7 pretty-format: 29.7.0 dev: true @@ -2125,6 +2162,17 @@ packages: through: 2.3.8 dev: true + /abort-controller/3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + dependencies: + event-target-shim: 5.0.1 + dev: true + + /abstract-logging/2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + dev: true + /accepts/1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2154,6 +2202,11 @@ packages: engines: {node: '>=0.4.0'} dev: true + /acorn-walk/8.3.2: + resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} + engines: {node: '>=0.4.0'} + dev: true + /acorn/8.11.2: resolution: {integrity: sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==} engines: {node: '>=0.4.0'} @@ -2169,6 +2222,15 @@ packages: - supports-color dev: true + /ajv-formats/2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.12.0 + dev: true + /ajv-keywords/3.5.2_ajv@6.12.6: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -2251,6 +2313,10 @@ packages: normalize-path: 3.0.0 picomatch: 2.3.1 + /archy/1.0.0: + resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} + dev: true + /arg/4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} dev: true @@ -2341,6 +2407,17 @@ packages: engines: {node: '>= 0.4'} dev: true + /avvio/8.3.0: + resolution: {integrity: sha512-VBVH0jubFr9LdFASy/vNtm5giTrnbVquWBhT0fyizuNK2rQ7e7ONU2plZQWUNqtE1EmxFEb+kbSkFRkstiaS9Q==} + dependencies: + '@fastify/error': 3.4.1 + archy: 1.0.0 + debug: 4.3.4 + fastq: 1.17.1 + transitivePeerDependencies: + - supports-color + dev: true + /axios/1.6.5: resolution: {integrity: sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==} dependencies: @@ -2643,6 +2720,13 @@ packages: base64-js: 1.5.1 ieee754: 1.2.1 + /buffer/6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: true + /builtin-modules/3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -3318,8 +3402,8 @@ packages: engines: {node: '>=8'} dev: true - /diff-sequences/29.4.3: - resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==} + /diff-sequences/29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true @@ -3731,6 +3815,12 @@ packages: resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} dev: true + /estree-walker/3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.5 + dev: true + /esutils/2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3741,6 +3831,11 @@ packages: engines: {node: '>= 0.6'} dev: true + /event-target-shim/5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + dev: true + /eventemitter3/5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} dev: true @@ -3849,6 +3944,14 @@ packages: iconv-lite: 0.4.24 tmp: 0.0.33 + /fast-content-type-parse/1.1.0: + resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} + dev: true + + /fast-decode-uri-component/1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + dev: true + /fast-deep-equal/3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -3872,10 +3975,28 @@ packages: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true + /fast-json-stringify/5.12.0: + resolution: {integrity: sha512-7Nnm9UPa7SfHRbHVA1kJQrGXCRzB7LMlAAqHXQFkEQqueJm1V8owm0FsE/2Do55/4CcdhwiLQERaKomOnKQkyA==} + dependencies: + '@fastify/merge-json-schemas': 0.1.1 + ajv: 8.12.0 + ajv-formats: 2.1.1 + fast-deep-equal: 3.1.3 + fast-uri: 2.3.0 + json-schema-ref-resolver: 1.0.1 + rfdc: 1.3.0 + dev: true + /fast-levenshtein/2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true + /fast-querystring/1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + dependencies: + fast-decode-uri-component: 1.0.1 + dev: true + /fast-redact/3.1.2: resolution: {integrity: sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw==} engines: {node: '>=6'} @@ -3885,8 +4006,39 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} dev: true - /fastq/1.15.0: - resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} + /fast-uri/2.3.0: + resolution: {integrity: sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw==} + dev: true + + /fastify-plugin/4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + dev: true + + /fastify/4.26.0: + resolution: {integrity: sha512-Fq/7ziWKc6pYLYLIlCRaqJqEVTIZ5tZYfcW/mDK2AQ9v/sqjGFpj0On0/7hU50kbPVjLO4de+larPA1WwPZSfw==} + dependencies: + '@fastify/ajv-compiler': 3.5.0 + '@fastify/error': 3.4.1 + '@fastify/fast-json-stringify-compiler': 4.3.0 + abstract-logging: 2.0.1 + avvio: 8.3.0 + fast-content-type-parse: 1.1.0 + fast-json-stringify: 5.12.0 + find-my-way: 8.1.0 + light-my-request: 5.11.0 + pino: 8.18.0 + process-warning: 3.0.0 + proxy-addr: 2.0.7 + rfdc: 1.3.0 + secure-json-parse: 2.7.0 + semver: 7.5.4 + toad-cache: 3.7.0 + transitivePeerDependencies: + - supports-color + dev: true + + /fastq/1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} dependencies: reusify: 1.0.4 dev: true @@ -3925,6 +4077,15 @@ packages: - supports-color dev: true + /find-my-way/8.1.0: + resolution: {integrity: sha512-41QwjCGcVTODUmLLqTMeoHeiozbMXYMAE1CKFiDyi9zVZ2Vjh0yz3MF0WQZoIb+cmzP/XlbFjlF2NtJmvZHznA==} + engines: {node: '>=14'} + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 2.0.0 + dev: true + /find-node-modules/2.1.3: resolution: {integrity: sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==} dependencies: @@ -4938,6 +5099,12 @@ packages: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true + /json-schema-ref-resolver/1.0.1: + resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==} + dependencies: + fast-deep-equal: 3.1.3 + dev: true + /json-schema-traverse/0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true @@ -5073,6 +5240,14 @@ packages: type-check: 0.4.0 dev: true + /light-my-request/5.11.0: + resolution: {integrity: sha512-qkFCeloXCOMpmEdZ/MV91P8AT4fjwFXWaAFz3lUeStM8RcoM1ks4J/F8r1b3r6y/H4u3ACEJ1T+Gv5bopj7oDA==} + dependencies: + cookie: 0.5.0 + process-warning: 2.3.2 + set-cookie-parser: 2.6.0 + dev: true + /lilconfig/3.0.0: resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==} engines: {node: '>=14'} @@ -5132,9 +5307,12 @@ packages: json5: 2.2.3 dev: true - /local-pkg/0.4.3: - resolution: {integrity: sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==} + /local-pkg/0.5.0: + resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} engines: {node: '>=14'} + dependencies: + mlly: 1.4.2 + pkg-types: 1.0.3 dev: true /locate-path/5.0.0: @@ -5617,6 +5795,11 @@ packages: resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==} dev: true + /on-exit-leak-free/2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + dev: true + /on-finished/2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -5703,9 +5886,9 @@ packages: yocto-queue: 0.1.0 dev: true - /p-limit/4.0.0: - resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + /p-limit/5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} dependencies: yocto-queue: 1.0.0 dev: true @@ -5869,6 +6052,13 @@ packages: split2: 4.1.0 dev: true + /pino-abstract-transport/1.1.0: + resolution: {integrity: sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==} + dependencies: + readable-stream: 4.5.2 + split2: 4.1.0 + dev: true + /pino-pretty/7.6.1: resolution: {integrity: sha512-H7N6ZYkiyrfwBGW9CSjx0uyO9Q2Lyt73881+OTYk8v3TiTdgN92QHrWlEq/LeWw5XtDP64jeSk3mnc6T+xX9/w==} hasBin: true @@ -5892,6 +6082,10 @@ packages: resolution: {integrity: sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==} dev: true + /pino-std-serializers/6.2.2: + resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} + dev: true + /pino/7.11.0: resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} hasBin: true @@ -5909,6 +6103,23 @@ packages: thread-stream: 0.15.2 dev: true + /pino/8.18.0: + resolution: {integrity: sha512-Mz/gKiRyuXu4HnpHgi1YWdHQCoWMufapzooisvFn78zl4dZciAxS+YeRkUxXl1ee/SzU80YCz1zpECCh4oC6Aw==} + hasBin: true + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.1.2 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 1.1.0 + pino-std-serializers: 6.2.2 + process-warning: 3.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.4.2 + sonic-boom: 3.8.0 + thread-stream: 2.4.1 + dev: true + /pirates/4.0.5: resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==} engines: {node: '>= 6'} @@ -6025,6 +6236,19 @@ packages: resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} dev: true + /process-warning/2.3.2: + resolution: {integrity: sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==} + dev: true + + /process-warning/3.0.0: + resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} + dev: true + + /process/0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + dev: true + /proxy-addr/2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -6156,6 +6380,17 @@ packages: string_decoder: 1.3.0 util-deprecate: 1.0.2 + /readable-stream/4.5.2: + resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + dev: true + /readdirp/3.4.0: resolution: {integrity: sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==} engines: {node: '>=8.10.0'} @@ -6167,6 +6402,11 @@ packages: engines: {node: '>= 12.13.0'} dev: true + /real-require/0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + dev: true + /redent/3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -6274,6 +6514,11 @@ packages: signal-exit: 3.0.7 dev: true + /ret/0.2.2: + resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==} + engines: {node: '>=4'} + dev: true + /reusify/1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -6363,6 +6608,12 @@ packages: is-regex: 1.1.4 dev: true + /safe-regex2/2.0.0: + resolution: {integrity: sha512-PaUSFsUaNNuKwkBijoAPHAK6/eM6VirvyPWlZ7BAQy4D+hCvh4B6lIG+nPdhbFfIbP+gTGBcrdsOaUs0F+ZBOQ==} + dependencies: + ret: 0.2.2 + dev: true + /safe-stable-stringify/2.4.2: resolution: {integrity: sha512-gMxvPJYhP0O9n2pvcfYfIuYgbledAOJFcqRThtPRmjscaipiwcwPPKLytpVzMkG2HAN87Qmo2d4PtGiri1dSLA==} engines: {node: '>=10'} @@ -6585,6 +6836,12 @@ packages: atomic-sleep: 1.0.0 dev: true + /sonic-boom/3.8.0: + resolution: {integrity: sha512-ybz6OYOUjoQQCQ/i4LU8kaToD8ACtYP+Cj5qd2AO36bwbdewxWJ3ArmJ2cr6AvxlL2o0PqnCcPGUgkILbfkaCA==} + dependencies: + atomic-sleep: 1.0.0 + dev: true + /source-list-map/2.0.1: resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} dev: true @@ -6669,8 +6926,8 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - /std-env/3.4.3: - resolution: {integrity: sha512-f9aPhy8fYBuMN+sNfakZV18U39PbalgjXG3lLB9WkaYTxijru61wb57V9wxxNthXM5Sd88ETBWi29qLAsHO52Q==} + /std-env/3.7.0: + resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} dev: true /stream-combiner2/1.1.1: @@ -6941,6 +7198,12 @@ packages: real-require: 0.1.0 dev: true + /thread-stream/2.4.1: + resolution: {integrity: sha512-d/Ex2iWd1whipbT681JmTINKw0ZwOUBZm7+Gjs64DHuX34mmw8vJL2bFAaNacaW72zYiTJxSHi5abUuOi5nsfg==} + dependencies: + real-require: 0.2.0 + dev: true + /through/2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -6961,8 +7224,8 @@ packages: resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} dev: true - /tinypool/0.7.0: - resolution: {integrity: sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==} + /tinypool/0.8.2: + resolution: {integrity: sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==} engines: {node: '>=14.0.0'} dev: true @@ -6988,6 +7251,11 @@ packages: dependencies: is-number: 7.0.0 + /toad-cache/3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + dev: true + /toidentifier/1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -7360,14 +7628,13 @@ packages: engines: {node: '>= 0.8'} dev: true - /vite-node/0.34.6_@types+node@18.17.14: - resolution: {integrity: sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==} - engines: {node: '>=v14.18.0'} + /vite-node/1.2.2_@types+node@18.17.14: + resolution: {integrity: sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg==} + engines: {node: ^18.0.0 || >=20.0.0} hasBin: true dependencies: cac: 6.7.14 debug: 4.3.4 - mlly: 1.4.2 pathe: 1.1.1 picocolors: 1.0.0 vite: 5.0.12_@types+node@18.17.14 @@ -7418,7 +7685,7 @@ packages: fsevents: 2.3.3 dev: true - /vitest-environment-miniflare/2.14.1_vitest@0.34.6: + /vitest-environment-miniflare/2.14.1_vitest@1.2.2: resolution: {integrity: sha512-efMpx9XnpjHeIN1lnEMO+4Ky9xSFM0VeG8Ilf+5Uyh8U8lNuJ+qTTfr76LQ6MQcNzkLMo4byh0YxaZo8QfIYrw==} engines: {node: '>=16.13'} peerDependencies: @@ -7429,28 +7696,28 @@ packages: '@miniflare/shared': 2.14.1 '@miniflare/shared-test-environment': 2.14.1 undici: 5.20.0 - vitest: 0.34.6_jsdom@23.2.0 + vitest: 1.2.2_b4fzwn3atmkdkqrawvd5volizi transitivePeerDependencies: - bufferutil - utf-8-validate dev: true - /vitest/0.34.6_jsdom@23.2.0: - resolution: {integrity: sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==} - engines: {node: '>=v14.18.0'} + /vitest/1.2.2_b4fzwn3atmkdkqrawvd5volizi: + resolution: {integrity: sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw==} + engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@vitest/browser': '*' - '@vitest/ui': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': ^1.0.0 + '@vitest/ui': ^1.0.0 happy-dom: '*' jsdom: '*' - playwright: '*' - safaridriver: '*' - webdriverio: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@types/node': + optional: true '@vitest/browser': optional: true '@vitest/ui': @@ -7459,37 +7726,29 @@ packages: optional: true jsdom: optional: true - playwright: - optional: true - safaridriver: - optional: true - webdriverio: - optional: true dependencies: - '@types/chai': 4.3.9 - '@types/chai-subset': 1.3.4 '@types/node': 18.17.14 - '@vitest/expect': 0.34.6 - '@vitest/runner': 0.34.6 - '@vitest/snapshot': 0.34.6 - '@vitest/spy': 0.34.6 - '@vitest/utils': 0.34.6 - acorn: 8.11.2 - acorn-walk: 8.2.0 + '@vitest/expect': 1.2.2 + '@vitest/runner': 1.2.2 + '@vitest/snapshot': 1.2.2 + '@vitest/spy': 1.2.2 + '@vitest/utils': 1.2.2 + acorn-walk: 8.3.2 cac: 6.7.14 chai: 4.3.10 debug: 4.3.4 + execa: 8.0.1 jsdom: 23.2.0 - local-pkg: 0.4.3 + local-pkg: 0.5.0 magic-string: 0.30.5 pathe: 1.1.1 picocolors: 1.0.0 - std-env: 3.4.3 + std-env: 3.7.0 strip-literal: 1.3.0 tinybench: 2.5.1 - tinypool: 0.7.0 + tinypool: 0.8.2 vite: 5.0.12_@types+node@18.17.14 - vite-node: 0.34.6_@types+node@18.17.14 + vite-node: 1.2.2_@types+node@18.17.14 why-is-node-running: 2.2.2 transitivePeerDependencies: - less @@ -7771,19 +8030,6 @@ packages: optional: true dev: true - /ws/8.14.2: - resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: true - /ws/8.16.0: resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} engines: {node: '>=10.0.0'} From a1fb0476e09de50a4065503c7d11ea9c9b945d8b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 14 Feb 2024 18:02:39 +0100 Subject: [PATCH 11/69] fix(ws): opt-out form native event cancellation --- src/core/handlers/WebSocketHandler.ts | 28 +++++++++++++++----------- src/core/utils/handleWebSocketEvent.ts | 19 +++++++++++------ 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index be0d4e136..abb43786c 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -30,7 +30,8 @@ type WebSocketHandlerIncomingEvent = MessageEvent<{ }> export const kEmitter = Symbol('kEmitter') -export const kRun = Symbol('kRun') +export const kDispatchEvent = Symbol('kDispatchEvent') +export const kDefaultPrevented = Symbol('kDefaultPrevented') export class WebSocketHandler { protected [kEmitter]: Emitter @@ -54,25 +55,28 @@ export class WebSocketHandler { event: WebSocketHandlerIncomingEvent parsedResult: WebSocketHandlerParsedResult }): boolean { - const { match } = args.parsedResult - return match.matches + return args.parsedResult.match.matches } - async [kRun](args: { event: MessageEvent }): Promise { - const parsedResult = this.parse({ event: args.event }) - const shouldIntercept = this.predicate({ event: args.event, parsedResult }) + async [kDispatchEvent](event: MessageEvent): Promise { + const parsedResult = this.parse({ event }) + const shouldIntercept = this.predicate({ event, parsedResult }) if (!shouldIntercept) { return } - const connectionEvent = args.event - - // At this point, the WebSocket connection URL has matched the handler. - // Prevent the default behavior of establishing the connection as-is. - connectionEvent.preventDefault() + // Account for other matching event handlers that've already prevented this event. + if (!Reflect.get(event, kDefaultPrevented)) { + // At this point, the WebSocket connection URL has matched the handler. + // Prevent the default behavior of establishing the connection as-is. + // Use internal symbol because we aren't actually dispatching this + // event. Events can only marked as cancelable and can be prevented + // when dispatched on an EventTarget. + Reflect.set(event, kDefaultPrevented, true) + } - const connection = connectionEvent.data + const connection = event.data // Emit the connection event on the handler. // This is what the developer adds listeners for. diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index 2f30e31a8..20e21a8b4 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -1,5 +1,9 @@ import { RequestHandler } from '../handlers/RequestHandler' -import { WebSocketHandler, kRun } from '../handlers/WebSocketHandler' +import { + WebSocketHandler, + kDefaultPrevented, + kDispatchEvent, +} from '../handlers/WebSocketHandler' import { webSocketInterceptor } from '../ws/webSocketInterceptor' export function handleWebSocketEvent( @@ -8,7 +12,12 @@ export function handleWebSocketEvent( webSocketInterceptor.on('connection', (connection) => { const connectionEvent = new MessageEvent('connection', { data: connection, - cancelable: true, + }) + + Object.defineProperty(connectionEvent, kDefaultPrevented, { + enumerable: false, + writable: true, + value: false, }) // Iterate over the handlers and forward the connection @@ -16,15 +25,13 @@ export function handleWebSocketEvent( // to dispatching that event onto multiple listeners. for (const handler of handlers) { if (handler instanceof WebSocketHandler) { - // Never await the run function because event handlers - // are side-effectful and don't block the event loop. - handler[kRun]({ event: connectionEvent }) + handler[kDispatchEvent](connectionEvent) } } // If none of the "ws" handlers matched, // establish the WebSocket connection as-is. - if (!connectionEvent.defaultPrevented) { + if (!Reflect.get(connectionEvent, kDefaultPrevented)) { connection.server.connect() connection.client.addEventListener('message', (event) => { connection.server.send(event.data) From 2f91ac8989f87048ed2664d0bb6c725bcd52c5b6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 14 Feb 2024 18:02:55 +0100 Subject: [PATCH 12/69] test(ws): add server tests --- ...pt.test.ts => ws.intercept.client.test.ts} | 1 + test/node/ws-api/ws.intercept.server.test.ts | 132 ++++++++++++++++++ test/support/WebSocketServer.ts | 8 +- .../vitest-environment-node-websocket.ts | 7 +- 4 files changed, 144 insertions(+), 4 deletions(-) rename test/node/ws-api/{ws.intercept.test.ts => ws.intercept.client.test.ts} (99%) create mode 100644 test/node/ws-api/ws.intercept.server.test.ts diff --git a/test/node/ws-api/ws.intercept.test.ts b/test/node/ws-api/ws.intercept.client.test.ts similarity index 99% rename from test/node/ws-api/ws.intercept.test.ts rename to test/node/ws-api/ws.intercept.client.test.ts index 70db69c7f..9fcc2ed12 100644 --- a/test/node/ws-api/ws.intercept.test.ts +++ b/test/node/ws-api/ws.intercept.client.test.ts @@ -17,6 +17,7 @@ beforeAll(async () => { }) afterEach(() => { + server.resetHandlers() wsServer.closeAllClients() wsServer.removeAllListeners() }) diff --git a/test/node/ws-api/ws.intercept.server.test.ts b/test/node/ws-api/ws.intercept.server.test.ts new file mode 100644 index 000000000..4bb76e2e2 --- /dev/null +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -0,0 +1,132 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const server = setupServer() +const originalServer = new WebSocketServer() + +const service = ws.link('ws://*') + +beforeAll(async () => { + server.listen() + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.closeAllClients() + originalServer.removeAllListeners() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('intercepts incoming server text message', async () => { + const serverMessageListener = vi.fn() + const clientMessageListener = vi.fn() + + originalServer.on('connection', (client) => { + client.send('hello') + }) + server.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const ws = new WebSocket(originalServer.url) + ws.addEventListener('message', clientMessageListener) + + await vi.waitFor(() => { + expect(serverMessageListener).toHaveBeenCalledTimes(1) + + const serverMessage = serverMessageListener.mock.calls[0][0] as MessageEvent + expect(serverMessage.type).toBe('message') + expect(serverMessage.data).toBe('hello') + + expect(clientMessageListener).toHaveBeenCalledTimes(1) + + const clientMessage = clientMessageListener.mock.calls[0][0] as MessageEvent + expect(clientMessage.type).toBe('message') + expect(clientMessage.data).toBe('hello') + }) +}) + +it('intercepts incoming server Blob message', async () => { + const serverMessageListener = vi.fn() + const clientMessageListener = vi.fn() + + originalServer.on('connection', async (client) => { + /** + * @note You should use plain `Blob` instead. + * For some reason, the "ws" package has trouble accepting + * it as an input (expects a Buffer). + */ + client.send(await new Blob(['hello']).arrayBuffer()) + }) + server.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const ws = new WebSocket(originalServer.url) + ws.addEventListener('message', clientMessageListener) + + await vi.waitFor(() => { + expect(serverMessageListener).toHaveBeenCalledTimes(1) + + const serverMessage = serverMessageListener.mock.calls[0][0] as MessageEvent + expect(serverMessage.type).toBe('message') + expect(serverMessage.data).toEqual(new Blob(['hello'])) + + expect(clientMessageListener).toHaveBeenCalledTimes(1) + + const clientMessage = clientMessageListener.mock.calls[0][0] as MessageEvent + expect(clientMessage.type).toBe('message') + expect(clientMessage.data).toEqual(new Blob(['hello'])) + }) +}) + +it('intercepts incoming ArrayBuffer message', async () => { + const encoder = new TextEncoder() + const serverMessageListener = vi.fn() + const clientMessageListener = vi.fn() + + originalServer.on('connection', async (client) => { + client.send(encoder.encode('hello world')) + }) + server.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const ws = new WebSocket(originalServer.url) + ws.addEventListener('message', clientMessageListener) + + await vi.waitFor(() => { + expect(serverMessageListener).toHaveBeenCalledTimes(1) + + const serverMessage = serverMessageListener.mock.calls[0][0] as MessageEvent + expect(serverMessage.type).toBe('message') + /** + * @note For some reason, "ws" still sends back a Blob. + */ + expect(serverMessage.data).toEqual(new Blob(['hello world'])) + + expect(clientMessageListener).toHaveBeenCalledTimes(1) + + const clientMessage = clientMessageListener.mock.calls[0][0] as MessageEvent + expect(clientMessage.type).toBe('message') + expect(clientMessage.data).toEqual(new Blob(['hello world'])) + }) +}) diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts index 546c14168..773a39e9f 100644 --- a/test/support/WebSocketServer.ts +++ b/test/support/WebSocketServer.ts @@ -21,9 +21,11 @@ export class WebSocketServer extends Emitter { this.app = fastify() this.app.register(fastifyWebSocket) this.app.register(async (fastify) => { - fastify.get('/', { websocket: true }, (connection) => { - this.clients.add(connection.socket) - this.emit('connection', connection.socket) + fastify.get('/', { websocket: true }, ({ socket }) => { + this.clients.add(socket) + socket.once('close', () => this.clients.delete(socket)) + + this.emit('connection', socket) }) }) } diff --git a/test/support/environments/vitest-environment-node-websocket.ts b/test/support/environments/vitest-environment-node-websocket.ts index 4fe1b93ad..16d616f7d 100644 --- a/test/support/environments/vitest-environment-node-websocket.ts +++ b/test/support/environments/vitest-environment-node-websocket.ts @@ -9,7 +9,12 @@ export default { name: 'node-with-websocket', transformMode: 'ssr', async setup(global, options) { - const { teardown } = await builtinEnvironments.jsdom.setup(global, options) + /** + * @note It's crucial this extend the Node.js environment. + * JSDOM polyfills the global "Event", making it unusable + * with Node's "EventTarget". + */ + const { teardown } = await builtinEnvironments.node.setup(global, options) Reflect.set(globalThis, 'WebSocket', WebSocket) From 292104c4a495f5151d3bb0648131d7590d01fcab Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 14 Feb 2024 18:32:51 +0100 Subject: [PATCH 13/69] fix(ws): stale ".currentHandlers()" ref --- src/core/utils/handleWebSocketEvent.ts | 3 ++- src/node/SetupServerCommonApi.ts | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index 20e21a8b4..eebb0adc0 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -7,9 +7,10 @@ import { import { webSocketInterceptor } from '../ws/webSocketInterceptor' export function handleWebSocketEvent( - handlers: Array, + getCurrentHandlers: () => Array, ) { webSocketInterceptor.on('connection', (connection) => { + const handlers = getCurrentHandlers() const connectionEvent = new MessageEvent('connection', { data: connection, }) diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index f02dbde56..7d6d8b00e 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -18,6 +18,7 @@ import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { mergeRight } from '~/core/utils/internal/mergeRight' import { devUtils } from '~/core/utils/internal/devUtils' import type { SetupServerCommon } from './glossary' +import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' export const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', @@ -82,6 +83,10 @@ export class SetupServerCommonApi ) }, ) + + handleWebSocketEvent(() => { + return this.handlersController.currentHandlers() + }) } public listen(options: Partial = {}): void { From decffc09ce5fce4385ada3c7d9fbdd8bf9e772a5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 15 Feb 2024 11:30:22 +0100 Subject: [PATCH 14/69] test(ws): add runtime handler tests --- test/node/ws-api/ws.use.test.ts | 132 ++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 test/node/ws-api/ws.use.test.ts diff --git a/test/node/ws-api/ws.use.test.ts b/test/node/ws-api/ws.use.test.ts new file mode 100644 index 000000000..9af66826c --- /dev/null +++ b/test/node/ws-api/ws.use.test.ts @@ -0,0 +1,132 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const service = ws.link('wss://*') + +const server = setupServer( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello, client!') + } + }) + }), +) + +beforeAll(() => { + server.listen() +}) + +afterAll(() => { + server.close() +}) + +it.concurrent( + 'resolves outgoing events using initial handlers', + server.boundary(async () => { + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenCalledWith('hello, client!') + expect(messageListener).toHaveBeenCalledTimes(1) + }) + }), +) + +it.concurrent( + 'overrides an outgoing event listener', + server.boundary(async () => { + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Stopping immediate event propagation will prevent + // the same message listener in the initial handler + // from being called. + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenCalledWith('howdy, client!') + expect(messageListener).toHaveBeenCalledTimes(1) + }) + }), +) + +it.concurrent( + 'combines initial and override listeners', + server.boundary(async () => { + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Not stopping the event propagation will result in both + // the override handler and the runtime handler sending + // data to the client in order. The override handler is + // prepended, so it will send data first. + client.send('override data') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + // The runtime handler is executed first, so it sends its message first. + expect(messageListener).toHaveBeenNthCalledWith(1, 'override data') + // The initial handler will send its message next. + expect(messageListener).toHaveBeenNthCalledWith(2, 'hello, client!') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }), +) + +it.concurrent( + 'combines initial and override listeners in the opposite order', + async () => { + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Queuing the send to the next tick will ensure + // that the initial handler sends data first, + // and this override handler sends data next. + queueMicrotask(() => { + client.send('override data') + }) + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hello, client!') + expect(messageListener).toHaveBeenNthCalledWith(2, 'override data') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }, +) From 245f55fe4d8951e605d7f899164806c617f42e42 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 15 Feb 2024 13:38:35 +0100 Subject: [PATCH 15/69] test(ws): add server event patching tests --- test/node/ws-api/ws.event-patching.test.ts | 121 +++++++++++++++++++ test/node/ws-api/ws.intercept.server.test.ts | 3 +- test/node/ws-api/ws.use.test.ts | 44 +++++++ test/support/WebSocketServer.ts | 5 + 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 test/node/ws-api/ws.event-patching.test.ts diff --git a/test/node/ws-api/ws.event-patching.test.ts b/test/node/ws-api/ws.event-patching.test.ts new file mode 100644 index 000000000..320e1d7d4 --- /dev/null +++ b/test/node/ws-api/ws.event-patching.test.ts @@ -0,0 +1,121 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const service = ws.link('ws://*') +const originalServer = new WebSocketServer() + +const server = setupServer( + service.on('connection', ({ server }) => { + server.connect() + }), +) + +beforeAll(async () => { + server.listen() + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('patches incoming server message', async () => { + originalServer.once('connection', (client) => { + client.send('hi from John') + }) + + server.use( + service.on('connection', ({ client, server }) => { + /** + * @note Since the initial handler connects to the server, + * there's no need to call `server.connect()` again. + */ + server.addEventListener('message', (event) => { + // Preventing the default stops the server-to-client forwarding. + // It means that the WebSocket client won't receive the + // actual server message. + event.preventDefault() + client.send(event.data.replace('John', 'Sarah')) + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hi from Sarah') + expect(messageListener).toHaveBeenCalledTimes(1) + }) +}) + +it('combines original and mock server messages', async () => { + originalServer.once('connection', (client) => { + client.send('original message') + }) + + server.use( + service.on('connection', ({ client, server }) => { + server.addEventListener('message', () => { + client.send('mocked message') + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onopen = () => ws.send('hello') + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + /** + * @note That the server will send the message as soon as the client + * connects. This happens before the event handler is called. + */ + expect(messageListener).toHaveBeenNthCalledWith(1, 'original message') + expect(messageListener).toHaveBeenNthCalledWith(2, 'mocked message') + expect(messageListener).toHaveBeenCalledTimes(2) + }) +}) + +it('combines original and mock server messages in the different order', async () => { + originalServer.once('connection', (client) => { + client.send('original message') + }) + + server.use( + service.on('connection', ({ client, server }) => { + server.addEventListener('message', (event) => { + /** + * @note To change the incoming server events order, + * prevent the default, send a mocked message, and + * then send the original message as-is. + */ + event.preventDefault() + client.send('mocked message') + client.send(event.data) + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'mocked message') + expect(messageListener).toHaveBeenNthCalledWith(2, 'original message') + expect(messageListener).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/node/ws-api/ws.intercept.server.test.ts b/test/node/ws-api/ws.intercept.server.test.ts index 4bb76e2e2..7cc3e7651 100644 --- a/test/node/ws-api/ws.intercept.server.test.ts +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -17,8 +17,7 @@ beforeAll(async () => { afterEach(() => { server.resetHandlers() - originalServer.closeAllClients() - originalServer.removeAllListeners() + originalServer.resetState() }) afterAll(async () => { diff --git a/test/node/ws-api/ws.use.test.ts b/test/node/ws-api/ws.use.test.ts index 9af66826c..f6cc8135d 100644 --- a/test/node/ws-api/ws.use.test.ts +++ b/test/node/ws-api/ws.use.test.ts @@ -12,6 +12,10 @@ const server = setupServer( if (event.data === 'hello') { client.send('hello, client!') } + + if (event.data === 'fallthrough') { + client.send('ok') + } }) }), ) @@ -130,3 +134,43 @@ it.concurrent( }) }, ) + +it.concurrent( + 'does not affect unrelated events', + server.boundary(async () => { + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Stopping immediate event propagation will prevent + // the same message listener in the initial handler + // from being called. + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => { + messageListener(event.data) + + if (event.data === 'howdy, client!') { + ws.send('fallthrough') + } + } + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'howdy, client!') + }) + + await vi.waitFor(() => { + // The initial handler still sends data to unrelated events. + expect(messageListener).toHaveBeenNthCalledWith(2, 'ok') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }), +) diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts index 773a39e9f..c9e5861de 100644 --- a/test/support/WebSocketServer.ts +++ b/test/support/WebSocketServer.ts @@ -45,6 +45,11 @@ export class WebSocketServer extends Emitter { this._url = url.href } + public resetState(): void { + this.closeAllClients() + this.removeAllListeners() + } + public closeAllClients(): void { this.clients.forEach((client) => { client.close() From 2e7863f7c35fa5fdb141aac7a600becb8b5b3c2e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 15 Feb 2024 15:21:11 +0100 Subject: [PATCH 16/69] chore: update @mswjs/interceptors --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index ebee09b7b..042476af1 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.1", + "@mswjs/interceptors": "^0.26.2", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f05b82932..826df981c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.1 + '@mswjs/interceptors': ^0.26.2 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.1 + '@mswjs/interceptors': 0.26.2 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1139,8 +1139,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.1: - resolution: {integrity: sha512-iK3hLdSp8153NQKU8BdnJQoa0V+tBdHZPNmmAZwLLG2GN/I64PpnbyEOT81SOPFDghpfdtTccfR1L0oEpfhxTA==} + /@mswjs/interceptors/0.26.2: + resolution: {integrity: sha512-78Y/5MMYrPckJoDYl3MA5dKWn9L7pB5ue5MRkx0oLuxhHCDBLgaYwvXxrGyiwNf3xfWUrSB9FWkjob/gG8NoFQ==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 5c72475ebe1324d62627c39e4c5d251e7541de40 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 15 Feb 2024 15:21:24 +0100 Subject: [PATCH 17/69] test(ws): add server error forwarding test --- test/node/ws-api/ws.server.connect.test.ts | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 test/node/ws-api/ws.server.connect.test.ts diff --git a/test/node/ws-api/ws.server.connect.test.ts b/test/node/ws-api/ws.server.connect.test.ts new file mode 100644 index 000000000..82159ea1d --- /dev/null +++ b/test/node/ws-api/ws.server.connect.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const service = ws.link('ws://*') +const originalServer = new WebSocketServer() + +const server = setupServer() + +beforeAll(async () => { + server.listen() + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('does not connect to the actual server by default', async () => { + const serverConnectionListener = vi.fn() + const mockConnectionListener = vi.fn() + + originalServer.once('connection', serverConnectionListener) + server.use(service.on('connection', mockConnectionListener)) + + new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(mockConnectionListener).toHaveBeenCalledTimes(1) + expect(serverConnectionListener).not.toHaveBeenCalled() + }) +}) + +it('connects to the actual server after calling "server.connect()"', async () => { + const serverConnectionListener = vi.fn() + const mockConnectionListener = vi.fn() + + originalServer.once('connection', serverConnectionListener) + + server.use( + service.on('connection', ({ server }) => { + mockConnectionListener() + server.connect() + }), + ) + + new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(mockConnectionListener).toHaveBeenCalledTimes(1) + expect(serverConnectionListener).toHaveBeenCalledTimes(1) + }) +}) + +it('forward incoming server events to the client by default', async () => { + originalServer.once('connection', (client) => client.send('hello')) + + server.use( + service.on('connection', ({ server }) => { + server.connect() + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hello') + expect(messageListener).toHaveBeenCalledTimes(1) + }) +}) + +it('throws an error when connecting to a non-existing server', async () => { + server.use( + service.on('connection', ({ server }) => { + server.connect() + }), + ) + + const errorListener = vi.fn() + const ws = new WebSocket('wss://localhost:9876') + ws.onerror = errorListener + + await vi.waitFor(() => { + expect(errorListener).toHaveBeenCalledTimes(1) + }) +}) From 317a3db2252576ae39eabea0b5813ba9d4797767 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 16 Feb 2024 17:16:55 +0100 Subject: [PATCH 18/69] fix(setupWorker): add websocket event handling --- src/browser/setupWorker/setupWorker.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index 44ac57e95..8061904b6 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -22,6 +22,7 @@ import type { LifeCycleEventsMap } from '~/core/sharedOptions' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { SetupWorker } from './glossary' import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' +import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' interface Listener { target: EventTarget @@ -177,6 +178,10 @@ export class SetupWorkerApi options, ) as SetupWorkerInternalContext['startOptions'] + handleWebSocketEvent(() => { + return this.handlersController.currentHandlers() + }) + return await this.startHandler(this.context.startOptions, options) } From 0a389e77bce8d1f5bf302eda2cdc1179f1e8e569 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 16 Feb 2024 17:17:13 +0100 Subject: [PATCH 19/69] feat(ws): add "broadcast" and "broadcastExcept" apis --- src/core/ws/ws.ts | 99 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 516c87d16..0d693787b 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -1,3 +1,4 @@ +import { WebSocketClientConnection } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' import { WebSocketHandler, kEmitter, @@ -6,16 +7,29 @@ import { import type { Path } from '../utils/matching/matchRequestUrl' import { webSocketInterceptor } from './webSocketInterceptor' +const wsBroadcastChannel = new BroadcastChannel('msw:ws') + /** * Intercepts outgoing WebSocket connections to the given URL. * * @example * const chat = ws.link('wss://chat.example.com') - * chat.on('connection', (connection) => {}) + * chat.on('connection', ({ client }) => { + * client.send('hello from server!') + * }) */ function createWebSocketLinkHandler(url: Path) { webSocketInterceptor.apply() + /** + * @note The set of all WebSocket clients in THIS runtime. + * This will be the accumulated list of all clients for Node.js. + * But in the browser, each tab will create its own runtime, + * so this set will only contain the matching clients from + * that isolated runtime (no shared runtime). + */ + const runtimeClients = new Set() + return { on( event: K, @@ -23,6 +37,30 @@ function createWebSocketLinkHandler(url: Path) { ): WebSocketHandler { const handler = new WebSocketHandler(url) + wsBroadcastChannel.addEventListener('message', (event) => { + const { type, payload } = event.data + + switch (type) { + case 'message': { + const { data, ignoreClients } = payload + + runtimeClients.forEach((client) => { + if (!ignoreClients || !ignoreClients.includes(client.id)) { + client.send(data) + } + }) + break + } + } + }) + + handler[kEmitter].on('connection', ({ client }) => { + runtimeClients.add(client) + client.addEventListener('close', () => { + runtimeClients.delete(client) + }) + }) + // The "handleWebSocketEvent" function will invoke // the "run()" method on the WebSocketHandler. // If the handler matches, it will emit the "connection" @@ -31,6 +69,65 @@ function createWebSocketLinkHandler(url: Path) { return handler }, + + /** + * Broadcasts the given data to all WebSocket clients. + * + * @example + * const service = ws.link('wss://example.com') + * service.on('connection', () => { + * service.broadcast('hello, everyone!') + * }) + */ + broadcast(data: any): void { + // Broadcast to all the clients of this runtime. + runtimeClients.forEach((client) => client.send(data)) + + // Broadcast to all the clients from other runtimes. + wsBroadcastChannel.postMessage({ + type: 'message', + payload: { data }, + }) + }, + + /** + * Broadcasts the given data to all WebSocket clients + * except the ones provided in the `clients` argument. + * + * @example + * const service = ws.link('wss://example.com') + * service.on('connection', ({ client }) => { + * service.broadcastExcept(client, 'hi, the rest of you!') + * }) + */ + broadcastExcept( + clients: WebSocketClientConnection | Array, + data: any, + ): void { + const ignoreClients = Array.prototype + .concat(clients) + .map((client) => client.id) + + // Broadcast this event to all the clients of this runtime + // except for the given ignored clients. This is needed + // so a "broadcastExcept()" call in another runtime affects + // this runtime but respects the ignored clients. + runtimeClients.forEach((otherClient) => { + if (!ignoreClients.includes(otherClient.id)) { + otherClient.send(data) + } + }) + + // Broadcast to all the clients from other runtimes, + // respecting the list of ignored client IDs. + wsBroadcastChannel.postMessage({ + type: 'message', + payload: { + data, + ignoreClients, + }, + }) + }, } } From 073fe991b970d50829a8c361b27845feb77cd4f8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 18 Feb 2024 17:36:47 +0100 Subject: [PATCH 20/69] chore: update @mswjs/interceptors --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- src/core/ws/ws.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 042476af1..76407e527 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.2", + "@mswjs/interceptors": "^0.26.4", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 826df981c..2fef4672a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.2 + '@mswjs/interceptors': ^0.26.4 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.2 + '@mswjs/interceptors': 0.26.4 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1139,8 +1139,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.2: - resolution: {integrity: sha512-78Y/5MMYrPckJoDYl3MA5dKWn9L7pB5ue5MRkx0oLuxhHCDBLgaYwvXxrGyiwNf3xfWUrSB9FWkjob/gG8NoFQ==} + /@mswjs/interceptors/0.26.4: + resolution: {integrity: sha512-EMXxLxO4u574IGt7rGC5GBjDCcZL2h1FqVu/V9IRsMKVSPE9QZl8bb8LGJFZwVp2GBcJNdiw7Vdn0t5wUSBvhg==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 0d693787b..10fc1dd60 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -1,4 +1,4 @@ -import { WebSocketClientConnection } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import type { WebSocketClientConnection } from '@mswjs/interceptors/WebSocket' import { WebSocketHandler, kEmitter, From 654c91d512c507d78b989a9122d32b87559fe336 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 18 Feb 2024 19:03:43 +0100 Subject: [PATCH 21/69] feat: implement "clients" and "WebSocketClientManager" --- .../ws/SerializedWebSocketClientConnection.ts | 129 ++++++++++++++++++ src/core/ws/ws.ts | 81 ++++------- 2 files changed, 152 insertions(+), 58 deletions(-) create mode 100644 src/core/ws/SerializedWebSocketClientConnection.ts diff --git a/src/core/ws/SerializedWebSocketClientConnection.ts b/src/core/ws/SerializedWebSocketClientConnection.ts new file mode 100644 index 000000000..07d976dbb --- /dev/null +++ b/src/core/ws/SerializedWebSocketClientConnection.ts @@ -0,0 +1,129 @@ +import type { + WebSocketData, + WebSocketClientConnection, + WebSocketClientConnectionProtocol, +} from '@mswjs/interceptors/WebSocket' + +export const kAddByClientId = Symbol('kAddByClientId') + +/** + * A manager responsible for accumulating WebSocket client + * connections across different browser runtimes. + */ +export class WebSocketClientManager { + /** + * All active WebSocket client connections. + */ + public clients: Set + + constructor(private channel: BroadcastChannel) { + this.clients = new Set() + + this.channel.addEventListener('message', (message) => { + const { type, payload } = message.data + + switch (type) { + case 'connection:open': { + // When another runtime notifies about a new connection, + // create a connection wrapper class and add it to the set. + this.onRemoteConnection(payload.id, payload.url) + break + } + } + }) + } + + /** + * Adds the given WebSocket client connection to the set + * of all connections. The given connection is always the complete + * connection object because `addConnection()` is called only + * for the opened connections in the same runtime. + */ + public addConnection(client: WebSocketClientConnection): void { + // Add this connection to the immediate set of connections. + this.clients.add(client) + + // Signal to other runtimes about this connection. + this.channel.postMessage({ + type: 'connection:open', + payload: { + id: client.id, + url: client.url, + }, + }) + + // Instruct the current client how to handle events + // coming from other runtimes (e.g. when broadcasting). + this.channel.addEventListener('message', (message) => { + const { type, payload } = message.data + + // Ignore broadcasted messages for other clients. + if (payload.clientId !== client.id) { + return + } + + switch (type) { + case 'send': { + client.send(payload.data) + break + } + + case 'close': { + client.close(payload.code, payload.reason) + break + } + } + }) + } + + /** + * Adds a client connection wrapper to operate with + * WebSocket client connections in other runtimes. + */ + private onRemoteConnection(id: string, url: URL): void { + this.clients.add( + // Create a connection-compatible instance that can + // operate with this client from a different runtime + // using the BroadcastChannel messages. + new WebSocketRemoteClientConnection(id, url, this.channel), + ) + } +} + +/** + * A wrapper class to operate with WebSocket client connections + * from other runtimes. This class maintains 1-1 public API + * compatibility to the `WebSocketClientConnection` but relies + * on the given `BroadcastChannel` to communicate instructions + * with the client connections from other runtimes. + */ +class WebSocketRemoteClientConnection + implements WebSocketClientConnectionProtocol +{ + constructor( + public readonly id: string, + public readonly url: URL, + private channel: BroadcastChannel, + ) {} + + send(data: WebSocketData): void { + this.channel.postMessage({ + type: 'send', + payload: { + clientId: this.id, + data, + }, + }) + } + + close(code?: number | undefined, reason?: string | undefined): void { + this.channel.postMessage({ + type: 'close', + payload: { + clientId: this.id, + code, + reason, + }, + }) + } +} diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 10fc1dd60..033b25c99 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -1,4 +1,7 @@ -import type { WebSocketClientConnection } from '@mswjs/interceptors/WebSocket' +import type { + WebSocketClientConnectionProtocol, + WebSocketData, +} from '@mswjs/interceptors/WebSocket' import { WebSocketHandler, kEmitter, @@ -6,8 +9,9 @@ import { } from '../handlers/WebSocketHandler' import type { Path } from '../utils/matching/matchRequestUrl' import { webSocketInterceptor } from './webSocketInterceptor' +import { WebSocketClientManager } from './SerializedWebSocketClientConnection' -const wsBroadcastChannel = new BroadcastChannel('msw:ws') +const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') /** * Intercepts outgoing WebSocket connections to the given URL. @@ -20,45 +24,22 @@ const wsBroadcastChannel = new BroadcastChannel('msw:ws') */ function createWebSocketLinkHandler(url: Path) { webSocketInterceptor.apply() - - /** - * @note The set of all WebSocket clients in THIS runtime. - * This will be the accumulated list of all clients for Node.js. - * But in the browser, each tab will create its own runtime, - * so this set will only contain the matching clients from - * that isolated runtime (no shared runtime). - */ - const runtimeClients = new Set() + const clientManager = new WebSocketClientManager(wsBroadcastChannel) return { + clients: clientManager.clients, on( event: K, listener: (...args: WebSocketHandlerEventMap[K]) => void, ): WebSocketHandler { const handler = new WebSocketHandler(url) - wsBroadcastChannel.addEventListener('message', (event) => { - const { type, payload } = event.data - - switch (type) { - case 'message': { - const { data, ignoreClients } = payload - - runtimeClients.forEach((client) => { - if (!ignoreClients || !ignoreClients.includes(client.id)) { - client.send(data) - } - }) - break - } - } - }) - + // Add the connection event listener for when the + // handler matches and emits a connection event. + // When that happens, store that connection in the + // set of all connections for reference. handler[kEmitter].on('connection', ({ client }) => { - runtimeClients.add(client) - client.addEventListener('close', () => { - runtimeClients.delete(client) - }) + clientManager.addConnection(client) }) // The "handleWebSocketEvent" function will invoke @@ -79,15 +60,11 @@ function createWebSocketLinkHandler(url: Path) { * service.broadcast('hello, everyone!') * }) */ - broadcast(data: any): void { - // Broadcast to all the clients of this runtime. - runtimeClients.forEach((client) => client.send(data)) - - // Broadcast to all the clients from other runtimes. - wsBroadcastChannel.postMessage({ - type: 'message', - payload: { data }, - }) + broadcast(data: WebSocketData): void { + // This will invoke "send()" on the immediate clients + // in this runtime and post a message to the broadcast channel + // to trigger send for the clients in other runtimes. + this.broadcastExcept([], data) }, /** @@ -101,32 +78,20 @@ function createWebSocketLinkHandler(url: Path) { * }) */ broadcastExcept( - clients: WebSocketClientConnection | Array, - data: any, + clients: + | WebSocketClientConnectionProtocol + | Array, + data: WebSocketData, ): void { const ignoreClients = Array.prototype .concat(clients) .map((client) => client.id) - // Broadcast this event to all the clients of this runtime - // except for the given ignored clients. This is needed - // so a "broadcastExcept()" call in another runtime affects - // this runtime but respects the ignored clients. - runtimeClients.forEach((otherClient) => { + clientManager.clients.forEach((otherClient) => { if (!ignoreClients.includes(otherClient.id)) { otherClient.send(data) } }) - - // Broadcast to all the clients from other runtimes, - // respecting the list of ignored client IDs. - wsBroadcastChannel.postMessage({ - type: 'message', - payload: { - data, - ignoreClients, - }, - }) }, } } From ec0154f9f151fe381b7d73cc1262f4aba0d8fb4c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 25 Feb 2024 22:52:32 +0100 Subject: [PATCH 22/69] chore(WebSocketClientManager): annotate channel messages --- .../ws/SerializedWebSocketClientConnection.ts | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/core/ws/SerializedWebSocketClientConnection.ts b/src/core/ws/SerializedWebSocketClientConnection.ts index 07d976dbb..5f354c7b0 100644 --- a/src/core/ws/SerializedWebSocketClientConnection.ts +++ b/src/core/ws/SerializedWebSocketClientConnection.ts @@ -4,6 +4,30 @@ import type { WebSocketClientConnectionProtocol, } from '@mswjs/interceptors/WebSocket' +type WebSocketBroadcastChannelMessage = + | { + type: 'connection:open' + payload: { + id: string + url: URL + } + } + | { + type: 'send' + payload: { + clientId: string + data: WebSocketData + } + } + | { + type: 'close' + payload: { + clientId: string + code?: number + reason?: string + } + } + export const kAddByClientId = Symbol('kAddByClientId') /** @@ -20,7 +44,7 @@ export class WebSocketClientManager { this.clients = new Set() this.channel.addEventListener('message', (message) => { - const { type, payload } = message.data + const { type, payload } = message.data as WebSocketBroadcastChannelMessage switch (type) { case 'connection:open': { @@ -50,15 +74,19 @@ export class WebSocketClientManager { id: client.id, url: client.url, }, - }) + } as WebSocketBroadcastChannelMessage) // Instruct the current client how to handle events // coming from other runtimes (e.g. when broadcasting). this.channel.addEventListener('message', (message) => { - const { type, payload } = message.data + const { type, payload } = message.data as WebSocketBroadcastChannelMessage // Ignore broadcasted messages for other clients. - if (payload.clientId !== client.id) { + if ( + typeof payload === 'object' && + 'clientId' in payload && + payload.clientId !== client.id + ) { return } @@ -113,7 +141,7 @@ class WebSocketRemoteClientConnection clientId: this.id, data, }, - }) + } as WebSocketBroadcastChannelMessage) } close(code?: number | undefined, reason?: string | undefined): void { @@ -124,6 +152,6 @@ class WebSocketRemoteClientConnection code, reason, }, - }) + } as WebSocketBroadcastChannelMessage) } } From e22174f85ae917997aa17ae0be0eae037876343e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 25 Feb 2024 22:55:35 +0100 Subject: [PATCH 23/69] chore: rename file to "webSocketClientManager" --- ...izedWebSocketClientConnection.ts => WebSocketClientManager.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/core/ws/{SerializedWebSocketClientConnection.ts => WebSocketClientManager.ts} (100%) diff --git a/src/core/ws/SerializedWebSocketClientConnection.ts b/src/core/ws/WebSocketClientManager.ts similarity index 100% rename from src/core/ws/SerializedWebSocketClientConnection.ts rename to src/core/ws/WebSocketClientManager.ts From 54c5bafa1f68dd7cf9f18222723ef58c9334b6d6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 27 Feb 2024 17:33:26 +0100 Subject: [PATCH 24/69] test(WebSocketClientManager): add unit tests --- src/core/ws/WebSocketClientManager.test.ts | 159 +++++++++++++++++++++ src/core/ws/WebSocketClientManager.ts | 45 +++--- src/core/ws/ws.ts | 2 +- vitest.config.ts | 2 + 4 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 src/core/ws/WebSocketClientManager.test.ts diff --git a/src/core/ws/WebSocketClientManager.test.ts b/src/core/ws/WebSocketClientManager.test.ts new file mode 100644 index 000000000..56aa26f0f --- /dev/null +++ b/src/core/ws/WebSocketClientManager.test.ts @@ -0,0 +1,159 @@ +/** + * @vitest-environment node-websocket + */ +import { randomUUID } from 'node:crypto' +import { + WebSocketClientConnection, + WebSocketTransport, +} from '@mswjs/interceptors/WebSocket' +import { + WebSocketClientManager, + WebSocketBroadcastChannelMessage, + WebSocketRemoteClientConnection, +} from './WebSocketClientManager' + +const channel = new BroadcastChannel('test:channel') +vi.spyOn(channel, 'postMessage') + +const socket = new WebSocket('ws://localhost') +const transport = { + onOutgoing: vi.fn(), + onIncoming: vi.fn(), + onClose: vi.fn(), + send: vi.fn(), + close: vi.fn(), +} satisfies WebSocketTransport + +afterEach(() => { + vi.resetAllMocks() +}) + +it('adds a client from this runtime to the list of clients', () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection(socket, transport) + + manager.addConnection(connection) + + // Must add the client to the list of clients. + expect(Array.from(manager.clients.values())).toEqual([connection]) + + // Must emit the connection open event to notify other runtimes. + expect(channel.postMessage).toHaveBeenCalledWith({ + type: 'connection:open', + payload: { + clientId: connection.id, + url: new URL(socket.url), + }, + } satisfies WebSocketBroadcastChannelMessage) +}) + +it('adds a client from another runtime to the list of clients', async () => { + const clientId = randomUUID() + const url = new URL('ws://localhost') + const manager = new WebSocketClientManager(channel) + + channel.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'connection:open', + payload: { + clientId, + url, + }, + }, + }), + ) + + await vi.waitFor(() => { + expect(Array.from(manager.clients.values())).toEqual([ + new WebSocketRemoteClientConnection(clientId, url, channel), + ]) + }) +}) + +it('replays a "send" event coming from another runtime', async () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection(socket, transport) + manager.addConnection(connection) + vi.spyOn(connection, 'send') + + // Emulate another runtime signaling this connection to receive data. + channel.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'extraneous:send', + payload: { + clientId: connection.id, + data: 'hello', + }, + }, + }), + ) + + await vi.waitFor(() => { + // Must execute the requested operation on the connection. + expect(connection.send).toHaveBeenCalledWith('hello') + expect(connection.send).toHaveBeenCalledTimes(1) + }) +}) + +it('replays a "close" event coming from another runtime', async () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection(socket, transport) + manager.addConnection(connection) + vi.spyOn(connection, 'close') + + // Emulate another runtime signaling this connection to close. + channel.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'extraneous:close', + payload: { + clientId: connection.id, + code: 1000, + reason: 'Normal closure', + }, + }, + }), + ) + + await vi.waitFor(() => { + // Must execute the requested operation on the connection. + expect(connection.close).toHaveBeenCalledWith(1000, 'Normal closure') + expect(connection.close).toHaveBeenCalledTimes(1) + }) +}) + +it('removes the extraneous message listener when the connection closes', async () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection(socket, transport) + vi.spyOn(connection, 'close').mockImplementationOnce(() => { + /** + * @note This is a nasty hack so we don't have to uncouple + * the connection from transport. Creating a mock transport + * is difficult because it relies on the `WebSocketOverride` class. + * All we care here is that closing the connection triggers + * the transport closure, which it always does. + */ + connection['transport'].onClose() + }) + vi.spyOn(connection, 'send') + + manager.addConnection(connection) + connection.close() + + // Signals from other runtimes have no effect on the closed connection. + channel.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'extraneous:send', + payload: { + clientId: connection.id, + data: 'hello', + }, + }, + }), + ) + + expect(connection.send).not.toHaveBeenCalled() +}) diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index 5f354c7b0..06939f213 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -4,23 +4,23 @@ import type { WebSocketClientConnectionProtocol, } from '@mswjs/interceptors/WebSocket' -type WebSocketBroadcastChannelMessage = +export type WebSocketBroadcastChannelMessage = | { type: 'connection:open' payload: { - id: string + clientId: string url: URL } } | { - type: 'send' + type: 'extraneous:send' payload: { clientId: string data: WebSocketData } } | { - type: 'close' + type: 'extraneous:close' payload: { clientId: string code?: number @@ -50,7 +50,7 @@ export class WebSocketClientManager { case 'connection:open': { // When another runtime notifies about a new connection, // create a connection wrapper class and add it to the set. - this.onRemoteConnection(payload.id, payload.url) + this.onRemoteConnection(payload.clientId, payload.url) break } } @@ -58,28 +58,29 @@ export class WebSocketClientManager { } /** - * Adds the given WebSocket client connection to the set + * Adds the given `WebSocket` client connection to the set * of all connections. The given connection is always the complete * connection object because `addConnection()` is called only * for the opened connections in the same runtime. */ public addConnection(client: WebSocketClientConnection): void { - // Add this connection to the immediate set of connections. this.clients.add(client) // Signal to other runtimes about this connection. this.channel.postMessage({ type: 'connection:open', payload: { - id: client.id, + clientId: client.id, url: client.url, }, } as WebSocketBroadcastChannelMessage) // Instruct the current client how to handle events - // coming from other runtimes (e.g. when broadcasting). - this.channel.addEventListener('message', (message) => { - const { type, payload } = message.data as WebSocketBroadcastChannelMessage + // coming from other runtimes (e.g. when calling `.broadcast()`). + const handleExtraneousMessage = ( + message: MessageEvent, + ) => { + const { type, payload } = message.data // Ignore broadcasted messages for other clients. if ( @@ -91,16 +92,28 @@ export class WebSocketClientManager { } switch (type) { - case 'send': { + case 'extraneous:send': { client.send(payload.data) break } - case 'close': { + case 'extraneous:close': { client.close(payload.code, payload.reason) break } } + } + + const abortController = new AbortController() + + this.channel.addEventListener('message', handleExtraneousMessage, { + signal: abortController.signal, + }) + + // Once closed, this connection cannot be operated on. + // This must include the extraneous runtimes as well. + client.addEventListener('close', () => abortController.abort(), { + once: true, }) } @@ -125,7 +138,7 @@ export class WebSocketClientManager { * on the given `BroadcastChannel` to communicate instructions * with the client connections from other runtimes. */ -class WebSocketRemoteClientConnection +export class WebSocketRemoteClientConnection implements WebSocketClientConnectionProtocol { constructor( @@ -136,7 +149,7 @@ class WebSocketRemoteClientConnection send(data: WebSocketData): void { this.channel.postMessage({ - type: 'send', + type: 'extraneous:send', payload: { clientId: this.id, data, @@ -146,7 +159,7 @@ class WebSocketRemoteClientConnection close(code?: number | undefined, reason?: string | undefined): void { this.channel.postMessage({ - type: 'close', + type: 'extraneous:close', payload: { clientId: this.id, code, diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 033b25c99..2b66dda88 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -9,7 +9,7 @@ import { } from '../handlers/WebSocketHandler' import type { Path } from '../utils/matching/matchRequestUrl' import { webSocketInterceptor } from './webSocketInterceptor' -import { WebSocketClientManager } from './SerializedWebSocketClientConnection' +import { WebSocketClientManager } from './WebSocketClientManager' const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') diff --git a/vitest.config.ts b/vitest.config.ts index f007e4c53..bf071eab6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ // they are located next to the source code they are testing. dir: './src', alias: { + 'vitest-environment-node-websocket': + './test/support/environments/vitest-environment-node-websocket', '~/core': path.resolve(__dirname, 'src/core'), }, typecheck: { From 8f1071b266aa5a2f09b388f21527fdfa7d98e927 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 27 Feb 2024 18:46:09 +0100 Subject: [PATCH 25/69] test(ws): add unit tests --- src/core/utils/matching/matchRequestUrl.ts | 4 ++++ src/core/ws/ws.test.ts | 23 ++++++++++++++++++++++ src/core/ws/ws.ts | 11 ++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/core/ws/ws.test.ts diff --git a/src/core/utils/matching/matchRequestUrl.ts b/src/core/utils/matching/matchRequestUrl.ts index 3b9ce6ebf..5ea0115d4 100644 --- a/src/core/utils/matching/matchRequestUrl.ts +++ b/src/core/utils/matching/matchRequestUrl.ts @@ -71,3 +71,7 @@ export function matchRequestUrl(url: URL, path: Path, baseUrl?: string): Match { params, } } + +export function isPath(value: unknown): value is Path { + return typeof value === 'string' || value instanceof RegExp +} diff --git a/src/core/ws/ws.test.ts b/src/core/ws/ws.test.ts new file mode 100644 index 000000000..b5a7ef46d --- /dev/null +++ b/src/core/ws/ws.test.ts @@ -0,0 +1,23 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from './ws' + +it('exports the "link()" method', () => { + expect(ws).toHaveProperty('link') + expect(ws.link).toBeInstanceOf(Function) +}) + +it('throws an error when calling "ws.link()" without a URL argument', () => { + expect(() => + // @ts-expect-error Intentionally invalid call. + ws.link(), + ).toThrow('Expected a WebSocket server URL but got undefined') +}) + +it('throws an error when given a non-path argument to "ws.link()"', () => { + expect(() => + // @ts-expect-error Intentionally invalid argument. + ws.link(2), + ).toThrow('Expected a WebSocket server URL but got number') +}) diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 2b66dda88..01cec7973 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -1,3 +1,4 @@ +import { invariant } from 'outvariant' import type { WebSocketClientConnectionProtocol, WebSocketData, @@ -7,7 +8,7 @@ import { kEmitter, type WebSocketHandlerEventMap, } from '../handlers/WebSocketHandler' -import type { Path } from '../utils/matching/matchRequestUrl' +import { Path, isPath } from '../utils/matching/matchRequestUrl' import { webSocketInterceptor } from './webSocketInterceptor' import { WebSocketClientManager } from './WebSocketClientManager' @@ -23,6 +24,14 @@ const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') * }) */ function createWebSocketLinkHandler(url: Path) { + invariant(url, 'Expected a WebSocket server URL but got undefined') + + invariant( + isPath(url), + 'Expected a WebSocket server URL but got %s', + typeof url, + ) + webSocketInterceptor.apply() const clientManager = new WebSocketClientManager(wsBroadcastChannel) From b070b341a8e74da912fede2d5ceaff7bcbda603c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 27 Feb 2024 20:27:58 +0100 Subject: [PATCH 26/69] chore: upgrade @mswjs/interceptors to 0.26.6 --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 76407e527..c38f304b4 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.4", + "@mswjs/interceptors": "^0.26.6", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fef4672a..1bda1b38f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.4 + '@mswjs/interceptors': ^0.26.6 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.4 + '@mswjs/interceptors': 0.26.6 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1139,8 +1139,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.4: - resolution: {integrity: sha512-EMXxLxO4u574IGt7rGC5GBjDCcZL2h1FqVu/V9IRsMKVSPE9QZl8bb8LGJFZwVp2GBcJNdiw7Vdn0t5wUSBvhg==} + /@mswjs/interceptors/0.26.6: + resolution: {integrity: sha512-ce2Jn2ODQfVm3VaTy38Uwadh61FhY7mWMBHJB7IltLnWXAa/eXepbqFnXgrw8PCemPXsyf+WOQ0D0CB1GzazGg==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 8f070153ceaa177708ed16e6fe8ec6ee3e19dd73 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 28 Feb 2024 19:31:28 +0100 Subject: [PATCH 27/69] chore: fix "singleThread" vitest option --- test/node/vitest.config.ts | 35 +++++++++++++++-------------------- test/support/alias.ts | 20 ++++++++++++++++++++ vitest.config.ts | 13 +++++++++---- 3 files changed, 44 insertions(+), 24 deletions(-) create mode 100644 test/support/alias.ts diff --git a/test/node/vitest.config.ts b/test/node/vitest.config.ts index 53f3e1525..0557df44e 100644 --- a/test/node/vitest.config.ts +++ b/test/node/vitest.config.ts @@ -1,35 +1,30 @@ -import * as path from 'node:path' import { defineConfig } from 'vitest/config' - -const LIB_DIR = path.resolve(__dirname, '../../lib') +import { mswExports, customViteEnvironments } from '../support/alias' export default defineConfig({ test: { - /** - * @note Paths are resolved against CWD. - */ dir: './test/node', globals: true, alias: { - 'vitest-environment-node-websocket': - './test/support/environments/vitest-environment-node-websocket', - 'msw/node': path.resolve(LIB_DIR, 'node/index.mjs'), - 'msw/native': path.resolve(LIB_DIR, 'native/index.mjs'), - 'msw/browser': path.resolve(LIB_DIR, 'browser/index.mjs'), - msw: path.resolve(LIB_DIR, 'core/index.mjs'), + ...mswExports, + ...customViteEnvironments, }, environmentOptions: { jsdom: { url: 'http://localhost/', }, }, - /** - * @note Run Node.js integration tests in sequence. - * There's a test that involves building the library, - * which results in the "lib" directory being deleted. - * If any tests attempt to run during that window, - * they will fail, unable to resolve the "msw" import alias. - */ - singleThread: true, + poolOptions: { + threads: { + /** + * @note Run Node.js integration tests in sequence. + * There's a test that involves building the library, + * which results in the "lib" directory being deleted. + * If any tests attempt to run during that window, + * they will fail, unable to resolve the "msw" import alias. + */ + singleThread: true, + }, + }, }, }) diff --git a/test/support/alias.ts b/test/support/alias.ts new file mode 100644 index 000000000..7b8b49433 --- /dev/null +++ b/test/support/alias.ts @@ -0,0 +1,20 @@ +import * as path from 'node:path' + +const ROOT = path.resolve(__dirname, '../..') + +export function fromRoot(...paths: Array): string { + return path.resolve(ROOT, ...paths) +} + +export const mswExports = { + 'msw/node': fromRoot('/lib/node/index.mjs'), + 'msw/native': fromRoot('/lib/native/index.mjs'), + 'msw/browser': fromRoot('/lib/browser/index.mjs'), + msw: fromRoot('lib/core/index.mjs'), +} + +export const customViteEnvironments = { + 'vitest-environment-node-websocket': fromRoot( + '/test/support/environments/vitest-environment-node-websocket', + ), +} diff --git a/vitest.config.ts b/vitest.config.ts index bf071eab6..57ebb5dde 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,9 @@ -import * as path from 'node:path' import { defineConfig } from 'vitest/config' +import { + mswExports, + customViteEnvironments, + fromRoot, +} from './test/support/alias' export default defineConfig({ test: { @@ -8,9 +12,9 @@ export default defineConfig({ // they are located next to the source code they are testing. dir: './src', alias: { - 'vitest-environment-node-websocket': - './test/support/environments/vitest-environment-node-websocket', - '~/core': path.resolve(__dirname, 'src/core'), + ...mswExports, + ...customViteEnvironments, + '~/core': fromRoot('src/core'), }, typecheck: { // Load the TypeScript configuration to the unit tests. @@ -26,4 +30,5 @@ export default defineConfig({ }, }, }, + plugins: [tsconfigPaths()], }) From 7fed9732cacc57cb174c737055e3e32b03b2199d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 28 Feb 2024 20:37:18 +0100 Subject: [PATCH 28/69] chore: tidying up --- src/core/utils/handleWebSocketEvent.ts | 6 ++++++ test/node/ws-api/ws.intercept.client.test.ts | 10 ++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index eebb0adc0..284775807 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -13,6 +13,12 @@ export function handleWebSocketEvent( const handlers = getCurrentHandlers() const connectionEvent = new MessageEvent('connection', { data: connection, + /** + * @note This message event should be marked as "cancelable" + * to have its default prevented using "event.preventDefault()". + * There's a bug in Node.js that breaks the "cancelable" flag. + * @see https://github.com/nodejs/node/issues/51767 + */ }) Object.defineProperty(connectionEvent, kDefaultPrevented, { diff --git a/test/node/ws-api/ws.intercept.client.test.ts b/test/node/ws-api/ws.intercept.client.test.ts index 9fcc2ed12..d16ae9b35 100644 --- a/test/node/ws-api/ws.intercept.client.test.ts +++ b/test/node/ws-api/ws.intercept.client.test.ts @@ -4,7 +4,6 @@ import { ws } from 'msw' import { setupServer } from 'msw/node' import { WebSocketServer } from '../../support/WebSocketServer' -import { waitFor } from '../../support/waitFor' const server = setupServer() const wsServer = new WebSocketServer() @@ -18,8 +17,7 @@ beforeAll(async () => { afterEach(() => { server.resetHandlers() - wsServer.closeAllClients() - wsServer.removeAllListeners() + wsServer.resetState() }) afterAll(async () => { @@ -41,7 +39,7 @@ it('intercepts outgoing client text message', async () => { const ws = new WebSocket(wsServer.url) ws.onopen = () => ws.send('hello') - await waitFor(() => { + await vi.waitFor(() => { // Must intercept the outgoing client message event. expect(mockMessageListener).toHaveBeenCalledTimes(1) @@ -69,7 +67,7 @@ it('intercepts outgoing client Blob message', async () => { const ws = new WebSocket(wsServer.url) ws.onopen = () => ws.send(new Blob(['hello'])) - await waitFor(() => { + await vi.waitFor(() => { expect(mockMessageListener).toHaveBeenCalledTimes(1) const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent @@ -96,7 +94,7 @@ it('intercepts outgoing client ArrayBuffer message', async () => { const ws = new WebSocket(wsServer.url) ws.onopen = () => ws.send(new TextEncoder().encode('hello')) - await waitFor(() => { + await vi.waitFor(() => { expect(mockMessageListener).toHaveBeenCalledTimes(1) const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent From 9c6eeba57967d2be2d9c50d087bd1d76fea002fa Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 29 Feb 2024 16:21:56 +0100 Subject: [PATCH 29/69] fix: apply interceptor in listen/start --- src/browser/setupWorker/setupWorker.ts | 6 ++++++ src/core/utils/handleWebSocketEvent.ts | 1 + src/core/ws/ws.ts | 2 -- src/node/SetupServerCommonApi.ts | 3 +++ test/support/WebSocketServer.ts | 4 ++-- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index 8061904b6..24c008b30 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -22,6 +22,7 @@ import type { LifeCycleEventsMap } from '~/core/sharedOptions' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { SetupWorker } from './glossary' import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' +import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' interface Listener { @@ -181,6 +182,11 @@ export class SetupWorkerApi handleWebSocketEvent(() => { return this.handlersController.currentHandlers() }) + webSocketInterceptor.apply() + + this.subscriptions.push(() => { + webSocketInterceptor.dispose() + }) return await this.startHandler(this.context.startOptions, options) } diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index 284775807..5563d49a6 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -11,6 +11,7 @@ export function handleWebSocketEvent( ) { webSocketInterceptor.on('connection', (connection) => { const handlers = getCurrentHandlers() + const connectionEvent = new MessageEvent('connection', { data: connection, /** diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 01cec7973..c20b6bafb 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -9,7 +9,6 @@ import { type WebSocketHandlerEventMap, } from '../handlers/WebSocketHandler' import { Path, isPath } from '../utils/matching/matchRequestUrl' -import { webSocketInterceptor } from './webSocketInterceptor' import { WebSocketClientManager } from './WebSocketClientManager' const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') @@ -32,7 +31,6 @@ function createWebSocketLinkHandler(url: Path) { typeof url, ) - webSocketInterceptor.apply() const clientManager = new WebSocketClientManager(wsBroadcastChannel) return { diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index 7d6d8b00e..d293efdfd 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -19,6 +19,7 @@ import { mergeRight } from '~/core/utils/internal/mergeRight' import { devUtils } from '~/core/utils/internal/devUtils' import type { SetupServerCommon } from './glossary' import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' +import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' export const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', @@ -97,9 +98,11 @@ export class SetupServerCommonApi // Apply the interceptor when starting the server. this.interceptor.apply() + webSocketInterceptor.apply() this.subscriptions.push(() => { this.interceptor.dispose() + webSocketInterceptor.dispose() }) // Assert that the interceptor has been applied successfully. diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts index c9e5861de..1aa76eb43 100644 --- a/test/support/WebSocketServer.ts +++ b/test/support/WebSocketServer.ts @@ -38,8 +38,8 @@ export class WebSocketServer extends Emitter { return this._url } - public async listen(): Promise { - const address = await this.app.listen({ port: 0 }) + public async listen(port = 0): Promise { + const address = await this.app.listen({ port }) const url = new URL(address) url.protocol = url.protocol.replace(/^http/, 'ws') this._url = url.href From 082610a373e0c691ce7af7e7fea06b212cdd3577 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 29 Feb 2024 16:29:36 +0100 Subject: [PATCH 30/69] fix: updates interceptors to ditch revocable proxy --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c38f304b4..3bb136c99 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.6", + "@mswjs/interceptors": "^0.26.7", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bda1b38f..cd54fcf67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.6 + '@mswjs/interceptors': ^0.26.7 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.6 + '@mswjs/interceptors': 0.26.7 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1139,8 +1139,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.6: - resolution: {integrity: sha512-ce2Jn2ODQfVm3VaTy38Uwadh61FhY7mWMBHJB7IltLnWXAa/eXepbqFnXgrw8PCemPXsyf+WOQ0D0CB1GzazGg==} + /@mswjs/interceptors/0.26.7: + resolution: {integrity: sha512-5i7QZqJSmLLerm4HWDenpQmo0OBpegAP/q9mX2qobP1rz7ozfyFR5nXxzlCFrWBJk3JmHXlxQk32Vl2j4ONcTA==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From c5f6e52925cdccce8411f0ab5c934092a1f2ef6f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 29 Feb 2024 17:25:19 +0100 Subject: [PATCH 31/69] chore: remove "tsconfigPaths" from vitest.config.ts --- vitest.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index 57ebb5dde..43406fdc1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,5 +30,4 @@ export default defineConfig({ }, }, }, - plugins: [tsconfigPaths()], }) From 9750c3b6d1ef095c97dff5c90a211fc49d2bb514 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 29 Feb 2024 17:56:27 +0100 Subject: [PATCH 32/69] chore: forcefully exit in node-esm tests --- .gitignore | 3 ++- package.json | 2 +- test/modules/node/esm-node.test.ts | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 89624bdd8..e5a2045fd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ msw-*.tgz # Smoke test temporary files. /package.json.copy -/examples \ No newline at end of file +/examples +/test/modules/node/node-esm-tests diff --git a/package.json b/package.json index 987966201..cd71916b7 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "test:node": "vitest run --config=./test/node/vitest.config.ts", "test:native": "vitest --config=./test/native/vitest.config.ts", "test:browser": "playwright test -c ./test/browser/playwright.config.ts", - "test:modules:node": "vitest --config=./test/modules/node/vitest.config.ts", + "test:modules:node": "vitest run --config=./test/modules/node/vitest.config.ts", "test:modules:browser": "playwright test -c ./test/modules/browser/playwright.config.ts", "test:ts": "ts-node test/typings/run.ts", "prepare": "pnpm simple-git-hooks init", diff --git a/test/modules/node/esm-node.test.ts b/test/modules/node/esm-node.test.ts index 303a19e7f..e8619916e 100644 --- a/test/modules/node/esm-node.test.ts +++ b/test/modules/node/esm-node.test.ts @@ -32,6 +32,7 @@ const server = setupServer( http.get('/resource', () => new Response()) ) console.log(typeof server.listen) +process.exit(0) `, }) @@ -77,12 +78,13 @@ console.log('msw/node:', require.resolve('msw/node')) console.log('msw/native:', require.resolve('msw/native')) `, 'runtime.cjs': ` -import { http } from 'msw' -import { setupServer } from 'msw/node' +const { http } = require('msw') +const { setupServer } = require('msw/node') const server = setupServer( http.get('/resource', () => new Response()) ) console.log(typeof server.listen) +process.exit(0) `, }) From 1bb42ae5a8ca354e207b2fe0ddffce1b25974ff4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 12:37:06 +0100 Subject: [PATCH 33/69] fix(WebSocketClientManager): post URL string, cannot clone URL instances --- src/core/ws/WebSocketClientManager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index 06939f213..4048878b6 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -9,7 +9,7 @@ export type WebSocketBroadcastChannelMessage = type: 'connection:open' payload: { clientId: string - url: URL + url: string } } | { @@ -50,7 +50,7 @@ export class WebSocketClientManager { case 'connection:open': { // When another runtime notifies about a new connection, // create a connection wrapper class and add it to the set. - this.onRemoteConnection(payload.clientId, payload.url) + this.onRemoteConnection(payload.clientId, new URL(payload.url)) break } } @@ -71,7 +71,7 @@ export class WebSocketClientManager { type: 'connection:open', payload: { clientId: client.id, - url: client.url, + url: client.url.toString(), }, } as WebSocketBroadcastChannelMessage) From 69f13903cfe1a1b81bbc63aacd613c7deaad0b5d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 12:39:59 +0100 Subject: [PATCH 34/69] test: add client interception browser tests --- .../ws.intercept.cilent.browser.test.ts | 102 ++++++++++++++++++ test/browser/ws-api/ws.runtime.js | 10 ++ 2 files changed, 112 insertions(+) create mode 100644 test/browser/ws-api/ws.intercept.cilent.browser.test.ts create mode 100644 test/browser/ws-api/ws.runtime.js diff --git a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts new file mode 100644 index 000000000..891402465 --- /dev/null +++ b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts @@ -0,0 +1,102 @@ +import { test, expect } from '../playwright.extend' +import { ws } from '../../../src/core/ws/ws' +import { SetupWorker } from '../../../src/browser' + +declare global { + interface Window { + msw: { + ws: typeof ws + worker: SetupWorker + } + } +} + +test('intercepts outgoing client text message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + const clientMessagePromise = page.evaluate(() => { + const { worker, ws } = window.msw + + const service = ws.link('wss://example.com') + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + resolve(event.data) + }) + }), + ) + }) + }) + + await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => socket.send('hello world') + }) + + expect(await clientMessagePromise).toBe('hello world') +}) + +test('intercepts outgoing client Blob message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + const clientMessagePromise = page.evaluate(() => { + const { worker, ws } = window.msw + + const service = ws.link('wss://example.com') + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + resolve(event.data.text()) + }) + }), + ) + }) + }) + + await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => socket.send(new Blob(['hello world'])) + }) + + expect(await clientMessagePromise).toBe('hello world') +}) + +test('intercepts outgoing client ArrayBuffer message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + const clientMessagePromise = page.evaluate(() => { + const { worker, ws } = window.msw + + const service = ws.link('wss://example.com') + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + resolve(new TextDecoder().decode(event.data)) + }) + }), + ) + }) + }) + + await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => socket.send(new TextEncoder().encode('hello world')) + }) + + expect(await clientMessagePromise).toBe('hello world') +}) diff --git a/test/browser/ws-api/ws.runtime.js b/test/browser/ws-api/ws.runtime.js new file mode 100644 index 000000000..377022dde --- /dev/null +++ b/test/browser/ws-api/ws.runtime.js @@ -0,0 +1,10 @@ +import { ws } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker() +worker.start() + +window.msw = { + ws, + worker, +} From f1f9f73765b510961c83e1c8df4e62f38599166a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 14:07:49 +0100 Subject: [PATCH 35/69] chore: update @mswjs/interceptors to 0.26.8 --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 0481c1ee5..1b56d106b 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.7", + "@mswjs/interceptors": "^0.26.8", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96b56730f..373ea46e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.7 + '@mswjs/interceptors': ^0.26.8 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.7 + '@mswjs/interceptors': 0.26.8 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1116,8 +1116,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.7: - resolution: {integrity: sha512-5i7QZqJSmLLerm4HWDenpQmo0OBpegAP/q9mX2qobP1rz7ozfyFR5nXxzlCFrWBJk3JmHXlxQk32Vl2j4ONcTA==} + /@mswjs/interceptors/0.26.8: + resolution: {integrity: sha512-3vxmn2JDZqK4bGdH/0Dip9sZGE/ALCPtfJmDqrli7aL3zBv2pciti2etdqC0xGYcbGHpeRd+CkMVt7F/FYjikQ==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 3b5fa3d99282b2f23619f355ea1c9ef058372bfe Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 14:08:11 +0100 Subject: [PATCH 36/69] chore(WebSocketServer): use ipv4 addresses for "path-to-regexp" matching --- test/support/WebSocketServer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts index 1aa76eb43..8995fb8d7 100644 --- a/test/support/WebSocketServer.ts +++ b/test/support/WebSocketServer.ts @@ -39,7 +39,10 @@ export class WebSocketServer extends Emitter { } public async listen(port = 0): Promise { - const address = await this.app.listen({ port }) + const address = await this.app.listen({ + host: '127.0.0.1', + port, + }) const url = new URL(address) url.protocol = url.protocol.replace(/^http/, 'ws') this._url = url.href From 01ccef7ce029dd248696e10597c367ea5eb7dc4e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 14:08:40 +0100 Subject: [PATCH 37/69] test: call socket instance "socket" in tests --- test/node/ws-api/ws.intercept.client.test.ts | 19 ++++++++++--------- test/node/ws-api/ws.intercept.server.test.ts | 13 +++++++------ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/test/node/ws-api/ws.intercept.client.test.ts b/test/node/ws-api/ws.intercept.client.test.ts index d16ae9b35..9f9380482 100644 --- a/test/node/ws-api/ws.intercept.client.test.ts +++ b/test/node/ws-api/ws.intercept.client.test.ts @@ -36,8 +36,8 @@ it('intercepts outgoing client text message', async () => { ) wsServer.on('connection', realConnectionListener) - const ws = new WebSocket(wsServer.url) - ws.onopen = () => ws.send('hello') + const socket = new WebSocket(wsServer.url) + socket.onopen = () => socket.send('hello') await vi.waitFor(() => { // Must intercept the outgoing client message event. @@ -46,7 +46,7 @@ it('intercepts outgoing client text message', async () => { const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent expect(messageEvent.type).toBe('message') expect(messageEvent.data).toBe('hello') - expect(messageEvent.target).toBe(ws) + expect(messageEvent.target).toBe(socket) // Must not connect to the actual server by default. expect(realConnectionListener).not.toHaveBeenCalled() @@ -64,8 +64,8 @@ it('intercepts outgoing client Blob message', async () => { ) wsServer.on('connection', realConnectionListener) - const ws = new WebSocket(wsServer.url) - ws.onopen = () => ws.send(new Blob(['hello'])) + const socket = new WebSocket(wsServer.url) + socket.onopen = () => socket.send(new Blob(['hello'])) await vi.waitFor(() => { expect(mockMessageListener).toHaveBeenCalledTimes(1) @@ -73,7 +73,7 @@ it('intercepts outgoing client Blob message', async () => { const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent expect(messageEvent.type).toBe('message') expect(messageEvent.data.size).toBe(5) - expect(messageEvent.target).toEqual(ws) + expect(messageEvent.target).toEqual(socket) // Must not connect to the actual server by default. expect(realConnectionListener).not.toHaveBeenCalled() @@ -91,8 +91,9 @@ it('intercepts outgoing client ArrayBuffer message', async () => { ) wsServer.on('connection', realConnectionListener) - const ws = new WebSocket(wsServer.url) - ws.onopen = () => ws.send(new TextEncoder().encode('hello')) + const socket = new WebSocket(wsServer.url) + socket.binaryType = 'arraybuffer' + socket.onopen = () => socket.send(new TextEncoder().encode('hello')) await vi.waitFor(() => { expect(mockMessageListener).toHaveBeenCalledTimes(1) @@ -100,7 +101,7 @@ it('intercepts outgoing client ArrayBuffer message', async () => { const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent expect(messageEvent.type).toBe('message') expect(messageEvent.data).toEqual(new TextEncoder().encode('hello')) - expect(messageEvent.target).toEqual(ws) + expect(messageEvent.target).toEqual(socket) // Must not connect to the actual server by default. expect(realConnectionListener).not.toHaveBeenCalled() diff --git a/test/node/ws-api/ws.intercept.server.test.ts b/test/node/ws-api/ws.intercept.server.test.ts index 7cc3e7651..cfb4542b5 100644 --- a/test/node/ws-api/ws.intercept.server.test.ts +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -39,8 +39,8 @@ it('intercepts incoming server text message', async () => { }), ) - const ws = new WebSocket(originalServer.url) - ws.addEventListener('message', clientMessageListener) + const socket = new WebSocket(originalServer.url) + socket.addEventListener('message', clientMessageListener) await vi.waitFor(() => { expect(serverMessageListener).toHaveBeenCalledTimes(1) @@ -76,8 +76,8 @@ it('intercepts incoming server Blob message', async () => { }), ) - const ws = new WebSocket(originalServer.url) - ws.addEventListener('message', clientMessageListener) + const socket = new WebSocket(originalServer.url) + socket.addEventListener('message', clientMessageListener) await vi.waitFor(() => { expect(serverMessageListener).toHaveBeenCalledTimes(1) @@ -109,8 +109,9 @@ it('intercepts incoming ArrayBuffer message', async () => { }), ) - const ws = new WebSocket(originalServer.url) - ws.addEventListener('message', clientMessageListener) + const socket = new WebSocket(originalServer.url) + socket.binaryType = 'arraybuffer' + socket.addEventListener('message', clientMessageListener) await vi.waitFor(() => { expect(serverMessageListener).toHaveBeenCalledTimes(1) From 7daef9e1e0d10081777e0d1c294a31a8786edcab Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 14:08:47 +0100 Subject: [PATCH 38/69] test: add server websocket tests --- .../ws.intercept.server.browser.test.ts | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 test/browser/ws-api/ws.intercept.server.browser.test.ts diff --git a/test/browser/ws-api/ws.intercept.server.browser.test.ts b/test/browser/ws-api/ws.intercept.server.browser.test.ts new file mode 100644 index 000000000..b547226b1 --- /dev/null +++ b/test/browser/ws-api/ws.intercept.server.browser.test.ts @@ -0,0 +1,152 @@ +import { test, expect } from '../playwright.extend' +import type { ws } from '../../../src/core/ws/ws' +import type { SetupWorker } from '../../../src/browser' +import { WebSocketServer } from '../../support/WebSocketServer' + +const server = new WebSocketServer() + +declare global { + interface Window { + msw: { + ws: typeof ws + worker: SetupWorker + } + } +} + +test.beforeAll(async () => { + await server.listen() +}) + +test.afterAll(async () => { + await server.close() +}) + +test('intercepts incoming server text message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + server.on('connection', (client) => { + client.send('hello') + }) + + const serverMessagePromise = page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + resolve(event.data) + }) + }), + ) + }) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onerror = () => reject(new Error('Socket error')) + socket.addEventListener('message', (event) => { + resolve(event.data) + }) + }) + }, server.url) + + expect(clientMessage).toBe('hello') + expect(await serverMessagePromise).toBe('hello') +}) + +test('intercepts incoming server Blob message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + server.on('connection', async (client) => { + /** + * @note `ws` doesn't accept sending Blobs. + */ + client.send(await new Blob(['hello']).arrayBuffer()) + }) + + const serverMessagePromise = page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + resolve(event.data.text()) + }) + }), + ) + }) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onerror = () => reject(new Error('Socket error')) + socket.addEventListener('message', (event) => { + resolve(event.data.text()) + }) + }) + }, server.url) + + expect(clientMessage).toBe('hello') + expect(await serverMessagePromise).toBe('hello') +}) + +test('intercepts outgoing server ArrayBuffer message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + const encoder = new TextEncoder() + server.on('connection', async (client) => { + client.binaryType = 'arraybuffer' + client.send(encoder.encode('hello')) + }) + + const serverMessagePromise = page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + resolve(new TextDecoder().decode(event.data)) + }) + }), + ) + }) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + socket.binaryType = 'arraybuffer' + + return new Promise((resolve, reject) => { + socket.onerror = () => reject(new Error('Socket error')) + socket.addEventListener('message', (event) => { + resolve(new TextDecoder().decode(event.data)) + }) + }) + }, server.url) + + expect(clientMessage).toBe('hello') + expect(await serverMessagePromise).toBe('hello') +}) From 7ca1306dfb70aea4f480ad1fdcad96857afc9d54 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 14:14:59 +0100 Subject: [PATCH 39/69] test(WebSocketClientManager): adjust tests for "url" type change --- src/core/ws/WebSocketClientManager.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/ws/WebSocketClientManager.test.ts b/src/core/ws/WebSocketClientManager.test.ts index 56aa26f0f..9cac29c04 100644 --- a/src/core/ws/WebSocketClientManager.test.ts +++ b/src/core/ws/WebSocketClientManager.test.ts @@ -42,7 +42,7 @@ it('adds a client from this runtime to the list of clients', () => { type: 'connection:open', payload: { clientId: connection.id, - url: new URL(socket.url), + url: socket.url, }, } satisfies WebSocketBroadcastChannelMessage) }) @@ -58,7 +58,7 @@ it('adds a client from another runtime to the list of clients', async () => { type: 'connection:open', payload: { clientId, - url, + url: url.href, }, }, }), From 082d62860eb276c8ce86351b88497a0f80a7a108 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 16:35:46 +0100 Subject: [PATCH 40/69] test: assert on ArrayBuffer instead of Blob --- test/node/ws-api/ws.intercept.server.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/node/ws-api/ws.intercept.server.test.ts b/test/node/ws-api/ws.intercept.server.test.ts index cfb4542b5..261291cc4 100644 --- a/test/node/ws-api/ws.intercept.server.test.ts +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -100,6 +100,7 @@ it('intercepts incoming ArrayBuffer message', async () => { const clientMessageListener = vi.fn() originalServer.on('connection', async (client) => { + client.binaryType = 'arraybuffer' client.send(encoder.encode('hello world')) }) server.use( @@ -118,15 +119,12 @@ it('intercepts incoming ArrayBuffer message', async () => { const serverMessage = serverMessageListener.mock.calls[0][0] as MessageEvent expect(serverMessage.type).toBe('message') - /** - * @note For some reason, "ws" still sends back a Blob. - */ - expect(serverMessage.data).toEqual(new Blob(['hello world'])) + expect(new TextDecoder().decode(serverMessage.data)).toBe('hello world') expect(clientMessageListener).toHaveBeenCalledTimes(1) const clientMessage = clientMessageListener.mock.calls[0][0] as MessageEvent expect(clientMessage.type).toBe('message') - expect(clientMessage.data).toEqual(new Blob(['hello world'])) + expect(new TextDecoder().decode(clientMessage.data)).toBe('hello world') }) }) From a0e0db167dc095380e630f6ad6b84ba05035c8a1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 16:52:21 +0100 Subject: [PATCH 41/69] test: add server.connect() browser tests --- .../ws.intercept.server.browser.test.ts | 4 +- .../ws-api/ws.server.connect.browser.test.ts | 122 ++++++++++++++++++ test/node/ws-api/ws.server.connect.test.ts | 2 +- 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 test/browser/ws-api/ws.server.connect.browser.test.ts diff --git a/test/browser/ws-api/ws.intercept.server.browser.test.ts b/test/browser/ws-api/ws.intercept.server.browser.test.ts index b547226b1..b77e341ba 100644 --- a/test/browser/ws-api/ws.intercept.server.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.server.browser.test.ts @@ -3,8 +3,6 @@ import type { ws } from '../../../src/core/ws/ws' import type { SetupWorker } from '../../../src/browser' import { WebSocketServer } from '../../support/WebSocketServer' -const server = new WebSocketServer() - declare global { interface Window { msw: { @@ -14,6 +12,8 @@ declare global { } } +const server = new WebSocketServer() + test.beforeAll(async () => { await server.listen() }) diff --git a/test/browser/ws-api/ws.server.connect.browser.test.ts b/test/browser/ws-api/ws.server.connect.browser.test.ts new file mode 100644 index 000000000..bb6b35f8a --- /dev/null +++ b/test/browser/ws-api/ws.server.connect.browser.test.ts @@ -0,0 +1,122 @@ +import { test, expect } from '../playwright.extend' +import type { ws } from '../../../src/core/ws/ws' +import type { SetupWorker } from '../../../src/browser' +import { WebSocketServer } from '../../support/WebSocketServer' + +declare global { + interface Window { + msw: { + ws: typeof ws + worker: SetupWorker + } + } +} + +const server = new WebSocketServer() + +test.beforeAll(async () => { + await server.listen() +}) + +test.afterAll(async () => { + await server.close() +}) + +test('does not connect to the actual server by default', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + server.once('connection', (client) => { + client.send('must not receive this') + }) + + await page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + worker.use( + service.on('connection', ({ client }) => { + queueMicrotask(() => client.send('mock')) + }), + ) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onmessage = (event) => { + resolve(event.data) + socket.close() + } + socket.onerror = reject + }) + }, server.url) + + expect(clientMessage).toBe('mock') +}) + +test('forwards incoming server events to the client once connected', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + server.once('connection', (client) => { + client.send('hello from server') + }) + + await page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + worker.use( + service.on('connection', ({ server }) => { + // Calling "connect()" establishes the connection + // to the actual WebSocket server. + server.connect() + }), + ) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onmessage = (event) => { + resolve(event.data) + socket.close() + } + socket.onerror = reject + }) + }, server.url) + + expect(clientMessage).toBe('hello from server') +}) + +test('throws an error when connecting to a non-existing server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + const error = await page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ server }) => { + server.connect() + }), + ) + + const socket = new WebSocket(serverUrl) + socket.onerror = () => resolve('Connection failed') + }) + }, 'ws://non-existing-websocket-address.com') + + expect(error).toMatch('Connection failed') +}) diff --git a/test/node/ws-api/ws.server.connect.test.ts b/test/node/ws-api/ws.server.connect.test.ts index 82159ea1d..59a1ddf44 100644 --- a/test/node/ws-api/ws.server.connect.test.ts +++ b/test/node/ws-api/ws.server.connect.test.ts @@ -61,7 +61,7 @@ it('connects to the actual server after calling "server.connect()"', async () => }) }) -it('forward incoming server events to the client by default', async () => { +it('forwards incoming server events to the client once connected', async () => { originalServer.once('connection', (client) => client.send('hello')) server.use( From 38ea8422acb27aa418f1041666f4123ead5fb418 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 16:52:35 +0100 Subject: [PATCH 42/69] test: add no error on non-existing connect browser test --- .../ws.intercept.cilent.browser.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts index 891402465..9ab18de82 100644 --- a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts @@ -11,6 +11,35 @@ declare global { } } +test('does not throw on connecting to a non-existing host', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + await page.evaluate(() => { + const { worker, ws } = window.msw + const service = ws.link('*') + + worker.use( + service.on('connection', ({ client }) => { + queueMicrotask(() => client.close()) + }), + ) + }) + + const clientClosePromise = page.evaluate(() => { + const socket = new WebSocket('ws://non-existing-host.com') + + return new Promise((resolve, reject) => { + socket.onclose = () => resolve() + socket.onerror = reject + }) + }) + + await expect(clientClosePromise).resolves.toBeUndefined() +}) + test('intercepts outgoing client text message', async ({ loadExample, page, From 8ec2183ae5cbb88da87bf43bbd49a6a1a68e3925 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 17:12:09 +0100 Subject: [PATCH 43/69] test: add ws.use() browser tests --- .../ws.intercept.cilent.browser.test.ts | 53 ++-- .../ws.intercept.server.browser.test.ts | 42 +-- test/browser/ws-api/ws.runtime.js | 5 +- test/browser/ws-api/ws.use.browser.test.ts | 258 ++++++++++++++++++ 4 files changed, 316 insertions(+), 42 deletions(-) create mode 100644 test/browser/ws-api/ws.use.browser.test.ts diff --git a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts index 9ab18de82..ac0e9fc33 100644 --- a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts @@ -1,12 +1,12 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' import { test, expect } from '../playwright.extend' -import { ws } from '../../../src/core/ws/ws' -import { SetupWorker } from '../../../src/browser' declare global { interface Window { msw: { ws: typeof ws - worker: SetupWorker + setupWorker: typeof setupWorker } } } @@ -15,17 +15,20 @@ test('does not throw on connecting to a non-existing host', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) - await page.evaluate(() => { - const { worker, ws } = window.msw + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw const service = ws.link('*') - worker.use( + const worker = setupWorker( service.on('connection', ({ client }) => { queueMicrotask(() => client.close()) }), ) + await worker.start() }) const clientClosePromise = page.evaluate(() => { @@ -44,21 +47,23 @@ test('intercepts outgoing client text message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) const clientMessagePromise = page.evaluate(() => { - const { worker, ws } = window.msw - + const { setupWorker, ws } = window.msw const service = ws.link('wss://example.com') - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ client }) => { client.addEventListener('message', (event) => { resolve(event.data) }) }), ) + await worker.start() }) }) @@ -74,21 +79,23 @@ test('intercepts outgoing client Blob message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) const clientMessagePromise = page.evaluate(() => { - const { worker, ws } = window.msw - + const { setupWorker, ws } = window.msw const service = ws.link('wss://example.com') - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ client }) => { client.addEventListener('message', (event) => { resolve(event.data.text()) }) }), ) + await worker.start() }) }) @@ -104,21 +111,23 @@ test('intercepts outgoing client ArrayBuffer message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) const clientMessagePromise = page.evaluate(() => { - const { worker, ws } = window.msw - + const { setupWorker, ws } = window.msw const service = ws.link('wss://example.com') - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ client }) => { client.addEventListener('message', (event) => { resolve(new TextDecoder().decode(event.data)) }) }), ) + await worker.start() }) }) diff --git a/test/browser/ws-api/ws.intercept.server.browser.test.ts b/test/browser/ws-api/ws.intercept.server.browser.test.ts index b77e341ba..d70b972b6 100644 --- a/test/browser/ws-api/ws.intercept.server.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.server.browser.test.ts @@ -1,13 +1,13 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' import { test, expect } from '../playwright.extend' -import type { ws } from '../../../src/core/ws/ws' -import type { SetupWorker } from '../../../src/browser' import { WebSocketServer } from '../../support/WebSocketServer' declare global { interface Window { msw: { ws: typeof ws - worker: SetupWorker + setupWorker: typeof setupWorker } } } @@ -26,18 +26,20 @@ test('intercepts incoming server text message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) server.on('connection', (client) => { client.send('hello') }) const serverMessagePromise = page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { @@ -45,6 +47,7 @@ test('intercepts incoming server text message', async ({ }) }), ) + await worker.start() }) }, server.url) @@ -67,21 +70,24 @@ test('intercepts incoming server Blob message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) server.on('connection', async (client) => { /** - * @note `ws` doesn't accept sending Blobs. + * `ws` doesn't support sending Blobs. + * @see https://github.com/websockets/ws/issues/2206 */ client.send(await new Blob(['hello']).arrayBuffer()) }) const serverMessagePromise = page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { @@ -89,6 +95,7 @@ test('intercepts incoming server Blob message', async ({ }) }), ) + await worker.start() }) }, server.url) @@ -111,7 +118,9 @@ test('intercepts outgoing server ArrayBuffer message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) const encoder = new TextEncoder() server.on('connection', async (client) => { @@ -120,11 +129,11 @@ test('intercepts outgoing server ArrayBuffer message', async ({ }) const serverMessagePromise = page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { @@ -132,6 +141,7 @@ test('intercepts outgoing server ArrayBuffer message', async ({ }) }), ) + await worker.start() }) }, server.url) diff --git a/test/browser/ws-api/ws.runtime.js b/test/browser/ws-api/ws.runtime.js index 377022dde..59b41ee87 100644 --- a/test/browser/ws-api/ws.runtime.js +++ b/test/browser/ws-api/ws.runtime.js @@ -1,10 +1,7 @@ import { ws } from 'msw' import { setupWorker } from 'msw/browser' -const worker = setupWorker() -worker.start() - window.msw = { ws, - worker, + setupWorker, } diff --git a/test/browser/ws-api/ws.use.browser.test.ts b/test/browser/ws-api/ws.use.browser.test.ts new file mode 100644 index 000000000..9d5b07165 --- /dev/null +++ b/test/browser/ws-api/ws.use.browser.test.ts @@ -0,0 +1,258 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('resolves outgoing events using initial handlers', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello from mock') + } + }) + }), + ) + await worker.start() + }) + + const clientMessage = await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => resolve(event.data) + socket.onerror = reject + }) + }) + + expect(clientMessage).toBe('hello from mock') +}) + +test('overrides an outgoing event listener', async ({ loadExample, page }) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('must not be sent') + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + }) + + const clientMessage = await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onopen = () => socket.send('hello') + + socket.onmessage = (event) => resolve(event.data) + socket.onerror = reject + }) + }) + + expect(clientMessage).toBe('howdy, client!') +}) + +test('combines initial and override listeners', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // This will be sent the last since the initial + // event listener is attached the first. + client.send('hello from mock') + client.close() + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // This will be sent first since the override listener + // is attached the last. + client.send('override data') + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => messages.push(event.data) + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['override data', 'hello from mock']) +}) + +test('combines initial and override listeners in the opposite order', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello from mock') + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Queue this send to the next tick so it + // happens after the initial listener's send. + queueMicrotask(() => { + client.send('override data') + client.close() + }) + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => messages.push(event.data) + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['hello from mock', 'override data']) +}) + +test('does not affect unrelated events', async ({ loadExample, page }) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('must not be sent') + } + + if (event.data === 'fallthrough') { + client.send('ok') + client.close() + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => { + messages.push(event.data) + if (event.data === 'howdy, client!') { + socket.send('fallthrough') + } + } + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['howdy, client!', 'ok']) +}) From 47aa4732a5205f87bbc7060e0557f9fd94b0a74a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 17:19:26 +0100 Subject: [PATCH 44/69] test: fix leftout test --- .../ws-api/ws.server.connect.browser.test.ts | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/test/browser/ws-api/ws.server.connect.browser.test.ts b/test/browser/ws-api/ws.server.connect.browser.test.ts index bb6b35f8a..6577a99ee 100644 --- a/test/browser/ws-api/ws.server.connect.browser.test.ts +++ b/test/browser/ws-api/ws.server.connect.browser.test.ts @@ -1,13 +1,13 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' import { test, expect } from '../playwright.extend' -import type { ws } from '../../../src/core/ws/ws' -import type { SetupWorker } from '../../../src/browser' import { WebSocketServer } from '../../support/WebSocketServer' declare global { interface Window { msw: { ws: typeof ws - worker: SetupWorker + setupWorker: typeof setupWorker } } } @@ -26,21 +26,24 @@ test('does not connect to the actual server by default', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) server.once('connection', (client) => { client.send('must not receive this') }) - await page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + await page.evaluate(async (serverUrl) => { + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - worker.use( + const worker = setupWorker( service.on('connection', ({ client }) => { queueMicrotask(() => client.send('mock')) }), ) + await worker.start() }, server.url) const clientMessage = await page.evaluate((serverUrl) => { @@ -62,23 +65,26 @@ test('forwards incoming server events to the client once connected', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) server.once('connection', (client) => { client.send('hello from server') }) - await page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + await page.evaluate(async (serverUrl) => { + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - worker.use( + const worker = setupWorker( service.on('connection', ({ server }) => { // Calling "connect()" establishes the connection // to the actual WebSocket server. server.connect() }), ) + await worker.start() }, server.url) const clientMessage = await page.evaluate((serverUrl) => { @@ -100,18 +106,21 @@ test('throws an error when connecting to a non-existing server', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) const error = await page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ server }) => { server.connect() }), ) + await worker.start() const socket = new WebSocket(serverUrl) socket.onerror = () => resolve('Connection failed') From 09b4087f9f2ece97ff9114a06cd57be22a9ccbe3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 17:26:23 +0100 Subject: [PATCH 45/69] chore(release): v2.3.0-ws.rc-1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1b56d106b..92637b4f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.2.3", + "version": "2.3.0-ws.rc-1", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From 372f1833b2ecb34fee668959d1cec1c3d6d37ed7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 14 Mar 2024 18:34:20 +0100 Subject: [PATCH 46/69] fix: export "WebSocketHandler" and "WebSocketHandlerEventMap" --- src/core/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index 5cfdb4b59..18d06ab56 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,15 +2,19 @@ import { checkGlobals } from './utils/internal/checkGlobals' export { SetupApi } from './SetupApi' -/* Request handlers */ +/* HTTP handlers */ export { RequestHandler } from './handlers/RequestHandler' export { http } from './http' export { HttpHandler, HttpMethods } from './handlers/HttpHandler' export { graphql } from './graphql' export { GraphQLHandler } from './handlers/GraphQLHandler' -/* WebSocket */ +/* WebSocket handler */ export { ws } from './ws/ws' +export { + WebSocketHandler, + WebSocketHandlerEventMap, +} from './handlers/WebSocketHandler' /* Utils */ export { matchRequestUrl } from './utils/matching/matchRequestUrl' From b04859c95ed3f6c8e5f18274b221d52ecb6236ef Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 17 Mar 2024 22:15:37 +0100 Subject: [PATCH 47/69] fix: marks "WebSocketHandlerEventMap" as type export --- src/core/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/index.ts b/src/core/index.ts index 18d06ab56..97564c760 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -13,7 +13,7 @@ export { GraphQLHandler } from './handlers/GraphQLHandler' export { ws } from './ws/ws' export { WebSocketHandler, - WebSocketHandlerEventMap, + type WebSocketHandlerEventMap, } from './handlers/WebSocketHandler' /* Utils */ From 3e8a345a83d5fc9c2ce56b533b931266f58beb20 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 24 Mar 2024 19:06:11 +0100 Subject: [PATCH 48/69] chore: update @mswjs/interceptors to 0.26.11 (server.close) --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c4d7ad127..65243f627 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.8", + "@mswjs/interceptors": "^0.26.11", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0518856f2..026b6f59e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.8 + '@mswjs/interceptors': ^0.26.11 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.1 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.8 + '@mswjs/interceptors': 0.26.11 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1116,8 +1116,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.8: - resolution: {integrity: sha512-3vxmn2JDZqK4bGdH/0Dip9sZGE/ALCPtfJmDqrli7aL3zBv2pciti2etdqC0xGYcbGHpeRd+CkMVt7F/FYjikQ==} + /@mswjs/interceptors/0.26.11: + resolution: {integrity: sha512-hSRh0Ia1br2vf+Tec++btQ402XM+IcAqqGdbka54h1HkxIZUc7s2WpJux1Cfke0ubequL2BkNg4Be3xKfvehGA==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 7a93c9503f627e5fd44c8aba571a2037825aeba0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 24 Mar 2024 19:14:21 +0100 Subject: [PATCH 49/69] test(setupServer): ws apply test --- test/node/ws-api/ws.listen.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 test/node/ws-api/ws.listen.test.ts diff --git a/test/node/ws-api/ws.listen.test.ts b/test/node/ws-api/ws.listen.test.ts new file mode 100644 index 000000000..a4911bd77 --- /dev/null +++ b/test/node/ws-api/ws.listen.test.ts @@ -0,0 +1,24 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const api = ws.link('wss://example.com') +const server = setupServer(api.on('connection', () => {})) + +afterAll(() => { + server.close() +}) + +it('does not apply the interceptor until server.listen() is called', async () => { + const raw = new WebSocket('wss://example.com') + expect(raw.constructor.name).toBe('WebSocket') + expect(raw).toBeInstanceOf(EventTarget) + + server.listen() + + const mocked = new WebSocket('wss://example.com') + expect(mocked.constructor.name).not.toBe('WebSocket') + expect(mocked).toBeInstanceOf(EventTarget) +}) From 04ae9e9a942dc2d080a1b4a4bbcd5889fc597eb8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 24 Mar 2024 19:18:17 +0100 Subject: [PATCH 50/69] test(setupWorker): ws apply test --- test/browser/ws-api/ws.apply.browser.test.ts | 44 ++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test/browser/ws-api/ws.apply.browser.test.ts diff --git a/test/browser/ws-api/ws.apply.browser.test.ts b/test/browser/ws-api/ws.apply.browser.test.ts new file mode 100644 index 000000000..749d0f7c1 --- /dev/null +++ b/test/browser/ws-api/ws.apply.browser.test.ts @@ -0,0 +1,44 @@ +import type { ws } from 'msw' +import type { SetupWorker, setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + worker: SetupWorker + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('does not apply the interceptor until "worker.start()" is called', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(() => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + window.worker = setupWorker(api.on('connection', () => {})) + }) + + await expect( + page.evaluate(() => { + return new WebSocket('wss://example.com').constructor.name + }), + ).resolves.toBe('WebSocket') + + await page.evaluate(async () => { + await window.worker.start() + }) + + await expect( + page.evaluate(() => { + return new WebSocket('wss://example.com').constructor.name + }), + ).resolves.not.toBe('WebSocket') +}) From 59a648b51b5f2c18129183797027ec57a56a4be0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 24 Mar 2024 19:18:36 +0100 Subject: [PATCH 51/69] test: fix typo in "ws.intercept.client.browser.test.ts" --- ...cilent.browser.test.ts => ws.intercept.client.browser.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/browser/ws-api/{ws.intercept.cilent.browser.test.ts => ws.intercept.client.browser.test.ts} (100%) diff --git a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts b/test/browser/ws-api/ws.intercept.client.browser.test.ts similarity index 100% rename from test/browser/ws-api/ws.intercept.cilent.browser.test.ts rename to test/browser/ws-api/ws.intercept.client.browser.test.ts From 32dacec76854dff6ede68a6dc5afb8beca989bea Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 24 Mar 2024 20:19:00 +0100 Subject: [PATCH 52/69] test: rename test for consistency --- test/node/ws-api/ws.apply.test.ts | 34 ++++++++++++++++++++++++++++++ test/node/ws-api/ws.listen.test.ts | 24 --------------------- 2 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 test/node/ws-api/ws.apply.test.ts delete mode 100644 test/node/ws-api/ws.listen.test.ts diff --git a/test/node/ws-api/ws.apply.test.ts b/test/node/ws-api/ws.apply.test.ts new file mode 100644 index 000000000..137c65ef4 --- /dev/null +++ b/test/node/ws-api/ws.apply.test.ts @@ -0,0 +1,34 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer() + +afterEach(() => { + server.close() +}) + +it('does not patch WebSocket class if no event handlers were defined', () => { + server.listen() + + const raw = new WebSocket('wss://example.com') + expect(raw.constructor.name).toBe('WebSocket') + expect(raw).toBeInstanceOf(EventTarget) +}) + +it('does not patch WebSocket class until server.listen() is called', () => { + const api = ws.link('wss://example.com') + server.use(api.on('connection', () => {})) + + const raw = new WebSocket('wss://example.com') + expect(raw.constructor.name).toBe('WebSocket') + expect(raw).toBeInstanceOf(EventTarget) + + server.listen() + + const mocked = new WebSocket('wss://example.com') + expect(mocked.constructor.name).not.toBe('WebSocket') + expect(mocked).toBeInstanceOf(EventTarget) +}) diff --git a/test/node/ws-api/ws.listen.test.ts b/test/node/ws-api/ws.listen.test.ts deleted file mode 100644 index a4911bd77..000000000 --- a/test/node/ws-api/ws.listen.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @vitest-environment node-websocket - */ -import { ws } from 'msw' -import { setupServer } from 'msw/node' - -const api = ws.link('wss://example.com') -const server = setupServer(api.on('connection', () => {})) - -afterAll(() => { - server.close() -}) - -it('does not apply the interceptor until server.listen() is called', async () => { - const raw = new WebSocket('wss://example.com') - expect(raw.constructor.name).toBe('WebSocket') - expect(raw).toBeInstanceOf(EventTarget) - - server.listen() - - const mocked = new WebSocket('wss://example.com') - expect(mocked.constructor.name).not.toBe('WebSocket') - expect(mocked).toBeInstanceOf(EventTarget) -}) From 180205d24621cc5d55921b1b50872f448dd06aaf Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 26 Mar 2024 14:58:47 +0100 Subject: [PATCH 53/69] fix(getTimestamp): support milliseconds --- src/core/utils/logging/getTimestamp.test.ts | 20 ++++++++++++++------ src/core/utils/logging/getTimestamp.ts | 15 +++++++++++++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/core/utils/logging/getTimestamp.test.ts b/src/core/utils/logging/getTimestamp.test.ts index f7c70dc0c..be71c21d4 100644 --- a/src/core/utils/logging/getTimestamp.test.ts +++ b/src/core/utils/logging/getTimestamp.test.ts @@ -1,18 +1,26 @@ import { getTimestamp } from './getTimestamp' beforeAll(() => { - // Stub native `Date` prototype methods used in the tested module, - // to always produce a predictable value for testing purposes. - vi.spyOn(global.Date.prototype, 'getHours').mockImplementation(() => 12) - vi.spyOn(global.Date.prototype, 'getMinutes').mockImplementation(() => 4) - vi.spyOn(global.Date.prototype, 'getSeconds').mockImplementation(() => 8) + vi.useFakeTimers() }) afterAll(() => { - vi.restoreAllMocks() + vi.useRealTimers() }) test('returns a timestamp string of the invocation time', () => { + vi.setSystemTime(new Date('2024-01-01 12:4:8')) const timestamp = getTimestamp() expect(timestamp).toBe('12:04:08') }) + +test('returns a timestamp with milliseconds', () => { + vi.setSystemTime(new Date('2024-01-01 12:4:8')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.000') + + vi.setSystemTime(new Date('2024-01-01 12:4:8.4')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.400') + + vi.setSystemTime(new Date('2024-01-01 12:4:8.123')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.123') +}) diff --git a/src/core/utils/logging/getTimestamp.ts b/src/core/utils/logging/getTimestamp.ts index 28e8d689a..6a4be8a07 100644 --- a/src/core/utils/logging/getTimestamp.ts +++ b/src/core/utils/logging/getTimestamp.ts @@ -1,12 +1,23 @@ +interface GetTimestampOptions { + milliseconds?: boolean +} + /** * Returns a timestamp string in a "HH:MM:SS" format. */ -export function getTimestamp(): string { +export function getTimestamp(options?: GetTimestampOptions): string { const now = new Date() - return [now.getHours(), now.getMinutes(), now.getSeconds()] + let timestamp = [now.getHours(), now.getMinutes(), now.getSeconds()] + .filter(Boolean) .map(String) .map((chunk) => chunk.slice(0, 2)) .map((chunk) => chunk.padStart(2, '0')) .join(':') + + if (options?.milliseconds) { + timestamp += `.${now.getMilliseconds().toString().padStart(3, '0')}` + } + + return timestamp } From 64b6a82e1a9a79a6e650ee2a50628cfd7583018d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 29 Mar 2024 17:45:50 +0100 Subject: [PATCH 54/69] test(ws): assert WebSocket patch without event handlers --- test/node/ws-api/ws.apply.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/node/ws-api/ws.apply.test.ts b/test/node/ws-api/ws.apply.test.ts index 137c65ef4..e958f1797 100644 --- a/test/node/ws-api/ws.apply.test.ts +++ b/test/node/ws-api/ws.apply.test.ts @@ -10,11 +10,11 @@ afterEach(() => { server.close() }) -it('does not patch WebSocket class if no event handlers were defined', () => { +it('patches WebSocket class even if no event handlers were defined', () => { server.listen() const raw = new WebSocket('wss://example.com') - expect(raw.constructor.name).toBe('WebSocket') + expect(raw.constructor.name).toBe('WebSocketOverride') expect(raw).toBeInstanceOf(EventTarget) }) From fc8b67d32d82f59fc0a4b4e454ed69938ae7f2f4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 30 Mar 2024 10:38:12 +0100 Subject: [PATCH 55/69] feat(ws): add logging to WebSocket connections (#2112) --- package.json | 2 +- pnpm-lock.yaml | 8 +- src/browser/setupWorker/setupWorker.ts | 21 +- src/core/handlers/WebSocketHandler.ts | 60 +- src/core/utils/handleWebSocketEvent.ts | 68 +- src/core/utils/logging/getTimestamp.test.ts | 6 + src/core/utils/logging/getTimestamp.ts | 10 +- src/core/ws/utils/attachWebSocketLogger.ts | 262 ++++++++ src/core/ws/utils/getMessageLength.test.ts | 16 + src/core/ws/utils/getMessageLength.ts | 19 + src/core/ws/utils/getPublicData.test.ts | 38 ++ src/core/ws/utils/getPublicData.ts | 17 + src/core/ws/utils/truncateMessage.test.ts | 12 + src/core/ws/utils/truncateMessage.ts | 9 + src/node/SetupServerCommonApi.ts | 8 +- .../browser/ws-api/ws.logging.browser.test.ts | 636 ++++++++++++++++++ 16 files changed, 1107 insertions(+), 85 deletions(-) create mode 100644 src/core/ws/utils/attachWebSocketLogger.ts create mode 100644 src/core/ws/utils/getMessageLength.test.ts create mode 100644 src/core/ws/utils/getMessageLength.ts create mode 100644 src/core/ws/utils/getPublicData.test.ts create mode 100644 src/core/ws/utils/getPublicData.ts create mode 100644 src/core/ws/utils/truncateMessage.test.ts create mode 100644 src/core/ws/utils/truncateMessage.ts create mode 100644 test/browser/ws-api/ws.logging.browser.test.ts diff --git a/package.json b/package.json index 03add0b14..0485a5629 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.14", + "@mswjs/interceptors": "^0.26.15", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 805b289b4..c5a458c75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.14 + '@mswjs/interceptors': ^0.26.15 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.1 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.14 + '@mswjs/interceptors': 0.26.15 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1139,8 +1139,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.14: - resolution: {integrity: sha512-q4S8RGjOUzv3A3gCawuKkUEcNJXjdPaSqoRHFvuZPWQnc7yOw702iGBRDMJoBK+l0KSv9XN8YP5ek6duRzrpqw==} + /@mswjs/interceptors/0.26.15: + resolution: {integrity: sha512-HM47Lu1YFmnYHKMBynFfjCp0U/yRskHj/8QEJW0CBEPOlw8Gkmjfll+S9b8M7V5CNDw2/ciRxjjnWeaCiblSIQ==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index 24c008b30..54d446b95 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -24,6 +24,7 @@ import { SetupWorker } from './glossary' import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' +import { attachWebSocketLogger } from '~/core/ws/utils/attachWebSocketLogger' interface Listener { target: EventTarget @@ -179,9 +180,25 @@ export class SetupWorkerApi options, ) as SetupWorkerInternalContext['startOptions'] - handleWebSocketEvent(() => { - return this.handlersController.currentHandlers() + // Enable WebSocket interception. + handleWebSocketEvent({ + getHandlers: () => { + return this.handlersController.currentHandlers() + }, + onMockedConnection: (connection) => { + if (!this.context.startOptions.quiet) { + // Attach the logger for mocked connections since + // those won't be visible in the browser's devtools. + attachWebSocketLogger(connection) + } + }, + onPassthroughConnection() { + /** + * @fixme Call some "onUnhandledConnection". + */ + }, }) + webSocketInterceptor.apply() this.subscriptions.push(() => { diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index abb43786c..bf916f37d 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -1,47 +1,41 @@ import { Emitter } from 'strict-event-emitter' -import type { - WebSocketClientConnection, - WebSocketServerConnection, -} from '@mswjs/interceptors/WebSocket' +import type { WebSocketConnectionData } from '@mswjs/interceptors/WebSocket' import { type Match, type Path, type PathParams, matchRequestUrl, } from '../utils/matching/matchRequestUrl' +import { getCallFrame } from '../utils/internal/getCallFrame' type WebSocketHandlerParsedResult = { match: Match } export type WebSocketHandlerEventMap = { - connection: [ - args: { - client: WebSocketClientConnection - server: WebSocketServerConnection - params: PathParams - }, - ] + connection: [args: WebSocketHandlerConnection] } -type WebSocketHandlerIncomingEvent = MessageEvent<{ - client: WebSocketClientConnection - server: WebSocketServerConnection -}> +interface WebSocketHandlerConnection extends WebSocketConnectionData { + params: PathParams +} export const kEmitter = Symbol('kEmitter') export const kDispatchEvent = Symbol('kDispatchEvent') -export const kDefaultPrevented = Symbol('kDefaultPrevented') +export const kSender = Symbol('kSender') export class WebSocketHandler { + public callFrame?: string + protected [kEmitter]: Emitter constructor(private readonly url: Path) { this[kEmitter] = new Emitter() + this.callFrame = getCallFrame(new Error()) } public parse(args: { - event: WebSocketHandlerIncomingEvent + event: MessageEvent }): WebSocketHandlerParsedResult { const connection = args.event.data const match = matchRequestUrl(connection.client.url, this.url) @@ -52,38 +46,26 @@ export class WebSocketHandler { } public predicate(args: { - event: WebSocketHandlerIncomingEvent + event: MessageEvent parsedResult: WebSocketHandlerParsedResult }): boolean { return args.parsedResult.match.matches } - async [kDispatchEvent](event: MessageEvent): Promise { + async [kDispatchEvent]( + event: MessageEvent, + ): Promise { const parsedResult = this.parse({ event }) - const shouldIntercept = this.predicate({ event, parsedResult }) - - if (!shouldIntercept) { - return - } - - // Account for other matching event handlers that've already prevented this event. - if (!Reflect.get(event, kDefaultPrevented)) { - // At this point, the WebSocket connection URL has matched the handler. - // Prevent the default behavior of establishing the connection as-is. - // Use internal symbol because we aren't actually dispatching this - // event. Events can only marked as cancelable and can be prevented - // when dispatched on an EventTarget. - Reflect.set(event, kDefaultPrevented, true) - } - const connection = event.data - // Emit the connection event on the handler. - // This is what the developer adds listeners for. - this[kEmitter].emit('connection', { + const resolvedConnection: WebSocketHandlerConnection = { client: connection.client, server: connection.server, params: parsedResult.match.params || {}, - }) + } + + // Emit the connection event on the handler. + // This is what the developer adds listeners for. + this[kEmitter].emit('connection', resolvedConnection) } } diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index 5563d49a6..6649e359b 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -1,45 +1,55 @@ +import type { WebSocketConnectionData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' import { RequestHandler } from '../handlers/RequestHandler' -import { - WebSocketHandler, - kDefaultPrevented, - kDispatchEvent, -} from '../handlers/WebSocketHandler' +import { WebSocketHandler, kDispatchEvent } from '../handlers/WebSocketHandler' import { webSocketInterceptor } from '../ws/webSocketInterceptor' -export function handleWebSocketEvent( - getCurrentHandlers: () => Array, -) { +interface HandleWebSocketEventOptions { + getHandlers: () => Array + onMockedConnection: (connection: WebSocketConnectionData) => void + onPassthroughConnection: (onnection: WebSocketConnectionData) => void +} + +export function handleWebSocketEvent(options: HandleWebSocketEventOptions) { webSocketInterceptor.on('connection', (connection) => { - const handlers = getCurrentHandlers() + const handlers = options.getHandlers() const connectionEvent = new MessageEvent('connection', { data: connection, - /** - * @note This message event should be marked as "cancelable" - * to have its default prevented using "event.preventDefault()". - * There's a bug in Node.js that breaks the "cancelable" flag. - * @see https://github.com/nodejs/node/issues/51767 - */ }) - Object.defineProperty(connectionEvent, kDefaultPrevented, { - enumerable: false, - writable: true, - value: false, - }) + // First, filter only those WebSocket handlers that + // match the "ws.link()" endpoint predicate. Don't dispatch + // anything yet so the logger can be attached to the connection + // before it potentially sends events. + const matchingHandlers = handlers.filter( + (handler): handler is WebSocketHandler => { + if (handler instanceof WebSocketHandler) { + return handler.predicate({ + event: connectionEvent, + parsedResult: handler.parse({ + event: connectionEvent, + }), + }) + } - // Iterate over the handlers and forward the connection - // event to WebSocket event handlers. This is equivalent - // to dispatching that event onto multiple listeners. - for (const handler of handlers) { - if (handler instanceof WebSocketHandler) { + return false + }, + ) + + if (matchingHandlers.length > 0) { + options?.onMockedConnection(connection) + + // Iterate over the handlers and forward the connection + // event to WebSocket event handlers. This is equivalent + // to dispatching that event onto multiple listeners. + for (const handler of matchingHandlers) { handler[kDispatchEvent](connectionEvent) } - } + } else { + options?.onPassthroughConnection(connection) - // If none of the "ws" handlers matched, - // establish the WebSocket connection as-is. - if (!Reflect.get(connectionEvent, kDefaultPrevented)) { + // If none of the "ws" handlers matched, + // establish the WebSocket connection as-is. connection.server.connect() connection.client.addEventListener('message', (event) => { connection.server.send(event.data) diff --git a/src/core/utils/logging/getTimestamp.test.ts b/src/core/utils/logging/getTimestamp.test.ts index be71c21d4..04b488686 100644 --- a/src/core/utils/logging/getTimestamp.test.ts +++ b/src/core/utils/logging/getTimestamp.test.ts @@ -18,9 +18,15 @@ test('returns a timestamp with milliseconds', () => { vi.setSystemTime(new Date('2024-01-01 12:4:8')) expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.000') + vi.setSystemTime(new Date('2024-01-01 12:4:8.000')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.000') + vi.setSystemTime(new Date('2024-01-01 12:4:8.4')) expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.400') vi.setSystemTime(new Date('2024-01-01 12:4:8.123')) expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.123') + + vi.setSystemTime(new Date('2024-01-01 12:00:00')) + expect(getTimestamp({ milliseconds: true })).toBe('12:00:00.000') }) diff --git a/src/core/utils/logging/getTimestamp.ts b/src/core/utils/logging/getTimestamp.ts index 6a4be8a07..a53605355 100644 --- a/src/core/utils/logging/getTimestamp.ts +++ b/src/core/utils/logging/getTimestamp.ts @@ -7,16 +7,10 @@ interface GetTimestampOptions { */ export function getTimestamp(options?: GetTimestampOptions): string { const now = new Date() - - let timestamp = [now.getHours(), now.getMinutes(), now.getSeconds()] - .filter(Boolean) - .map(String) - .map((chunk) => chunk.slice(0, 2)) - .map((chunk) => chunk.padStart(2, '0')) - .join(':') + const timestamp = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}` if (options?.milliseconds) { - timestamp += `.${now.getMilliseconds().toString().padStart(3, '0')}` + return `${timestamp}.${now.getMilliseconds().toString().padStart(3, '0')}` } return timestamp diff --git a/src/core/ws/utils/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts new file mode 100644 index 000000000..bcc25fc81 --- /dev/null +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -0,0 +1,262 @@ +import type { + WebSocketClientConnection, + WebSocketConnectionData, + WebSocketData, +} from '@mswjs/interceptors/WebSocket' +import { devUtils } from '../../utils/internal/devUtils' +import { getTimestamp } from '../../utils/logging/getTimestamp' +import { toPublicUrl } from '../../utils/request/toPublicUrl' +import { getMessageLength } from './getMessageLength' +import { getPublicData } from './getPublicData' + +export function attachWebSocketLogger( + connection: WebSocketConnectionData, +): void { + const { client, server } = connection + + logConnectionOpen(client) + + // Log the events sent from the WebSocket client. + // WebSocket client connection object is written from the + // server's perspective so these message events are outgoing. + /** + * @todo Provide the reference to the exact event handler + * that called this `client.send()`. + */ + client.addEventListener('message', (event) => { + logOutgoingClientMessage(event) + }) + + client.addEventListener('close', (event) => { + logConnectionClose(event) + }) + + // Log the events received by the WebSocket client. + // "client.socket" references the actual WebSocket instance + // so these message events are incoming messages. + client.socket.addEventListener('message', (event) => { + logIncomingClientMessage(event) + }) + + // Log client errors (connection closures due to errors). + client.socket.addEventListener('error', (event) => { + logClientError(event) + }) + + client.send = new Proxy(client.send, { + apply(target, thisArg, args) { + const [data] = args + const messageEvent = new MessageEvent('message', { data }) + Object.defineProperties(messageEvent, { + currentTarget: { + enumerable: true, + writable: false, + value: client.socket, + }, + target: { + enumerable: true, + writable: false, + value: client.socket, + }, + }) + logIncomingMockedClientMessage(messageEvent) + + return Reflect.apply(target, thisArg, args) + }, + }) + + server.addEventListener( + 'open', + () => { + server.addEventListener('message', (event) => { + logIncomingServerMessage(event) + }) + }, + { once: true }, + ) + + // Log outgoing client events initiated by the event handler. + // The actual client never sent these but the handler did. + server.send = new Proxy(server.send, { + apply(target, thisArg, args) { + const [data] = args + const messageEvent = new MessageEvent('message', { data }) + Object.defineProperties(messageEvent, { + currentTarget: { + enumerable: true, + writable: false, + value: server['realWebSocket'], + }, + target: { + enumerable: true, + writable: false, + value: server['realWebSocket'], + }, + }) + + logOutgoingMockedClientMessage(messageEvent) + + return Reflect.apply(target, thisArg, args) + }, + }) +} + +/** + * Prints the WebSocket connection. + * This is meant to be logged by every WebSocket handler + * that intercepted this connection. This helps you see + * what handlers observe this connection. + */ +export function logConnectionOpen(client: WebSocketClientConnection) { + const publicUrl = toPublicUrl(client.url) + + console.groupCollapsed( + devUtils.formatMessage(`${getTimestamp()} %c▸%c ${publicUrl}`), + 'color:blue', + 'color:inherit', + ) + console.log('Client:', client.socket) + console.groupEnd() +} + +/** + * Prints the outgoing client message. + */ +export async function logOutgoingClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c↑%c ${publicData} %c${byteLength}%c`, + ), + 'color:green', + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +/** + * Prints the outgoing client message initiated + * by `server.send()` in the event handler. + */ +export async function logOutgoingMockedClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⇡%c ${publicData} %c${byteLength}%c`, + ), + 'color:orangered', + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +/** + * Prings the message received by the WebSocket client. + * This is fired when the "message" event is dispatched + * on the actual WebSocket client instance, and translates to + * the client receiving a message from the server. + */ +export async function logIncomingClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c↓%c ${publicData} %c${byteLength}%c`, + ), + 'color:red', + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +/** + * Prints the outgoing client message initiated + * by `client.send()` in the event handler. + */ +export async function logIncomingMockedClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, + ), + 'color:orangered', + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +function logConnectionClose(event: CloseEvent) { + const target = event.target as WebSocket + const publicUrl = toPublicUrl(target.url) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c■%c ${publicUrl}`, + ), + 'color:blue', + 'color:inherit', + ) + console.log(event) + console.groupEnd() +} + +export async function logIncomingServerMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, + ), + 'color:orangered', + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +function logClientError(event: Event) { + const socket = event.target as WebSocket + const publicUrl = toPublicUrl(socket.url) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c\u00D7%c ${publicUrl}`, + ), + 'color:red', + 'color:inherit', + ) + console.log(event) + console.groupEnd() +} diff --git a/src/core/ws/utils/getMessageLength.test.ts b/src/core/ws/utils/getMessageLength.test.ts new file mode 100644 index 000000000..af45718ee --- /dev/null +++ b/src/core/ws/utils/getMessageLength.test.ts @@ -0,0 +1,16 @@ +import { getMessageLength } from './getMessageLength' + +it('returns the length of the string', () => { + expect(getMessageLength('')).toBe(0) + expect(getMessageLength('hello')).toBe(5) +}) + +it('returns the size of the Blob', () => { + expect(getMessageLength(new Blob())).toBe(0) + expect(getMessageLength(new Blob(['hello']))).toBe(5) +}) + +it('returns the byte length of ArrayBuffer', () => { + expect(getMessageLength(new ArrayBuffer(0))).toBe(0) + expect(getMessageLength(new ArrayBuffer(5))).toBe(5) +}) diff --git a/src/core/ws/utils/getMessageLength.ts b/src/core/ws/utils/getMessageLength.ts new file mode 100644 index 000000000..a8e041955 --- /dev/null +++ b/src/core/ws/utils/getMessageLength.ts @@ -0,0 +1,19 @@ +import type { WebSocketData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' + +/** + * Returns the byte length of the given WebSocket message. + * @example + * getMessageLength('hello') // 5 + * getMessageLength(new Blob(['hello'])) // 5 + */ +export function getMessageLength(data: WebSocketData): number { + if (data instanceof Blob) { + return data.size + } + + if (data instanceof ArrayBuffer) { + return data.byteLength + } + + return new Blob([data]).size +} diff --git a/src/core/ws/utils/getPublicData.test.ts b/src/core/ws/utils/getPublicData.test.ts new file mode 100644 index 000000000..2820301f7 --- /dev/null +++ b/src/core/ws/utils/getPublicData.test.ts @@ -0,0 +1,38 @@ +import { getPublicData } from './getPublicData' + +it('returns a short string as-is', async () => { + expect(await getPublicData('')).toBe('') + expect(await getPublicData('hello')).toBe('hello') +}) + +it('returns a truncated long string', async () => { + expect(await getPublicData('this is a very long string')).toBe( + 'this is a very long stri…', + ) +}) + +it('returns a short Blob text as-is', async () => { + expect(await getPublicData(new Blob(['']))).toBe('Blob()') + expect(await getPublicData(new Blob(['hello']))).toBe('Blob(hello)') +}) + +it('returns a truncated long Blob text', async () => { + expect(await getPublicData(new Blob(['this is a very long string']))).toBe( + 'Blob(this is a very long stri…)', + ) +}) + +it('returns a short ArrayBuffer text as-is', async () => { + expect(await getPublicData(new TextEncoder().encode(''))).toBe( + 'ArrayBuffer()', + ) + expect(await getPublicData(new TextEncoder().encode('hello'))).toBe( + 'ArrayBuffer(hello)', + ) +}) + +it('returns a truncated ArrayBuffer text', async () => { + expect( + await getPublicData(new TextEncoder().encode('this is a very long string')), + ).toBe('ArrayBuffer(this is a very long stri…)') +}) diff --git a/src/core/ws/utils/getPublicData.ts b/src/core/ws/utils/getPublicData.ts new file mode 100644 index 000000000..8fd41b606 --- /dev/null +++ b/src/core/ws/utils/getPublicData.ts @@ -0,0 +1,17 @@ +import { WebSocketData } from '@mswjs/interceptors/WebSocket' +import { truncateMessage } from './truncateMessage' + +export async function getPublicData(data: WebSocketData): Promise { + if (data instanceof Blob) { + const text = await data.text() + return `Blob(${truncateMessage(text)})` + } + + // Handle all ArrayBuffer-like objects. + if (typeof data === 'object' && 'byteLength' in data) { + const text = new TextDecoder().decode(data) + return `ArrayBuffer(${truncateMessage(text)})` + } + + return truncateMessage(data) +} diff --git a/src/core/ws/utils/truncateMessage.test.ts b/src/core/ws/utils/truncateMessage.test.ts new file mode 100644 index 000000000..5e247a0e3 --- /dev/null +++ b/src/core/ws/utils/truncateMessage.test.ts @@ -0,0 +1,12 @@ +import { truncateMessage } from './truncateMessage' + +it('returns a short string as-is', () => { + expect(truncateMessage('')).toBe('') + expect(truncateMessage('hello')).toBe('hello') +}) + +it('truncates a long string', () => { + expect(truncateMessage('this is a very long string')).toBe( + 'this is a very long stri…', + ) +}) diff --git a/src/core/ws/utils/truncateMessage.ts b/src/core/ws/utils/truncateMessage.ts new file mode 100644 index 000000000..eae145e91 --- /dev/null +++ b/src/core/ws/utils/truncateMessage.ts @@ -0,0 +1,9 @@ +const MAX_LENGTH = 24 + +export function truncateMessage(message: string): string { + if (message.length <= MAX_LENGTH) { + return message + } + + return `${message.slice(0, MAX_LENGTH)}…` +} diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index d293efdfd..910f5e61b 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -85,8 +85,12 @@ export class SetupServerCommonApi }, ) - handleWebSocketEvent(() => { - return this.handlersController.currentHandlers() + handleWebSocketEvent({ + getHandlers: () => { + return this.handlersController.currentHandlers() + }, + onMockedConnection: () => {}, + onPassthroughConnection: () => {}, }) } diff --git a/test/browser/ws-api/ws.logging.browser.test.ts b/test/browser/ws-api/ws.logging.browser.test.ts new file mode 100644 index 000000000..500f053bc --- /dev/null +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -0,0 +1,636 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' +import { WebSocketServer } from '../../support/WebSocketServer' +import { waitFor } from '../../support/waitFor' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +const server = new WebSocketServer() + +test.beforeAll(async () => { + await server.listen() +}) + +test.afterEach(async () => { + server.resetState() +}) + +test.afterAll(async () => { + await server.close() +}) + +test('does not log anything if "quiet" was set to "true"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start({ quiet: true }) + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => { + ws.send('hello') + ws.send('world') + queueMicrotask(() => ws.close()) + } + + return new Promise((resolve, reject) => { + ws.onclose = () => resolve() + ws.onerror = () => reject(new Error('Client connection closed')) + }) + }) + + expect(consoleSpy.get('startGroupCollapsed')).toBeUndefined() +}) + +test('logs the client connection', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://example.com/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2} %c▸%c wss:\/\/example\.com\/path color:blue color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending text data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send('hello world') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c hello world %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending a long text data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send('this is an extremely long sentence to log out') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c this is an extremely lon… %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending Blob data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send(new Blob(['hello world'])) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(hello world\) %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending a long Blob data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => + ws.send(new Blob(['this is an extremely long sentence to log out'])) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(this is an extremely lon…\) %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending ArrayBuffer data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send(new TextEncoder().encode('hello world')) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(hello world\) %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending a long ArrayBuffer data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => + ws.send( + new TextEncoder().encode( + 'this is an extremely long sentence to log out', + ), + ) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(this is an extremely lon…\) %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs incoming client events', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.addListener('connection', (ws) => { + ws.send('hello from server') + + ws.addEventListener('message', (event) => { + if (event.data === 'how are you, server?') { + ws.send('thanks, not bad') + } + }) + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ client, server }) => { + server.connect() + client.addEventListener('message', (event) => { + server.send(event.data) + }) + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + const ws = new WebSocket(url) + ws.addEventListener('message', (event) => { + if (event.data === 'hello from server') { + ws.send('how are you, server?') + } + }) + }, server.url) + + // Initial message sent to every connected client. + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c hello from server %c17%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) + + // Message sent in response to a client message. + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c thanks, not bad %c15%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs raw incoming server events', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.addListener('connection', (ws) => { + ws.send('hello from server') + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ client, server }) => { + server.connect() + client.addEventListener('message', (event) => { + server.send(event.data) + }) + server.addEventListener('message', (event) => { + event.preventDefault() + // This is the only data the client will receive + // but we should still print the raw server message. + client.send('intercepted server event') + }) + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + // Raw server message. + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + // The actual message the client received (mocked). + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c intercepted server event %c24%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the client', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.close() + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:blue color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the event handler', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker( + api.on('connection', ({ client }) => { + client.close() + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://example.com/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:blue color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client events sent vi "server.send()"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ server }) => { + server.connect() + server.send('hello from handler') + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇡%c hello from handler %c18%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs incoming client events sent vi "client.send()"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ client }) => { + client.send('hello from handler') + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from handler %c18%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs client errors received via "client.close()"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker( + api.on('connection', ({ client }) => { + queueMicrotask(() => client.close(1003, 'Custom error')) + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://example.com/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c wss:\/\/example\.com\/path color:red color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs client errors received via server-sent close', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.on('connection', (ws) => { + queueMicrotask(() => ws.close(1003)) + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ server }) => { + server.connect() + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c ws:\/\/(.+):\d{4,}\/ color:red color:inherit$/, + ), + ]), + ) + }) +}) From 0776e73fbca32177157907a741d175d70febbece Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 1 Apr 2024 10:51:21 +0200 Subject: [PATCH 56/69] chore(release): v2.3.0-ws.rc-2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0485a5629..78284246a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-1", + "version": "2.3.0-ws.rc-2", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From c6450d919d8e4561b2da5fe410101376d46e1474 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 2 Apr 2024 10:41:42 +0200 Subject: [PATCH 57/69] chore: move "handleWebSocketEvent" to "ws" --- src/browser/setupWorker/setupWorker.ts | 2 +- src/core/{utils => ws}/handleWebSocketEvent.ts | 2 +- src/core/ws/ws.ts | 6 +++--- src/node/SetupServerCommonApi.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/core/{utils => ws}/handleWebSocketEvent.ts (96%) diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index 54d446b95..4cc2ad14b 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -23,7 +23,7 @@ import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { SetupWorker } from './glossary' import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' -import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' +import { handleWebSocketEvent } from '~/core/ws/handleWebSocketEvent' import { attachWebSocketLogger } from '~/core/ws/utils/attachWebSocketLogger' interface Listener { diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/ws/handleWebSocketEvent.ts similarity index 96% rename from src/core/utils/handleWebSocketEvent.ts rename to src/core/ws/handleWebSocketEvent.ts index 6649e359b..cfc6d18a9 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/ws/handleWebSocketEvent.ts @@ -1,7 +1,7 @@ import type { WebSocketConnectionData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' import { RequestHandler } from '../handlers/RequestHandler' import { WebSocketHandler, kDispatchEvent } from '../handlers/WebSocketHandler' -import { webSocketInterceptor } from '../ws/webSocketInterceptor' +import { webSocketInterceptor } from './webSocketInterceptor' interface HandleWebSocketEventOptions { getHandlers: () => Array diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index c20b6bafb..4b7896d9b 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -35,9 +35,9 @@ function createWebSocketLinkHandler(url: Path) { return { clients: clientManager.clients, - on( - event: K, - listener: (...args: WebSocketHandlerEventMap[K]) => void, + on( + event: EventType, + listener: (...args: WebSocketHandlerEventMap[EventType]) => void, ): WebSocketHandler { const handler = new WebSocketHandler(url) diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index 910f5e61b..8140582e8 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -18,7 +18,7 @@ import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { mergeRight } from '~/core/utils/internal/mergeRight' import { devUtils } from '~/core/utils/internal/devUtils' import type { SetupServerCommon } from './glossary' -import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' +import { handleWebSocketEvent } from '~/core/ws/handleWebSocketEvent' import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' export const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { From b29ceeac6dfc0c0a1e79bbd4346b6242b4dad437 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 2 Apr 2024 10:56:36 +0200 Subject: [PATCH 58/69] fix: export "WebSocketLink" type --- src/core/index.ts | 2 +- src/core/ws/ws.ts | 78 ++++++++++++++++++++++++++++------------------- 2 files changed, 47 insertions(+), 33 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index 97564c760..79b2e945c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -10,7 +10,7 @@ export { graphql } from './graphql' export { GraphQLHandler } from './handlers/GraphQLHandler' /* WebSocket handler */ -export { ws } from './ws/ws' +export { ws, type WebSocketLink } from './ws/ws' export { WebSocketHandler, type WebSocketHandlerEventMap, diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 4b7896d9b..911ec5059 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -13,6 +13,47 @@ import { WebSocketClientManager } from './WebSocketClientManager' const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') +export type WebSocketLink = { + /** + * A set of all WebSocket clients connected + * to this link. + */ + clients: Set + + on( + event: EventType, + listener: (...args: WebSocketHandlerEventMap[EventType]) => void, + ): WebSocketHandler + + /** + * Broadcasts the given data to all WebSocket clients. + * + * @example + * const service = ws.link('wss://example.com') + * service.on('connection', () => { + * service.broadcast('hello, everyone!') + * }) + */ + broadcast(data: WebSocketData): void + + /** + * Broadcasts the given data to all WebSocket clients + * except the ones provided in the `clients` argument. + * + * @example + * const service = ws.link('wss://example.com') + * service.on('connection', ({ client }) => { + * service.broadcastExcept(client, 'hi, the rest of you!') + * }) + */ + broadcastExcept( + clients: + | WebSocketClientConnectionProtocol + | Array, + data: WebSocketData, + ): void +} + /** * Intercepts outgoing WebSocket connections to the given URL. * @@ -22,12 +63,12 @@ const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') * client.send('hello from server!') * }) */ -function createWebSocketLinkHandler(url: Path) { +function createWebSocketLinkHandler(url: Path): WebSocketLink { invariant(url, 'Expected a WebSocket server URL but got undefined') invariant( isPath(url), - 'Expected a WebSocket server URL but got %s', + 'Expected a WebSocket server URL to be a valid path but got %s', typeof url, ) @@ -35,10 +76,7 @@ function createWebSocketLinkHandler(url: Path) { return { clients: clientManager.clients, - on( - event: EventType, - listener: (...args: WebSocketHandlerEventMap[EventType]) => void, - ): WebSocketHandler { + on(event, listener) { const handler = new WebSocketHandler(url) // Add the connection event listener for when the @@ -58,38 +96,14 @@ function createWebSocketLinkHandler(url: Path) { return handler }, - /** - * Broadcasts the given data to all WebSocket clients. - * - * @example - * const service = ws.link('wss://example.com') - * service.on('connection', () => { - * service.broadcast('hello, everyone!') - * }) - */ - broadcast(data: WebSocketData): void { + broadcast(data) { // This will invoke "send()" on the immediate clients // in this runtime and post a message to the broadcast channel // to trigger send for the clients in other runtimes. this.broadcastExcept([], data) }, - /** - * Broadcasts the given data to all WebSocket clients - * except the ones provided in the `clients` argument. - * - * @example - * const service = ws.link('wss://example.com') - * service.on('connection', ({ client }) => { - * service.broadcastExcept(client, 'hi, the rest of you!') - * }) - */ - broadcastExcept( - clients: - | WebSocketClientConnectionProtocol - | Array, - data: WebSocketData, - ): void { + broadcastExcept(clients, data) { const ignoreClients = Array.prototype .concat(clients) .map((client) => client.id) From 2ace2897f2c3ae1a4e287868caa5685e3fafc017 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 2 Apr 2024 11:12:56 +0200 Subject: [PATCH 59/69] chore: move "ws" to core root --- src/core/index.ts | 2 +- src/core/{ws => }/ws.test.ts | 0 src/core/{ws => }/ws.ts | 6 +++--- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/core/{ws => }/ws.test.ts (100%) rename src/core/{ws => }/ws.ts (95%) diff --git a/src/core/index.ts b/src/core/index.ts index 79b2e945c..c4de17047 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -10,7 +10,7 @@ export { graphql } from './graphql' export { GraphQLHandler } from './handlers/GraphQLHandler' /* WebSocket handler */ -export { ws, type WebSocketLink } from './ws/ws' +export { ws, type WebSocketLink } from './ws' export { WebSocketHandler, type WebSocketHandlerEventMap, diff --git a/src/core/ws/ws.test.ts b/src/core/ws.test.ts similarity index 100% rename from src/core/ws/ws.test.ts rename to src/core/ws.test.ts diff --git a/src/core/ws/ws.ts b/src/core/ws.ts similarity index 95% rename from src/core/ws/ws.ts rename to src/core/ws.ts index 911ec5059..718d1ef0d 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws.ts @@ -7,9 +7,9 @@ import { WebSocketHandler, kEmitter, type WebSocketHandlerEventMap, -} from '../handlers/WebSocketHandler' -import { Path, isPath } from '../utils/matching/matchRequestUrl' -import { WebSocketClientManager } from './WebSocketClientManager' +} from './handlers/WebSocketHandler' +import { Path, isPath } from './utils/matching/matchRequestUrl' +import { WebSocketClientManager } from './ws/WebSocketClientManager' const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') From 718cf0eb287f9f0ea8133249f2f83f090d87cd53 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 2 Apr 2024 14:26:58 +0200 Subject: [PATCH 60/69] chore(release): v2.3.0-ws.rc-3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 813e0d92b..c30e536dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-2", + "version": "2.3.0-ws.rc-3", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From 1ffaed9e18c48129ab4f1c7f4f370366a19284b5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 2 Apr 2024 18:23:30 +0200 Subject: [PATCH 61/69] fix: use pretty colors for logs --- src/core/ws/utils/attachWebSocketLogger.ts | 25 +++++++---- .../browser/ws-api/ws.logging.browser.test.ts | 44 +++++++++++-------- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/core/ws/utils/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts index bcc25fc81..97fc330bc 100644 --- a/src/core/ws/utils/attachWebSocketLogger.ts +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -9,6 +9,13 @@ import { toPublicUrl } from '../../utils/request/toPublicUrl' import { getMessageLength } from './getMessageLength' import { getPublicData } from './getPublicData' +const colors = { + blue: '#3b82f6', + green: '#22c55e', + red: '#ef4444', + orange: '#ff6a33', +} + export function attachWebSocketLogger( connection: WebSocketConnectionData, ): void { @@ -111,8 +118,8 @@ export function logConnectionOpen(client: WebSocketClientConnection) { const publicUrl = toPublicUrl(client.url) console.groupCollapsed( - devUtils.formatMessage(`${getTimestamp()} %c▸%c ${publicUrl}`), - 'color:blue', + devUtils.formatMessage(`${getTimestamp()} %c▶%c ${publicUrl}`), + `color:${colors.blue}`, 'color:inherit', ) console.log('Client:', client.socket) @@ -132,7 +139,7 @@ export async function logOutgoingClientMessage( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c↑%c ${publicData} %c${byteLength}%c`, ), - 'color:green', + `color:${colors.green}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -155,7 +162,7 @@ export async function logOutgoingMockedClientMessage( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c⇡%c ${publicData} %c${byteLength}%c`, ), - 'color:orangered', + `color:${colors.orange}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -180,7 +187,7 @@ export async function logIncomingClientMessage( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c↓%c ${publicData} %c${byteLength}%c`, ), - 'color:red', + `color:${colors.red}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -203,7 +210,7 @@ export async function logIncomingMockedClientMessage( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, ), - 'color:orangered', + `color:${colors.orange}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -220,7 +227,7 @@ function logConnectionClose(event: CloseEvent) { devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c■%c ${publicUrl}`, ), - 'color:blue', + `color:${colors.blue}`, 'color:inherit', ) console.log(event) @@ -237,7 +244,7 @@ export async function logIncomingServerMessage( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, ), - 'color:orangered', + `color:${colors.green}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -254,7 +261,7 @@ function logClientError(event: Event) { devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c\u00D7%c ${publicUrl}`, ), - 'color:red', + `color:${colors.blue}`, 'color:inherit', ) console.log(event) diff --git a/test/browser/ws-api/ws.logging.browser.test.ts b/test/browser/ws-api/ws.logging.browser.test.ts index 500f053bc..9ad968566 100644 --- a/test/browser/ws-api/ws.logging.browser.test.ts +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -86,7 +86,7 @@ test('logs the client connection', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2} %c▸%c wss:\/\/example\.com\/path color:blue color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2} %c▶%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, ), ]), ) @@ -119,7 +119,7 @@ test('logs outgoing client event sending text data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c hello world %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c hello world %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -152,7 +152,7 @@ test('logs outgoing client event sending a long text data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c this is an extremely lon… %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c this is an extremely lon… %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -185,7 +185,7 @@ test('logs outgoing client event sending Blob data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(hello world\) %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -219,7 +219,7 @@ test('logs outgoing client event sending a long Blob data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(this is an extremely lon…\) %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -252,7 +252,7 @@ test('logs outgoing client event sending ArrayBuffer data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(hello world\) %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -290,7 +290,7 @@ test('logs outgoing client event sending a long ArrayBuffer data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(this is an extremely lon…\) %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -345,7 +345,7 @@ test('logs incoming client events', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c hello from server %c17%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c hello from server %c17%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -356,7 +356,7 @@ test('logs incoming client events', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c thanks, not bad %c15%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c thanks, not bad %c15%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -404,13 +404,19 @@ test('logs raw incoming server events', async ({ await waitFor(() => { expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ - // Raw server message. + // The actual (raw) message recieved from the server. expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), - // The actual message the client received (mocked). + + // The mocked message sent from the event handler (client.send()). + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c intercepted server event %c24%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + + // The actual message the client received (i.e. mocked). expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c intercepted server event %c24%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c intercepted server event %c24%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -443,7 +449,7 @@ test('logs the close event initiated by the client', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:blue color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, ), ]), ) @@ -479,7 +485,7 @@ test('logs the close event initiated by the event handler', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:blue color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, ), ]), ) @@ -516,7 +522,7 @@ test('logs outgoing client events sent vi "server.send()"', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇡%c hello from handler %c18%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇡%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -552,7 +558,7 @@ test('logs incoming client events sent vi "client.send()"', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from handler %c18%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -588,7 +594,7 @@ test('logs client errors received via "client.close()"', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c wss:\/\/example\.com\/path color:red color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, ), ]), ) @@ -628,7 +634,7 @@ test('logs client errors received via server-sent close', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c ws:\/\/(.+):\d{4,}\/ color:red color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c ws:\/\/(.+):\d{4,}\/ color:#3b82f6 color:inherit$/, ), ]), ) From 46ffbc413e525fd7a3e0129d070949704e9111a1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 6 Apr 2024 18:19:56 +0200 Subject: [PATCH 62/69] test(ws): fix wrong assertion message --- src/core/ws.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/ws.test.ts b/src/core/ws.test.ts index b5a7ef46d..22cc58b47 100644 --- a/src/core/ws.test.ts +++ b/src/core/ws.test.ts @@ -19,5 +19,5 @@ it('throws an error when given a non-path argument to "ws.link()"', () => { expect(() => // @ts-expect-error Intentionally invalid argument. ws.link(2), - ).toThrow('Expected a WebSocket server URL but got number') + ).toThrow('Expected a WebSocket server URL to be a valid path but got number') }) From dddd4fb7f019a07bb9d3ae04300dec398f2e5414 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 7 Apr 2024 11:15:57 +0200 Subject: [PATCH 63/69] feat(ws): enable client-to-server forwarding by default --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- src/core/ws/handleWebSocketEvent.ts | 3 --- test/browser/ws-api/ws.logging.browser.test.ts | 7 +------ 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index c30e536dd..c9b5d4a28 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.15", + "@mswjs/interceptors": "^0.27.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6d2830d3..4de33bf40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^1.1.0 version: 1.1.0 '@mswjs/interceptors': - specifier: ^0.26.15 - version: 0.26.15 + specifier: ^0.27.0 + version: 0.27.0 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -1453,8 +1453,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors@0.26.15: - resolution: {integrity: sha512-HM47Lu1YFmnYHKMBynFfjCp0U/yRskHj/8QEJW0CBEPOlw8Gkmjfll+S9b8M7V5CNDw2/ciRxjjnWeaCiblSIQ==} + /@mswjs/interceptors@0.27.0: + resolution: {integrity: sha512-Thxe9GXcw1rSlQA4eNs1j72MJrvl3PuzwnSk7OmevLoHkNGwltAWgGAK8EATCweAG41wKPmSjsA5mV9KUOwBew==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 diff --git a/src/core/ws/handleWebSocketEvent.ts b/src/core/ws/handleWebSocketEvent.ts index cfc6d18a9..115213863 100644 --- a/src/core/ws/handleWebSocketEvent.ts +++ b/src/core/ws/handleWebSocketEvent.ts @@ -51,9 +51,6 @@ export function handleWebSocketEvent(options: HandleWebSocketEventOptions) { // If none of the "ws" handlers matched, // establish the WebSocket connection as-is. connection.server.connect() - connection.client.addEventListener('message', (event) => { - connection.server.send(event.data) - }) } }) } diff --git a/test/browser/ws-api/ws.logging.browser.test.ts b/test/browser/ws-api/ws.logging.browser.test.ts index 9ad968566..b7393bbaa 100644 --- a/test/browser/ws-api/ws.logging.browser.test.ts +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -323,9 +323,6 @@ test('logs incoming client events', async ({ const worker = setupWorker( api.on('connection', ({ client, server }) => { server.connect() - client.addEventListener('message', (event) => { - server.send(event.data) - }) }), ) await worker.start() @@ -383,9 +380,7 @@ test('logs raw incoming server events', async ({ const worker = setupWorker( api.on('connection', ({ client, server }) => { server.connect() - client.addEventListener('message', (event) => { - server.send(event.data) - }) + server.addEventListener('message', (event) => { event.preventDefault() // This is the only data the client will receive From 7d2a971ec2e9e63f96668cb45caeea37d833edf8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 9 Apr 2024 16:29:24 -0600 Subject: [PATCH 64/69] chore(release): v2.3.0-ws.rc-4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c9b5d4a28..86196e7c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-3", + "version": "2.3.0-ws.rc-4", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From f6a8723395e32907cf7f98d00f180e7a773f0800 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 9 Apr 2024 17:04:49 -0600 Subject: [PATCH 65/69] fix: update @mswjs/interceptors to 0.27.1 --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 86196e7c3..e185cce1f 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.27.0", + "@mswjs/interceptors": "^0.27.1", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4de33bf40..d1d0f84d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^1.1.0 version: 1.1.0 '@mswjs/interceptors': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.27.1 + version: 0.27.1 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -1453,8 +1453,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors@0.27.0: - resolution: {integrity: sha512-Thxe9GXcw1rSlQA4eNs1j72MJrvl3PuzwnSk7OmevLoHkNGwltAWgGAK8EATCweAG41wKPmSjsA5mV9KUOwBew==} + /@mswjs/interceptors@0.27.1: + resolution: {integrity: sha512-V76Q1iKW/FO7j1nln5GN9alyVSKvgjSCm1ZQhxvMk+6IZybjPuE907POQLPhdvcSgp6vFpAPffTakfEnfs+46Q==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 74215a351c1f1a80ef8bf95ad066d64885e0323f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 9 Apr 2024 17:34:09 -0600 Subject: [PATCH 66/69] chore(release): v2.3.0-ws.rc-5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e185cce1f..226583f4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-4", + "version": "2.3.0-ws.rc-5", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From ef0ebe38b7ee90bfa3a76e96d1d91216010ac355 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 12 Apr 2024 13:04:32 -0600 Subject: [PATCH 67/69] fix(WebSocketClientManager): use localStorage for clients persistence (#2127) --- src/browser/setupWorker/stop/createStop.ts | 4 + src/core/ws.ts | 6 +- src/core/ws/WebSocketClientManager.test.ts | 88 ++++---- src/core/ws/WebSocketClientManager.ts | 152 +++++++++---- .../browser/ws-api/ws.clients.browser.test.ts | 205 ++++++++++++++++++ 5 files changed, 364 insertions(+), 91 deletions(-) create mode 100644 test/browser/ws-api/ws.clients.browser.test.ts diff --git a/src/browser/setupWorker/stop/createStop.ts b/src/browser/setupWorker/stop/createStop.ts index 48c37996d..6c0e7b7d9 100644 --- a/src/browser/setupWorker/stop/createStop.ts +++ b/src/browser/setupWorker/stop/createStop.ts @@ -1,4 +1,5 @@ import { devUtils } from '~/core/utils/internal/devUtils' +import { MSW_WEBSOCKET_CLIENTS_KEY } from '~/core/ws/WebSocketClientManager' import { SetupWorkerInternalContext, StopHandler } from '../glossary' import { printStopMessage } from './utils/printStopMessage' @@ -24,6 +25,9 @@ export const createStop = ( context.isMockingEnabled = false window.clearInterval(context.keepAliveInterval) + // Clear the WebSocket clients from the shared storage. + localStorage.removeItem(MSW_WEBSOCKET_CLIENTS_KEY) + printStopMessage({ quiet: context.startOptions?.quiet }) } } diff --git a/src/core/ws.ts b/src/core/ws.ts index 718d1ef0d..77fb681ef 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -72,10 +72,12 @@ function createWebSocketLinkHandler(url: Path): WebSocketLink { typeof url, ) - const clientManager = new WebSocketClientManager(wsBroadcastChannel) + const clientManager = new WebSocketClientManager(wsBroadcastChannel, url) return { - clients: clientManager.clients, + get clients() { + return clientManager.clients + }, on(event, listener) { const handler = new WebSocketHandler(url) diff --git a/src/core/ws/WebSocketClientManager.test.ts b/src/core/ws/WebSocketClientManager.test.ts index 9cac29c04..8db322f8e 100644 --- a/src/core/ws/WebSocketClientManager.test.ts +++ b/src/core/ws/WebSocketClientManager.test.ts @@ -1,79 +1,73 @@ /** * @vitest-environment node-websocket */ -import { randomUUID } from 'node:crypto' import { WebSocketClientConnection, + WebSocketData, WebSocketTransport, } from '@mswjs/interceptors/WebSocket' import { WebSocketClientManager, WebSocketBroadcastChannelMessage, - WebSocketRemoteClientConnection, } from './WebSocketClientManager' const channel = new BroadcastChannel('test:channel') vi.spyOn(channel, 'postMessage') const socket = new WebSocket('ws://localhost') -const transport = { - onOutgoing: vi.fn(), - onIncoming: vi.fn(), - onClose: vi.fn(), - send: vi.fn(), - close: vi.fn(), -} satisfies WebSocketTransport + +class TestWebSocketTransport extends EventTarget implements WebSocketTransport { + send(_data: WebSocketData): void {} + close(_code?: number | undefined, _reason?: string | undefined): void {} +} afterEach(() => { vi.resetAllMocks() }) it('adds a client from this runtime to the list of clients', () => { - const manager = new WebSocketClientManager(channel) - const connection = new WebSocketClientConnection(socket, transport) + const manager = new WebSocketClientManager(channel, '*') + const connection = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) manager.addConnection(connection) // Must add the client to the list of clients. expect(Array.from(manager.clients.values())).toEqual([connection]) - - // Must emit the connection open event to notify other runtimes. - expect(channel.postMessage).toHaveBeenCalledWith({ - type: 'connection:open', - payload: { - clientId: connection.id, - url: socket.url, - }, - } satisfies WebSocketBroadcastChannelMessage) }) -it('adds a client from another runtime to the list of clients', async () => { - const clientId = randomUUID() - const url = new URL('ws://localhost') - const manager = new WebSocketClientManager(channel) +it('adds multiple clients from this runtime to the list of clients', () => { + const manager = new WebSocketClientManager(channel, '*') + const connectionOne = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + manager.addConnection(connectionOne) - channel.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'connection:open', - payload: { - clientId, - url: url.href, - }, - }, - }), + // Must add the client to the list of clients. + expect(Array.from(manager.clients.values())).toEqual([connectionOne]) + + const connectionTwo = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), ) + manager.addConnection(connectionTwo) - await vi.waitFor(() => { - expect(Array.from(manager.clients.values())).toEqual([ - new WebSocketRemoteClientConnection(clientId, url, channel), - ]) - }) + // Must add the new cilent to the list as well. + expect(Array.from(manager.clients.values())).toEqual([ + connectionOne, + connectionTwo, + ]) }) it('replays a "send" event coming from another runtime', async () => { - const manager = new WebSocketClientManager(channel) - const connection = new WebSocketClientConnection(socket, transport) + const manager = new WebSocketClientManager(channel, '*') + const connection = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) manager.addConnection(connection) vi.spyOn(connection, 'send') @@ -98,8 +92,11 @@ it('replays a "send" event coming from another runtime', async () => { }) it('replays a "close" event coming from another runtime', async () => { - const manager = new WebSocketClientManager(channel) - const connection = new WebSocketClientConnection(socket, transport) + const manager = new WebSocketClientManager(channel, '*') + const connection = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) manager.addConnection(connection) vi.spyOn(connection, 'close') @@ -125,7 +122,8 @@ it('replays a "close" event coming from another runtime', async () => { }) it('removes the extraneous message listener when the connection closes', async () => { - const manager = new WebSocketClientManager(channel) + const manager = new WebSocketClientManager(channel, '*') + const transport = new TestWebSocketTransport() const connection = new WebSocketClientConnection(socket, transport) vi.spyOn(connection, 'close').mockImplementationOnce(() => { /** @@ -135,7 +133,7 @@ it('removes the extraneous message listener when the connection closes', async ( * All we care here is that closing the connection triggers * the transport closure, which it always does. */ - connection['transport'].onClose() + transport.dispatchEvent(new Event('close')) }) vi.spyOn(connection, 'send') diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index 4048878b6..e9d11d468 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -1,17 +1,14 @@ +import { invariant } from 'outvariant' import type { WebSocketData, WebSocketClientConnection, WebSocketClientConnectionProtocol, } from '@mswjs/interceptors/WebSocket' +import { matchRequestUrl, type Path } from '../utils/matching/matchRequestUrl' + +export const MSW_WEBSOCKET_CLIENTS_KEY = 'msw:ws:clients' export type WebSocketBroadcastChannelMessage = - | { - type: 'connection:open' - payload: { - clientId: string - url: string - } - } | { type: 'extraneous:send' payload: { @@ -28,33 +25,122 @@ export type WebSocketBroadcastChannelMessage = } } -export const kAddByClientId = Symbol('kAddByClientId') +type SerializedClient = { + clientId: string + url: string +} /** * A manager responsible for accumulating WebSocket client * connections across different browser runtimes. */ export class WebSocketClientManager { + private inMemoryClients: Set + + constructor( + private channel: BroadcastChannel, + private url: Path, + ) { + this.inMemoryClients = new Set() + + if (typeof localStorage !== 'undefined') { + // When the worker clears the local storage key in "worker.stop()", + // also clear the in-memory clients map. + localStorage.removeItem = new Proxy(localStorage.removeItem, { + apply: (target, thisArg, args) => { + const [key] = args + + if (key === MSW_WEBSOCKET_CLIENTS_KEY) { + this.inMemoryClients.clear() + } + + return Reflect.apply(target, thisArg, args) + }, + }) + } + } + /** * All active WebSocket client connections. */ - public clients: Set + get clients(): Set { + // In the browser, different runtimes use "localStorage" + // as the shared source of all the clients. + if (typeof localStorage !== 'undefined') { + const inMemoryClients = Array.from(this.inMemoryClients) + + console.log('get clients()', inMemoryClients, this.getSerializedClients()) + + return new Set( + inMemoryClients.concat( + this.getSerializedClients() + // Filter out the serialized clients that are already present + // in this runtime in-memory. This is crucial because a remote client + // wrapper CANNOT send a message to the client in THIS runtime + // (the "message" event on broadcast channel won't trigger). + .filter((serializedClient) => { + if ( + inMemoryClients.every( + (client) => client.id !== serializedClient.clientId, + ) + ) { + return serializedClient + } + }) + .map((serializedClient) => { + return new WebSocketRemoteClientConnection( + serializedClient.clientId, + new URL(serializedClient.url), + this.channel, + ) + }), + ), + ) + } + + // In Node.js, the manager acts as a singleton, and all clients + // are kept in-memory. + return this.inMemoryClients + } - constructor(private channel: BroadcastChannel) { - this.clients = new Set() + private getSerializedClients(): Array { + invariant( + typeof localStorage !== 'undefined', + 'Failed to call WebSocketClientManager#getSerializedClients() in a non-browser environment. This is likely a bug in MSW. Please, report it on GitHub: https://github.com/mswjs/msw', + ) - this.channel.addEventListener('message', (message) => { - const { type, payload } = message.data as WebSocketBroadcastChannelMessage + const clientsJson = localStorage.getItem(MSW_WEBSOCKET_CLIENTS_KEY) - switch (type) { - case 'connection:open': { - // When another runtime notifies about a new connection, - // create a connection wrapper class and add it to the set. - this.onRemoteConnection(payload.clientId, new URL(payload.url)) - break - } - } + if (!clientsJson) { + return [] + } + + const allClients = JSON.parse(clientsJson) as Array + const matchingClients = allClients.filter((client) => { + return matchRequestUrl(new URL(client.url), this.url).matches }) + + return matchingClients + } + + private addClient(client: WebSocketClientConnection): void { + this.inMemoryClients.add(client) + + if (typeof localStorage !== 'undefined') { + const serializedClients = this.getSerializedClients() + + // Serialize the current client for other runtimes to create + // a remote wrapper over it. This has no effect on the current runtime. + const nextSerializedClients = serializedClients.concat({ + clientId: client.id, + url: client.url.href, + } as SerializedClient) + + localStorage.setItem( + MSW_WEBSOCKET_CLIENTS_KEY, + JSON.stringify(nextSerializedClients), + ) + } } /** @@ -64,16 +150,7 @@ export class WebSocketClientManager { * for the opened connections in the same runtime. */ public addConnection(client: WebSocketClientConnection): void { - this.clients.add(client) - - // Signal to other runtimes about this connection. - this.channel.postMessage({ - type: 'connection:open', - payload: { - clientId: client.id, - url: client.url.toString(), - }, - } as WebSocketBroadcastChannelMessage) + this.addClient(client) // Instruct the current client how to handle events // coming from other runtimes (e.g. when calling `.broadcast()`). @@ -116,19 +193,6 @@ export class WebSocketClientManager { once: true, }) } - - /** - * Adds a client connection wrapper to operate with - * WebSocket client connections in other runtimes. - */ - private onRemoteConnection(id: string, url: URL): void { - this.clients.add( - // Create a connection-compatible instance that can - // operate with this client from a different runtime - // using the BroadcastChannel messages. - new WebSocketRemoteClientConnection(id, url, this.channel), - ) - } } /** diff --git a/test/browser/ws-api/ws.clients.browser.test.ts b/test/browser/ws-api/ws.clients.browser.test.ts new file mode 100644 index 000000000..4d0a4a77d --- /dev/null +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -0,0 +1,205 @@ +import type { WebSocketLink, ws } from 'msw' +import type { SetupWorker, setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + worker: SetupWorker + link: WebSocketLink + ws: WebSocket + messages: string[] + } +} + +test('returns the number of active clients in the same runtime', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.on('connection', () => {})) + window.link = api + await worker.start() + }) + + // Must return 0 when no clients are present. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(0) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return 1 after a single client joined. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(1) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return 2 now that another client has joined. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(2) +}) + +test('returns the number of active clients across different runtimes', async ({ + loadExample, + context, +}) => { + const { compilation } = await loadExample( + require.resolve('./ws.runtime.js'), + { + skipActivation: true, + }, + ) + + const pageOne = await context.newPage() + const pageTwo = await context.newPage() + + for (const page of [pageOne, pageTwo]) { + await page.goto(compilation.previewUrl) + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.on('connection', () => {})) + window.link = api + await worker.start() + }) + } + + await pageOne.bringToFront() + await pageOne.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + expect(await pageOne.evaluate(() => window.link.clients.size)).toBe(1) + expect(await pageTwo.evaluate(() => window.link.clients.size)).toBe(1) + + await pageTwo.bringToFront() + await pageTwo.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + expect(await pageTwo.evaluate(() => window.link.clients.size)).toBe(2) + expect(await pageOne.evaluate(() => window.link.clients.size)).toBe(2) +}) + +test('broadcasts messages across runtimes', async ({ + loadExample, + context, +}) => { + const { compilation } = await loadExample( + require.resolve('./ws.runtime.js'), + { + skipActivation: true, + }, + ) + + const pageOne = await context.newPage() + const pageTwo = await context.newPage() + + for (const page of [pageOne, pageTwo]) { + await page.goto(compilation.previewUrl) + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker( + api.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + api.broadcast(event.data) + }) + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + window.messages = [] + const ws = new WebSocket('wss://example.com') + window.ws = ws + ws.onmessage = (event) => { + window.messages.push(event.data) + } + }) + } + + await pageOne.evaluate(() => { + window.ws.send('hi from one') + }) + expect(await pageOne.evaluate(() => window.messages)).toEqual(['hi from one']) + expect(await pageTwo.evaluate(() => window.messages)).toEqual(['hi from one']) + + await pageTwo.evaluate(() => { + window.ws.send('hi from two') + }) + + expect(await pageTwo.evaluate(() => window.messages)).toEqual([ + 'hi from one', + 'hi from two', + ]) + expect(await pageOne.evaluate(() => window.messages)).toEqual([ + 'hi from one', + 'hi from two', + ]) +}) + +test('clears the list of clients when the worker is stopped', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.on('connection', () => {})) + window.link = api + window.worker = worker + await worker.start() + }) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return 1 after a single client joined. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(1) + + await page.evaluate(() => { + window.worker.stop() + }) + + // Must return 0. + // The localStorage has been purged, and the in-memory manager clients too. + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) +}) From 25da0dad4ca6f2a034a1baa567f3f82a2edcf003 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 12 Apr 2024 13:05:07 -0600 Subject: [PATCH 68/69] chore(release): v2.3.0-ws.rc-6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 226583f4c..bd22b5894 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-5", + "version": "2.3.0-ws.rc-6", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From 026f24f27e1cc1bb91fea119dcc74f376aff48b6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 15 Apr 2024 22:51:40 +0200 Subject: [PATCH 69/69] fix: purge persisted clients on page reload (#2133) --- .../setupWorker/start/createStartHandler.ts | 6 ++ src/core/ws/WebSocketClientManager.ts | 3 +- .../browser/ws-api/ws.clients.browser.test.ts | 55 ++++++++++++++++--- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/browser/setupWorker/start/createStartHandler.ts b/src/browser/setupWorker/start/createStartHandler.ts index dd9c35ebb..6629590f2 100644 --- a/src/browser/setupWorker/start/createStartHandler.ts +++ b/src/browser/setupWorker/start/createStartHandler.ts @@ -1,4 +1,5 @@ import { devUtils } from '~/core/utils/internal/devUtils' +import { MSW_WEBSOCKET_CLIENTS_KEY } from '~/core/ws/WebSocketClientManager' import { getWorkerInstance } from './utils/getWorkerInstance' import { enableMocking } from './utils/enableMocking' import { SetupWorkerInternalContext, StartHandler } from '../glossary' @@ -71,6 +72,11 @@ Please consider using a custom "serviceWorker.url" option to point to the actual // Make sure we're always clearing the interval - there are reports that not doing this can // cause memory leaks in headless browser environments. window.clearInterval(context.keepAliveInterval) + + // Purge persisted clients on page reload. + // WebSocket clients will get new IDs on reload so persisting them + // makes little sense. + localStorage.removeItem(MSW_WEBSOCKET_CLIENTS_KEY) }) // Check if the active Service Worker has been generated diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index e9d11d468..0a7680736 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -43,9 +43,8 @@ export class WebSocketClientManager { ) { this.inMemoryClients = new Set() + // Purge in-memory clients when the worker stops. if (typeof localStorage !== 'undefined') { - // When the worker clears the local storage key in "worker.stop()", - // also clear the in-memory clients map. localStorage.removeItem = new Proxy(localStorage.removeItem, { apply: (target, thisArg, args) => { const [key] = args diff --git a/test/browser/ws-api/ws.clients.browser.test.ts b/test/browser/ws-api/ws.clients.browser.test.ts index 4d0a4a77d..f811a3838 100644 --- a/test/browser/ws-api/ws.clients.browser.test.ts +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -183,23 +183,62 @@ test('clears the list of clients when the worker is stopped', async ({ await worker.start() }) + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) + await page.evaluate(async () => { const ws = new WebSocket('wss://example.com') await new Promise((done) => (ws.onopen = done)) }) - // Must return 1 after a single client joined. - expect( - await page.evaluate(() => { - return window.link.clients.size - }), - ).toBe(1) + // Must return the number of joined clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(1) await page.evaluate(() => { window.worker.stop() }) - // Must return 0. - // The localStorage has been purged, and the in-memory manager clients too. + // Must purge the local storage on reload. + // The worker has been started as a part of the test, not runtime, + // so it will start with empty clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) +}) + +test('clears the list of clients when the page is reloaded', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + const enableMocking = async () => { + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.on('connection', () => {})) + window.link = api + window.worker = worker + await worker.start() + }) + } + + await enableMocking(page) + + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return the number of joined clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(1) + + await page.reload() + await enableMocking() + + // Must purge the local storage on reload. + // The worker has been started as a part of the test, not runtime, + // so it will start with empty clients. expect(await page.evaluate(() => window.link.clients.size)).toBe(0) })