diff --git a/.gitignore b/.gitignore index ecf326fe8..d1021a5e5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,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 90bb1f861..ec89d5e9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0", + "version": "2.3.0-ws.rc-6", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", @@ -79,7 +79,7 @@ "test:node": "vitest run --config=./test/node/vitest.config.ts", "test:native": "vitest --config=./test/native/vitest.config.ts", "test:browser": "playwright test -c ./test/browser/playwright.config.ts", - "test:modules:node": "vitest --config=./test/modules/node/vitest.config.ts", + "test:modules:node": "vitest run --config=./test/modules/node/vitest.config.ts", "test:modules:browser": "playwright test -c ./test/modules/browser/playwright.config.ts", "test:ts": "vitest --typecheck --config=./test/typings/vitest.config.ts", "prepare": "pnpm simple-git-hooks init", @@ -154,6 +154,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.40.1", @@ -163,6 +164,7 @@ "@types/glob": "^8.1.0", "@types/json-bigint": "^1.0.4", "@types/node": "18.x", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@web/dev-server": "^0.1.38", @@ -178,6 +180,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "express": "^4.18.2", + "fastify": "^4.26.0", "fs-extra": "^11.2.0", "fs-teardown": "^0.3.0", "glob": "^10.3.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2aa6f5fa4..bea03f1df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,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 @@ -91,6 +94,9 @@ devDependencies: '@types/node': specifier: 18.x version: 18.19.28 + '@types/ws': + specifier: ^8.5.10 + version: 8.5.10 '@typescript-eslint/eslint-plugin': specifier: ^7.2.0 version: 7.5.0(@typescript-eslint/parser@7.5.0)(eslint@8.57.0)(typescript@5.4.3) @@ -136,6 +142,9 @@ devDependencies: express: specifier: ^4.18.2 version: 4.19.2 + fastify: + specifier: ^4.26.0 + version: 4.27.0 fs-extra: specifier: ^11.2.0 version: 11.2.0 @@ -1121,11 +1130,45 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@fastify/ajv-compiler@3.5.0: + resolution: {integrity: sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==} + dependencies: + ajv: 8.12.0 + ajv-formats: 2.1.1(ajv@8.12.0) + fast-uri: 2.3.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.15.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.16.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -2121,6 +2164,12 @@ packages: '@types/node': 18.19.28 dev: true + /@types/ws@8.5.10: + resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} + dependencies: + '@types/node': 18.19.28 + dev: true + /@types/yargs-parser@21.0.3: resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} dev: true @@ -2512,6 +2561,17 @@ packages: through: 2.3.8 dev: true + /abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + dependencies: + event-target-shim: 5.0.1 + dev: true + + /abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + dev: true + /accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2556,6 +2616,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: @@ -2637,6 +2719,10 @@ packages: picomatch: 2.3.1 dev: true + /archy@1.0.0: + resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} + dev: true + /arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} dev: true @@ -2753,6 +2839,17 @@ packages: possible-typed-array-names: 1.0.0 dev: true + /avvio@8.3.0: + resolution: {integrity: sha512-VBVH0jubFr9LdFASy/vNtm5giTrnbVquWBhT0fyizuNK2rQ7e7ONU2plZQWUNqtE1EmxFEb+kbSkFRkstiaS9Q==} + dependencies: + '@fastify/error': 3.4.1 + archy: 1.0.0 + debug: 4.3.4 + fastq: 1.17.1 + transitivePeerDependencies: + - supports-color + dev: true + /axios@1.6.8: resolution: {integrity: sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==} dependencies: @@ -3049,6 +3146,13 @@ packages: ieee754: 1.2.1 dev: true + /buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: true + /builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -4266,6 +4370,11 @@ packages: engines: {node: '>= 0.6'} dev: true + /event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + dev: true + /eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} dev: true @@ -4375,6 +4484,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 @@ -4398,10 +4515,28 @@ packages: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true + /fast-json-stringify@5.15.1: + resolution: {integrity: sha512-JopGtkvvguRqrS4gHXSSA2jf4pDgOZkeBAkLO1LbzOpiOMo7/kugoR+KiWifpLpluaVeYDkAuxCJOj4Gyc6L9A==} + 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.3.0 + json-schema-ref-resolver: 1.0.1 + rfdc: 1.3.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'} @@ -4411,6 +4546,37 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} dev: true + /fast-uri@2.3.0: + resolution: {integrity: sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw==} + dev: true + + /fastify-plugin@4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + dev: true + + /fastify@4.27.0: + resolution: {integrity: sha512-ci9IXzbigB8dyi0mSy3faa3Bsj0xWAPb9JeT4KRzubdSb6pNhcADRUaXCBml6V1Ss/a05kbtQls5LBmhHydoTA==} + dependencies: + '@fastify/ajv-compiler': 3.5.0 + '@fastify/error': 3.4.1 + '@fastify/fast-json-stringify-compiler': 4.3.0 + abstract-logging: 2.0.1 + avvio: 8.3.0 + fast-content-type-parse: 1.1.0 + fast-json-stringify: 5.15.1 + find-my-way: 8.2.0 + light-my-request: 5.13.0 + pino: 9.0.0 + process-warning: 3.0.0 + proxy-addr: 2.0.7 + rfdc: 1.3.1 + secure-json-parse: 2.7.0 + semver: 7.6.0 + toad-cache: 3.7.0 + transitivePeerDependencies: + - supports-color + dev: true + /fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} dependencies: @@ -4452,6 +4618,15 @@ packages: - supports-color dev: true + /find-my-way@8.2.0: + resolution: {integrity: sha512-HdWXgFYc6b1BJcOBDBwjqWuHJj1WYiqrxSh25qtU4DabpMFdj/gSunNBQb83t+8Zt67D7CXEzJWTkxaShMTMOA==} + 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: @@ -5492,6 +5667,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 @@ -5633,6 +5814,14 @@ packages: type-check: 0.4.0 dev: true + /light-my-request@5.13.0: + resolution: {integrity: sha512-9IjUN9ZyCS9pTG+KqTDEQo68Sui2lHsYBrfMyVUTTZ3XhH8PMZq7xO94Kr+eP9dhi/kcKsx4N41p2IXEBil1pQ==} + dependencies: + cookie: 0.6.0 + process-warning: 3.0.0 + set-cookie-parser: 2.6.0 + dev: true + /lilconfig@3.0.0: resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==} engines: {node: '>=14'} @@ -6203,6 +6392,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'} @@ -6459,6 +6653,13 @@ packages: split2: 4.2.0 dev: true + /pino-abstract-transport@1.2.0: + resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==} + dependencies: + readable-stream: 4.5.2 + split2: 4.2.0 + dev: true + /pino-pretty@7.6.1: resolution: {integrity: sha512-H7N6ZYkiyrfwBGW9CSjx0uyO9Q2Lyt73881+OTYk8v3TiTdgN92QHrWlEq/LeWw5XtDP64jeSk3mnc6T+xX9/w==} hasBin: true @@ -6482,6 +6683,10 @@ packages: resolution: {integrity: sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==} dev: true + /pino-std-serializers@6.2.2: + resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} + dev: true + /pino@7.11.0: resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} hasBin: true @@ -6499,6 +6704,23 @@ packages: thread-stream: 0.15.2 dev: true + /pino@9.0.0: + resolution: {integrity: sha512-uI1ThkzTShNSwvsUM6b4ND8ANzWURk9zTELMztFkmnCQeR/4wkomJ+echHee5GMWGovoSfjwdeu80DsFIt7mbA==} + hasBin: true + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 1.2.0 + pino-std-serializers: 6.2.2 + process-warning: 3.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.4.3 + sonic-boom: 3.8.1 + thread-stream: 2.7.0 + dev: true + /pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} @@ -6605,6 +6827,15 @@ 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@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + dev: true + /proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -6732,6 +6963,17 @@ packages: util-deprecate: 1.0.2 dev: true + /readable-stream@4.5.2: + resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + dev: true + /readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -6744,6 +6986,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'} @@ -6848,6 +7095,11 @@ packages: signal-exit: 3.0.7 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'} @@ -6953,6 +7205,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'} @@ -7185,6 +7443,12 @@ packages: atomic-sleep: 1.0.0 dev: true + /sonic-boom@3.8.1: + resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} + dependencies: + atomic-sleep: 1.0.0 + dev: true + /source-list-map@2.0.1: resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} dev: true @@ -7554,6 +7818,12 @@ packages: real-require: 0.1.0 dev: true + /thread-stream@2.7.0: + resolution: {integrity: sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==} + dependencies: + real-require: 0.2.0 + dev: true + /through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} dependencies: @@ -7604,6 +7874,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'} diff --git a/src/browser/setupWorker/glossary.ts b/src/browser/setupWorker/glossary.ts index a51d0f73c..4fb053108 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 @@ -87,7 +85,7 @@ export interface SetupWorkerInternalContext { startOptions: RequiredDeep worker: ServiceWorker | null registration: ServiceWorkerRegistration | null - getRequestHandlers(): Array + getRequestHandlers(): Array requests: Map emitter: Emitter keepAliveInterval?: number @@ -211,7 +209,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. @@ -226,14 +224,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..4cc2ad14b 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,31 @@ export class SetupWorkerApi options, ) as SetupWorkerInternalContext['startOptions'] + // Enable WebSocket interception. + handleWebSocketEvent({ + getHandlers: () => { + return this.handlersController.currentHandlers() + }, + onMockedConnection: (connection) => { + if (!this.context.startOptions.quiet) { + // Attach the logger for mocked connections since + // those won't be visible in the browser's devtools. + attachWebSocketLogger(connection) + } + }, + onPassthroughConnection() { + /** + * @fixme Call some "onUnhandledConnection". + */ + }, + }) + + webSocketInterceptor.apply() + + this.subscriptions.push(() => { + webSocketInterceptor.dispose() + }) + return await this.startHandler(this.context.startOptions, options) } @@ -193,6 +222,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/createStartHandler.ts b/src/browser/setupWorker/start/createStartHandler.ts index dd9c35ebb..6629590f2 100644 --- a/src/browser/setupWorker/start/createStartHandler.ts +++ b/src/browser/setupWorker/start/createStartHandler.ts @@ -1,4 +1,5 @@ import { devUtils } from '~/core/utils/internal/devUtils' +import { MSW_WEBSOCKET_CLIENTS_KEY } from '~/core/ws/WebSocketClientManager' import { getWorkerInstance } from './utils/getWorkerInstance' import { enableMocking } from './utils/enableMocking' import { SetupWorkerInternalContext, StartHandler } from '../glossary' @@ -71,6 +72,11 @@ Please consider using a custom "serviceWorker.url" option to point to the actual // Make sure we're always clearing the interval - there are reports that not doing this can // cause memory leaks in headless browser environments. window.clearInterval(context.keepAliveInterval) + + // Purge persisted clients on page reload. + // WebSocket clients will get new IDs on reload so persisting them + // makes little sense. + localStorage.removeItem(MSW_WEBSOCKET_CLIENTS_KEY) }) // Check if the active Service Worker has been generated diff --git a/src/browser/setupWorker/stop/createStop.ts b/src/browser/setupWorker/stop/createStop.ts index 48c37996d..6c0e7b7d9 100644 --- a/src/browser/setupWorker/stop/createStop.ts +++ b/src/browser/setupWorker/stop/createStop.ts @@ -1,4 +1,5 @@ import { devUtils } from '~/core/utils/internal/devUtils' +import { MSW_WEBSOCKET_CLIENTS_KEY } from '~/core/ws/WebSocketClientManager' import { SetupWorkerInternalContext, StopHandler } from '../glossary' import { printStopMessage } from './utils/printStopMessage' @@ -24,6 +25,9 @@ export const createStop = ( context.isMockingEnabled = false window.clearInterval(context.keepAliveInterval) + // Clear the WebSocket clients from the shared storage. + localStorage.removeItem(MSW_WEBSOCKET_CLIENTS_KEY) + printStopMessage({ quiet: context.startOptions?.quiet }) } } diff --git a/src/core/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..bf916f37d --- /dev/null +++ b/src/core/handlers/WebSocketHandler.ts @@ -0,0 +1,71 @@ +import { Emitter } from 'strict-event-emitter' +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] +} + +interface WebSocketHandlerConnection extends WebSocketConnectionData { + params: PathParams +} + +export const kEmitter = Symbol('kEmitter') +export const kDispatchEvent = Symbol('kDispatchEvent') +export const kSender = Symbol('kSender') + +export class WebSocketHandler { + public callFrame?: string + + protected [kEmitter]: Emitter + + constructor(private readonly url: Path) { + this[kEmitter] = new Emitter() + this.callFrame = getCallFrame(new Error()) + } + + public parse(args: { + event: 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 = { + client: connection.client, + server: connection.server, + params: parsedResult.match.params || {}, + } + + // Emit the connection event on the handler. + // This is what the developer adds listeners for. + this[kEmitter].emit('connection', resolvedConnection) + } +} diff --git a/src/core/index.ts b/src/core/index.ts index 6e8aa5ac9..c4de17047 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,13 +2,20 @@ 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, +} from './handlers/WebSocketHandler' + /* Utils */ export { matchRequestUrl } from './utils/matching/matchRequestUrl' export * from './utils/handleRequest' 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 e685a143b..cea1a1e09 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -1,6 +1,5 @@ import { until } from '@open-draft/until' import { Emitter } from 'strict-event-emitter' -import { RequestHandler } from '../handlers/RequestHandler' import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions' import { RequiredDeep } from '../typeUtils' import { HandlersExecutionResult, executeHandlers } from './executeHandlers' @@ -45,7 +44,7 @@ export interface HandleRequestOptions { export async function handleRequest( request: Request, requestId: string, - handlers: Array, + handlers: Array, options: RequiredDeep, emitter: Emitter, handleRequestOptions?: HandleRequestOptions, diff --git a/src/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 737c54594..4a5de24d6 100644 --- a/src/core/utils/matching/matchRequestUrl.test.ts +++ b/src/core/utils/matching/matchRequestUrl.test.ts @@ -61,6 +61,50 @@ describe('matchRequestUrl', () => { expect(match).toHaveProperty('matches', false) expect(match).toHaveProperty('params', {}) }) + + test('returns true for matching WebSocket URL', () => { + expect( + matchRequestUrl(new URL('ws://test.mswjs.io'), 'ws://test.mswjs.io'), + ).toEqual({ + matches: true, + params: {}, + }) + expect( + matchRequestUrl(new URL('wss://test.mswjs.io'), 'wss://test.mswjs.io'), + ).toEqual({ + matches: true, + params: {}, + }) + }) + + test('returns false for non-matching WebSocket URL', () => { + expect( + matchRequestUrl(new URL('ws://test.mswjs.io'), 'ws://foo.mswjs.io'), + ).toEqual({ + matches: false, + params: {}, + }) + expect( + matchRequestUrl(new URL('wss://test.mswjs.io'), 'wss://completely.diff'), + ).toEqual({ + matches: false, + params: {}, + }) + }) + + test('returns path parameters when matched a WebSocket URL', () => { + expect( + matchRequestUrl( + new URL('wss://test.mswjs.io'), + 'wss://:service.mswjs.io', + ), + ).toEqual({ + matches: true, + params: { + service: 'test', + }, + }) + }) }) describe('coercePath', () => { 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..77fb681ef --- /dev/null +++ b/src/core/ws.ts @@ -0,0 +1,124 @@ +import { invariant } from 'outvariant' +import type { + WebSocketClientConnectionProtocol, + WebSocketData, +} from '@mswjs/interceptors/WebSocket' +import { + WebSocketHandler, + kEmitter, + type WebSocketHandlerEventMap, +} from './handlers/WebSocketHandler' +import { Path, isPath } from './utils/matching/matchRequestUrl' +import { WebSocketClientManager } from './ws/WebSocketClientManager' + +const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') + +export type WebSocketLink = { + /** + * A set of all WebSocket clients connected + * to this link. + */ + clients: Set + + on( + event: EventType, + listener: (...args: WebSocketHandlerEventMap[EventType]) => void, + ): WebSocketHandler + + /** + * Broadcasts the given data to all WebSocket clients. + * + * @example + * const service = ws.link('wss://example.com') + * service.on('connection', () => { + * service.broadcast('hello, everyone!') + * }) + */ + broadcast(data: WebSocketData): void + + /** + * Broadcasts the given data to all WebSocket clients + * except the ones provided in the `clients` argument. + * + * @example + * const service = ws.link('wss://example.com') + * service.on('connection', ({ client }) => { + * service.broadcastExcept(client, 'hi, the rest of you!') + * }) + */ + broadcastExcept( + clients: + | WebSocketClientConnectionProtocol + | Array, + data: WebSocketData, + ): void +} + +/** + * Intercepts outgoing WebSocket connections to the given URL. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * chat.on('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(wsBroadcastChannel, url) + + return { + get clients() { + return clientManager.clients + }, + on(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', ({ client }) => { + 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) + } + }) + }, + } +} + +export const ws = { + link: createWebSocketLinkHandler, +} diff --git a/src/core/ws/WebSocketClientManager.test.ts b/src/core/ws/WebSocketClientManager.test.ts new file mode 100644 index 000000000..8db322f8e --- /dev/null +++ b/src/core/ws/WebSocketClientManager.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node-websocket + */ +import { + WebSocketClientConnection, + WebSocketData, + WebSocketTransport, +} from '@mswjs/interceptors/WebSocket' +import { + WebSocketClientManager, + WebSocketBroadcastChannelMessage, +} from './WebSocketClientManager' + +const channel = new BroadcastChannel('test: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', () => { + const manager = new WebSocketClientManager(channel, '*') + const connection = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + + manager.addConnection(connection) + + // Must add the client to the list of clients. + expect(Array.from(manager.clients.values())).toEqual([connection]) +}) + +it('adds multiple clients from this runtime to the list of clients', () => { + const manager = new WebSocketClientManager(channel, '*') + const connectionOne = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + manager.addConnection(connectionOne) + + // Must add the client to the list of clients. + expect(Array.from(manager.clients.values())).toEqual([connectionOne]) + + const connectionTwo = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + manager.addConnection(connectionTwo) + + // 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(), + ) + 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(), + ) + 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') + + 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..0a7680736 --- /dev/null +++ b/src/core/ws/WebSocketClientManager.ts @@ -0,0 +1,233 @@ +import { invariant } from 'outvariant' +import type { + WebSocketData, + WebSocketClientConnection, + WebSocketClientConnectionProtocol, +} from '@mswjs/interceptors/WebSocket' +import { matchRequestUrl, type Path } from '../utils/matching/matchRequestUrl' + +export const MSW_WEBSOCKET_CLIENTS_KEY = 'msw:ws:clients' + +export type WebSocketBroadcastChannelMessage = + | { + type: 'extraneous:send' + payload: { + clientId: string + data: WebSocketData + } + } + | { + type: 'extraneous:close' + payload: { + clientId: string + code?: number + reason?: string + } + } + +type SerializedClient = { + clientId: string + url: string +} + +/** + * A manager responsible for accumulating WebSocket client + * connections across different browser runtimes. + */ +export class WebSocketClientManager { + private inMemoryClients: Set + + constructor( + private channel: BroadcastChannel, + private url: Path, + ) { + this.inMemoryClients = new Set() + + // Purge in-memory clients when the worker stops. + if (typeof localStorage !== 'undefined') { + localStorage.removeItem = new Proxy(localStorage.removeItem, { + apply: (target, thisArg, args) => { + const [key] = args + + if (key === MSW_WEBSOCKET_CLIENTS_KEY) { + this.inMemoryClients.clear() + } + + return Reflect.apply(target, thisArg, args) + }, + }) + } + } + + /** + * All active WebSocket client connections. + */ + get clients(): Set { + // In the browser, different runtimes use "localStorage" + // as the shared source of all the clients. + if (typeof localStorage !== 'undefined') { + const inMemoryClients = Array.from(this.inMemoryClients) + + console.log('get clients()', inMemoryClients, this.getSerializedClients()) + + return new Set( + inMemoryClients.concat( + this.getSerializedClients() + // Filter out the serialized clients that are already present + // in this runtime in-memory. This is crucial because a remote client + // wrapper CANNOT send a message to the client in THIS runtime + // (the "message" event on broadcast channel won't trigger). + .filter((serializedClient) => { + if ( + inMemoryClients.every( + (client) => client.id !== serializedClient.clientId, + ) + ) { + return serializedClient + } + }) + .map((serializedClient) => { + return new WebSocketRemoteClientConnection( + serializedClient.clientId, + new URL(serializedClient.url), + this.channel, + ) + }), + ), + ) + } + + // In Node.js, the manager acts as a singleton, and all clients + // are kept in-memory. + return this.inMemoryClients + } + + private getSerializedClients(): Array { + invariant( + typeof localStorage !== 'undefined', + 'Failed to call WebSocketClientManager#getSerializedClients() in a non-browser environment. This is likely a bug in MSW. Please, report it on GitHub: https://github.com/mswjs/msw', + ) + + const clientsJson = localStorage.getItem(MSW_WEBSOCKET_CLIENTS_KEY) + + if (!clientsJson) { + return [] + } + + const allClients = JSON.parse(clientsJson) as Array + const matchingClients = allClients.filter((client) => { + return matchRequestUrl(new URL(client.url), this.url).matches + }) + + return matchingClients + } + + private addClient(client: WebSocketClientConnection): void { + this.inMemoryClients.add(client) + + if (typeof localStorage !== 'undefined') { + const serializedClients = this.getSerializedClients() + + // Serialize the current client for other runtimes to create + // a remote wrapper over it. This has no effect on the current runtime. + const nextSerializedClients = serializedClients.concat({ + clientId: client.id, + url: client.url.href, + } as SerializedClient) + + localStorage.setItem( + MSW_WEBSOCKET_CLIENTS_KEY, + JSON.stringify(nextSerializedClients), + ) + } + } + + /** + * Adds the given `WebSocket` client connection to the set + * of all connections. The given connection is always the complete + * connection object because `addConnection()` is called only + * for the opened connections in the same runtime. + */ + public addConnection(client: WebSocketClientConnection): void { + this.addClient(client) + + // Instruct the current client how to handle events + // coming from other runtimes (e.g. when calling `.broadcast()`). + const handleExtraneousMessage = ( + message: MessageEvent, + ) => { + const { type, payload } = message.data + + // Ignore broadcasted messages for other clients. + if ( + 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/handleWebSocketEvent.ts b/src/core/ws/handleWebSocketEvent.ts new file mode 100644 index 000000000..115213863 --- /dev/null +++ b/src/core/ws/handleWebSocketEvent.ts @@ -0,0 +1,56 @@ +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' + +interface HandleWebSocketEventOptions { + getHandlers: () => Array + onMockedConnection: (connection: WebSocketConnectionData) => void + onPassthroughConnection: (onnection: WebSocketConnectionData) => void +} + +export function handleWebSocketEvent(options: HandleWebSocketEventOptions) { + webSocketInterceptor.on('connection', (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 = handlers.filter( + (handler): handler is WebSocketHandler => { + if (handler instanceof WebSocketHandler) { + return handler.predicate({ + event: connectionEvent, + parsedResult: handler.parse({ + event: connectionEvent, + }), + }) + } + + return false + }, + ) + + if (matchingHandlers.length > 0) { + options?.onMockedConnection(connection) + + // Iterate over the handlers and forward the connection + // event to WebSocket event handlers. This is equivalent + // to dispatching that event onto multiple listeners. + for (const handler of matchingHandlers) { + handler[kDispatchEvent](connectionEvent) + } + } else { + options?.onPassthroughConnection(connection) + + // If none of the "ws" handlers matched, + // establish the WebSocket connection as-is. + 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..97fc330bc --- /dev/null +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -0,0 +1,269 @@ +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 = { + blue: '#3b82f6', + green: '#22c55e', + red: '#ef4444', + orange: '#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 the events received by the WebSocket client. + // "client.socket" references the actual WebSocket instance + // so these message events are incoming messages. + client.socket.addEventListener('message', (event) => { + logIncomingClientMessage(event) + }) + + // Log client errors (connection closures due to errors). + client.socket.addEventListener('error', (event) => { + logClientError(event) + }) + + client.send = new Proxy(client.send, { + apply(target, thisArg, args) { + const [data] = args + const messageEvent = new MessageEvent('message', { data }) + Object.defineProperties(messageEvent, { + currentTarget: { + enumerable: true, + writable: false, + value: client.socket, + }, + target: { + enumerable: true, + writable: false, + value: client.socket, + }, + }) + logIncomingMockedClientMessage(messageEvent) + + return Reflect.apply(target, thisArg, args) + }, + }) + + server.addEventListener( + 'open', + () => { + server.addEventListener('message', (event) => { + logIncomingServerMessage(event) + }) + }, + { once: true }, + ) + + // Log outgoing client events initiated by the event handler. + // The actual client never sent these but the handler did. + server.send = new Proxy(server.send, { + apply(target, thisArg, args) { + const [data] = args + const messageEvent = new MessageEvent('message', { data }) + Object.defineProperties(messageEvent, { + currentTarget: { + enumerable: true, + writable: false, + value: server['realWebSocket'], + }, + target: { + enumerable: true, + writable: false, + value: server['realWebSocket'], + }, + }) + + logOutgoingMockedClientMessage(messageEvent) + + return Reflect.apply(target, thisArg, args) + }, + }) +} + +/** + * Prints the WebSocket connection. + * This is meant to be logged by every WebSocket handler + * that intercepted this connection. This helps you see + * what handlers observe this connection. + */ +export function logConnectionOpen(client: WebSocketClientConnection) { + const publicUrl = toPublicUrl(client.url) + + console.groupCollapsed( + devUtils.formatMessage(`${getTimestamp()} %c▶%c ${publicUrl}`), + `color:${colors.blue}`, + 'color:inherit', + ) + console.log('Client:', client.socket) + console.groupEnd() +} + +/** + * Prints the outgoing client message. + */ +export async function logOutgoingClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c↑%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.green}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +/** + * Prints the outgoing client message initiated + * by `server.send()` in the event handler. + */ +export async function logOutgoingMockedClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⇡%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.orange}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +/** + * Prings the message received by the WebSocket client. + * This is fired when the "message" event is dispatched + * on the actual WebSocket client instance, and translates to + * the client receiving a message from the server. + */ +export async function logIncomingClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c↓%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.red}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +/** + * Prints the outgoing client message initiated + * by `client.send()` in the event handler. + */ +export async function logIncomingMockedClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.orange}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +function logConnectionClose(event: CloseEvent) { + const target = event.target as WebSocket + const publicUrl = toPublicUrl(target.url) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c■%c ${publicUrl}`, + ), + `color:${colors.blue}`, + 'color:inherit', + ) + console.log(event) + console.groupEnd() +} + +export async function logIncomingServerMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.green}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +function logClientError(event: Event) { + const socket = event.target as WebSocket + const publicUrl = toPublicUrl(socket.url) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c\u00D7%c ${publicUrl}`, + ), + `color:${colors.blue}`, + 'color:inherit', + ) + console.log(event) + console.groupEnd() +} diff --git a/src/core/ws/utils/getMessageLength.test.ts b/src/core/ws/utils/getMessageLength.test.ts new file mode 100644 index 000000000..af45718ee --- /dev/null +++ b/src/core/ws/utils/getMessageLength.test.ts @@ -0,0 +1,16 @@ +import { getMessageLength } from './getMessageLength' + +it('returns the length of the string', () => { + expect(getMessageLength('')).toBe(0) + expect(getMessageLength('hello')).toBe(5) +}) + +it('returns the size of the Blob', () => { + expect(getMessageLength(new Blob())).toBe(0) + expect(getMessageLength(new Blob(['hello']))).toBe(5) +}) + +it('returns the byte length of ArrayBuffer', () => { + expect(getMessageLength(new ArrayBuffer(0))).toBe(0) + expect(getMessageLength(new ArrayBuffer(5))).toBe(5) +}) diff --git a/src/core/ws/utils/getMessageLength.ts b/src/core/ws/utils/getMessageLength.ts new file mode 100644 index 000000000..a8e041955 --- /dev/null +++ b/src/core/ws/utils/getMessageLength.ts @@ -0,0 +1,19 @@ +import type { WebSocketData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' + +/** + * Returns the byte length of the given WebSocket message. + * @example + * getMessageLength('hello') // 5 + * getMessageLength(new Blob(['hello'])) // 5 + */ +export function getMessageLength(data: WebSocketData): number { + if (data instanceof Blob) { + return data.size + } + + if (data instanceof ArrayBuffer) { + return data.byteLength + } + + return new Blob([data]).size +} diff --git a/src/core/ws/utils/getPublicData.test.ts b/src/core/ws/utils/getPublicData.test.ts new file mode 100644 index 000000000..2820301f7 --- /dev/null +++ b/src/core/ws/utils/getPublicData.test.ts @@ -0,0 +1,38 @@ +import { getPublicData } from './getPublicData' + +it('returns a short string as-is', async () => { + expect(await getPublicData('')).toBe('') + expect(await getPublicData('hello')).toBe('hello') +}) + +it('returns a truncated long string', async () => { + expect(await getPublicData('this is a very long string')).toBe( + 'this is a very long stri…', + ) +}) + +it('returns a short Blob text as-is', async () => { + expect(await getPublicData(new Blob(['']))).toBe('Blob()') + expect(await getPublicData(new Blob(['hello']))).toBe('Blob(hello)') +}) + +it('returns a truncated long Blob text', async () => { + expect(await getPublicData(new Blob(['this is a very long string']))).toBe( + 'Blob(this is a very long stri…)', + ) +}) + +it('returns a short ArrayBuffer text as-is', async () => { + expect(await getPublicData(new TextEncoder().encode(''))).toBe( + 'ArrayBuffer()', + ) + expect(await getPublicData(new TextEncoder().encode('hello'))).toBe( + 'ArrayBuffer(hello)', + ) +}) + +it('returns a truncated ArrayBuffer text', async () => { + expect( + await getPublicData(new TextEncoder().encode('this is a very long string')), + ).toBe('ArrayBuffer(this is a very long stri…)') +}) diff --git a/src/core/ws/utils/getPublicData.ts b/src/core/ws/utils/getPublicData.ts new file mode 100644 index 000000000..8fd41b606 --- /dev/null +++ b/src/core/ws/utils/getPublicData.ts @@ -0,0 +1,17 @@ +import { WebSocketData } from '@mswjs/interceptors/WebSocket' +import { truncateMessage } from './truncateMessage' + +export async function getPublicData(data: WebSocketData): Promise { + if (data instanceof Blob) { + const text = await data.text() + return `Blob(${truncateMessage(text)})` + } + + // Handle all ArrayBuffer-like objects. + if (typeof data === 'object' && 'byteLength' in data) { + const text = new TextDecoder().decode(data) + return `ArrayBuffer(${truncateMessage(text)})` + } + + return truncateMessage(data) +} diff --git a/src/core/ws/utils/truncateMessage.test.ts b/src/core/ws/utils/truncateMessage.test.ts new file mode 100644 index 000000000..5e247a0e3 --- /dev/null +++ b/src/core/ws/utils/truncateMessage.test.ts @@ -0,0 +1,12 @@ +import { truncateMessage } from './truncateMessage' + +it('returns a short string as-is', () => { + expect(truncateMessage('')).toBe('') + expect(truncateMessage('hello')).toBe('hello') +}) + +it('truncates a long string', () => { + expect(truncateMessage('this is a very long string')).toBe( + 'this is a very long stri…', + ) +}) diff --git a/src/core/ws/utils/truncateMessage.ts b/src/core/ws/utils/truncateMessage.ts new file mode 100644 index 000000000..eae145e91 --- /dev/null +++ b/src/core/ws/utils/truncateMessage.ts @@ -0,0 +1,9 @@ +const MAX_LENGTH = 24 + +export function truncateMessage(message: string): string { + if (message.length <= MAX_LENGTH) { + return message + } + + return `${message.slice(0, MAX_LENGTH)}…` +} diff --git a/src/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 85c534d31..111bd6581 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -14,9 +14,12 @@ 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 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 +37,7 @@ export class SetupServerCommonApi constructor( interceptors: Array<{ new (): Interceptor }>, - handlers: Array, + handlers: Array, ) { super(...handlers) @@ -87,6 +90,14 @@ export class SetupServerCommonApi ) }, ) + + handleWebSocketEvent({ + getHandlers: () => { + return this.handlersController.currentHandlers() + }, + onMockedConnection: () => {}, + onPassthroughConnection: () => {}, + }) } public listen(options: Partial = {}): void { @@ -97,9 +108,11 @@ export class SetupServerCommonApi // Apply the interceptor when starting the server. this.interceptor.apply() + webSocketInterceptor.apply() this.subscriptions.push(() => { this.interceptor.dispose() + webSocketInterceptor.dispose() }) // Assert that the interceptor has been applied successfully. diff --git a/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..749d0f7c1 --- /dev/null +++ b/test/browser/ws-api/ws.apply.browser.test.ts @@ -0,0 +1,44 @@ +import type { ws } from 'msw' +import type { SetupWorker, setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + worker: SetupWorker + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('does not apply the interceptor until "worker.start()" is called', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(() => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + window.worker = setupWorker(api.on('connection', () => {})) + }) + + await expect( + page.evaluate(() => { + return new WebSocket('wss://example.com').constructor.name + }), + ).resolves.toBe('WebSocket') + + await page.evaluate(async () => { + await window.worker.start() + }) + + await expect( + page.evaluate(() => { + return new WebSocket('wss://example.com').constructor.name + }), + ).resolves.not.toBe('WebSocket') +}) 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..f811a3838 --- /dev/null +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -0,0 +1,244 @@ +import type { WebSocketLink, ws } from 'msw' +import type { SetupWorker, setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + worker: SetupWorker + link: WebSocketLink + ws: WebSocket + messages: string[] + } +} + +test('returns the number of active clients in the same runtime', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.on('connection', () => {})) + window.link = api + await worker.start() + }) + + // Must return 0 when no clients are present. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(0) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return 1 after a single client joined. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(1) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return 2 now that another client has joined. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(2) +}) + +test('returns the number of active clients across different runtimes', async ({ + loadExample, + context, +}) => { + const { compilation } = await loadExample( + require.resolve('./ws.runtime.js'), + { + skipActivation: true, + }, + ) + + const pageOne = await context.newPage() + const pageTwo = await context.newPage() + + for (const page of [pageOne, pageTwo]) { + await page.goto(compilation.previewUrl) + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.on('connection', () => {})) + window.link = api + await worker.start() + }) + } + + await pageOne.bringToFront() + await pageOne.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + expect(await pageOne.evaluate(() => window.link.clients.size)).toBe(1) + expect(await pageTwo.evaluate(() => window.link.clients.size)).toBe(1) + + await pageTwo.bringToFront() + await pageTwo.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + expect(await pageTwo.evaluate(() => window.link.clients.size)).toBe(2) + expect(await pageOne.evaluate(() => window.link.clients.size)).toBe(2) +}) + +test('broadcasts messages across runtimes', async ({ + loadExample, + context, +}) => { + const { compilation } = await loadExample( + require.resolve('./ws.runtime.js'), + { + skipActivation: true, + }, + ) + + const pageOne = await context.newPage() + const pageTwo = await context.newPage() + + for (const page of [pageOne, pageTwo]) { + await page.goto(compilation.previewUrl) + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker( + api.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + api.broadcast(event.data) + }) + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + window.messages = [] + const ws = new WebSocket('wss://example.com') + window.ws = ws + ws.onmessage = (event) => { + window.messages.push(event.data) + } + }) + } + + await pageOne.evaluate(() => { + window.ws.send('hi from one') + }) + expect(await pageOne.evaluate(() => window.messages)).toEqual(['hi from one']) + expect(await pageTwo.evaluate(() => window.messages)).toEqual(['hi from one']) + + await pageTwo.evaluate(() => { + window.ws.send('hi from two') + }) + + expect(await pageTwo.evaluate(() => window.messages)).toEqual([ + 'hi from one', + 'hi from two', + ]) + expect(await pageOne.evaluate(() => window.messages)).toEqual([ + 'hi from one', + 'hi from two', + ]) +}) + +test('clears the list of clients when the worker is stopped', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.on('connection', () => {})) + window.link = api + window.worker = worker + await worker.start() + }) + + 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.on('connection', () => {})) + window.link = api + window.worker = worker + await worker.start() + }) + } + + await enableMocking(page) + + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return the number of joined clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(1) + + await page.reload() + await enableMocking() + + // Must purge the local storage on reload. + // The worker has been started as a part of the test, not runtime, + // so it will start with empty clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) +}) 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..ac0e9fc33 --- /dev/null +++ b/test/browser/ws-api/ws.intercept.client.browser.test.ts @@ -0,0 +1,140 @@ +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.on('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.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + 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.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + 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.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + 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..d70b972b6 --- /dev/null +++ b/test/browser/ws-api/ws.intercept.server.browser.test.ts @@ -0,0 +1,162 @@ +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.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + 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.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + 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.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + resolve(new TextDecoder().decode(event.data)) + }) + }), + ) + 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..b7393bbaa --- /dev/null +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -0,0 +1,637 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' +import { WebSocketServer } from '../../support/WebSocketServer' +import { waitFor } from '../../support/waitFor' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +const server = new WebSocketServer() + +test.beforeAll(async () => { + await server.listen() +}) + +test.afterEach(async () => { + server.resetState() +}) + +test.afterAll(async () => { + await server.close() +}) + +test('does not log anything if "quiet" was set to "true"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start({ quiet: true }) + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => { + ws.send('hello') + ws.send('world') + queueMicrotask(() => ws.close()) + } + + return new Promise((resolve, reject) => { + ws.onclose = () => resolve() + ws.onerror = () => reject(new Error('Client connection closed')) + }) + }) + + expect(consoleSpy.get('startGroupCollapsed')).toBeUndefined() +}) + +test('logs the client connection', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://example.com/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2} %c▶%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending text data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send('hello world') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c hello world %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending a long text data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send('this is an extremely long sentence to log out') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c this is an extremely lon… %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending Blob data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send(new Blob(['hello world'])) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending a long Blob data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => + ws.send(new Blob(['this is an extremely long sentence to log out'])) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending ArrayBuffer data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send(new TextEncoder().encode('hello world')) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending a long ArrayBuffer data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => + ws.send( + new TextEncoder().encode( + 'this is an extremely long sentence to log out', + ), + ) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs incoming client events', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.addListener('connection', (ws) => { + ws.send('hello from server') + + ws.addEventListener('message', (event) => { + if (event.data === 'how are you, server?') { + ws.send('thanks, not bad') + } + }) + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ client, server }) => { + server.connect() + }), + ) + 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, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.addListener('connection', (ws) => { + ws.send('hello from server') + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ client, server }) => { + server.connect() + + 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. + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + + // The mocked message sent from the event handler (client.send()). + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c intercepted server event %c24%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + + // The actual message the client received (i.e. mocked). + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c intercepted server event %c24%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the client', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.close() + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the event handler', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker( + api.on('connection', ({ client }) => { + client.close() + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://example.com/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client events sent vi "server.send()"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ server }) => { + server.connect() + server.send('hello from handler') + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇡%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs incoming client events sent vi "client.send()"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ client }) => { + client.send('hello from handler') + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs client errors received via "client.close()"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker( + api.on('connection', ({ client }) => { + queueMicrotask(() => client.close(1003, 'Custom error')) + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://example.com/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs client errors received via server-sent close', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.on('connection', (ws) => { + queueMicrotask(() => ws.close(1003)) + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ server }) => { + server.connect() + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c ws:\/\/(.+):\d{4,}\/ color:#3b82f6 color: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..6577a99ee --- /dev/null +++ b/test/browser/ws-api/ws.server.connect.browser.test.ts @@ -0,0 +1,131 @@ +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.on('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.close() + } + socket.onerror = reject + }) + }, server.url) + + expect(clientMessage).toBe('mock') +}) + +test('forwards incoming server events to the client once connected', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + 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.on('connection', ({ server }) => { + // Calling "connect()" establishes the connection + // to the actual WebSocket server. + server.connect() + }), + ) + await worker.start() + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + 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.on('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..9d5b07165 --- /dev/null +++ b/test/browser/ws-api/ws.use.browser.test.ts @@ -0,0 +1,258 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('resolves outgoing events using initial handlers', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello from mock') + } + }) + }), + ) + await worker.start() + }) + + const clientMessage = await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => resolve(event.data) + socket.onerror = reject + }) + }) + + expect(clientMessage).toBe('hello from mock') +}) + +test('overrides an outgoing event listener', async ({ loadExample, page }) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('must not be sent') + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + }) + + const clientMessage = await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onopen = () => socket.send('hello') + + socket.onmessage = (event) => resolve(event.data) + socket.onerror = reject + }) + }) + + expect(clientMessage).toBe('howdy, client!') +}) + +test('combines initial and override listeners', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // This will be sent the last since the initial + // event listener is attached the first. + client.send('hello from mock') + client.close() + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // This will be sent first since the override listener + // is attached the last. + client.send('override data') + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => messages.push(event.data) + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['override data', 'hello from mock']) +}) + +test('combines initial and override listeners in the opposite order', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello from mock') + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Queue this send to the next tick so it + // happens after the initial listener's send. + queueMicrotask(() => { + client.send('override data') + client.close() + }) + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => messages.push(event.data) + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['hello from mock', 'override data']) +}) + +test('does not affect unrelated events', async ({ loadExample, page }) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('must not be sent') + } + + if (event.data === 'fallthrough') { + client.send('ok') + client.close() + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => { + messages.push(event.data) + if (event.data === 'howdy, client!') { + socket.send('fallthrough') + } + } + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['howdy, client!', 'ok']) +}) diff --git a/test/modules/node/esm-node.test.ts b/test/modules/node/esm-node.test.ts index 303a19e7f..e8619916e 100644 --- a/test/modules/node/esm-node.test.ts +++ b/test/modules/node/esm-node.test.ts @@ -32,6 +32,7 @@ const server = setupServer( http.get('/resource', () => new Response()) ) console.log(typeof server.listen) +process.exit(0) `, }) @@ -77,12 +78,13 @@ console.log('msw/node:', require.resolve('msw/node')) console.log('msw/native:', require.resolve('msw/native')) `, 'runtime.cjs': ` -import { http } from 'msw' -import { setupServer } from 'msw/node' +const { http } = require('msw') +const { setupServer } = require('msw/node') const server = setupServer( http.get('/resource', () => new Response()) ) console.log(typeof server.listen) +process.exit(0) `, }) diff --git a/test/node/vitest.config.ts b/test/node/vitest.config.ts index fe4b6c3bf..0557df44e 100644 --- a/test/node/vitest.config.ts +++ b/test/node/vitest.config.ts @@ -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/ws.apply.test.ts b/test/node/ws-api/ws.apply.test.ts new file mode 100644 index 000000000..e958f1797 --- /dev/null +++ b/test/node/ws-api/ws.apply.test.ts @@ -0,0 +1,34 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer() + +afterEach(() => { + server.close() +}) + +it('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.on('connection', () => {})) + + const raw = new WebSocket('wss://example.com') + expect(raw.constructor.name).toBe('WebSocket') + expect(raw).toBeInstanceOf(EventTarget) + + server.listen() + + const mocked = new WebSocket('wss://example.com') + expect(mocked.constructor.name).not.toBe('WebSocket') + expect(mocked).toBeInstanceOf(EventTarget) +}) diff --git a/test/node/ws-api/ws.event-patching.test.ts b/test/node/ws-api/ws.event-patching.test.ts new file mode 100644 index 000000000..320e1d7d4 --- /dev/null +++ b/test/node/ws-api/ws.event-patching.test.ts @@ -0,0 +1,121 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const service = ws.link('ws://*') +const originalServer = new WebSocketServer() + +const server = setupServer( + service.on('connection', ({ server }) => { + server.connect() + }), +) + +beforeAll(async () => { + server.listen() + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('patches incoming server message', async () => { + originalServer.once('connection', (client) => { + client.send('hi from John') + }) + + server.use( + service.on('connection', ({ client, server }) => { + /** + * @note Since the initial handler connects to the server, + * there's no need to call `server.connect()` again. + */ + server.addEventListener('message', (event) => { + // Preventing the default stops the server-to-client forwarding. + // It means that the WebSocket client won't receive the + // actual server message. + event.preventDefault() + client.send(event.data.replace('John', 'Sarah')) + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hi from Sarah') + expect(messageListener).toHaveBeenCalledTimes(1) + }) +}) + +it('combines original and mock server messages', async () => { + originalServer.once('connection', (client) => { + client.send('original message') + }) + + server.use( + service.on('connection', ({ client, server }) => { + server.addEventListener('message', () => { + client.send('mocked message') + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onopen = () => ws.send('hello') + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + /** + * @note That the server will send the message as soon as the client + * connects. This happens before the event handler is called. + */ + expect(messageListener).toHaveBeenNthCalledWith(1, 'original message') + expect(messageListener).toHaveBeenNthCalledWith(2, 'mocked message') + expect(messageListener).toHaveBeenCalledTimes(2) + }) +}) + +it('combines original and mock server messages in the different order', async () => { + originalServer.once('connection', (client) => { + client.send('original message') + }) + + server.use( + service.on('connection', ({ client, server }) => { + server.addEventListener('message', (event) => { + /** + * @note To change the incoming server events order, + * prevent the default, send a mocked message, and + * then send the original message as-is. + */ + event.preventDefault() + client.send('mocked message') + client.send(event.data) + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'mocked message') + expect(messageListener).toHaveBeenNthCalledWith(2, 'original message') + expect(messageListener).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/node/ws-api/ws.intercept.client.test.ts b/test/node/ws-api/ws.intercept.client.test.ts new file mode 100644 index 000000000..9f9380482 --- /dev/null +++ b/test/node/ws-api/ws.intercept.client.test.ts @@ -0,0 +1,109 @@ +/** + * @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.on('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.on('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.on('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..261291cc4 --- /dev/null +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -0,0 +1,130 @@ +/** + * @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.on('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.on('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.on('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..59a1ddf44 --- /dev/null +++ b/test/node/ws-api/ws.server.connect.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const service = ws.link('ws://*') +const originalServer = new WebSocketServer() + +const server = setupServer() + +beforeAll(async () => { + server.listen() + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('does not connect to the actual server by default', async () => { + const serverConnectionListener = vi.fn() + const mockConnectionListener = vi.fn() + + originalServer.once('connection', serverConnectionListener) + server.use(service.on('connection', mockConnectionListener)) + + new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(mockConnectionListener).toHaveBeenCalledTimes(1) + expect(serverConnectionListener).not.toHaveBeenCalled() + }) +}) + +it('connects to the actual server after calling "server.connect()"', async () => { + const serverConnectionListener = vi.fn() + const mockConnectionListener = vi.fn() + + originalServer.once('connection', serverConnectionListener) + + server.use( + service.on('connection', ({ server }) => { + mockConnectionListener() + server.connect() + }), + ) + + new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(mockConnectionListener).toHaveBeenCalledTimes(1) + expect(serverConnectionListener).toHaveBeenCalledTimes(1) + }) +}) + +it('forwards incoming server events to the client once connected', async () => { + originalServer.once('connection', (client) => client.send('hello')) + + server.use( + service.on('connection', ({ server }) => { + server.connect() + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hello') + expect(messageListener).toHaveBeenCalledTimes(1) + }) +}) + +it('throws an error when connecting to a non-existing server', async () => { + server.use( + service.on('connection', ({ server }) => { + server.connect() + }), + ) + + const errorListener = vi.fn() + const ws = new WebSocket('wss://localhost:9876') + ws.onerror = errorListener + + await vi.waitFor(() => { + expect(errorListener).toHaveBeenCalledTimes(1) + }) +}) 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..f6cc8135d --- /dev/null +++ b/test/node/ws-api/ws.use.test.ts @@ -0,0 +1,176 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const service = ws.link('wss://*') + +const server = setupServer( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello, client!') + } + + 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.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Stopping immediate event propagation will prevent + // the same message listener in the initial handler + // from being called. + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenCalledWith('howdy, client!') + expect(messageListener).toHaveBeenCalledTimes(1) + }) + }), +) + +it.concurrent( + 'combines initial and override listeners', + server.boundary(async () => { + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Not stopping the event propagation will result in both + // the override handler and the runtime handler sending + // data to the client in order. The override handler is + // prepended, so it will send data first. + client.send('override data') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + // The runtime handler is executed first, so it sends its message first. + expect(messageListener).toHaveBeenNthCalledWith(1, 'override data') + // The initial handler will send its message next. + expect(messageListener).toHaveBeenNthCalledWith(2, 'hello, client!') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }), +) + +it.concurrent( + 'combines initial and override listeners in the opposite order', + async () => { + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Queuing the send to the next tick will ensure + // that the initial handler sends data first, + // and this override handler sends data next. + queueMicrotask(() => { + client.send('override data') + }) + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hello, client!') + expect(messageListener).toHaveBeenNthCalledWith(2, 'override data') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }, +) + +it.concurrent( + 'does not affect unrelated events', + server.boundary(async () => { + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Stopping immediate event propagation will prevent + // the same message listener in the initial handler + // from being called. + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => { + messageListener(event.data) + + if (event.data === 'howdy, client!') { + ws.send('fallthrough') + } + } + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'howdy, client!') + }) + + await vi.waitFor(() => { + // The initial handler still sends data to unrelated events. + expect(messageListener).toHaveBeenNthCalledWith(2, 'ok') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }), +) diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts 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/vitest.config.ts b/vitest.config.ts index f007e4c53..43406fdc1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,9 @@ -import * as path from 'node:path' import { defineConfig } from 'vitest/config' +import { + mswExports, + customViteEnvironments, + fromRoot, +} from './test/support/alias' export default defineConfig({ test: { @@ -8,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.