diff --git a/.gitignore b/.gitignore index 69d931b07..0f49110a8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,5 @@ msw-*.tgz # Smoke test temporary files. /package.json.copy /examples - -tsconfig.vitest-temp.json \ No newline at end of file +/test/modules/node/node-esm-tests +tsconfig.vitest-temp.json diff --git a/package.json b/package.json index 27bae7a81..b0528bfcb 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,12 @@ "import": "./lib/core/graphql.mjs", "default": "./lib/core/graphql.js" }, + "./core/ws": { + "types": "./lib/core/ws.d.ts", + "require": "./lib/core/ws.js", + "import": "./lib/core/ws.mjs", + "default": "./lib/core/ws.js" + }, "./mockServiceWorker.js": "./lib/mockServiceWorker.js", "./package.json": "./package.json" }, @@ -138,6 +144,7 @@ "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.36.5", + "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", @@ -154,6 +161,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.1", "@playwright/test": "^1.48.0", @@ -177,6 +185,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "express": "^5.0.0", + "fastify": "^4.26.0", "fs-extra": "^11.2.0", "fs-teardown": "^0.3.0", "glob": "^11.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80f302a08..453726cbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: '@mswjs/interceptors': specifier: ^0.36.5 version: 0.36.5 + '@open-draft/deferred-promise': + specifier: ^2.2.0 + version: 2.2.0 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -64,6 +67,9 @@ devDependencies: '@commitlint/config-conventional': specifier: ^18.4.4 version: 18.6.3 + '@fastify/websocket': + specifier: ^8.3.1 + version: 8.3.1 '@open-draft/test-server': specifier: ^0.4.2 version: 0.4.2 @@ -133,6 +139,9 @@ devDependencies: express: specifier: ^5.0.0 version: 5.0.0 + fastify: + specifier: ^4.26.0 + version: 4.28.1 fs-extra: specifier: ^11.2.0 version: 11.2.0 @@ -1549,11 +1558,45 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@fastify/ajv-compiler@3.6.0: + resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==} + dependencies: + ajv: 8.12.0 + ajv-formats: 2.1.1(ajv@8.12.0) + fast-uri: 2.4.0 + dev: true + /@fastify/busboy@2.1.1: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} 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.16.1 + 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.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -1838,7 +1881,7 @@ packages: '@miniflare/core': 2.14.4 '@miniflare/shared': 2.14.4 undici: 5.28.4 - ws: 8.16.0 + ws: 8.18.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -2918,6 +2961,10 @@ packages: through: 2.3.8 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'} @@ -2976,6 +3023,28 @@ packages: - supports-color dev: true + /ajv-formats@2.1.1(ajv@8.12.0): + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.12.0 + dev: true + + /ajv-formats@3.0.1(ajv@8.12.0): + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + 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: @@ -3174,6 +3243,13 @@ packages: possible-typed-array-names: 1.0.0 dev: true + /avvio@8.4.0: + resolution: {integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==} + dependencies: + '@fastify/error': 3.4.1 + fastq: 1.17.1 + dev: true + /axios@1.7.7: resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} dependencies: @@ -3913,6 +3989,11 @@ packages: engines: {node: '>= 0.6'} dev: true + /cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + dev: true + /cookies@0.9.1: resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} engines: {node: '>= 0.8'} @@ -4968,6 +5049,14 @@ packages: tmp: 0.0.33 dev: true + /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 @@ -4991,10 +5080,28 @@ packages: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true + /fast-json-stringify@5.16.1: + resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} + dependencies: + '@fastify/merge-json-schemas': 0.1.1 + ajv: 8.12.0 + ajv-formats: 3.0.1(ajv@8.12.0) + fast-deep-equal: 3.1.3 + fast-uri: 2.4.0 + json-schema-ref-resolver: 1.0.1 + rfdc: 1.4.1 + 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.5.0: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} @@ -5004,6 +5111,35 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} dev: true + /fast-uri@2.4.0: + resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==} + dev: true + + /fastify-plugin@4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + dev: true + + /fastify@4.28.1: + resolution: {integrity: sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ==} + dependencies: + '@fastify/ajv-compiler': 3.6.0 + '@fastify/error': 3.4.1 + '@fastify/fast-json-stringify-compiler': 4.3.0 + abstract-logging: 2.0.1 + avvio: 8.4.0 + fast-content-type-parse: 1.1.0 + fast-json-stringify: 5.16.1 + find-my-way: 8.2.2 + light-my-request: 5.14.0 + pino: 9.5.0 + process-warning: 3.0.0 + proxy-addr: 2.0.7 + rfdc: 1.4.1 + secure-json-parse: 2.7.0 + semver: 7.6.0 + toad-cache: 3.7.0 + dev: true + /fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} dependencies: @@ -5079,6 +5215,15 @@ packages: - supports-color dev: true + /find-my-way@8.2.2: + resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==} + engines: {node: '>=14'} + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 3.1.0 + dev: true + /find-node-modules@2.1.3: resolution: {integrity: sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==} dependencies: @@ -6157,6 +6302,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 @@ -6299,6 +6450,14 @@ packages: type-check: 0.4.0 dev: true + /light-my-request@5.14.0: + resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==} + dependencies: + cookie: 0.7.2 + process-warning: 3.0.0 + set-cookie-parser: 2.6.0 + dev: true + /lilconfig@3.1.1: resolution: {integrity: sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==} engines: {node: '>=14'} @@ -6924,6 +7083,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'} @@ -7233,6 +7397,12 @@ packages: split2: 4.2.0 dev: true + /pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + dependencies: + split2: 4.2.0 + dev: true + /pino-pretty@7.6.1: resolution: {integrity: sha512-H7N6ZYkiyrfwBGW9CSjx0uyO9Q2Lyt73881+OTYk8v3TiTdgN92QHrWlEq/LeWw5XtDP64jeSk3mnc6T+xX9/w==} hasBin: true @@ -7256,6 +7426,10 @@ packages: resolution: {integrity: sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==} dev: true + /pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + dev: true + /pino@7.11.0: resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} hasBin: true @@ -7273,6 +7447,23 @@ packages: thread-stream: 0.15.2 dev: true + /pino@9.5.0: + resolution: {integrity: sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==} + hasBin: true + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 4.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.4.3 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + dev: true + /pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} @@ -7399,6 +7590,14 @@ packages: resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} dev: true + /process-warning@3.0.0: + resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} + dev: true + + /process-warning@4.0.0: + resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==} + dev: true + /proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -7559,6 +7758,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'} @@ -7664,6 +7868,11 @@ packages: signal-exit: 4.1.0 dev: true + /ret@0.4.3: + resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==} + engines: {node: '>=10'} + dev: true + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -7806,6 +8015,12 @@ packages: is-regex: 1.1.4 dev: true + /safe-regex2@3.1.0: + resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==} + dependencies: + ret: 0.4.3 + dev: true + /safe-stable-stringify@2.4.3: resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} engines: {node: '>=10'} @@ -8065,6 +8280,12 @@ packages: atomic-sleep: 1.0.0 dev: true + /sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + dependencies: + atomic-sleep: 1.0.0 + dev: true + /source-list-map@2.0.1: resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} dev: true @@ -8433,6 +8654,12 @@ packages: real-require: 0.1.0 dev: true + /thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + dependencies: + real-require: 0.2.0 + dev: true + /through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} dependencies: @@ -8502,6 +8729,11 @@ packages: is-number: 7.0.0 dev: true + /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'} @@ -9308,19 +9540,6 @@ packages: optional: true dev: true - /ws@8.16.0: - resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} - 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.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} diff --git a/src/browser/setupWorker/glossary.ts b/src/browser/setupWorker/glossary.ts index 70951ede9..a9452ab41 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 @@ -92,7 +90,7 @@ export interface SetupWorkerInternalContext { startOptions: RequiredDeep worker: ServiceWorker | null registration: ServiceWorkerRegistration | null - getRequestHandlers(): Array + getRequestHandlers(): Array requests: Map emitter: Emitter keepAliveInterval?: number @@ -216,7 +214,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. @@ -231,14 +229,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 500bcb9cb..f83c67e0d 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -18,9 +18,13 @@ 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' +import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' +import { handleWebSocketEvent } from '~/core/ws/handleWebSocketEvent' +import { attachWebSocketLogger } from '~/core/ws/utils/attachWebSocketLogger' interface Listener { target: EventTarget @@ -37,7 +41,7 @@ export class SetupWorkerApi private stopHandler: StopHandler = null as any private listeners: Array - constructor(...handlers: Array) { + constructor(...handlers: Array) { super(...handlers) invariant( @@ -176,6 +180,29 @@ export class SetupWorkerApi options, ) as SetupWorkerInternalContext['startOptions'] + // Enable the WebSocket interception. + handleWebSocketEvent({ + getUnhandledRequestStrategy: () => { + return this.context.startOptions.onUnhandledRequest + }, + 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() {}, + }) + webSocketInterceptor.apply() + + this.subscriptions.push(() => { + webSocketInterceptor.dispose() + }) + return await this.startHandler(this.context.startOptions, options) } @@ -193,6 +220,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/browser/setupWorker/start/createRequestListener.ts b/src/browser/setupWorker/start/createRequestListener.ts index 627617e49..e9e1cf904 100644 --- a/src/browser/setupWorker/start/createRequestListener.ts +++ b/src/browser/setupWorker/start/createRequestListener.ts @@ -9,6 +9,8 @@ import { } from './utils/createMessageChannel' import { parseWorkerRequest } from '../../utils/parseWorkerRequest' import { RequestHandler } from '~/core/handlers/RequestHandler' +import { HttpHandler } from '~/core/handlers/HttpHandler' +import { GraphQLHandler } from '~/core/handlers/GraphQLHandler' import { handleRequest } from '~/core/utils/handleRequest' import { RequiredDeep } from '~/core/typeUtils' import { devUtils } from '~/core/utils/internal/devUtils' @@ -43,7 +45,11 @@ export const createRequestListener = ( await handleRequest( request, requestId, - context.getRequestHandlers(), + context.getRequestHandlers().filter((handler) => { + return ( + handler instanceof HttpHandler || handler instanceof GraphQLHandler + ) + }), options, context.emitter, { diff --git a/src/browser/setupWorker/start/createStartHandler.ts b/src/browser/setupWorker/start/createStartHandler.ts index 8a5f55321..c77034b6d 100644 --- a/src/browser/setupWorker/start/createStartHandler.ts +++ b/src/browser/setupWorker/start/createStartHandler.ts @@ -71,6 +71,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) + + // Notify others about this client disconnecting. + // E.g. this will purge the in-memory WebSocket clients since + // starting the worker again will assign them new IDs. + window.postMessage({ type: 'msw/worker:stop' }) }) // Check if the active Service Worker has been generated diff --git a/src/browser/setupWorker/stop/createStop.ts b/src/browser/setupWorker/stop/createStop.ts index 48c37996d..331f62196 100644 --- a/src/browser/setupWorker/stop/createStop.ts +++ b/src/browser/setupWorker/stop/createStop.ts @@ -24,6 +24,12 @@ export const createStop = ( context.isMockingEnabled = false window.clearInterval(context.keepAliveInterval) + // Post the internal stop message on the window + // to let any logic know when the worker has stopped. + // E.g. the WebSocket client manager needs this to know + // when to clear its in-memory clients list. + window.postMessage({ type: 'msw/worker:stop' }) + printStopMessage({ quiet: context.startOptions?.quiet }) } } diff --git a/src/core/SetupApi.ts b/src/core/SetupApi.ts index dbe2c8cbc..e908e5994 100644 --- a/src/core/SetupApi.ts +++ b/src/core/SetupApi.ts @@ -1,38 +1,42 @@ 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' export abstract class HandlersController { - abstract prepend(runtimeHandlers: Array): void - abstract reset(nextHandles: Array): void - abstract currentHandlers(): Array + abstract prepend( + runtimeHandlers: Array, + ): void + abstract reset(nextHandles: Array): void + abstract currentHandlers(): Array } export class InMemoryHandlersController implements HandlersController { - private handlers: Array + private handlers: Array - constructor(private initialHandlers: Array) { + constructor( + private initialHandlers: Array, + ) { this.handlers = [...initialHandlers] } - public prepend(runtimeHandles: Array): void { + public prepend( + runtimeHandles: Array, + ): void { this.handlers.unshift(...runtimeHandles) } - public reset(nextHandlers: Array): void { + public reset(nextHandlers: Array): void { this.handlers = nextHandlers.length > 0 ? [...nextHandlers] : [...this.initialHandlers] } - public currentHandlers(): Array { + public currentHandlers(): Array { return this.handlers } } @@ -47,7 +51,7 @@ export abstract class SetupApi extends Disposable { public readonly events: LifeCycleEventEmitter - constructor(...initialHandlers: Array) { + constructor(...initialHandlers: Array) { super() invariant( @@ -71,12 +75,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( @@ -89,17 +95,19 @@ export abstract class SetupApi extends Disposable { public restoreHandlers(): void { this.handlersController.currentHandlers().forEach((handler) => { - handler.isUsed = false + if ('isUsed' in handler) { + handler.isUsed = false + } }) } - public resetHandlers(...nextHandlers: Array): void { + public resetHandlers( + ...nextHandlers: Array + ): void { this.handlersController.reset(nextHandlers) } - public listHandlers(): ReadonlyArray< - RequestHandler - > { + public listHandlers(): ReadonlyArray { return toReadonlyArray(this.handlersController.currentHandlers()) } diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts new file mode 100644 index 000000000..26d443872 --- /dev/null +++ b/src/core/handlers/WebSocketHandler.ts @@ -0,0 +1,142 @@ +import { Emitter } from 'strict-event-emitter' +import { createRequestId } from '@mswjs/interceptors' +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: WebSocketHandlerConnection] +} + +export interface WebSocketHandlerConnection extends WebSocketConnectionData { + params: PathParams +} + +export const kEmitter = Symbol('kEmitter') +export const kDispatchEvent = Symbol('kDispatchEvent') +export const kSender = Symbol('kSender') +const kStopPropagationPatched = Symbol('kStopPropagationPatched') +const KOnStopPropagation = Symbol('KOnStopPropagation') + +export class WebSocketHandler { + public id: string + public callFrame?: string + + protected [kEmitter]: Emitter + + constructor(private readonly url: Path) { + this.id = createRequestId() + + this[kEmitter] = new Emitter() + this.callFrame = getCallFrame(new Error()) + } + + public parse(args: { + event: MessageEvent + }): WebSocketHandlerParsedResult { + const connection = args.event.data + const match = matchRequestUrl(connection.client.url, this.url) + + return { + match, + } + } + + public predicate(args: { + event: MessageEvent + parsedResult: WebSocketHandlerParsedResult + }): boolean { + return args.parsedResult.match.matches + } + + async [kDispatchEvent]( + event: MessageEvent, + ): Promise { + const parsedResult = this.parse({ event }) + const connection = event.data + + const resolvedConnection: WebSocketHandlerConnection = { + ...connection, + params: parsedResult.match.params || {}, + } + + // Support `event.stopPropagation()` for various client/server events. + connection.client.addEventListener( + 'message', + createStopPropagationListener(this), + ) + connection.client.addEventListener( + 'close', + createStopPropagationListener(this), + ) + + connection.server.addEventListener( + 'open', + createStopPropagationListener(this), + ) + connection.server.addEventListener( + 'message', + createStopPropagationListener(this), + ) + connection.server.addEventListener( + 'error', + createStopPropagationListener(this), + ) + connection.server.addEventListener( + 'close', + createStopPropagationListener(this), + ) + + // Emit the connection event on the handler. + // This is what the developer adds listeners for. + this[kEmitter].emit('connection', resolvedConnection) + } +} + +function createStopPropagationListener(handler: WebSocketHandler) { + return function stopPropagationListener(event: Event) { + const propagationStoppedAt = Reflect.get(event, 'kPropagationStoppedAt') as + | string + | undefined + + if (propagationStoppedAt && handler.id !== propagationStoppedAt) { + event.stopImmediatePropagation() + return + } + + Object.defineProperty(event, KOnStopPropagation, { + value(this: WebSocketHandler) { + Object.defineProperty(event, 'kPropagationStoppedAt', { + value: handler.id, + }) + }, + configurable: true, + }) + + // Since the same event instance is shared between all client/server objects, + // make sure to patch its `stopPropagation` method only once. + if (!Reflect.get(event, kStopPropagationPatched)) { + event.stopPropagation = new Proxy(event.stopPropagation, { + apply: (target, thisArg, args) => { + Reflect.get(event, KOnStopPropagation)?.call(handler) + return Reflect.apply(target, thisArg, args) + }, + }) + + Object.defineProperty(event, kStopPropagationPatched, { + value: true, + // If something else attempts to redefine this, throw. + configurable: false, + }) + } + } +} diff --git a/src/core/index.ts b/src/core/index.ts index 6e8aa5ac9..9cd723080 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,13 +2,21 @@ 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 handler */ +export { ws, type WebSocketLink } from './ws' +export { + WebSocketHandler, + type WebSocketHandlerEventMap, + type WebSocketHandlerConnection, +} from './handlers/WebSocketHandler' + /* Utils */ export { matchRequestUrl } from './utils/matching/matchRequestUrl' export * from './utils/handleRequest' @@ -45,6 +53,8 @@ export type { } from './handlers/GraphQLHandler' export type { GraphQLRequestHandler, GraphQLResponseResolver } from './graphql' +export type { WebSocketData, WebSocketEventListener } from './ws' + export type { Path, PathParams, Match } from './utils/matching/matchRequestUrl' export type { ParsedGraphQLRequest } from './utils/internal/parseGraphQLRequest' 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 766b22ce6..d78259c2e 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -1,8 +1,8 @@ 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 type { RequestHandler } from '../handlers/RequestHandler' import { HandlersExecutionResult, executeHandlers } from './executeHandlers' import { onUnhandledRequest } from './request/onUnhandledRequest' import { storeResponseCookies } from './request/storeResponseCookies' diff --git a/src/core/utils/logging/getTimestamp.test.ts b/src/core/utils/logging/getTimestamp.test.ts index f7c70dc0c..04b488686 100644 --- a/src/core/utils/logging/getTimestamp.test.ts +++ b/src/core/utils/logging/getTimestamp.test.ts @@ -1,18 +1,32 @@ 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.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 28e8d689a..a53605355 100644 --- a/src/core/utils/logging/getTimestamp.ts +++ b/src/core/utils/logging/getTimestamp.ts @@ -1,12 +1,17 @@ +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() + const timestamp = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}` + + if (options?.milliseconds) { + return `${timestamp}.${now.getMilliseconds().toString().padStart(3, '0')}` + } - return [now.getHours(), now.getMinutes(), now.getSeconds()] - .map(String) - .map((chunk) => chunk.slice(0, 2)) - .map((chunk) => chunk.padStart(2, '0')) - .join(':') + return timestamp } diff --git a/src/core/utils/matching/matchRequestUrl.test.ts b/src/core/utils/matching/matchRequestUrl.test.ts index 54575f709..b6c6143af 100644 --- a/src/core/utils/matching/matchRequestUrl.test.ts +++ b/src/core/utils/matching/matchRequestUrl.test.ts @@ -72,6 +72,50 @@ describe('matchRequestUrl', () => { userId: undefined, }) }) + + 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', () => { 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.test.ts b/src/core/ws.test.ts new file mode 100644 index 000000000..22cc58b47 --- /dev/null +++ b/src/core/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 to be a valid path but got number') +}) diff --git a/src/core/ws.ts b/src/core/ws.ts new file mode 100644 index 000000000..745615951 --- /dev/null +++ b/src/core/ws.ts @@ -0,0 +1,166 @@ +import { invariant } from 'outvariant' +import type { + WebSocketData, + WebSocketClientConnectionProtocol, +} from '@mswjs/interceptors/WebSocket' +import { + WebSocketHandler, + kEmitter, + type WebSocketHandlerEventMap, +} from './handlers/WebSocketHandler' +import { Path, isPath } from './utils/matching/matchRequestUrl' +import { WebSocketClientManager } from './ws/WebSocketClientManager' + +function isBroadcastChannelWithUnref( + channel: BroadcastChannel, +): channel is BroadcastChannel & NodeJS.RefCounted { + return typeof Reflect.get(channel, 'unref') !== 'undefined' +} + +const webSocketChannel = new BroadcastChannel('msw:websocket-client-manager') + +if (isBroadcastChannelWithUnref(webSocketChannel)) { + // Allows the Node.js thread to exit if it is the only active handle in the event system. + // https://nodejs.org/api/worker_threads.html#broadcastchannelunref + webSocketChannel.unref() +} + +export type WebSocketEventListener< + EventType extends keyof WebSocketHandlerEventMap, +> = (...args: WebSocketHandlerEventMap[EventType]) => void + +export type WebSocketLink = { + /** + * A set of all WebSocket clients connected + * to this link. + * + * @see {@link https://mswjs.io/docs/api/ws#clients `clients` API reference} + */ + clients: Set + + /** + * Adds an event listener to this WebSocket link. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * chat.addEventListener('connection', listener) + * + * @see {@link https://mswjs.io/docs/api/ws#onevent-listener `on()` API reference} + */ + addEventListener( + event: EventType, + listener: WebSocketEventListener, + ): WebSocketHandler + + /** + * Broadcasts the given data to all WebSocket clients. + * + * @example + * const service = ws.link('wss://example.com') + * service.addEventListener('connection', () => { + * service.broadcast('hello, everyone!') + * }) + * + * @see {@link https://mswjs.io/docs/api/ws#broadcastdata `broadcast()` API reference} + */ + 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.addEventListener('connection', ({ client }) => { + * service.broadcastExcept(client, 'hi, the rest of you!') + * }) + * + * @see {@link https://mswjs.io/docs/api/ws#broadcastexceptclients-data `broadcast()` API reference} + */ + broadcastExcept( + clients: + | WebSocketClientConnectionProtocol + | Array, + data: WebSocketData, + ): void +} + +/** + * Intercepts outgoing WebSocket connections to the given URL. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * chat.addEventListener('connection', ({ client }) => { + * client.send('hello from server!') + * }) + */ +function createWebSocketLinkHandler(url: Path): WebSocketLink { + invariant(url, 'Expected a WebSocket server URL but got undefined') + + invariant( + isPath(url), + 'Expected a WebSocket server URL to be a valid path but got %s', + typeof url, + ) + + const clientManager = new WebSocketClientManager(webSocketChannel) + + return { + get clients() { + return clientManager.clients + }, + addEventListener(event, listener) { + const handler = new WebSocketHandler(url) + + // 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', async ({ client }) => { + await clientManager.addConnection(client) + }) + + // 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 + }, + + 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) + }, + + broadcastExcept(clients, data) { + const ignoreClients = Array.prototype + .concat(clients) + .map((client) => client.id) + + clientManager.clients.forEach((otherClient) => { + if (!ignoreClients.includes(otherClient.id)) { + otherClient.send(data) + } + }) + }, + } +} + +/** + * A namespace to intercept and mock WebSocket connections. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * + * @see {@link https://mswjs.io/docs/api/ws `ws` API reference} + * @see {@link https://mswjs.io/docs/basics/handling-websocket-events Handling WebSocket events} + */ +export const ws = { + link: createWebSocketLinkHandler, +} + +export { WebSocketData } diff --git a/src/core/ws/WebSocketClientManager.test.ts b/src/core/ws/WebSocketClientManager.test.ts new file mode 100644 index 000000000..6740b2113 --- /dev/null +++ b/src/core/ws/WebSocketClientManager.test.ts @@ -0,0 +1,164 @@ +// @vitest-environment node-websocket +import { setMaxListeners } from 'node:events' +import { + WebSocketClientConnection, + WebSocketData, + WebSocketTransport, +} from '@mswjs/interceptors/WebSocket' +import { + WebSocketClientManager, + WebSocketBroadcastChannelMessage, +} from './WebSocketClientManager' + +const channel = new BroadcastChannel('test:channel') + +/** + * @note Increase the number of maximum event listeners + * because the same channel is shared between different + * manager instances in different tests. + */ +setMaxListeners(Number.MAX_SAFE_INTEGER, channel) + +vi.spyOn(channel, 'postMessage') + +const socket = new WebSocket('ws://localhost') + +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', async () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + + await manager.addConnection(connection) + + // Must add the client to the list of clients. + expect(Array.from(manager.clients.values())).toEqual([connection]) +}) + +it('adds multiple clients from this runtime to the list of clients', async () => { + const manager = new WebSocketClientManager(channel) + const connectionOne = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + await manager.addConnection(connectionOne) + + // Must add the client to the list of clients. + expect(Array.from(manager.clients.values())).toEqual([connectionOne]) + + const connectionTwo = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + await manager.addConnection(connectionTwo) + + // 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, + new TestWebSocketTransport(), + ) + await 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, + new TestWebSocketTransport(), + ) + await 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 transport = new TestWebSocketTransport() + 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. + */ + transport.dispatchEvent(new Event('close')) + }) + vi.spyOn(connection, 'send') + + await 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 new file mode 100644 index 000000000..44ce81e1c --- /dev/null +++ b/src/core/ws/WebSocketClientManager.ts @@ -0,0 +1,211 @@ +import type { + WebSocketData, + WebSocketClientConnection, + WebSocketClientConnectionProtocol, +} from '@mswjs/interceptors/WebSocket' +import { WebSocketClientStore } from './WebSocketClientStore' +import { WebSocketMemoryClientStore } from './WebSocketMemoryClientStore' +import { WebSocketIndexedDBClientStore } from './WebSocketIndexedDBClientStore' + +export type WebSocketBroadcastChannelMessage = + | { + type: 'extraneous:send' + payload: { + clientId: string + data: WebSocketData + } + } + | { + type: 'extraneous:close' + payload: { + clientId: string + code?: number + reason?: string + } + } + +/** + * A manager responsible for accumulating WebSocket client + * connections across different browser runtimes. + */ +export class WebSocketClientManager { + private store: WebSocketClientStore + private runtimeClients: Map + private allClients: Set + + constructor(private channel: BroadcastChannel) { + // Store the clients in the IndexedDB in the browser, + // otherwise, store the clients in memory. + this.store = + typeof indexedDB !== 'undefined' + ? new WebSocketIndexedDBClientStore() + : new WebSocketMemoryClientStore() + + this.runtimeClients = new Map() + this.allClients = new Set() + + this.channel.addEventListener('message', (message) => { + if (message.data?.type === 'db:update') { + this.flushDatabaseToMemory() + } + }) + + if (typeof window !== 'undefined') { + window.addEventListener('message', async (message) => { + if (message.data?.type === 'msw/worker:stop') { + await this.removeRuntimeClients() + } + }) + } + } + + private async flushDatabaseToMemory() { + const storedClients = await this.store.getAll() + + this.allClients = new Set( + storedClients.map((client) => { + const runtimeClient = this.runtimeClients.get(client.id) + + /** + * @note For clients originating in this runtime, use their + * direct references. No need to wrap them in a remote connection. + */ + if (runtimeClient) { + return runtimeClient + } + + return new WebSocketRemoteClientConnection( + client.id, + new URL(client.url), + this.channel, + ) + }), + ) + } + + private async removeRuntimeClients(): Promise { + await this.store.deleteMany(Array.from(this.runtimeClients.keys())) + this.runtimeClients.clear() + await this.flushDatabaseToMemory() + this.notifyOthersAboutDatabaseUpdate() + } + + /** + * All active WebSocket client connections. + */ + get clients(): Set { + return this.allClients + } + + /** + * Notify other runtimes about the database update + * using the shared `BroadcastChannel` instance. + */ + private notifyOthersAboutDatabaseUpdate(): void { + this.channel.postMessage({ type: 'db:update' }) + } + + private async addClient(client: WebSocketClientConnection): Promise { + await this.store.add(client) + // Sync the in-memory clients in this runtime with the + // updated database. This pulls in all the stored clients. + await this.flushDatabaseToMemory() + this.notifyOthersAboutDatabaseUpdate() + } + + /** + * 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 async addConnection(client: WebSocketClientConnection): Promise { + // Store this client in the map of clients created in this runtime. + // This way, the manager can distinguish between this runtime clients + // and extraneous runtime clients when synchronizing clients storage. + this.runtimeClients.set(client.id, client) + + // Add the new client to the storage. + await this.addClient(client) + + // Handle the incoming BroadcastChannel messages from other runtimes + // that attempt to control this runtime (via a remote connection wrapper). + // E.g. another runtime calling `client.send()` for the client in this runtime. + const handleExtraneousMessage = ( + message: MessageEvent, + ) => { + const { type, payload } = message.data + + // Ignore broadcasted messages for other clients. + if ( + typeof payload === 'object' && + 'clientId' in payload && + payload.clientId !== client.id + ) { + return + } + + switch (type) { + case 'extraneous:send': { + client.send(payload.data) + break + } + + 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, + }) + } +} + +/** + * 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. + */ +export class WebSocketRemoteClientConnection + implements WebSocketClientConnectionProtocol +{ + constructor( + public readonly id: string, + public readonly url: URL, + private channel: BroadcastChannel, + ) {} + + send(data: WebSocketData): void { + this.channel.postMessage({ + type: 'extraneous:send', + payload: { + clientId: this.id, + data, + }, + } as WebSocketBroadcastChannelMessage) + } + + close(code?: number | undefined, reason?: string | undefined): void { + this.channel.postMessage({ + type: 'extraneous:close', + payload: { + clientId: this.id, + code, + reason, + }, + } as WebSocketBroadcastChannelMessage) + } +} diff --git a/src/core/ws/WebSocketClientStore.ts b/src/core/ws/WebSocketClientStore.ts new file mode 100644 index 000000000..6e4c302b6 --- /dev/null +++ b/src/core/ws/WebSocketClientStore.ts @@ -0,0 +1,14 @@ +import type { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/WebSocket' + +export interface SerializedWebSocketClient { + id: string + url: string +} + +export abstract class WebSocketClientStore { + public abstract add(client: WebSocketClientConnectionProtocol): Promise + + public abstract getAll(): Promise> + + public abstract deleteMany(clientIds: Array): Promise +} diff --git a/src/core/ws/WebSocketIndexedDBClientStore.ts b/src/core/ws/WebSocketIndexedDBClientStore.ts new file mode 100644 index 000000000..b8509b7a9 --- /dev/null +++ b/src/core/ws/WebSocketIndexedDBClientStore.ts @@ -0,0 +1,145 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import { + type SerializedWebSocketClient, + WebSocketClientStore, +} from './WebSocketClientStore' + +const DB_NAME = 'msw-websocket-clients' +const DB_STORE_NAME = 'clients' + +export class WebSocketIndexedDBClientStore implements WebSocketClientStore { + private db: Promise + + constructor() { + this.db = this.createDatabase() + } + + public async add(client: WebSocketClientConnectionProtocol): Promise { + const promise = new DeferredPromise() + const store = await this.getStore() + + /** + * @note Use `.put()` instead of `.add()` to allow setting clients + * that already exist in the database. This can happen if a single page + * has multiple event handlers. Each handler will receive the "connection" + * event in parallel, and try to set that WebSocket client in the database. + */ + const request = store.put({ + id: client.id, + url: client.url.href, + } satisfies SerializedWebSocketClient) + + request.onsuccess = () => { + promise.resolve() + } + request.onerror = () => { + // eslint-disable-next-line no-console + console.error(request.error) + promise.reject( + new Error( + `Failed to add WebSocket client "${client.id}". There is likely an additional output above.`, + ), + ) + } + + return promise + } + + public async getAll(): Promise> { + const promise = new DeferredPromise>() + const store = await this.getStore() + const request = store.getAll() as IDBRequest< + Array + > + + request.onsuccess = () => { + promise.resolve(request.result) + } + request.onerror = () => { + // eslint-disable-next-line no-console + console.log(request.error) + promise.reject( + new Error( + `Failed to get all WebSocket clients. There is likely an additional output above.`, + ), + ) + } + + return promise + } + + public async deleteMany(clientIds: Array): Promise { + const promise = new DeferredPromise() + const store = await this.getStore() + + for (const clientId of clientIds) { + store.delete(clientId) + } + + store.transaction.oncomplete = () => { + promise.resolve() + } + store.transaction.onerror = () => { + // eslint-disable-next-line no-console + console.error(store.transaction.error) + promise.reject( + new Error( + `Failed to delete WebSocket clients [${clientIds.join(', ')}]. There is likely an additional output above.`, + ), + ) + } + + return promise + } + + private async createDatabase(): Promise { + const promise = new DeferredPromise() + const request = indexedDB.open(DB_NAME, 1) + + request.onsuccess = ({ currentTarget }) => { + const db = Reflect.get(currentTarget!, 'result') as IDBDatabase + + if (db.objectStoreNames.contains(DB_STORE_NAME)) { + return promise.resolve(db) + } + } + + request.onupgradeneeded = async ({ currentTarget }) => { + const db = Reflect.get(currentTarget!, 'result') as IDBDatabase + if (db.objectStoreNames.contains(DB_STORE_NAME)) { + return + } + + const store = db.createObjectStore(DB_STORE_NAME, { keyPath: 'id' }) + store.transaction.oncomplete = () => { + promise.resolve(db) + } + store.transaction.onerror = () => { + // eslint-disable-next-line no-console + console.error(store.transaction.error) + promise.reject( + new Error( + 'Failed to create WebSocket client store. There is likely an additional output above.', + ), + ) + } + } + request.onerror = () => { + // eslint-disable-next-line no-console + console.error(request.error) + promise.reject( + new Error( + 'Failed to open an IndexedDB database. There is likely an additional output above.', + ), + ) + } + + return promise + } + + private async getStore(): Promise { + const db = await this.db + return db.transaction(DB_STORE_NAME, 'readwrite').objectStore(DB_STORE_NAME) + } +} diff --git a/src/core/ws/WebSocketMemoryClientStore.ts b/src/core/ws/WebSocketMemoryClientStore.ts new file mode 100644 index 000000000..2f97a26fb --- /dev/null +++ b/src/core/ws/WebSocketMemoryClientStore.ts @@ -0,0 +1,27 @@ +import { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import { + SerializedWebSocketClient, + WebSocketClientStore, +} from './WebSocketClientStore' + +export class WebSocketMemoryClientStore implements WebSocketClientStore { + private store: Map + + constructor() { + this.store = new Map() + } + + public async add(client: WebSocketClientConnectionProtocol): Promise { + this.store.set(client.id, { id: client.id, url: client.url.href }) + } + + public getAll(): Promise> { + return Promise.resolve(Array.from(this.store.values())) + } + + public async deleteMany(clientIds: Array): Promise { + for (const clientId of clientIds) { + this.store.delete(clientId) + } + } +} diff --git a/src/core/ws/handleWebSocketEvent.ts b/src/core/ws/handleWebSocketEvent.ts new file mode 100644 index 000000000..a20bd6ec4 --- /dev/null +++ b/src/core/ws/handleWebSocketEvent.ts @@ -0,0 +1,82 @@ +import type { WebSocketConnectionData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import { RequestHandler } from '../handlers/RequestHandler' +import { WebSocketHandler, kDispatchEvent } from '../handlers/WebSocketHandler' +import { webSocketInterceptor } from './webSocketInterceptor' +import { + onUnhandledRequest, + UnhandledRequestStrategy, +} from '../utils/request/onUnhandledRequest' + +interface HandleWebSocketEventOptions { + getUnhandledRequestStrategy: () => UnhandledRequestStrategy + getHandlers: () => Array + onMockedConnection: (connection: WebSocketConnectionData) => void + onPassthroughConnection: (onnection: WebSocketConnectionData) => void +} + +export function handleWebSocketEvent(options: HandleWebSocketEventOptions) { + webSocketInterceptor.on('connection', async (connection) => { + const handlers = options.getHandlers() + + const connectionEvent = new MessageEvent('connection', { + data: connection, + }) + + // 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: Array = [] + + for (const handler of handlers) { + if ( + handler instanceof WebSocketHandler && + handler.predicate({ + event: connectionEvent, + parsedResult: handler.parse({ + event: connectionEvent, + }), + }) + ) { + matchingHandlers.push(handler) + } + } + + 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 { + // Construct a request representing this WebSocket connection. + const request = new Request(connection.client.url, { + headers: { + upgrade: 'websocket', + connection: 'upgrade', + }, + }) + await onUnhandledRequest( + request, + options.getUnhandledRequestStrategy(), + ).catch((error) => { + const errorEvent = new Event('error') + Object.defineProperty(errorEvent, 'cause', { + enumerable: true, + configurable: false, + value: error, + }) + connection.client.socket.dispatchEvent(errorEvent) + }) + + options?.onPassthroughConnection(connection) + + // If none of the "ws" handlers matched, + // establish the WebSocket connection as-is. + connection.server.connect() + } + }) +} diff --git a/src/core/ws/utils/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts new file mode 100644 index 000000000..7e416a639 --- /dev/null +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -0,0 +1,259 @@ +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' + +const colors = { + system: '#3b82f6', + outgoing: '#22c55e', + incoming: '#ef4444', + mocked: '#ff6a33', +} + +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 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, + }, + }) + + queueMicrotask(() => { + 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.socket, + }, + target: { + enumerable: true, + writable: false, + value: server.socket, + }, + }) + + 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) + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage(`${getTimestamp()} %c▶%c ${publicUrl}`), + `color:${colors.system}`, + 'color:inherit', + ) + // eslint-disable-next-line no-console + console.log('Client:', client.socket) + // eslint-disable-next-line no-console + console.groupEnd() +} + +function logConnectionClose(event: CloseEvent) { + const target = event.target as WebSocket + const publicUrl = toPublicUrl(target.url) + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c■%c ${publicUrl}`, + ), + `color:${colors.system}`, + 'color:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + console.groupEnd() +} + +function logClientError(event: Event) { + const socket = event.target as WebSocket + const publicUrl = toPublicUrl(socket.url) + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c\u00D7%c ${publicUrl}`, + ), + `color:${colors.system}`, + 'color:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + console.groupEnd() +} + +/** + * Prints the outgoing client message. + */ +async function logOutgoingClientMessage(event: MessageEvent) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + const arrow = event.defaultPrevented ? '⇡' : '⬆' + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c${arrow}%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.outgoing}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + console.groupEnd() +} + +/** + * Prints the outgoing client message initiated + * by `server.send()` in the event handler. + */ +async function logOutgoingMockedClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⬆%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.mocked}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + console.groupEnd() +} + +/** + * Prints the outgoing client message initiated + * by `client.send()` in the event handler. + */ +async function logIncomingMockedClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⬇%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.mocked}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + console.groupEnd() +} + +async function logIncomingServerMessage(event: MessageEvent) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + const arrow = event.defaultPrevented ? '⇣' : '⬇' + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c${arrow}%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.incoming}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + 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/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/node/SetupServerApi.ts b/src/node/SetupServerApi.ts index 9cce10439..4c86847c8 100644 --- a/src/node/SetupServerApi.ts +++ b/src/node/SetupServerApi.ts @@ -4,14 +4,15 @@ import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { FetchInterceptor } from '@mswjs/interceptors/fetch' import { HandlersController } from '~/core/SetupApi' import type { RequestHandler } from '~/core/handlers/RequestHandler' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import type { SetupServer } from './glossary' import { SetupServerCommonApi } from './SetupServerCommonApi' const store = new AsyncLocalStorage() type RequestHandlersContext = { - initialHandlers: Array - handlers: Array + initialHandlers: Array + handlers: Array } /** @@ -22,7 +23,7 @@ type RequestHandlersContext = { class AsyncHandlersController implements HandlersController { private rootContext: RequestHandlersContext - constructor(initialHandlers: Array) { + constructor(initialHandlers: Array) { this.rootContext = { initialHandlers, handlers: [] } } @@ -30,18 +31,18 @@ class AsyncHandlersController implements HandlersController { return store.getStore() || this.rootContext } - public prepend(runtimeHandlers: Array) { + public prepend(runtimeHandlers: Array) { this.context.handlers.unshift(...runtimeHandlers) } - public reset(nextHandlers: Array) { + public reset(nextHandlers: Array) { const context = this.context context.handlers = [] context.initialHandlers = nextHandlers.length > 0 ? nextHandlers : context.initialHandlers } - public currentHandlers(): Array { + public currentHandlers(): Array { const { initialHandlers, handlers } = this.context return handlers.concat(initialHandlers) } @@ -51,7 +52,7 @@ export class SetupServerApi extends SetupServerCommonApi implements SetupServer { - constructor(handlers: Array) { + constructor(handlers: Array) { super( [ClientRequestInterceptor, XMLHttpRequestInterceptor, FetchInterceptor], handlers, diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index 97a738b56..dca9bd85a 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -14,9 +14,14 @@ import type { LifeCycleEventsMap, SharedOptions } from '~/core/sharedOptions' import { SetupApi } from '~/core/SetupApi' import { handleRequest } from '~/core/utils/handleRequest' import type { RequestHandler } from '~/core/handlers/RequestHandler' +import { HttpHandler } from '~/core/handlers/HttpHandler' +import { GraphQLHandler } from '~/core/handlers/GraphQLHandler' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { mergeRight } from '~/core/utils/internal/mergeRight' import { InternalError, devUtils } from '~/core/utils/internal/devUtils' import type { SetupServerCommon } from './glossary' +import { handleWebSocketEvent } from '~/core/ws/handleWebSocketEvent' +import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' export const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', @@ -34,7 +39,7 @@ export class SetupServerCommonApi constructor( interceptors: Array<{ new (): Interceptor }>, - handlers: Array, + handlers: Array, ) { super(...handlers) @@ -58,7 +63,12 @@ export class SetupServerCommonApi const response = await handleRequest( request, requestId, - this.handlersController.currentHandlers(), + this.handlersController.currentHandlers().filter((handler) => { + return ( + handler instanceof HttpHandler || + handler instanceof GraphQLHandler + ) + }), this.resolvedOptions, this.emitter, ) @@ -90,6 +100,19 @@ export class SetupServerCommonApi ) }, ) + + // Preconfigure the WebSocket interception but don't enable it just yet. + // It will be enabled when the server starts. + handleWebSocketEvent({ + getUnhandledRequestStrategy: () => { + return this.resolvedOptions.onUnhandledRequest + }, + getHandlers: () => { + return this.handlersController.currentHandlers() + }, + onMockedConnection: () => {}, + onPassthroughConnection: () => {}, + }) } public listen(options: Partial = {}): void { @@ -100,10 +123,11 @@ export class SetupServerCommonApi // Apply the interceptor when starting the server. this.interceptor.apply() + this.subscriptions.push(() => this.interceptor.dispose()) - this.subscriptions.push(() => { - this.interceptor.dispose() - }) + // Apply the WebSocket interception. + webSocketInterceptor.apply() + this.subscriptions.push(() => webSocketInterceptor.dispose()) // Assert that the interceptor has been applied successfully. // Also guards us from forgetting to call "interceptor.apply()" diff --git a/src/node/glossary.ts b/src/node/glossary.ts index 895418d87..7f52c9f91 100644 --- a/src/node/glossary.ts +++ b/src/node/glossary.ts @@ -1,8 +1,6 @@ import type { PartialDeep } from 'type-fest' -import type { - RequestHandler, - RequestHandlerDefaultInfo, -} from '~/core/handlers/RequestHandler' +import type { RequestHandler } from '~/core/handlers/RequestHandler' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import type { LifeCycleEventEmitter, LifeCycleEventsMap, @@ -29,7 +27,7 @@ export interface SetupServerCommon { * * @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 SetupServerCommon { * * @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 9fb3102e7..cb2ee7ec4 100644 --- a/src/node/setupServer.ts +++ b/src/node/setupServer.ts @@ -1,4 +1,5 @@ import type { RequestHandler } from '~/core/handlers/RequestHandler' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { SetupServerApi } from './SetupServerApi' /** @@ -8,7 +9,7 @@ import { SetupServerApi } from './SetupServerApi' * @see {@link https://mswjs.io/docs/api/setup-server `setupServer()` API reference} */ export const setupServer = ( - ...handlers: Array + ...handlers: Array ): SetupServerApi => { return new SetupServerApi(handlers) } 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..478c92597 --- /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.addEventListener('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') +}) diff --git a/test/browser/ws-api/ws.client.send.test.ts b/test/browser/ws-api/ws.client.send.test.ts new file mode 100644 index 000000000..84a30b226 --- /dev/null +++ b/test/browser/ws-api/ws.client.send.test.ts @@ -0,0 +1,132 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import type { Page } from '@playwright/test' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('sends data to a single client on connection', 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('wss://example.com') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + // Send a message to the client as soon as it connects. + client.send('hello world') + }), + ) + await worker.start() + }) + + const clientMessage = await page.evaluate(async () => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onmessage = (event) => resolve(event.data) + socket.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) + }) + + expect(clientMessage).toBe('hello world') +}) + +test('sends data to multiple clients on connection', async ({ + loadExample, + browser, + page, +}) => { + const { compilation } = await loadExample( + require.resolve('./ws.runtime.js'), + { + skipActivation: true, + }, + ) + + async function createSocketAndGetFirstMessage(page: Page) { + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + // Send a message to the client as soon as it connects. + client.send('hello world') + }), + ) + await worker.start() + }) + + return page.evaluate(async () => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onmessage = (event) => resolve(event.data) + socket.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) + }) + } + + const secondPage = await browser.newPage() + await secondPage.goto(compilation.previewUrl) + + const [firstClientMessage, secondClientMessage] = await Promise.all([ + createSocketAndGetFirstMessage(page), + createSocketAndGetFirstMessage(secondPage), + ]) + + expect(firstClientMessage).toBe('hello world') + expect(secondClientMessage).toBe('hello world') +}) + +test('sends data in response to a client message', 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('wss://example.com') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (typeof event.data === 'string' && event.data === 'hello') { + client.send('hello world') + } + }) + }), + ) + await worker.start() + }) + + const clientMessage = await page.evaluate(async () => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => { + socket.send('ignore this') + socket.send('hello') + } + + return new Promise((resolve, reject) => { + socket.onmessage = (event) => resolve(event.data) + socket.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) + }) + + expect(clientMessage).toBe('hello world') +}) 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..098fed76c --- /dev/null +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -0,0 +1,253 @@ +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.addEventListener('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.addEventListener('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, + page, +}) => { + 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') + + // @ts-ignore + window.api = api + + const worker = setupWorker( + api.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + api.broadcast(event.data) + }) + }), + ) + await worker.start() + + window.worker = worker + }) + + await page.evaluate(() => { + window.messages = [] + const ws = new WebSocket('wss://example.com') + window.ws = ws + ws.onmessage = (event) => { + window.messages.push(event.data) + } + }) + } + + await page.pause() + + 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.addEventListener('connection', () => {})) + window.link = api + window.worker = worker + 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 the number of joined clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(1) + + await page.evaluate(() => { + window.worker.stop() + }) + + // 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.addEventListener('connection', () => {})) + window.link = api + window.worker = worker + await worker.start() + }) + } + + await enableMocking() + + 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) +}) diff --git a/test/browser/ws-api/ws.intercept.client.browser.test.ts b/test/browser/ws-api/ws.intercept.client.browser.test.ts new file mode 100644 index 000000000..8fddd4e3d --- /dev/null +++ b/test/browser/ws-api/ws.intercept.client.browser.test.ts @@ -0,0 +1,146 @@ +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('does not throw on connecting to a non-existing host', 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.addEventListener('connection', ({ client }) => { + queueMicrotask(() => client.close()) + }), + ) + await worker.start() + }) + + 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, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + const clientMessagePromise = page.evaluate(() => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (typeof event.data === 'string') { + resolve(event.data) + } + }) + }), + ) + await worker.start() + }) + }) + + 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'), { + skipActivation: true, + }) + + const clientMessagePromise = page.evaluate(() => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data instanceof Blob) { + resolve(event.data.text()) + } + }) + }), + ) + await worker.start() + }) + }) + + 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'), { + skipActivation: true, + }) + + const clientMessagePromise = page.evaluate(() => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data instanceof Uint8Array) { + resolve(new TextDecoder().decode(event.data)) + } + }) + }), + ) + await worker.start() + }) + }) + + 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.intercept.server.browser.test.ts b/test/browser/ws-api/ws.intercept.server.browser.test.ts new file mode 100644 index 000000000..34dd7c9e5 --- /dev/null +++ b/test/browser/ws-api/ws.intercept.server.browser.test.ts @@ -0,0 +1,166 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' +import { WebSocketServer } from '../../support/WebSocketServer' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +const server = new WebSocketServer() + +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'), { + skipActivation: true, + }) + + server.on('connection', (client) => { + client.send('hello') + }) + + const serverMessagePromise = page.evaluate((serverUrl) => { + const { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + if (typeof event.data === 'string') { + resolve(event.data) + } + }) + }), + ) + await worker.start() + }) + }, 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'), { + skipActivation: true, + }) + + server.on('connection', async (client) => { + /** + * `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 { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + if (event.data instanceof Blob) { + resolve(event.data.text()) + } + }) + }), + ) + await worker.start() + }) + }, 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'), { + skipActivation: true, + }) + + const encoder = new TextEncoder() + server.on('connection', async (client) => { + client.binaryType = 'arraybuffer' + client.send(encoder.encode('hello')) + }) + + const serverMessagePromise = page.evaluate((serverUrl) => { + const { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + resolve(new TextDecoder().decode(event.data as Uint8Array)) + }) + }), + ) + await worker.start() + }) + }, 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') +}) 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..383f0330b --- /dev/null +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -0,0 +1,694 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' +import { WebSocketServer } from '../../support/WebSocketServer' + +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://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start({ quiet: true }) + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/path') + ws.onopen = () => { + ws.send('hello') + ws.send('world') + 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 open event', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://localhost/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2} %c▶%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the client', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/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:\/\/localhost\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the original server', async ({ + loadExample, + spyOnConsole, + page, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.on('connection', (ws) => { + ws.close(1003) + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.addEventListener('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■%c ws:\/\/(.+):\d{4,}\/ color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the event handler', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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://localhost/*') + const worker = setupWorker( + api.addEventListener('connection', ({ client }) => { + client.close() + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://localhost/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:\/\/localhost\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending text', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/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:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending long text', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/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:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending Blob', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/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:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending long Blob', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/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:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending ArrayBuffer data', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/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:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending long ArrayBuffer', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/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:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs incoming server messages', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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.addEventListener('connection', ({ client, server }) => { + server.connect() + }), + ) + 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:#ef4444 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:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs raw incoming server events', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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.addEventListener('connection', ({ client, server }) => { + server.connect() + + 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([ + // The actual (raw) message recieved from the server. + // The arrow is dotted because the message's default has been prevented. + expect.stringMatching( + /^\[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$/, + ), + + // 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$/, + ), + ]), + ) + }) +}) + +test('logs mocked outgoing client message (server.send)', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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.addEventListener('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:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs mocked incoming server message (client.send)', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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.addEventListener('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:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('marks the prevented outgoing client event as dashed', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + event.preventDefault() + }) + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + const socket = new WebSocket(url) + socket.onopen = () => socket.send('hello world') + }, 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 world %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('marks the prevented incoming server event as dashed', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + 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.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + event.preventDefault() + }) + }), + ) + 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 server %c17%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) diff --git a/test/browser/ws-api/ws.runtime.js b/test/browser/ws-api/ws.runtime.js new file mode 100644 index 000000000..59b41ee87 --- /dev/null +++ b/test/browser/ws-api/ws.runtime.js @@ -0,0 +1,7 @@ +import { ws } from 'msw' +import { setupWorker } from 'msw/browser' + +window.msw = { + ws, + setupWorker, +} 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..c9f070cb5 --- /dev/null +++ b/test/browser/ws-api/ws.server.connect.browser.test.ts @@ -0,0 +1,128 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' +import { WebSocketServer } from '../../support/WebSocketServer' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof 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'), { + skipActivation: true, + }) + + server.once('connection', (client) => { + client.send('must not receive this') + }) + + await page.evaluate(async (serverUrl) => { + const { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + queueMicrotask(() => client.send('mock')) + }), + ) + await worker.start() + }, 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.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) + }, 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'), { + skipActivation: true, + }) + + server.once('connection', (client) => { + client.send('hello from server') + }) + + await page.evaluate(async (serverUrl) => { + const { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + const worker = setupWorker( + service.addEventListener('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) => { + 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'), { + skipActivation: true, + }) + + const error = await page.evaluate((serverUrl) => { + const { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ server }) => { + server.connect() + }), + ) + await worker.start() + + 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/browser/ws-api/ws.use.browser.test.ts b/test/browser/ws-api/ws.use.browser.test.ts new file mode 100644 index 000000000..d323192b7 --- /dev/null +++ b/test/browser/ws-api/ws.use.browser.test.ts @@ -0,0 +1,257 @@ +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.addEventListener('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.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('must not be sent') + } + }) + }), + ) + await worker.start() + + worker.use( + service.addEventListener('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.addEventListener('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.addEventListener('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.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello from mock') + } + }) + }), + ) + await worker.start() + + worker.use( + service.addEventListener('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.addEventListener('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.addEventListener('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']) +}) diff --git a/test/modules/node/esm-node.test.ts b/test/modules/node/esm-node.test.ts index 303a19e7f..5ecf123bc 100644 --- a/test/modules/node/esm-node.test.ts +++ b/test/modules/node/esm-node.test.ts @@ -77,8 +77,8 @@ 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()) ) diff --git a/test/node/vitest.config.mts b/test/node/vitest.config.mts index fe4b6c3bf..0557df44e 100644 --- a/test/node/vitest.config.mts +++ b/test/node/vitest.config.mts @@ -1,33 +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: { - '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. - */ - poolOptions: { threads: { 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/node/ws-api/on-unhandled-request/callback.test.ts b/test/node/ws-api/on-unhandled-request/callback.test.ts new file mode 100644 index 000000000..e49c9ed9f --- /dev/null +++ b/test/node/ws-api/on-unhandled-request/callback.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const service = ws.link('wss://localhost:4321') +const server = setupServer() + +const onUnhandledRequest = vi.fn() + +beforeAll(() => { + server.listen({ onUnhandledRequest }) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + vi.resetAllMocks() +}) + +afterAll(() => { + server.close() + vi.restoreAllMocks() +}) + +it('calls a custom callback on an unhandled WebSocket connection', async () => { + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(onUnhandledRequest).toHaveBeenCalledOnce() + + const [request] = onUnhandledRequest.mock.calls[0] + expect(request).toBeInstanceOf(Request) + expect(request.method).toBe('GET') + expect(request.url).toBe('wss://localhost:4321/') + expect(Array.from(request.headers)).toEqual([ + ['connection', 'upgrade'], + ['upgrade', 'websocket'], + ]) +}) + +it('does not call a custom callback for a handled WebSocket connection', async () => { + server.use(service.addEventListener('connection', () => {})) + + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(onUnhandledRequest).not.toHaveBeenCalled() +}) diff --git a/test/node/ws-api/on-unhandled-request/error.test.ts b/test/node/ws-api/on-unhandled-request/error.test.ts new file mode 100644 index 000000000..5b34d2b1b --- /dev/null +++ b/test/node/ws-api/on-unhandled-request/error.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { InternalError } from '../../../../src/core/utils/internal/devUtils' + +const service = ws.link('wss://localhost:4321') +const server = setupServer() + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'error' }) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + vi.resetAllMocks() +}) + +afterAll(() => { + server.close() + vi.restoreAllMocks() +}) + +it( + 'errors on unhandled WebSocket connection', + server.boundary(async () => { + const socket = new WebSocket('wss://localhost:4321') + const errorListener = vi.fn() + + await vi.waitUntil(() => { + return new Promise((resolve, reject) => { + // These are intentionally swapped. The connection MUST error. + socket.addEventListener('error', errorListener) + socket.addEventListener('error', resolve) + socket.onopen = () => { + reject(new Error('WebSocket connection opened unexpectedly')) + } + }) + }) + + expect(console.error).toHaveBeenCalledWith( + `\ +[MSW] Error: intercepted a request without a matching request handler: + + • GET wss://localhost:4321/ + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/getting-started/mocks`, + ) + + expect(errorListener).toHaveBeenCalledOnce() + + // Must forward the original `onUnhandledRequest` error as the + // `cause` property of the error event emitted on the connection. + const [event] = errorListener.mock.calls[0] + expect(event).toBeInstanceOf(Event) + expect(event.type).toBe('error') + expect(event.cause).toEqual( + new InternalError( + '[MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.', + ), + ) + }), +) + +it( + 'does not error on handled WebSocket connection', + server.boundary(async () => { + server.use(service.addEventListener('connection', () => {})) + + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(console.error).not.toHaveBeenCalled() + }), +) diff --git a/test/node/ws-api/on-unhandled-request/warn.test.ts b/test/node/ws-api/on-unhandled-request/warn.test.ts new file mode 100644 index 000000000..5473692a3 --- /dev/null +++ b/test/node/ws-api/on-unhandled-request/warn.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const service = ws.link('wss://localhost:4321') +const server = setupServer() + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'warn' }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + vi.resetAllMocks() +}) + +afterAll(() => { + server.close() + vi.restoreAllMocks() +}) + +it( + 'warns on unhandled WebSocket connection', + server.boundary(async () => { + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(console.warn).toHaveBeenCalledWith( + `\ +[MSW] Warning: intercepted a request without a matching request handler: + + • GET wss://localhost:4321/ + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/getting-started/mocks`, + ) + }), +) + +it( + 'does not warn on handled WebSocket connection', + server.boundary(async () => { + server.use(service.addEventListener('connection', () => {})) + + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(console.warn).not.toHaveBeenCalled() + }), +) 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..22b750334 --- /dev/null +++ b/test/node/ws-api/ws.apply.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer() + +afterEach(() => { + server.close() +}) + +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('WebSocketOverride') + 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.addEventListener('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.event-patching.test.ts b/test/node/ws-api/ws.event-patching.test.ts new file mode 100644 index 000000000..1027caa41 --- /dev/null +++ b/test/node/ws-api/ws.event-patching.test.ts @@ -0,0 +1,119 @@ +// @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.addEventListener('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.addEventListener('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.addEventListener('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.addEventListener('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.client.test.ts b/test/node/ws-api/ws.intercept.client.test.ts new file mode 100644 index 000000000..f511da037 --- /dev/null +++ b/test/node/ws-api/ws.intercept.client.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const server = setupServer() +const wsServer = new WebSocketServer() + +const service = ws.link('ws://*') + +beforeAll(async () => { + server.listen() + await wsServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + wsServer.resetState() +}) + +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.addEventListener('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const socket = new WebSocket(wsServer.url) + socket.onopen = () => socket.send('hello') + + await vi.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(socket) + + // 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.addEventListener('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const socket = new WebSocket(wsServer.url) + socket.onopen = () => socket.send(new Blob(['hello'])) + + await vi.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(socket) + + // 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.addEventListener('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const socket = new WebSocket(wsServer.url) + socket.binaryType = 'arraybuffer' + socket.onopen = () => socket.send(new TextEncoder().encode('hello')) + + await vi.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(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 new file mode 100644 index 000000000..a67c3884b --- /dev/null +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -0,0 +1,128 @@ +// @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.resetState() +}) + +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.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const socket = new WebSocket(originalServer.url) + socket.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.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const socket = new WebSocket(originalServer.url) + socket.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.binaryType = 'arraybuffer' + client.send(encoder.encode('hello world')) + }) + server.use( + service.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const socket = new WebSocket(originalServer.url) + socket.binaryType = 'arraybuffer' + socket.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(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(new TextDecoder().decode(clientMessage.data)).toBe('hello world') + }) +}) 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..468b43ae6 --- /dev/null +++ b/test/node/ws-api/ws.server.connect.test.ts @@ -0,0 +1,95 @@ +// @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.addEventListener('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.addEventListener('connection', ({ server }) => { + mockConnectionListener() + server.connect() + }), + ) + + new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(mockConnectionListener).toHaveBeenCalledTimes(1) + expect(serverConnectionListener).toHaveBeenCalledTimes(1) + }) +}) + +it('forwards incoming server events to the client once connected', async () => { + originalServer.once('connection', (client) => client.send('hello')) + + server.use( + service.addEventListener('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.addEventListener('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) + }) +}) diff --git a/test/node/ws-api/ws.stop-propagation.test.ts b/test/node/ws-api/ws.stop-propagation.test.ts new file mode 100644 index 000000000..b7cbb3cf6 --- /dev/null +++ b/test/node/ws-api/ws.stop-propagation.test.ts @@ -0,0 +1,493 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const server = setupServer() +const service = ws.link('ws://*') + +const originalServer = new WebSocketServer() + +beforeAll(async () => { + server.listen({ + // We are intentionally connecting to non-existing WebSocket URLs. + // Skip the unhandled request warnings, they are intentional. + onUnhandledRequest: 'bypass', + }) + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('stops propagation for client "message" event', async () => { + const clientMessageListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `client` beloning to a different event handler. + event.stopPropagation() + clientMessageListener(1) + }) + + client.addEventListener('message', () => { + clientMessageListener(2) + }) + }), + + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', () => { + clientMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', () => { + clientMessageListener(4) + }) + + process.nextTick(() => { + client.close() + }) + }), + ) + + const ws = new WebSocket('ws://localhost') + ws.onopen = () => ws.send('hello world') + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(clientMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(clientMessageListener).toHaveBeenNthCalledWith(2, 2) + expect(clientMessageListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for client "message" event', async () => { + const clientMessageListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `client` beloning to a different event handler. + event.stopImmediatePropagation() + clientMessageListener(1) + }) + + client.addEventListener('message', () => { + clientMessageListener(2) + }) + + client.addEventListener('message', () => { + clientMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', () => { + clientMessageListener(4) + }) + + process.nextTick(() => { + client.close() + }) + }), + ) + + const ws = new WebSocket('ws://localhost') + ws.onopen = () => ws.send('hello world') + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(clientMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(clientMessageListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "open" event', async () => { + const serverOpenListener = vi.fn<[number]>() + + originalServer.addListener('connection', () => {}) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('open', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `server` beloning to a different event handler. + event.stopPropagation() + serverOpenListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('open', () => { + serverOpenListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverOpenListener).toHaveBeenNthCalledWith(1, 1) + expect(serverOpenListener).toHaveBeenNthCalledWith(2, 2) + expect(serverOpenListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "open" event', async () => { + const serverOpenListener = vi.fn<[number]>() + + originalServer.addListener('connection', () => {}) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('open', (event) => { + event.stopImmediatePropagation() + serverOpenListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('open', () => { + serverOpenListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverOpenListener).toHaveBeenNthCalledWith(1, 1) + expect(serverOpenListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "message" event', async () => { + const serverMessageListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + // Send data from the original server to trigger the "message" event. + ws.send('hello') + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('message', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `server` beloning to a different event handler. + event.stopPropagation() + serverMessageListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('message', () => { + serverMessageListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(serverMessageListener).toHaveBeenNthCalledWith(2, 2) + expect(serverMessageListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "message" event', async () => { + const serverMessageListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + // Send data from the original server to trigger the "message" event. + ws.send('hello') + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('message', (event) => { + event.stopImmediatePropagation() + serverMessageListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('message', () => { + serverMessageListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(serverMessageListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "error" event', async () => { + const serverErrorListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('error', (event) => { + event.stopPropagation() + serverErrorListener(1) + }) + + server.addEventListener('error', () => { + serverErrorListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(4) + }) + }), + ) + + const ws = new WebSocket('ws://localhost/non-existing-path') + + await vi.waitFor(() => { + /** + * @note Ideally, await the "CLOSED" ready state, + * but Node.js doesn't dispatch it correctly. + * @see https://github.com/nodejs/undici/issues/3697 + */ + return new Promise((resolve) => { + ws.onerror = () => resolve() + }) + }) + + expect(serverErrorListener).toHaveBeenNthCalledWith(1, 1) + expect(serverErrorListener).toHaveBeenNthCalledWith(2, 2) + expect(serverErrorListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "error" event', async () => { + const serverErrorListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('error', (event) => { + event.stopImmediatePropagation() + serverErrorListener(1) + }) + + server.addEventListener('error', () => { + serverErrorListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(4) + }) + }), + ) + + const ws = new WebSocket('ws://localhost/non-existing-path') + + await vi.waitFor(() => { + /** + * @note Ideally, await the "CLOSED" ready state, + * but Node.js doesn't dispatch it correctly. + * @see https://github.com/nodejs/undici/issues/3697 + */ + return new Promise((resolve) => { + ws.onerror = () => resolve() + }) + }) + + expect(serverErrorListener).toHaveBeenNthCalledWith(1, 1) + expect(serverErrorListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "close" event', async () => { + const serverCloseListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + ws.close() + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('close', (event) => { + event.stopPropagation() + serverCloseListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('close', () => { + serverCloseListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverCloseListener).toHaveBeenNthCalledWith(1, 1) + expect(serverCloseListener).toHaveBeenNthCalledWith(2, 2) + expect(serverCloseListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "close" event', async () => { + const serverCloseListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + ws.close() + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('close', (event) => { + event.stopImmediatePropagation() + serverCloseListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('close', () => { + serverCloseListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverCloseListener).toHaveBeenNthCalledWith(1, 1) + expect(serverCloseListener).toHaveBeenCalledOnce() +}) 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..83405a414 --- /dev/null +++ b/test/node/ws-api/ws.use.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const service = ws.link('wss://*') + +const server = setupServer( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello, client!') + } + + if (event.data === 'fallthrough') { + client.send('ok') + } + }) + }), +) + +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.addEventListener('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.addEventListener('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.addEventListener('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) + }) + }, +) + +it.concurrent( + 'does not affect unrelated events', + server.boundary(async () => { + server.use( + service.addEventListener('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 new file mode 100644 index 000000000..8995fb8d7 --- /dev/null +++ b/test/support/WebSocketServer.ts @@ -0,0 +1,65 @@ +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 }, ({ socket }) => { + this.clients.add(socket) + socket.once('close', () => this.clients.delete(socket)) + + this.emit('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(port = 0): Promise { + 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 + } + + public resetState(): void { + this.closeAllClients() + this.removeAllListeners() + } + + public closeAllClients(): void { + this.clients.forEach((client) => { + client.close() + }) + } + + public async close(): Promise { + return this.app.close() + } +} 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/test/support/environments/vitest-environment-node-websocket.ts b/test/support/environments/vitest-environment-node-websocket.ts new file mode 100644 index 000000000..16d616f7d --- /dev/null +++ b/test/support/environments/vitest-environment-node-websocket.ts @@ -0,0 +1,25 @@ +/** + * 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) { + /** + * @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) + + return { + teardown, + } + }, +} diff --git a/test/typings/ws.test-d.ts b/test/typings/ws.test-d.ts new file mode 100644 index 000000000..612369390 --- /dev/null +++ b/test/typings/ws.test-d.ts @@ -0,0 +1,156 @@ +import { it, expectTypeOf } from 'vitest' +import { + WebSocketData, + WebSocketLink, + WebSocketHandlerConnection, + ws, +} from 'msw' +import { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/WebSocket' + +it('supports URL as the link argument', () => { + expectTypeOf(ws.link('ws://localhost')).toEqualTypeOf() +}) + +it('supports RegExp as the link argument', () => { + expectTypeOf(ws.link(/\/ws$/)).toEqualTypeOf() +}) + +it('exposes root-level link APIs', () => { + const link = ws.link('ws://localhost') + + expectTypeOf(link.addEventListener).toBeFunction() + expectTypeOf(link.broadcast).toBeFunction() + expectTypeOf(link.broadcastExcept).toBeFunction() + expectTypeOf(link.clients).toEqualTypeOf< + Set + >() +}) + +it('supports "connection" event listener', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', (connection) => { + expectTypeOf(connection).toEqualTypeOf() + }) +}) + +it('errors on arbitrary event names passed to the link', () => { + const link = ws.link('ws://localhost') + + link.addEventListener( + // @ts-expect-error Unknown event name "abc". + 'abc', + () => {}, + ) +}) + +/** + * Client API. + */ + +it('exposes root-level "client" APIs', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + expectTypeOf(client.id).toBeString() + expectTypeOf(client.socket).toEqualTypeOf() + expectTypeOf(client.url).toEqualTypeOf() + + expectTypeOf(client.addEventListener).toBeFunction() + expectTypeOf(client.send).toBeFunction() + expectTypeOf(client.removeEventListener).toBeFunction() + expectTypeOf(client.close).toBeFunction() + }) +}) + +it('supports "message" event listener on the client', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + expectTypeOf(event).toEqualTypeOf>() + }) + }) +}) + +it('supports "close" event listener on the client', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + client.addEventListener('close', (event) => { + expectTypeOf(event).toMatchTypeOf() + }) + }) +}) + +it('errors on arbitrary event names passed to the client', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + client.addEventListener( + // @ts-expect-error Unknown event name "abc". + 'abc', + () => {}, + ) + }) +}) + +/** + * Server API. + */ + +it('exposes root-level "server" APIs', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + expectTypeOf(server.socket).toEqualTypeOf() + + expectTypeOf(server.connect).toEqualTypeOf<() => void>() + expectTypeOf(server.addEventListener).toBeFunction() + expectTypeOf(server.send).toBeFunction() + expectTypeOf(server.removeEventListener).toBeFunction() + expectTypeOf(server.close).toBeFunction() + }) +}) + +it('supports "message" event listener on the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener('message', (event) => { + expectTypeOf(event).toEqualTypeOf>() + }) + }) +}) + +it('supports "open" event listener on the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener('open', (event) => { + expectTypeOf(event).toMatchTypeOf() + }) + }) +}) + +it('supports "close" event listener on the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener('close', (event) => { + expectTypeOf(event).toMatchTypeOf() + }) + }) +}) + +it('errors on arbitrary event names passed to the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener( + // @ts-expect-error Unknown event name "abc". + 'abc', + () => {}, + ) + }) +}) diff --git a/vitest.config.mts b/vitest.config.mts index f007e4c53..43406fdc1 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -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,7 +12,9 @@ export default defineConfig({ // they are located next to the source code they are testing. dir: './src', alias: { - '~/core': path.resolve(__dirname, 'src/core'), + ...mswExports, + ...customViteEnvironments, + '~/core': fromRoot('src/core'), }, typecheck: { // Load the TypeScript configuration to the unit tests.