diff --git a/.github/workflows/auto.yml b/.github/workflows/auto.yml index 41dcf1438..72f2ba2a1 100644 --- a/.github/workflows/auto.yml +++ b/.github/workflows/auto.yml @@ -1,5 +1,7 @@ name: auto + on: [push] + jobs: cancel-previous-workflows: runs-on: macos-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0fa59640..312b5e3b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,10 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index c331792d5..25ccd9296 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml new file mode 100644 index 000000000..0f50a7b50 --- /dev/null +++ b/.github/workflows/lock-closed-issues.yml @@ -0,0 +1,19 @@ +name: locked-closed-issues + +on: + schedule: + - cron: '0 0 * * *' + +permissions: + issues: write + +jobs: + action: + runs-on: macos-latest + steps: + - uses: dessant/lock-threads@v5 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + issue-inactive-days: '14' + issue-lock-reason: '' + process-only: 'issues' diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml new file mode 100644 index 000000000..d679baa0a --- /dev/null +++ b/.github/workflows/release-preview.yml @@ -0,0 +1,60 @@ +name: release-preview + +on: + pull_request_review: + types: [submitted] + workflow_dispatch: + +jobs: + check: + # Trigger the permissions check whenever someone approves a pull request. + # They must have the write permissions to the repo in order to + # trigger preview package publishing. + if: github.event_name == 'workflow_dispatch' || github.event.review.state == 'approved' + runs-on: ubuntu-latest + outputs: + has-permissions: ${{ steps.checkPermissions.outputs.require-result }} + steps: + - name: Check permissions + id: checkPermissions + uses: actions-cool/check-user-permission@v2 + with: + require: 'write' + + publish: + # The approving user must pass the permissions check + # to trigger the preview publish. + needs: check + if: github.event_name == 'workflow_dispatch' || needs.check.outputs.has-permissions == 'true' + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 8.15.6 + + - name: Install dependencies + run: pnpm install + + - name: Install Playwright browsers + run: pnpm exec playwright install + + - name: Lint + run: pnpm lint + + - name: Build + run: pnpm build + + - name: Tests + run: pnpm test + + - name: Publish preview + run: pnpm dlx pkg-pr-new@0.0 publish --compact --pnpm --comment=update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ed8349f9..6a0bbfccb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,21 +8,25 @@ on: jobs: release: runs-on: macos-latest + permissions: + contents: read + id-token: write steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ secrets.GH_ADMIN_TOKEN }} - - name: Setup Node.js - uses: actions/setup-node@v3 + - name: Set up Node.js + uses: actions/setup-node@v4 with: node-version: 18 always-auth: true registry-url: https://registry.npmjs.org - - uses: pnpm/action-setup@v4 + - name: Set up pnpm + uses: pnpm/action-setup@v4 with: version: 8.15.6 diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 18b124ffa..e98d4acad 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -15,14 +15,14 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 - - name: Set up PNPM + - name: Set up pnpm uses: pnpm/action-setup@v4 with: version: 8.15.6 diff --git a/.github/workflows/typescript-nightly.yml b/.github/workflows/typescript-nightly.yml index 9449cd3ba..a292f12bb 100644 --- a/.github/workflows/typescript-nightly.yml +++ b/.github/workflows/typescript-nightly.yml @@ -11,7 +11,7 @@ jobs: runs-on: macos-latest steps: - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 @@ -37,10 +37,10 @@ jobs: if: ${{ needs.compare.outputs.latest_version != needs.compare.outputs.rc_version }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 diff --git a/.gitignore b/.gitignore index 69d931b07..0f49110a8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,5 @@ msw-*.tgz # Smoke test temporary files. /package.json.copy /examples - -tsconfig.vitest-temp.json \ No newline at end of file +/test/modules/node/node-esm-tests +tsconfig.vitest-temp.json diff --git a/decisions/jest-support.md b/decisions/jest-support.md index a19e48fac..9ff0f061d 100644 --- a/decisions/jest-support.md +++ b/decisions/jest-support.md @@ -2,7 +2,7 @@ With the introduction of [Mock Service Worker 2.0](https://mswjs.io/blog/introducing-msw-2.0), the library has made a significant step forward in the effort of embracing and promoting web standards. Since that release, its contributors have reported multiple issues with Node.js simply because MSW exposed developers to using standard Node.js APIs. -Betting and advancing the web standards is one of the goals behind this project. One of such standards is ESM. It's the present and the future of JavaScript, and we are planning on switching to ESM-only in the years to come. For that transition to happen, we need to prioritize and, at times, make hard decisions. +Betting on the web standards is one of the goals behind this project. One of such standards is ESM. It's the present and the future of JavaScript, and we are planning on switching to ESM-only in the years to come. For that transition to happen, we need to prioritize and, at times, make hard decisions. **MSW offers no official support for Jest.** It doesn't mean MSW cannot be used in Jest. We maintain usage examples of both [Jest](https://github.com/mswjs/examples/tree/main/examples/with-jest) and [Jest+JSDOM](https://github.com/mswjs/examples/tree/main/examples/with-jest-jsdom) to attest to that. Although it's necessary to mention that those examples require additional setup to tackle underlying Jest or JSDOM issues. diff --git a/decisions/linting-worker-script.md b/decisions/linting-worker-script.md new file mode 100644 index 000000000..c8473ad6b --- /dev/null +++ b/decisions/linting-worker-script.md @@ -0,0 +1,9 @@ +# Linting the worker script + +When linting your application, you may encounter warnings or errors originating from the `mockServiceWorker.js` script. Please refrain from opening pull requests to add the `ignore` pragma comments to the script itself. + +## Solution + +**Make sure that the worker script is ignored by your linting tools**. The worker script isn't a part of your application's code but a static asset. It must be ignored during linting and prettifying in the same way all your static assets in `/public` are ignored. Please configure your tools respectively. + +If there are warnings/errors originating from the worker script, it's likely your public directory is not ignored by your linting tools. You may consider ignoring the entire public directory if that suits your project's conventions. diff --git a/decisions/releases.md b/decisions/releases.md index 42dc3ead2..8a7a0d813 100644 --- a/decisions/releases.md +++ b/decisions/releases.md @@ -4,9 +4,13 @@ MSW uses the [Release](https://github.com/ossjs/release) library to automate its ## Release schedule -The next version of the library releases automatically **every 3 days**. +The next version of **the library releases automatically every day**. We do our best to choose the optimal release window to accumulate multiple changes under a single release. We do deviate from the release schedule in emergency cases, like when a critical issue has been fixed and needs an immediate release. > [!IMPORTANT] > Please do not ping the library maintainers to release a new version of MSW. Be patient and wait for the automated release to happen. Subscribe to any issue or pull request you are interested in, and you will be notified whenever it gets released. + +## Preview releases + +This repository is configured to **release work-in-progress pull requests** using [okg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new). Once the pull request in question is approved, it will automatically be published to a temporary registry. Follow the instructions in the automated comment to install the work-in-progress version of the package. diff --git a/media/msw-logo-black.svg b/media/msw-logo-black.svg index 460e56f74..5b133c721 100644 --- a/media/msw-logo-black.svg +++ b/media/msw-logo-black.svg @@ -2,14 +2,11 @@ msw-logo-black - \ No newline at end of file diff --git a/media/msw-logo.svg b/media/msw-logo.svg index f5de6fb2b..9a4ba9195 100644 --- a/media/msw-logo.svg +++ b/media/msw-logo.svg @@ -1,13 +1,11 @@ - LOGO - \ No newline at end of file diff --git a/media/msw-video-thumbnail.jpg b/media/msw-video-thumbnail.jpg index a7b444f9c..57826e4d2 100644 Binary files a/media/msw-video-thumbnail.jpg and b/media/msw-video-thumbnail.jpg differ diff --git a/package.json b/package.json index 7514d8110..f98362b22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.4.10", + "version": "2.6.1", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", @@ -58,6 +58,12 @@ "import": "./lib/core/graphql.mjs", "default": "./lib/core/graphql.js" }, + "./core/ws": { + "types": "./lib/core/ws.d.ts", + "require": "./lib/core/ws.js", + "import": "./lib/core/ws.mjs", + "default": "./lib/core/ws.js" + }, "./mockServiceWorker.js": "./lib/mockServiceWorker.js", "./package.json": "./package.json" }, @@ -136,8 +142,9 @@ "@bundled-es-modules/cookie": "^2.0.1", "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", - "@inquirer/confirm": "^3.0.0", - "@mswjs/interceptors": "^0.35.8", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.36.5", + "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", @@ -154,6 +161,7 @@ "devDependencies": { "@commitlint/cli": "^18.4.4", "@commitlint/config-conventional": "^18.4.4", + "@fastify/websocket": "^8.3.1", "@open-draft/test-server": "^0.4.2", "@ossjs/release": "^0.8.1", "@playwright/test": "^1.48.0", @@ -177,6 +185,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "express": "^5.0.0", + "fastify": "^4.26.0", "fs-extra": "^11.2.0", "fs-teardown": "^0.3.0", "glob": "^11.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19ea6ebb8..96e9a96d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,11 +15,14 @@ dependencies: specifier: ^0.1.6 version: 0.1.6 '@inquirer/confirm': - specifier: ^3.0.0 - version: 3.1.1 + specifier: ^5.0.0 + version: 5.0.1(@types/node@18.19.28) '@mswjs/interceptors': - specifier: ^0.35.8 - version: 0.35.8 + specifier: ^0.36.5 + version: 0.36.10 + '@open-draft/deferred-promise': + specifier: ^2.2.0 + version: 2.2.0 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -64,6 +67,9 @@ devDependencies: '@commitlint/config-conventional': specifier: ^18.4.4 version: 18.6.3 + '@fastify/websocket': + specifier: ^8.3.1 + version: 8.3.1 '@open-draft/test-server': specifier: ^0.4.2 version: 0.4.2 @@ -133,6 +139,9 @@ devDependencies: express: specifier: ^5.0.0 version: 5.0.0 + fastify: + specifier: ^4.26.0 + version: 4.28.1 fs-extra: specifier: ^11.2.0 version: 11.2.0 @@ -1549,11 +1558,45 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@fastify/ajv-compiler@3.6.0: + resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==} + dependencies: + ajv: 8.12.0 + ajv-formats: 2.1.1(ajv@8.12.0) + fast-uri: 2.4.0 + dev: true + /@fastify/busboy@2.1.1: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} dev: true + /@fastify/error@3.4.1: + resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} + dev: true + + /@fastify/fast-json-stringify-compiler@4.3.0: + resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} + dependencies: + fast-json-stringify: 5.16.1 + dev: true + + /@fastify/merge-json-schemas@0.1.1: + resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} + dependencies: + fast-deep-equal: 3.1.3 + dev: true + + /@fastify/websocket@8.3.1: + resolution: {integrity: sha512-hsQYHHJme/kvP3ZS4v/WMUznPBVeeQHHwAoMy1LiN6m/HuPfbdXq1MBJ4Nt8qX1YI+eVbog4MnOsU7MTozkwYA==} + dependencies: + fastify-plugin: 4.5.1 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -1580,36 +1623,46 @@ packages: resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} dev: true - /@inquirer/confirm@3.1.1: - resolution: {integrity: sha512-epf2RVHJJxX5qF85U41PBq9qq2KTJW9sKNLx6+bb2/i2rjXgeoHVGUm8kJxZHavrESgXgBLKCABcfOJYIso8cQ==} + /@inquirer/confirm@5.0.1(@types/node@18.19.28): + resolution: {integrity: sha512-6ycMm7k7NUApiMGfVc32yIPp28iPKxhGRMqoNDiUjq2RyTAkbs5Fx0TdzBqhabcKvniDdAAvHCmsRjnNfTsogw==} engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' dependencies: - '@inquirer/core': 7.1.1 - '@inquirer/type': 1.2.1 + '@inquirer/core': 10.0.1(@types/node@18.19.28) + '@inquirer/type': 3.0.0(@types/node@18.19.28) + '@types/node': 18.19.28 dev: false - /@inquirer/core@7.1.1: - resolution: {integrity: sha512-rD1UI3eARN9qJBcLRXPOaZu++Bg+xsk0Tuz1EUOXEW+UbYif1sGjr0Tw7lKejHzKD9IbXE1CEtZ+xR/DrNlQGQ==} + /@inquirer/core@10.0.1(@types/node@18.19.28): + resolution: {integrity: sha512-KKTgjViBQUi3AAssqjUFMnMO3CM3qwCHvePV9EW+zTKGKafFGFF01sc1yOIYjLJ7QU52G/FbzKc+c01WLzXmVQ==} engines: {node: '>=18'} dependencies: - '@inquirer/type': 1.2.1 - '@types/mute-stream': 0.0.4 - '@types/node': 20.12.2 - '@types/wrap-ansi': 3.0.0 + '@inquirer/figures': 1.0.7 + '@inquirer/type': 3.0.0(@types/node@18.19.28) ansi-escapes: 4.3.2 - chalk: 4.1.2 - cli-spinners: 2.9.2 cli-width: 4.1.0 - figures: 3.2.0 - mute-stream: 1.0.0 + mute-stream: 2.0.0 signal-exit: 4.1.0 strip-ansi: 6.0.1 wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.2 + transitivePeerDependencies: + - '@types/node' + dev: false + + /@inquirer/figures@1.0.7: + resolution: {integrity: sha512-m+Trk77mp54Zma6xLkLuY+mvanPxlE4A7yNKs2HBiyZ4UkVs28Mv5c/pgWrHeInx+USHeX/WEPzjrWrcJiQgjw==} + engines: {node: '>=18'} dev: false - /@inquirer/type@1.2.1: - resolution: {integrity: sha512-xwMfkPAxeo8Ji/IxfUSqzRi0/+F2GIqJmpc5/thelgMGsjNZcjDDRBO9TLXT1s/hdx/mK5QbVIvgoLIFgXhTMQ==} + /@inquirer/type@3.0.0(@types/node@18.19.28): + resolution: {integrity: sha512-YYykfbw/lefC7yKj7nanzQXILM7r3suIvyFlCcMskc99axmsSewXWkAfXKwMbgxL76iAFVmRwmYdwNZNc8gjog==} engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + dependencies: + '@types/node': 18.19.28 dev: false /@isaacs/cliui@8.0.2: @@ -1829,14 +1882,14 @@ packages: '@miniflare/core': 2.14.4 '@miniflare/shared': 2.14.4 undici: 5.28.4 - ws: 8.16.0 + ws: 8.18.0 transitivePeerDependencies: - bufferutil - utf-8-validate dev: true - /@mswjs/interceptors@0.35.8: - resolution: {integrity: sha512-PFfqpHplKa7KMdoQdj5td03uG05VK2Ng1dG0sP4pT9h0dGSX2v9txYt/AnrzPb/vAmfyBBC0NQV7VaBEX+efgQ==} + /@mswjs/interceptors@0.36.10: + resolution: {integrity: sha512-GXrJgakgJW3DWKueebkvtYgGKkxA7s0u5B0P5syJM5rvQUnrpLPigvci8Hukl7yEM+sU06l+er2Fgvx/gmiRgg==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -2443,12 +2496,6 @@ packages: resolution: {integrity: sha512-PLwiVvTBg59tGFL/8VpcGvqOu3L4OuveNvPi0EYbWchRdEVP++yRUXJPFl+CApKEq13017/4Nf7aQ5lTtHUNsA==} dev: true - /@types/mute-stream@0.0.4: - resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} - dependencies: - '@types/node': 18.19.28 - dev: false - /@types/node-fetch@2.6.11: resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==} dependencies: @@ -2465,12 +2512,6 @@ packages: dependencies: undici-types: 5.26.5 - /@types/node@20.12.2: - resolution: {integrity: sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==} - dependencies: - undici-types: 5.26.5 - dev: false - /@types/normalize-package-data@2.4.4: resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} dev: true @@ -2532,10 +2573,6 @@ packages: resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} dev: true - /@types/wrap-ansi@3.0.0: - resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} - dev: false - /@types/ws@7.4.7: resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} dependencies: @@ -2925,6 +2962,10 @@ packages: through: 2.3.8 dev: true + /abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + dev: true + /accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2983,6 +3024,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: @@ -3181,6 +3244,13 @@ packages: possible-typed-array-names: 1.0.0 dev: true + /avvio@8.4.0: + resolution: {integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==} + dependencies: + '@fastify/error': 3.4.1 + fastq: 1.17.1 + dev: true + /axios@1.7.7: resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} dependencies: @@ -3687,6 +3757,7 @@ packages: /cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + dev: true /cli-truncate@4.0.0: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} @@ -3916,7 +3987,6 @@ packages: /cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - dev: false /cookies@0.9.1: resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} @@ -4665,6 +4735,7 @@ packages: /escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + dev: true /escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} @@ -4971,6 +5042,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 @@ -4994,10 +5073,28 @@ packages: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true + /fast-json-stringify@5.16.1: + resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} + dependencies: + '@fastify/merge-json-schemas': 0.1.1 + ajv: 8.12.0 + ajv-formats: 3.0.1(ajv@8.12.0) + fast-deep-equal: 3.1.3 + fast-uri: 2.4.0 + json-schema-ref-resolver: 1.0.1 + rfdc: 1.4.1 + dev: true + /fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true + /fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + dependencies: + fast-decode-uri-component: 1.0.1 + dev: true + /fast-redact@3.5.0: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} @@ -5007,6 +5104,35 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} dev: true + /fast-uri@2.4.0: + resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==} + dev: true + + /fastify-plugin@4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + dev: true + + /fastify@4.28.1: + resolution: {integrity: sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ==} + dependencies: + '@fastify/ajv-compiler': 3.6.0 + '@fastify/error': 3.4.1 + '@fastify/fast-json-stringify-compiler': 4.3.0 + abstract-logging: 2.0.1 + avvio: 8.4.0 + fast-content-type-parse: 1.1.0 + fast-json-stringify: 5.16.1 + find-my-way: 8.2.2 + light-my-request: 5.14.0 + pino: 9.5.0 + process-warning: 3.0.0 + proxy-addr: 2.0.7 + rfdc: 1.4.1 + secure-json-parse: 2.7.0 + semver: 7.6.0 + toad-cache: 3.7.0 + dev: true + /fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} dependencies: @@ -5029,6 +5155,7 @@ packages: engines: {node: '>=8'} dependencies: escape-string-regexp: 1.0.5 + dev: true /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} @@ -5081,6 +5208,15 @@ packages: - supports-color dev: true + /find-my-way@8.2.2: + resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==} + engines: {node: '>=14'} + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 3.1.0 + dev: true + /find-node-modules@2.1.3: resolution: {integrity: sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==} dependencies: @@ -6158,6 +6294,12 @@ packages: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true + /json-schema-ref-resolver@1.0.1: + resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==} + dependencies: + fast-deep-equal: 3.1.3 + dev: true + /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true @@ -6299,6 +6441,14 @@ packages: type-check: 0.4.0 dev: true + /light-my-request@5.14.0: + resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==} + dependencies: + cookie: 0.7.2 + process-warning: 3.0.0 + set-cookie-parser: 2.6.0 + dev: true + /lilconfig@3.1.1: resolution: {integrity: sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==} engines: {node: '>=14'} @@ -6755,9 +6905,9 @@ packages: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} dev: true - /mute-stream@1.0.0: - resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + /mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} dev: false /mz@2.7.0: @@ -6916,6 +7066,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'} @@ -7224,6 +7379,12 @@ packages: split2: 4.2.0 dev: true + /pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + dependencies: + split2: 4.2.0 + dev: true + /pino-pretty@7.6.1: resolution: {integrity: sha512-H7N6ZYkiyrfwBGW9CSjx0uyO9Q2Lyt73881+OTYk8v3TiTdgN92QHrWlEq/LeWw5XtDP64jeSk3mnc6T+xX9/w==} hasBin: true @@ -7247,6 +7408,10 @@ packages: resolution: {integrity: sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==} dev: true + /pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + dev: true + /pino@7.11.0: resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} hasBin: true @@ -7264,6 +7429,23 @@ packages: thread-stream: 0.15.2 dev: true + /pino@9.5.0: + resolution: {integrity: sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==} + hasBin: true + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 4.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.4.3 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + dev: true + /pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} @@ -7390,6 +7572,14 @@ packages: resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} dev: true + /process-warning@3.0.0: + resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} + dev: true + + /process-warning@4.0.0: + resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==} + dev: true + /proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -7550,6 +7740,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'} @@ -7654,6 +7849,11 @@ packages: signal-exit: 4.1.0 dev: true + /ret@0.4.3: + resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==} + engines: {node: '>=10'} + dev: true + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -7796,6 +7996,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'} @@ -8055,6 +8261,12 @@ packages: atomic-sleep: 1.0.0 dev: true + /sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + dependencies: + atomic-sleep: 1.0.0 + dev: true + /source-list-map@2.0.1: resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} dev: true @@ -8423,6 +8635,12 @@ packages: real-require: 0.1.0 dev: true + /thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + dependencies: + real-require: 0.2.0 + dev: true + /through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} dependencies: @@ -8492,6 +8710,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'} @@ -9298,19 +9521,6 @@ packages: optional: true dev: true - /ws@8.16.0: - resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: true - /ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -9402,3 +9612,8 @@ packages: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'} dev: true + + /yoctocolors-cjs@2.1.2: + resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} + engines: {node: '>=18'} + dev: false diff --git a/release.config.json b/release.config.json index 112b4ae97..8a27b7be2 100644 --- a/release.config.json +++ b/release.config.json @@ -2,7 +2,7 @@ "profiles": [ { "name": "latest", - "use": "pnpm publish --no-git-checks" + "use": "NPM_CONFIG_PROVENANCE=true pnpm publish --no-git-checks" } ] } diff --git a/src/browser/setupWorker/glossary.ts b/src/browser/setupWorker/glossary.ts index a51d0f73c..a9452ab41 100644 --- a/src/browser/setupWorker/glossary.ts +++ b/src/browser/setupWorker/glossary.ts @@ -5,13 +5,11 @@ import { SharedOptions, } from '~/core/sharedOptions' import { ServiceWorkerMessage } from './start/utils/createMessageChannel' -import { - RequestHandler, - RequestHandlerDefaultInfo, -} from '~/core/handlers/RequestHandler' +import { RequestHandler } from '~/core/handlers/RequestHandler' import type { HttpRequestEventMap, Interceptor } from '@mswjs/interceptors' -import { Path } from '~/core/utils/matching/matchRequestUrl' -import { RequiredDeep } from '~/core/typeUtils' +import type { Path } from '~/core/utils/matching/matchRequestUrl' +import type { RequiredDeep } from '~/core/typeUtils' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' export type ResolvedPath = Path | URL @@ -53,7 +51,12 @@ export type ServiceWorkerIncomingResponse = Pick< * Map of the events that can be received from the Service Worker. */ export interface ServiceWorkerIncomingEventsMap { - MOCKING_ENABLED: boolean + MOCKING_ENABLED: { + client: { + id: string + frameType: string + } + } INTEGRITY_CHECK_RESPONSE: { packageVersion: string checksum: string @@ -87,7 +90,7 @@ export interface SetupWorkerInternalContext { startOptions: RequiredDeep worker: ServiceWorker | null registration: ServiceWorkerRegistration | null - getRequestHandlers(): Array + getRequestHandlers(): Array requests: Map emitter: Emitter keepAliveInterval?: number @@ -211,7 +214,7 @@ export interface SetupWorker { * * @see {@link https://mswjs.io/docs/api/setup-worker/use `worker.use()` API reference} */ - use: (...handlers: RequestHandler[]) => void + use: (...handlers: Array) => void /** * Marks all request handlers that respond using `res.once()` as unused. @@ -226,14 +229,16 @@ export interface SetupWorker { * * @see {@link https://mswjs.io/docs/api/setup-worker/reset-handlers `worker.resetHandlers()` API reference} */ - resetHandlers: (...nextHandlers: RequestHandler[]) => void + resetHandlers: ( + ...nextHandlers: Array + ) => void /** * Returns a readonly list of currently active request handlers. * * @see {@link https://mswjs.io/docs/api/setup-worker/list-handlers `worker.listHandlers()` API reference} */ - listHandlers(): ReadonlyArray> + listHandlers(): ReadonlyArray /** * Life-cycle events. diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index 500bcb9cb..f83c67e0d 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -18,9 +18,13 @@ import { createFallbackStop } from './stop/createFallbackStop' import { devUtils } from '~/core/utils/internal/devUtils' import { SetupApi } from '~/core/SetupApi' import { mergeRight } from '~/core/utils/internal/mergeRight' -import { LifeCycleEventsMap } from '~/core/sharedOptions' +import type { LifeCycleEventsMap } from '~/core/sharedOptions' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { SetupWorker } from './glossary' import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' +import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' +import { handleWebSocketEvent } from '~/core/ws/handleWebSocketEvent' +import { attachWebSocketLogger } from '~/core/ws/utils/attachWebSocketLogger' interface Listener { target: EventTarget @@ -37,7 +41,7 @@ export class SetupWorkerApi private stopHandler: StopHandler = null as any private listeners: Array - constructor(...handlers: Array) { + constructor(...handlers: Array) { super(...handlers) invariant( @@ -176,6 +180,29 @@ export class SetupWorkerApi options, ) as SetupWorkerInternalContext['startOptions'] + // Enable the WebSocket interception. + handleWebSocketEvent({ + getUnhandledRequestStrategy: () => { + return this.context.startOptions.onUnhandledRequest + }, + getHandlers: () => { + return this.handlersController.currentHandlers() + }, + onMockedConnection: (connection) => { + if (!this.context.startOptions.quiet) { + // Attach the logger for mocked connections since + // those won't be visible in the browser's devtools. + attachWebSocketLogger(connection) + } + }, + onPassthroughConnection() {}, + }) + webSocketInterceptor.apply() + + this.subscriptions.push(() => { + webSocketInterceptor.dispose() + }) + return await this.startHandler(this.context.startOptions, options) } @@ -193,6 +220,8 @@ export class SetupWorkerApi * * @see {@link https://mswjs.io/docs/api/setup-worker `setupWorker()` API reference} */ -export function setupWorker(...handlers: Array): SetupWorker { +export function setupWorker( + ...handlers: Array +): SetupWorker { return new SetupWorkerApi(...handlers) } diff --git a/src/browser/setupWorker/start/createFallbackRequestListener.ts b/src/browser/setupWorker/start/createFallbackRequestListener.ts index a87e5da81..1afee6f8d 100644 --- a/src/browser/setupWorker/start/createFallbackRequestListener.ts +++ b/src/browser/setupWorker/start/createFallbackRequestListener.ts @@ -8,6 +8,7 @@ import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { SetupWorkerInternalContext, StartOptions } from '../glossary' import type { RequiredDeep } from '~/core/typeUtils' import { handleRequest } from '~/core/utils/handleRequest' +import { isHandlerKind } from '~/core/utils/internal/isHandlerKind' export function createFallbackRequestListener( context: SetupWorkerInternalContext, @@ -24,7 +25,7 @@ export function createFallbackRequestListener( const response = await handleRequest( request, requestId, - context.getRequestHandlers(), + context.getRequestHandlers().filter(isHandlerKind('RequestHandler')), options, context.emitter, { diff --git a/src/browser/setupWorker/start/createRequestListener.ts b/src/browser/setupWorker/start/createRequestListener.ts index 627617e49..ec96603ae 100644 --- a/src/browser/setupWorker/start/createRequestListener.ts +++ b/src/browser/setupWorker/start/createRequestListener.ts @@ -13,6 +13,7 @@ import { handleRequest } from '~/core/utils/handleRequest' import { RequiredDeep } from '~/core/typeUtils' import { devUtils } from '~/core/utils/internal/devUtils' import { toResponseInit } from '~/core/utils/toResponseInit' +import { isHandlerKind } from '~/core/utils/internal/isHandlerKind' export const createRequestListener = ( context: SetupWorkerInternalContext, @@ -43,7 +44,7 @@ export const createRequestListener = ( await handleRequest( request, requestId, - context.getRequestHandlers(), + context.getRequestHandlers().filter(isHandlerKind('RequestHandler')), options, context.emitter, { diff --git a/src/browser/setupWorker/start/createStartHandler.ts b/src/browser/setupWorker/start/createStartHandler.ts index 8a5f55321..c77034b6d 100644 --- a/src/browser/setupWorker/start/createStartHandler.ts +++ b/src/browser/setupWorker/start/createStartHandler.ts @@ -71,6 +71,11 @@ Please consider using a custom "serviceWorker.url" option to point to the actual // Make sure we're always clearing the interval - there are reports that not doing this can // cause memory leaks in headless browser environments. window.clearInterval(context.keepAliveInterval) + + // Notify others about this client disconnecting. + // E.g. this will purge the in-memory WebSocket clients since + // starting the worker again will assign them new IDs. + window.postMessage({ type: 'msw/worker:stop' }) }) // Check if the active Service Worker has been generated diff --git a/src/browser/setupWorker/start/utils/enableMocking.ts b/src/browser/setupWorker/start/utils/enableMocking.ts index c0f19f314..f895ee2d5 100644 --- a/src/browser/setupWorker/start/utils/enableMocking.ts +++ b/src/browser/setupWorker/start/utils/enableMocking.ts @@ -10,7 +10,7 @@ export async function enableMocking( options: StartOptions, ) { context.workerChannel.send('MOCK_ACTIVATE') - await context.events.once('MOCKING_ENABLED') + const { payload } = await context.events.once('MOCKING_ENABLED') // Warn the developer on multiple "worker.start()" calls. // While this will not affect the worker in any way, @@ -28,5 +28,6 @@ export async function enableMocking( quiet: options.quiet, workerScope: context.registration?.scope, workerUrl: context.worker?.scriptURL, + client: payload.client, }) } diff --git a/src/browser/setupWorker/start/utils/printStartMessage.ts b/src/browser/setupWorker/start/utils/printStartMessage.ts index e49a77046..1d087fc14 100644 --- a/src/browser/setupWorker/start/utils/printStartMessage.ts +++ b/src/browser/setupWorker/start/utils/printStartMessage.ts @@ -1,3 +1,4 @@ +import type { ServiceWorkerIncomingEventsMap } from 'browser/setupWorker/glossary' import { devUtils } from '~/core/utils/internal/devUtils' export interface PrintStartMessageArgs { @@ -5,6 +6,7 @@ export interface PrintStartMessageArgs { message?: string workerUrl?: string workerScope?: string + client?: ServiceWorkerIncomingEventsMap['MOCKING_ENABLED']['client'] } /** @@ -41,6 +43,11 @@ export function printStartMessage(args: PrintStartMessageArgs = {}) { console.log('Worker scope:', args.workerScope) } + if (args.client) { + // eslint-disable-next-line no-console + console.log('Client ID: %s (%s)', args.client.id, args.client.frameType) + } + // eslint-disable-next-line no-console console.groupEnd() } diff --git a/src/browser/setupWorker/stop/createStop.ts b/src/browser/setupWorker/stop/createStop.ts index 48c37996d..331f62196 100644 --- a/src/browser/setupWorker/stop/createStop.ts +++ b/src/browser/setupWorker/stop/createStop.ts @@ -24,6 +24,12 @@ export const createStop = ( context.isMockingEnabled = false window.clearInterval(context.keepAliveInterval) + // Post the internal stop message on the window + // to let any logic know when the worker has stopped. + // E.g. the WebSocket client manager needs this to know + // when to clear its in-memory clients list. + window.postMessage({ type: 'msw/worker:stop' }) + printStopMessage({ quiet: context.startOptions?.quiet }) } } diff --git a/src/core/SetupApi.ts b/src/core/SetupApi.ts index dbe2c8cbc..e908e5994 100644 --- a/src/core/SetupApi.ts +++ b/src/core/SetupApi.ts @@ -1,38 +1,42 @@ import { invariant } from 'outvariant' import { EventMap, Emitter } from 'strict-event-emitter' -import { - RequestHandler, - RequestHandlerDefaultInfo, -} from './handlers/RequestHandler' +import { RequestHandler } from './handlers/RequestHandler' import { LifeCycleEventEmitter } from './sharedOptions' import { devUtils } from './utils/internal/devUtils' import { pipeEvents } from './utils/internal/pipeEvents' import { toReadonlyArray } from './utils/internal/toReadonlyArray' import { Disposable } from './utils/internal/Disposable' +import type { WebSocketHandler } from './handlers/WebSocketHandler' export abstract class HandlersController { - abstract prepend(runtimeHandlers: Array): void - abstract reset(nextHandles: Array): void - abstract currentHandlers(): Array + abstract prepend( + runtimeHandlers: Array, + ): void + abstract reset(nextHandles: Array): void + abstract currentHandlers(): Array } export class InMemoryHandlersController implements HandlersController { - private handlers: Array + private handlers: Array - constructor(private initialHandlers: Array) { + constructor( + private initialHandlers: Array, + ) { this.handlers = [...initialHandlers] } - public prepend(runtimeHandles: Array): void { + public prepend( + runtimeHandles: Array, + ): void { this.handlers.unshift(...runtimeHandles) } - public reset(nextHandlers: Array): void { + public reset(nextHandlers: Array): void { this.handlers = nextHandlers.length > 0 ? [...nextHandlers] : [...this.initialHandlers] } - public currentHandlers(): Array { + public currentHandlers(): Array { return this.handlers } } @@ -47,7 +51,7 @@ export abstract class SetupApi extends Disposable { public readonly events: LifeCycleEventEmitter - constructor(...initialHandlers: Array) { + constructor(...initialHandlers: Array) { super() invariant( @@ -71,12 +75,14 @@ export abstract class SetupApi extends Disposable { }) } - private validateHandlers(handlers: ReadonlyArray): boolean { + private validateHandlers(handlers: ReadonlyArray): boolean { // Guard against incorrect call signature of the setup API. return handlers.every((handler) => !Array.isArray(handler)) } - public use(...runtimeHandlers: Array): void { + public use( + ...runtimeHandlers: Array + ): void { invariant( this.validateHandlers(runtimeHandlers), devUtils.formatMessage( @@ -89,17 +95,19 @@ export abstract class SetupApi extends Disposable { public restoreHandlers(): void { this.handlersController.currentHandlers().forEach((handler) => { - handler.isUsed = false + if ('isUsed' in handler) { + handler.isUsed = false + } }) } - public resetHandlers(...nextHandlers: Array): void { + public resetHandlers( + ...nextHandlers: Array + ): void { this.handlersController.reset(nextHandlers) } - public listHandlers(): ReadonlyArray< - RequestHandler - > { + public listHandlers(): ReadonlyArray { return toReadonlyArray(this.handlersController.currentHandlers()) } diff --git a/src/core/handlers/RequestHandler.ts b/src/core/handlers/RequestHandler.ts index f9d34f384..0a5e6f83d 100644 --- a/src/core/handlers/RequestHandler.ts +++ b/src/core/handlers/RequestHandler.ts @@ -7,6 +7,7 @@ import { import type { ResponseResolutionContext } from '../utils/executeHandlers' import type { MaybePromise } from '../typeUtils' import { StrictRequest, StrictResponse } from '..//HttpResponse' +import type { HandlerKind } from './common' export type DefaultRequestMultipartBody = Record< string, @@ -117,6 +118,8 @@ export abstract class RequestHandler< StrictRequest >() + private readonly __kind: HandlerKind + public info: HandlerInfo & RequestHandlerInternalInfo /** * Indicates whether this request handler has been used @@ -151,6 +154,7 @@ export abstract class RequestHandler< } this.isUsed = false + this.__kind = 'RequestHandler' } /** diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts new file mode 100644 index 000000000..f37f1bd6f --- /dev/null +++ b/src/core/handlers/WebSocketHandler.ts @@ -0,0 +1,146 @@ +import { Emitter } from 'strict-event-emitter' +import { createRequestId } from '@mswjs/interceptors' +import type { WebSocketConnectionData } from '@mswjs/interceptors/WebSocket' +import { + type Match, + type Path, + type PathParams, + matchRequestUrl, +} from '../utils/matching/matchRequestUrl' +import { getCallFrame } from '../utils/internal/getCallFrame' +import type { HandlerKind } from './common' + +type WebSocketHandlerParsedResult = { + match: Match +} + +export type WebSocketHandlerEventMap = { + connection: [args: WebSocketHandlerConnection] +} + +export interface WebSocketHandlerConnection extends WebSocketConnectionData { + params: PathParams +} + +export const kEmitter = Symbol('kEmitter') +export const kDispatchEvent = Symbol('kDispatchEvent') +export const kSender = Symbol('kSender') +const kStopPropagationPatched = Symbol('kStopPropagationPatched') +const KOnStopPropagation = Symbol('KOnStopPropagation') + +export class WebSocketHandler { + private readonly __kind: HandlerKind + + public id: string + public callFrame?: string + + protected [kEmitter]: Emitter + + constructor(private readonly url: Path) { + this.id = createRequestId() + + this[kEmitter] = new Emitter() + this.callFrame = getCallFrame(new Error()) + this.__kind = 'EventHandler' + } + + public parse(args: { + event: MessageEvent + }): WebSocketHandlerParsedResult { + const connection = args.event.data + const match = matchRequestUrl(connection.client.url, this.url) + + return { + match, + } + } + + public predicate(args: { + event: MessageEvent + parsedResult: WebSocketHandlerParsedResult + }): boolean { + return args.parsedResult.match.matches + } + + async [kDispatchEvent]( + event: MessageEvent, + ): Promise { + const parsedResult = this.parse({ event }) + const connection = event.data + + const resolvedConnection: WebSocketHandlerConnection = { + ...connection, + params: parsedResult.match.params || {}, + } + + // Support `event.stopPropagation()` for various client/server events. + connection.client.addEventListener( + 'message', + createStopPropagationListener(this), + ) + connection.client.addEventListener( + 'close', + createStopPropagationListener(this), + ) + + connection.server.addEventListener( + 'open', + createStopPropagationListener(this), + ) + connection.server.addEventListener( + 'message', + createStopPropagationListener(this), + ) + connection.server.addEventListener( + 'error', + createStopPropagationListener(this), + ) + connection.server.addEventListener( + 'close', + createStopPropagationListener(this), + ) + + // Emit the connection event on the handler. + // This is what the developer adds listeners for. + this[kEmitter].emit('connection', resolvedConnection) + } +} + +function createStopPropagationListener(handler: WebSocketHandler) { + return function stopPropagationListener(event: Event) { + const propagationStoppedAt = Reflect.get(event, 'kPropagationStoppedAt') as + | string + | undefined + + if (propagationStoppedAt && handler.id !== propagationStoppedAt) { + event.stopImmediatePropagation() + return + } + + Object.defineProperty(event, KOnStopPropagation, { + value(this: WebSocketHandler) { + Object.defineProperty(event, 'kPropagationStoppedAt', { + value: handler.id, + }) + }, + configurable: true, + }) + + // Since the same event instance is shared between all client/server objects, + // make sure to patch its `stopPropagation` method only once. + if (!Reflect.get(event, kStopPropagationPatched)) { + event.stopPropagation = new Proxy(event.stopPropagation, { + apply: (target, thisArg, args) => { + Reflect.get(event, KOnStopPropagation)?.call(handler) + return Reflect.apply(target, thisArg, args) + }, + }) + + Object.defineProperty(event, kStopPropagationPatched, { + value: true, + // If something else attempts to redefine this, throw. + configurable: false, + }) + } + } +} diff --git a/src/core/handlers/common.ts b/src/core/handlers/common.ts new file mode 100644 index 000000000..ef0d1018a --- /dev/null +++ b/src/core/handlers/common.ts @@ -0,0 +1 @@ +export type HandlerKind = 'RequestHandler' | 'EventHandler' diff --git a/src/core/index.ts b/src/core/index.ts index 6e8aa5ac9..9cd723080 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,13 +2,21 @@ import { checkGlobals } from './utils/internal/checkGlobals' export { SetupApi } from './SetupApi' -/* Request handlers */ +/* HTTP handlers */ export { RequestHandler } from './handlers/RequestHandler' export { http } from './http' export { HttpHandler, HttpMethods } from './handlers/HttpHandler' export { graphql } from './graphql' export { GraphQLHandler } from './handlers/GraphQLHandler' +/* WebSocket handler */ +export { ws, type WebSocketLink } from './ws' +export { + WebSocketHandler, + type WebSocketHandlerEventMap, + type WebSocketHandlerConnection, +} from './handlers/WebSocketHandler' + /* Utils */ export { matchRequestUrl } from './utils/matching/matchRequestUrl' export * from './utils/handleRequest' @@ -45,6 +53,8 @@ export type { } from './handlers/GraphQLHandler' export type { GraphQLRequestHandler, GraphQLResponseResolver } from './graphql' +export type { WebSocketData, WebSocketEventListener } from './ws' + export type { Path, PathParams, Match } from './utils/matching/matchRequestUrl' export type { ParsedGraphQLRequest } from './utils/internal/parseGraphQLRequest' diff --git a/src/core/utils/executeHandlers.ts b/src/core/utils/executeHandlers.ts index 34e4e7894..a1c450aeb 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 { diff --git a/src/core/utils/handleRequest.ts b/src/core/utils/handleRequest.ts index 766b22ce6..d78259c2e 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -1,8 +1,8 @@ import { until } from '@open-draft/until' import { Emitter } from 'strict-event-emitter' -import { RequestHandler } from '../handlers/RequestHandler' import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions' import { RequiredDeep } from '../typeUtils' +import type { RequestHandler } from '../handlers/RequestHandler' import { HandlersExecutionResult, executeHandlers } from './executeHandlers' import { onUnhandledRequest } from './request/onUnhandledRequest' import { storeResponseCookies } from './request/storeResponseCookies' diff --git a/src/core/utils/internal/isHandlerKind.test.ts b/src/core/utils/internal/isHandlerKind.test.ts new file mode 100644 index 000000000..84486fbe9 --- /dev/null +++ b/src/core/utils/internal/isHandlerKind.test.ts @@ -0,0 +1,64 @@ +import { GraphQLHandler } from '../../handlers/GraphQLHandler' +import { HttpHandler } from '../../handlers/HttpHandler' +import { RequestHandler } from '../../handlers/RequestHandler' +import { WebSocketHandler } from '../../handlers/WebSocketHandler' +import { isHandlerKind } from './isHandlerKind' + +it('returns true if expected a request handler and given a request handler', () => { + expect( + isHandlerKind('RequestHandler')(new HttpHandler('*', '*', () => {})), + ).toBe(true) + + expect( + isHandlerKind('RequestHandler')( + new GraphQLHandler('all', '*', '*', () => {}), + ), + ).toBe(true) +}) + +it('returns true if expected a request handler and given a custom request handler', () => { + class MyHandler extends RequestHandler { + constructor() { + super({ info: { header: '*' }, resolver: () => {} }) + } + predicate = () => false + log() {} + } + + expect(isHandlerKind('RequestHandler')(new MyHandler())).toBe(true) +}) + +it('returns false if expected a request handler but given event handler', () => { + expect(isHandlerKind('RequestHandler')(new WebSocketHandler('*'))).toBe(false) +}) + +it('returns false if expected a request handler but given arbitrary object', () => { + expect(isHandlerKind('RequestHandler')(undefined)).toBe(false) + expect(isHandlerKind('RequestHandler')(null)).toBe(false) + expect(isHandlerKind('RequestHandler')({})).toBe(false) + expect(isHandlerKind('RequestHandler')([])).toBe(false) + expect(isHandlerKind('RequestHandler')(123)).toBe(false) + expect(isHandlerKind('RequestHandler')('hello')).toBe(false) +}) + +it('returns true if expected an event handler and given an event handler', () => { + expect(isHandlerKind('EventHandler')(new WebSocketHandler('*'))).toBe(true) +}) + +it('returns true if expected an event handler and given a custom event handler', () => { + class MyEventHandler extends WebSocketHandler { + constructor() { + super('*') + } + } + expect(isHandlerKind('EventHandler')(new MyEventHandler())).toBe(true) +}) + +it('returns false if expected an event handler but given arbitrary object', () => { + expect(isHandlerKind('EventHandler')(undefined)).toBe(false) + expect(isHandlerKind('EventHandler')(null)).toBe(false) + expect(isHandlerKind('EventHandler')({})).toBe(false) + expect(isHandlerKind('EventHandler')([])).toBe(false) + expect(isHandlerKind('EventHandler')(123)).toBe(false) + expect(isHandlerKind('EventHandler')('hello')).toBe(false) +}) diff --git a/src/core/utils/internal/isHandlerKind.ts b/src/core/utils/internal/isHandlerKind.ts new file mode 100644 index 000000000..d877bc847 --- /dev/null +++ b/src/core/utils/internal/isHandlerKind.ts @@ -0,0 +1,21 @@ +import type { HandlerKind } from '../../handlers/common' +import type { RequestHandler } from '../../handlers/RequestHandler' +import type { WebSocketHandler } from '../../handlers/WebSocketHandler' + +/** + * A filter function that ensures that the provided argument + * is a handler of the given kind. This helps differentiate + * between different kinds of handlers, e.g. request and event handlers. + */ +export function isHandlerKind(kind: K) { + return ( + input: unknown, + ): input is K extends 'EventHandler' ? WebSocketHandler : RequestHandler => { + return ( + input != null && + typeof input === 'object' && + '__kind' in input && + input.__kind === kind + ) + } +} diff --git a/src/core/utils/logging/getTimestamp.test.ts b/src/core/utils/logging/getTimestamp.test.ts index f7c70dc0c..04b488686 100644 --- a/src/core/utils/logging/getTimestamp.test.ts +++ b/src/core/utils/logging/getTimestamp.test.ts @@ -1,18 +1,32 @@ import { getTimestamp } from './getTimestamp' beforeAll(() => { - // Stub native `Date` prototype methods used in the tested module, - // to always produce a predictable value for testing purposes. - vi.spyOn(global.Date.prototype, 'getHours').mockImplementation(() => 12) - vi.spyOn(global.Date.prototype, 'getMinutes').mockImplementation(() => 4) - vi.spyOn(global.Date.prototype, 'getSeconds').mockImplementation(() => 8) + vi.useFakeTimers() }) afterAll(() => { - vi.restoreAllMocks() + vi.useRealTimers() }) test('returns a timestamp string of the invocation time', () => { + vi.setSystemTime(new Date('2024-01-01 12:4:8')) const timestamp = getTimestamp() expect(timestamp).toBe('12:04:08') }) + +test('returns a timestamp with milliseconds', () => { + vi.setSystemTime(new Date('2024-01-01 12:4:8')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.000') + + vi.setSystemTime(new Date('2024-01-01 12:4:8.000')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.000') + + vi.setSystemTime(new Date('2024-01-01 12:4:8.4')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.400') + + vi.setSystemTime(new Date('2024-01-01 12:4:8.123')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.123') + + vi.setSystemTime(new Date('2024-01-01 12:00:00')) + expect(getTimestamp({ milliseconds: true })).toBe('12:00:00.000') +}) diff --git a/src/core/utils/logging/getTimestamp.ts b/src/core/utils/logging/getTimestamp.ts index 28e8d689a..a53605355 100644 --- a/src/core/utils/logging/getTimestamp.ts +++ b/src/core/utils/logging/getTimestamp.ts @@ -1,12 +1,17 @@ +interface GetTimestampOptions { + milliseconds?: boolean +} + /** * Returns a timestamp string in a "HH:MM:SS" format. */ -export function getTimestamp(): string { +export function getTimestamp(options?: GetTimestampOptions): string { const now = new Date() + const timestamp = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}` + + if (options?.milliseconds) { + return `${timestamp}.${now.getMilliseconds().toString().padStart(3, '0')}` + } - return [now.getHours(), now.getMinutes(), now.getSeconds()] - .map(String) - .map((chunk) => chunk.slice(0, 2)) - .map((chunk) => chunk.padStart(2, '0')) - .join(':') + return timestamp } diff --git a/src/core/utils/matching/matchRequestUrl.test.ts b/src/core/utils/matching/matchRequestUrl.test.ts index 54575f709..b6c6143af 100644 --- a/src/core/utils/matching/matchRequestUrl.test.ts +++ b/src/core/utils/matching/matchRequestUrl.test.ts @@ -72,6 +72,50 @@ describe('matchRequestUrl', () => { userId: undefined, }) }) + + test('returns true for matching WebSocket URL', () => { + expect( + matchRequestUrl(new URL('ws://test.mswjs.io'), 'ws://test.mswjs.io'), + ).toEqual({ + matches: true, + params: {}, + }) + expect( + matchRequestUrl(new URL('wss://test.mswjs.io'), 'wss://test.mswjs.io'), + ).toEqual({ + matches: true, + params: {}, + }) + }) + + test('returns false for non-matching WebSocket URL', () => { + expect( + matchRequestUrl(new URL('ws://test.mswjs.io'), 'ws://foo.mswjs.io'), + ).toEqual({ + matches: false, + params: {}, + }) + expect( + matchRequestUrl(new URL('wss://test.mswjs.io'), 'wss://completely.diff'), + ).toEqual({ + matches: false, + params: {}, + }) + }) + + test('returns path parameters when matched a WebSocket URL', () => { + expect( + matchRequestUrl( + new URL('wss://test.mswjs.io'), + 'wss://:service.mswjs.io', + ), + ).toEqual({ + matches: true, + params: { + service: 'test', + }, + }) + }) }) describe('coercePath', () => { diff --git a/src/core/utils/matching/matchRequestUrl.ts b/src/core/utils/matching/matchRequestUrl.ts index 3b9ce6ebf..5ea0115d4 100644 --- a/src/core/utils/matching/matchRequestUrl.ts +++ b/src/core/utils/matching/matchRequestUrl.ts @@ -71,3 +71,7 @@ export function matchRequestUrl(url: URL, path: Path, baseUrl?: string): Match { params, } } + +export function isPath(value: unknown): value is Path { + return typeof value === 'string' || value instanceof RegExp +} diff --git a/src/core/ws.test.ts b/src/core/ws.test.ts new file mode 100644 index 000000000..22cc58b47 --- /dev/null +++ b/src/core/ws.test.ts @@ -0,0 +1,23 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from './ws' + +it('exports the "link()" method', () => { + expect(ws).toHaveProperty('link') + expect(ws.link).toBeInstanceOf(Function) +}) + +it('throws an error when calling "ws.link()" without a URL argument', () => { + expect(() => + // @ts-expect-error Intentionally invalid call. + ws.link(), + ).toThrow('Expected a WebSocket server URL but got undefined') +}) + +it('throws an error when given a non-path argument to "ws.link()"', () => { + expect(() => + // @ts-expect-error Intentionally invalid argument. + ws.link(2), + ).toThrow('Expected a WebSocket server URL to be a valid path but got number') +}) diff --git a/src/core/ws.ts b/src/core/ws.ts new file mode 100644 index 000000000..745615951 --- /dev/null +++ b/src/core/ws.ts @@ -0,0 +1,166 @@ +import { invariant } from 'outvariant' +import type { + WebSocketData, + WebSocketClientConnectionProtocol, +} from '@mswjs/interceptors/WebSocket' +import { + WebSocketHandler, + kEmitter, + type WebSocketHandlerEventMap, +} from './handlers/WebSocketHandler' +import { Path, isPath } from './utils/matching/matchRequestUrl' +import { WebSocketClientManager } from './ws/WebSocketClientManager' + +function isBroadcastChannelWithUnref( + channel: BroadcastChannel, +): channel is BroadcastChannel & NodeJS.RefCounted { + return typeof Reflect.get(channel, 'unref') !== 'undefined' +} + +const webSocketChannel = new BroadcastChannel('msw:websocket-client-manager') + +if (isBroadcastChannelWithUnref(webSocketChannel)) { + // Allows the Node.js thread to exit if it is the only active handle in the event system. + // https://nodejs.org/api/worker_threads.html#broadcastchannelunref + webSocketChannel.unref() +} + +export type WebSocketEventListener< + EventType extends keyof WebSocketHandlerEventMap, +> = (...args: WebSocketHandlerEventMap[EventType]) => void + +export type WebSocketLink = { + /** + * A set of all WebSocket clients connected + * to this link. + * + * @see {@link https://mswjs.io/docs/api/ws#clients `clients` API reference} + */ + clients: Set + + /** + * Adds an event listener to this WebSocket link. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * chat.addEventListener('connection', listener) + * + * @see {@link https://mswjs.io/docs/api/ws#onevent-listener `on()` API reference} + */ + addEventListener( + event: EventType, + listener: WebSocketEventListener, + ): WebSocketHandler + + /** + * Broadcasts the given data to all WebSocket clients. + * + * @example + * const service = ws.link('wss://example.com') + * service.addEventListener('connection', () => { + * service.broadcast('hello, everyone!') + * }) + * + * @see {@link https://mswjs.io/docs/api/ws#broadcastdata `broadcast()` API reference} + */ + broadcast(data: WebSocketData): void + + /** + * Broadcasts the given data to all WebSocket clients + * except the ones provided in the `clients` argument. + * + * @example + * const service = ws.link('wss://example.com') + * service.addEventListener('connection', ({ client }) => { + * service.broadcastExcept(client, 'hi, the rest of you!') + * }) + * + * @see {@link https://mswjs.io/docs/api/ws#broadcastexceptclients-data `broadcast()` API reference} + */ + broadcastExcept( + clients: + | WebSocketClientConnectionProtocol + | Array, + data: WebSocketData, + ): void +} + +/** + * Intercepts outgoing WebSocket connections to the given URL. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * chat.addEventListener('connection', ({ client }) => { + * client.send('hello from server!') + * }) + */ +function createWebSocketLinkHandler(url: Path): WebSocketLink { + invariant(url, 'Expected a WebSocket server URL but got undefined') + + invariant( + isPath(url), + 'Expected a WebSocket server URL to be a valid path but got %s', + typeof url, + ) + + const clientManager = new WebSocketClientManager(webSocketChannel) + + return { + get clients() { + return clientManager.clients + }, + addEventListener(event, listener) { + const handler = new WebSocketHandler(url) + + // Add the connection event listener for when the + // handler matches and emits a connection event. + // When that happens, store that connection in the + // set of all connections for reference. + handler[kEmitter].on('connection', async ({ client }) => { + await clientManager.addConnection(client) + }) + + // The "handleWebSocketEvent" function will invoke + // the "run()" method on the WebSocketHandler. + // If the handler matches, it will emit the "connection" + // event. Attach the user-defined listener to that event. + handler[kEmitter].on(event, listener) + + return handler + }, + + broadcast(data) { + // This will invoke "send()" on the immediate clients + // in this runtime and post a message to the broadcast channel + // to trigger send for the clients in other runtimes. + this.broadcastExcept([], data) + }, + + broadcastExcept(clients, data) { + const ignoreClients = Array.prototype + .concat(clients) + .map((client) => client.id) + + clientManager.clients.forEach((otherClient) => { + if (!ignoreClients.includes(otherClient.id)) { + otherClient.send(data) + } + }) + }, + } +} + +/** + * A namespace to intercept and mock WebSocket connections. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * + * @see {@link https://mswjs.io/docs/api/ws `ws` API reference} + * @see {@link https://mswjs.io/docs/basics/handling-websocket-events Handling WebSocket events} + */ +export const ws = { + link: createWebSocketLinkHandler, +} + +export { WebSocketData } diff --git a/src/core/ws/WebSocketClientManager.test.ts b/src/core/ws/WebSocketClientManager.test.ts new file mode 100644 index 000000000..6740b2113 --- /dev/null +++ b/src/core/ws/WebSocketClientManager.test.ts @@ -0,0 +1,164 @@ +// @vitest-environment node-websocket +import { setMaxListeners } from 'node:events' +import { + WebSocketClientConnection, + WebSocketData, + WebSocketTransport, +} from '@mswjs/interceptors/WebSocket' +import { + WebSocketClientManager, + WebSocketBroadcastChannelMessage, +} from './WebSocketClientManager' + +const channel = new BroadcastChannel('test:channel') + +/** + * @note Increase the number of maximum event listeners + * because the same channel is shared between different + * manager instances in different tests. + */ +setMaxListeners(Number.MAX_SAFE_INTEGER, channel) + +vi.spyOn(channel, 'postMessage') + +const socket = new WebSocket('ws://localhost') + +class TestWebSocketTransport extends EventTarget implements WebSocketTransport { + send(_data: WebSocketData): void {} + close(_code?: number | undefined, _reason?: string | undefined): void {} +} + +afterEach(() => { + vi.resetAllMocks() +}) + +it('adds a client from this runtime to the list of clients', async () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + + await manager.addConnection(connection) + + // Must add the client to the list of clients. + expect(Array.from(manager.clients.values())).toEqual([connection]) +}) + +it('adds multiple clients from this runtime to the list of clients', async () => { + const manager = new WebSocketClientManager(channel) + const connectionOne = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + await manager.addConnection(connectionOne) + + // Must add the client to the list of clients. + expect(Array.from(manager.clients.values())).toEqual([connectionOne]) + + const connectionTwo = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + await manager.addConnection(connectionTwo) + + // Must add the new cilent to the list as well. + expect(Array.from(manager.clients.values())).toEqual([ + connectionOne, + connectionTwo, + ]) +}) + +it('replays a "send" event coming from another runtime', async () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + await manager.addConnection(connection) + vi.spyOn(connection, 'send') + + // Emulate another runtime signaling this connection to receive data. + channel.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'extraneous:send', + payload: { + clientId: connection.id, + data: 'hello', + }, + }, + }), + ) + + await vi.waitFor(() => { + // Must execute the requested operation on the connection. + expect(connection.send).toHaveBeenCalledWith('hello') + expect(connection.send).toHaveBeenCalledTimes(1) + }) +}) + +it('replays a "close" event coming from another runtime', async () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + await manager.addConnection(connection) + vi.spyOn(connection, 'close') + + // Emulate another runtime signaling this connection to close. + channel.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'extraneous:close', + payload: { + clientId: connection.id, + code: 1000, + reason: 'Normal closure', + }, + }, + }), + ) + + await vi.waitFor(() => { + // Must execute the requested operation on the connection. + expect(connection.close).toHaveBeenCalledWith(1000, 'Normal closure') + expect(connection.close).toHaveBeenCalledTimes(1) + }) +}) + +it('removes the extraneous message listener when the connection closes', async () => { + const manager = new WebSocketClientManager(channel) + const transport = new TestWebSocketTransport() + const connection = new WebSocketClientConnection(socket, transport) + vi.spyOn(connection, 'close').mockImplementationOnce(() => { + /** + * @note This is a nasty hack so we don't have to uncouple + * the connection from transport. Creating a mock transport + * is difficult because it relies on the `WebSocketOverride` class. + * All we care here is that closing the connection triggers + * the transport closure, which it always does. + */ + transport.dispatchEvent(new Event('close')) + }) + vi.spyOn(connection, 'send') + + await manager.addConnection(connection) + connection.close() + + // Signals from other runtimes have no effect on the closed connection. + channel.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'extraneous:send', + payload: { + clientId: connection.id, + data: 'hello', + }, + }, + }), + ) + + expect(connection.send).not.toHaveBeenCalled() +}) diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts new file mode 100644 index 000000000..44ce81e1c --- /dev/null +++ b/src/core/ws/WebSocketClientManager.ts @@ -0,0 +1,211 @@ +import type { + WebSocketData, + WebSocketClientConnection, + WebSocketClientConnectionProtocol, +} from '@mswjs/interceptors/WebSocket' +import { WebSocketClientStore } from './WebSocketClientStore' +import { WebSocketMemoryClientStore } from './WebSocketMemoryClientStore' +import { WebSocketIndexedDBClientStore } from './WebSocketIndexedDBClientStore' + +export type WebSocketBroadcastChannelMessage = + | { + type: 'extraneous:send' + payload: { + clientId: string + data: WebSocketData + } + } + | { + type: 'extraneous:close' + payload: { + clientId: string + code?: number + reason?: string + } + } + +/** + * A manager responsible for accumulating WebSocket client + * connections across different browser runtimes. + */ +export class WebSocketClientManager { + private store: WebSocketClientStore + private runtimeClients: Map + private allClients: Set + + constructor(private channel: BroadcastChannel) { + // Store the clients in the IndexedDB in the browser, + // otherwise, store the clients in memory. + this.store = + typeof indexedDB !== 'undefined' + ? new WebSocketIndexedDBClientStore() + : new WebSocketMemoryClientStore() + + this.runtimeClients = new Map() + this.allClients = new Set() + + this.channel.addEventListener('message', (message) => { + if (message.data?.type === 'db:update') { + this.flushDatabaseToMemory() + } + }) + + if (typeof window !== 'undefined') { + window.addEventListener('message', async (message) => { + if (message.data?.type === 'msw/worker:stop') { + await this.removeRuntimeClients() + } + }) + } + } + + private async flushDatabaseToMemory() { + const storedClients = await this.store.getAll() + + this.allClients = new Set( + storedClients.map((client) => { + const runtimeClient = this.runtimeClients.get(client.id) + + /** + * @note For clients originating in this runtime, use their + * direct references. No need to wrap them in a remote connection. + */ + if (runtimeClient) { + return runtimeClient + } + + return new WebSocketRemoteClientConnection( + client.id, + new URL(client.url), + this.channel, + ) + }), + ) + } + + private async removeRuntimeClients(): Promise { + await this.store.deleteMany(Array.from(this.runtimeClients.keys())) + this.runtimeClients.clear() + await this.flushDatabaseToMemory() + this.notifyOthersAboutDatabaseUpdate() + } + + /** + * All active WebSocket client connections. + */ + get clients(): Set { + return this.allClients + } + + /** + * Notify other runtimes about the database update + * using the shared `BroadcastChannel` instance. + */ + private notifyOthersAboutDatabaseUpdate(): void { + this.channel.postMessage({ type: 'db:update' }) + } + + private async addClient(client: WebSocketClientConnection): Promise { + await this.store.add(client) + // Sync the in-memory clients in this runtime with the + // updated database. This pulls in all the stored clients. + await this.flushDatabaseToMemory() + this.notifyOthersAboutDatabaseUpdate() + } + + /** + * Adds the given `WebSocket` client connection to the set + * of all connections. The given connection is always the complete + * connection object because `addConnection()` is called only + * for the opened connections in the same runtime. + */ + public async addConnection(client: WebSocketClientConnection): Promise { + // Store this client in the map of clients created in this runtime. + // This way, the manager can distinguish between this runtime clients + // and extraneous runtime clients when synchronizing clients storage. + this.runtimeClients.set(client.id, client) + + // Add the new client to the storage. + await this.addClient(client) + + // Handle the incoming BroadcastChannel messages from other runtimes + // that attempt to control this runtime (via a remote connection wrapper). + // E.g. another runtime calling `client.send()` for the client in this runtime. + const handleExtraneousMessage = ( + message: MessageEvent, + ) => { + const { type, payload } = message.data + + // Ignore broadcasted messages for other clients. + if ( + typeof payload === 'object' && + 'clientId' in payload && + payload.clientId !== client.id + ) { + return + } + + switch (type) { + case 'extraneous:send': { + client.send(payload.data) + break + } + + case 'extraneous:close': { + client.close(payload.code, payload.reason) + break + } + } + } + + const abortController = new AbortController() + + this.channel.addEventListener('message', handleExtraneousMessage, { + signal: abortController.signal, + }) + + // Once closed, this connection cannot be operated on. + // This must include the extraneous runtimes as well. + client.addEventListener('close', () => abortController.abort(), { + once: true, + }) + } +} + +/** + * A wrapper class to operate with WebSocket client connections + * from other runtimes. This class maintains 1-1 public API + * compatibility to the `WebSocketClientConnection` but relies + * on the given `BroadcastChannel` to communicate instructions + * with the client connections from other runtimes. + */ +export class WebSocketRemoteClientConnection + implements WebSocketClientConnectionProtocol +{ + constructor( + public readonly id: string, + public readonly url: URL, + private channel: BroadcastChannel, + ) {} + + send(data: WebSocketData): void { + this.channel.postMessage({ + type: 'extraneous:send', + payload: { + clientId: this.id, + data, + }, + } as WebSocketBroadcastChannelMessage) + } + + close(code?: number | undefined, reason?: string | undefined): void { + this.channel.postMessage({ + type: 'extraneous:close', + payload: { + clientId: this.id, + code, + reason, + }, + } as WebSocketBroadcastChannelMessage) + } +} diff --git a/src/core/ws/WebSocketClientStore.ts b/src/core/ws/WebSocketClientStore.ts new file mode 100644 index 000000000..6e4c302b6 --- /dev/null +++ b/src/core/ws/WebSocketClientStore.ts @@ -0,0 +1,14 @@ +import type { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/WebSocket' + +export interface SerializedWebSocketClient { + id: string + url: string +} + +export abstract class WebSocketClientStore { + public abstract add(client: WebSocketClientConnectionProtocol): Promise + + public abstract getAll(): Promise> + + public abstract deleteMany(clientIds: Array): Promise +} diff --git a/src/core/ws/WebSocketIndexedDBClientStore.ts b/src/core/ws/WebSocketIndexedDBClientStore.ts new file mode 100644 index 000000000..b8509b7a9 --- /dev/null +++ b/src/core/ws/WebSocketIndexedDBClientStore.ts @@ -0,0 +1,145 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import { + type SerializedWebSocketClient, + WebSocketClientStore, +} from './WebSocketClientStore' + +const DB_NAME = 'msw-websocket-clients' +const DB_STORE_NAME = 'clients' + +export class WebSocketIndexedDBClientStore implements WebSocketClientStore { + private db: Promise + + constructor() { + this.db = this.createDatabase() + } + + public async add(client: WebSocketClientConnectionProtocol): Promise { + const promise = new DeferredPromise() + const store = await this.getStore() + + /** + * @note Use `.put()` instead of `.add()` to allow setting clients + * that already exist in the database. This can happen if a single page + * has multiple event handlers. Each handler will receive the "connection" + * event in parallel, and try to set that WebSocket client in the database. + */ + const request = store.put({ + id: client.id, + url: client.url.href, + } satisfies SerializedWebSocketClient) + + request.onsuccess = () => { + promise.resolve() + } + request.onerror = () => { + // eslint-disable-next-line no-console + console.error(request.error) + promise.reject( + new Error( + `Failed to add WebSocket client "${client.id}". There is likely an additional output above.`, + ), + ) + } + + return promise + } + + public async getAll(): Promise> { + const promise = new DeferredPromise>() + const store = await this.getStore() + const request = store.getAll() as IDBRequest< + Array + > + + request.onsuccess = () => { + promise.resolve(request.result) + } + request.onerror = () => { + // eslint-disable-next-line no-console + console.log(request.error) + promise.reject( + new Error( + `Failed to get all WebSocket clients. There is likely an additional output above.`, + ), + ) + } + + return promise + } + + public async deleteMany(clientIds: Array): Promise { + const promise = new DeferredPromise() + const store = await this.getStore() + + for (const clientId of clientIds) { + store.delete(clientId) + } + + store.transaction.oncomplete = () => { + promise.resolve() + } + store.transaction.onerror = () => { + // eslint-disable-next-line no-console + console.error(store.transaction.error) + promise.reject( + new Error( + `Failed to delete WebSocket clients [${clientIds.join(', ')}]. There is likely an additional output above.`, + ), + ) + } + + return promise + } + + private async createDatabase(): Promise { + const promise = new DeferredPromise() + const request = indexedDB.open(DB_NAME, 1) + + request.onsuccess = ({ currentTarget }) => { + const db = Reflect.get(currentTarget!, 'result') as IDBDatabase + + if (db.objectStoreNames.contains(DB_STORE_NAME)) { + return promise.resolve(db) + } + } + + request.onupgradeneeded = async ({ currentTarget }) => { + const db = Reflect.get(currentTarget!, 'result') as IDBDatabase + if (db.objectStoreNames.contains(DB_STORE_NAME)) { + return + } + + const store = db.createObjectStore(DB_STORE_NAME, { keyPath: 'id' }) + store.transaction.oncomplete = () => { + promise.resolve(db) + } + store.transaction.onerror = () => { + // eslint-disable-next-line no-console + console.error(store.transaction.error) + promise.reject( + new Error( + 'Failed to create WebSocket client store. There is likely an additional output above.', + ), + ) + } + } + request.onerror = () => { + // eslint-disable-next-line no-console + console.error(request.error) + promise.reject( + new Error( + 'Failed to open an IndexedDB database. There is likely an additional output above.', + ), + ) + } + + return promise + } + + private async getStore(): Promise { + const db = await this.db + return db.transaction(DB_STORE_NAME, 'readwrite').objectStore(DB_STORE_NAME) + } +} diff --git a/src/core/ws/WebSocketMemoryClientStore.ts b/src/core/ws/WebSocketMemoryClientStore.ts new file mode 100644 index 000000000..2f97a26fb --- /dev/null +++ b/src/core/ws/WebSocketMemoryClientStore.ts @@ -0,0 +1,27 @@ +import { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import { + SerializedWebSocketClient, + WebSocketClientStore, +} from './WebSocketClientStore' + +export class WebSocketMemoryClientStore implements WebSocketClientStore { + private store: Map + + constructor() { + this.store = new Map() + } + + public async add(client: WebSocketClientConnectionProtocol): Promise { + this.store.set(client.id, { id: client.id, url: client.url.href }) + } + + public getAll(): Promise> { + return Promise.resolve(Array.from(this.store.values())) + } + + public async deleteMany(clientIds: Array): Promise { + for (const clientId of clientIds) { + this.store.delete(clientId) + } + } +} diff --git a/src/core/ws/handleWebSocketEvent.ts b/src/core/ws/handleWebSocketEvent.ts new file mode 100644 index 000000000..919ae0e89 --- /dev/null +++ b/src/core/ws/handleWebSocketEvent.ts @@ -0,0 +1,83 @@ +import type { WebSocketConnectionData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import { RequestHandler } from '../handlers/RequestHandler' +import { WebSocketHandler, kDispatchEvent } from '../handlers/WebSocketHandler' +import { webSocketInterceptor } from './webSocketInterceptor' +import { + onUnhandledRequest, + UnhandledRequestStrategy, +} from '../utils/request/onUnhandledRequest' +import { isHandlerKind } from '../utils/internal/isHandlerKind' + +interface HandleWebSocketEventOptions { + getUnhandledRequestStrategy: () => UnhandledRequestStrategy + getHandlers: () => Array + onMockedConnection: (connection: WebSocketConnectionData) => void + onPassthroughConnection: (onnection: WebSocketConnectionData) => void +} + +export function handleWebSocketEvent(options: HandleWebSocketEventOptions) { + webSocketInterceptor.on('connection', async (connection) => { + const handlers = options.getHandlers() + + const connectionEvent = new MessageEvent('connection', { + data: connection, + }) + + // First, filter only those WebSocket handlers that + // match the "ws.link()" endpoint predicate. Don't dispatch + // anything yet so the logger can be attached to the connection + // before it potentially sends events. + const matchingHandlers: Array = [] + + for (const handler of handlers) { + if ( + isHandlerKind('EventHandler')(handler) && + handler.predicate({ + event: connectionEvent, + parsedResult: handler.parse({ + event: connectionEvent, + }), + }) + ) { + matchingHandlers.push(handler) + } + } + + if (matchingHandlers.length > 0) { + options?.onMockedConnection(connection) + + // Iterate over the handlers and forward the connection + // event to WebSocket event handlers. This is equivalent + // to dispatching that event onto multiple listeners. + for (const handler of matchingHandlers) { + handler[kDispatchEvent](connectionEvent) + } + } else { + // Construct a request representing this WebSocket connection. + const request = new Request(connection.client.url, { + headers: { + upgrade: 'websocket', + connection: 'upgrade', + }, + }) + await onUnhandledRequest( + request, + options.getUnhandledRequestStrategy(), + ).catch((error) => { + const errorEvent = new Event('error') + Object.defineProperty(errorEvent, 'cause', { + enumerable: true, + configurable: false, + value: error, + }) + connection.client.socket.dispatchEvent(errorEvent) + }) + + options?.onPassthroughConnection(connection) + + // If none of the "ws" handlers matched, + // establish the WebSocket connection as-is. + connection.server.connect() + } + }) +} diff --git a/src/core/ws/utils/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts new file mode 100644 index 000000000..7e416a639 --- /dev/null +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -0,0 +1,259 @@ +import type { + WebSocketClientConnection, + WebSocketConnectionData, + WebSocketData, +} from '@mswjs/interceptors/WebSocket' +import { devUtils } from '../../utils/internal/devUtils' +import { getTimestamp } from '../../utils/logging/getTimestamp' +import { toPublicUrl } from '../../utils/request/toPublicUrl' +import { getMessageLength } from './getMessageLength' +import { getPublicData } from './getPublicData' + +const colors = { + system: '#3b82f6', + outgoing: '#22c55e', + incoming: '#ef4444', + mocked: '#ff6a33', +} + +export function attachWebSocketLogger( + connection: WebSocketConnectionData, +): void { + const { client, server } = connection + + logConnectionOpen(client) + + // Log the events sent from the WebSocket client. + // WebSocket client connection object is written from the + // server's perspective so these message events are outgoing. + /** + * @todo Provide the reference to the exact event handler + * that called this `client.send()`. + */ + client.addEventListener('message', (event) => { + logOutgoingClientMessage(event) + }) + + client.addEventListener('close', (event) => { + logConnectionClose(event) + }) + + // Log client errors (connection closures due to errors). + client.socket.addEventListener('error', (event) => { + logClientError(event) + }) + + client.send = new Proxy(client.send, { + apply(target, thisArg, args) { + const [data] = args + const messageEvent = new MessageEvent('message', { data }) + Object.defineProperties(messageEvent, { + currentTarget: { + enumerable: true, + writable: false, + value: client.socket, + }, + target: { + enumerable: true, + writable: false, + value: client.socket, + }, + }) + + queueMicrotask(() => { + logIncomingMockedClientMessage(messageEvent) + }) + + return Reflect.apply(target, thisArg, args) + }, + }) + + server.addEventListener( + 'open', + () => { + server.addEventListener('message', (event) => { + logIncomingServerMessage(event) + }) + }, + { once: true }, + ) + + // Log outgoing client events initiated by the event handler. + // The actual client never sent these but the handler did. + server.send = new Proxy(server.send, { + apply(target, thisArg, args) { + const [data] = args + const messageEvent = new MessageEvent('message', { data }) + Object.defineProperties(messageEvent, { + currentTarget: { + enumerable: true, + writable: false, + value: server.socket, + }, + target: { + enumerable: true, + writable: false, + value: server.socket, + }, + }) + + logOutgoingMockedClientMessage(messageEvent) + + return Reflect.apply(target, thisArg, args) + }, + }) +} + +/** + * Prints the WebSocket connection. + * This is meant to be logged by every WebSocket handler + * that intercepted this connection. This helps you see + * what handlers observe this connection. + */ +export function logConnectionOpen(client: WebSocketClientConnection) { + const publicUrl = toPublicUrl(client.url) + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage(`${getTimestamp()} %c▶%c ${publicUrl}`), + `color:${colors.system}`, + 'color:inherit', + ) + // eslint-disable-next-line no-console + console.log('Client:', client.socket) + // eslint-disable-next-line no-console + console.groupEnd() +} + +function logConnectionClose(event: CloseEvent) { + const target = event.target as WebSocket + const publicUrl = toPublicUrl(target.url) + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c■%c ${publicUrl}`, + ), + `color:${colors.system}`, + 'color:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + console.groupEnd() +} + +function logClientError(event: Event) { + const socket = event.target as WebSocket + const publicUrl = toPublicUrl(socket.url) + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c\u00D7%c ${publicUrl}`, + ), + `color:${colors.system}`, + 'color:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + console.groupEnd() +} + +/** + * Prints the outgoing client message. + */ +async function logOutgoingClientMessage(event: MessageEvent) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + const arrow = event.defaultPrevented ? '⇡' : '⬆' + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c${arrow}%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.outgoing}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + console.groupEnd() +} + +/** + * Prints the outgoing client message initiated + * by `server.send()` in the event handler. + */ +async function logOutgoingMockedClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⬆%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.mocked}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + console.groupEnd() +} + +/** + * Prints the outgoing client message initiated + * by `client.send()` in the event handler. + */ +async function logIncomingMockedClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⬇%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.mocked}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + console.groupEnd() +} + +async function logIncomingServerMessage(event: MessageEvent) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + const arrow = event.defaultPrevented ? '⇣' : '⬇' + + // eslint-disable-next-line no-console + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c${arrow}%c ${publicData} %c${byteLength}%c`, + ), + `color:${colors.incoming}`, + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + // eslint-disable-next-line no-console + console.log(event) + // eslint-disable-next-line no-console + console.groupEnd() +} diff --git a/src/core/ws/utils/getMessageLength.test.ts b/src/core/ws/utils/getMessageLength.test.ts new file mode 100644 index 000000000..af45718ee --- /dev/null +++ b/src/core/ws/utils/getMessageLength.test.ts @@ -0,0 +1,16 @@ +import { getMessageLength } from './getMessageLength' + +it('returns the length of the string', () => { + expect(getMessageLength('')).toBe(0) + expect(getMessageLength('hello')).toBe(5) +}) + +it('returns the size of the Blob', () => { + expect(getMessageLength(new Blob())).toBe(0) + expect(getMessageLength(new Blob(['hello']))).toBe(5) +}) + +it('returns the byte length of ArrayBuffer', () => { + expect(getMessageLength(new ArrayBuffer(0))).toBe(0) + expect(getMessageLength(new ArrayBuffer(5))).toBe(5) +}) diff --git a/src/core/ws/utils/getMessageLength.ts b/src/core/ws/utils/getMessageLength.ts new file mode 100644 index 000000000..a8e041955 --- /dev/null +++ b/src/core/ws/utils/getMessageLength.ts @@ -0,0 +1,19 @@ +import type { WebSocketData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' + +/** + * Returns the byte length of the given WebSocket message. + * @example + * getMessageLength('hello') // 5 + * getMessageLength(new Blob(['hello'])) // 5 + */ +export function getMessageLength(data: WebSocketData): number { + if (data instanceof Blob) { + return data.size + } + + if (data instanceof ArrayBuffer) { + return data.byteLength + } + + return new Blob([data]).size +} diff --git a/src/core/ws/utils/getPublicData.test.ts b/src/core/ws/utils/getPublicData.test.ts new file mode 100644 index 000000000..2820301f7 --- /dev/null +++ b/src/core/ws/utils/getPublicData.test.ts @@ -0,0 +1,38 @@ +import { getPublicData } from './getPublicData' + +it('returns a short string as-is', async () => { + expect(await getPublicData('')).toBe('') + expect(await getPublicData('hello')).toBe('hello') +}) + +it('returns a truncated long string', async () => { + expect(await getPublicData('this is a very long string')).toBe( + 'this is a very long stri…', + ) +}) + +it('returns a short Blob text as-is', async () => { + expect(await getPublicData(new Blob(['']))).toBe('Blob()') + expect(await getPublicData(new Blob(['hello']))).toBe('Blob(hello)') +}) + +it('returns a truncated long Blob text', async () => { + expect(await getPublicData(new Blob(['this is a very long string']))).toBe( + 'Blob(this is a very long stri…)', + ) +}) + +it('returns a short ArrayBuffer text as-is', async () => { + expect(await getPublicData(new TextEncoder().encode(''))).toBe( + 'ArrayBuffer()', + ) + expect(await getPublicData(new TextEncoder().encode('hello'))).toBe( + 'ArrayBuffer(hello)', + ) +}) + +it('returns a truncated ArrayBuffer text', async () => { + expect( + await getPublicData(new TextEncoder().encode('this is a very long string')), + ).toBe('ArrayBuffer(this is a very long stri…)') +}) diff --git a/src/core/ws/utils/getPublicData.ts b/src/core/ws/utils/getPublicData.ts new file mode 100644 index 000000000..8fd41b606 --- /dev/null +++ b/src/core/ws/utils/getPublicData.ts @@ -0,0 +1,17 @@ +import { WebSocketData } from '@mswjs/interceptors/WebSocket' +import { truncateMessage } from './truncateMessage' + +export async function getPublicData(data: WebSocketData): Promise { + if (data instanceof Blob) { + const text = await data.text() + return `Blob(${truncateMessage(text)})` + } + + // Handle all ArrayBuffer-like objects. + if (typeof data === 'object' && 'byteLength' in data) { + const text = new TextDecoder().decode(data) + return `ArrayBuffer(${truncateMessage(text)})` + } + + return truncateMessage(data) +} diff --git a/src/core/ws/utils/truncateMessage.test.ts b/src/core/ws/utils/truncateMessage.test.ts new file mode 100644 index 000000000..5e247a0e3 --- /dev/null +++ b/src/core/ws/utils/truncateMessage.test.ts @@ -0,0 +1,12 @@ +import { truncateMessage } from './truncateMessage' + +it('returns a short string as-is', () => { + expect(truncateMessage('')).toBe('') + expect(truncateMessage('hello')).toBe('hello') +}) + +it('truncates a long string', () => { + expect(truncateMessage('this is a very long string')).toBe( + 'this is a very long stri…', + ) +}) diff --git a/src/core/ws/utils/truncateMessage.ts b/src/core/ws/utils/truncateMessage.ts new file mode 100644 index 000000000..eae145e91 --- /dev/null +++ b/src/core/ws/utils/truncateMessage.ts @@ -0,0 +1,9 @@ +const MAX_LENGTH = 24 + +export function truncateMessage(message: string): string { + if (message.length <= MAX_LENGTH) { + return message + } + + return `${message.slice(0, MAX_LENGTH)}…` +} diff --git a/src/core/ws/webSocketInterceptor.ts b/src/core/ws/webSocketInterceptor.ts new file mode 100644 index 000000000..8a8b21f2d --- /dev/null +++ b/src/core/ws/webSocketInterceptor.ts @@ -0,0 +1,3 @@ +import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' + +export const webSocketInterceptor = new WebSocketInterceptor() diff --git a/src/mockServiceWorker.js b/src/mockServiceWorker.js index 04132a385..18592f3d5 100644 --- a/src/mockServiceWorker.js +++ b/src/mockServiceWorker.js @@ -62,7 +62,12 @@ self.addEventListener('message', async function (event) { sendToClient(client, { type: 'MOCKING_ENABLED', - payload: true, + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, }) break } @@ -155,6 +160,10 @@ async function handleRequest(event, requestId) { async function resolveMainClient(event) { const client = await self.clients.get(event.clientId) + if (activeClientIds.has(event.clientId)) { + return client + } + if (client?.frameType === 'top-level') { return client } diff --git a/src/node/SetupServerApi.ts b/src/node/SetupServerApi.ts index 9cce10439..4c86847c8 100644 --- a/src/node/SetupServerApi.ts +++ b/src/node/SetupServerApi.ts @@ -4,14 +4,15 @@ import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { FetchInterceptor } from '@mswjs/interceptors/fetch' import { HandlersController } from '~/core/SetupApi' import type { RequestHandler } from '~/core/handlers/RequestHandler' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import type { SetupServer } from './glossary' import { SetupServerCommonApi } from './SetupServerCommonApi' const store = new AsyncLocalStorage() type RequestHandlersContext = { - initialHandlers: Array - handlers: Array + initialHandlers: Array + handlers: Array } /** @@ -22,7 +23,7 @@ type RequestHandlersContext = { class AsyncHandlersController implements HandlersController { private rootContext: RequestHandlersContext - constructor(initialHandlers: Array) { + constructor(initialHandlers: Array) { this.rootContext = { initialHandlers, handlers: [] } } @@ -30,18 +31,18 @@ class AsyncHandlersController implements HandlersController { return store.getStore() || this.rootContext } - public prepend(runtimeHandlers: Array) { + public prepend(runtimeHandlers: Array) { this.context.handlers.unshift(...runtimeHandlers) } - public reset(nextHandlers: Array) { + public reset(nextHandlers: Array) { const context = this.context context.handlers = [] context.initialHandlers = nextHandlers.length > 0 ? nextHandlers : context.initialHandlers } - public currentHandlers(): Array { + public currentHandlers(): Array { const { initialHandlers, handlers } = this.context return handlers.concat(initialHandlers) } @@ -51,7 +52,7 @@ export class SetupServerApi extends SetupServerCommonApi implements SetupServer { - constructor(handlers: Array) { + constructor(handlers: Array) { super( [ClientRequestInterceptor, XMLHttpRequestInterceptor, FetchInterceptor], handlers, diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index 97a738b56..0d2104119 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -14,9 +14,13 @@ 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' +import { isHandlerKind } from '~/core/utils/internal/isHandlerKind' export const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', @@ -34,7 +38,7 @@ export class SetupServerCommonApi constructor( interceptors: Array<{ new (): Interceptor }>, - handlers: Array, + handlers: Array, ) { super(...handlers) @@ -58,7 +62,9 @@ export class SetupServerCommonApi const response = await handleRequest( request, requestId, - this.handlersController.currentHandlers(), + this.handlersController + .currentHandlers() + .filter(isHandlerKind('RequestHandler')), this.resolvedOptions, this.emitter, ) @@ -90,6 +96,19 @@ export class SetupServerCommonApi ) }, ) + + // Preconfigure the WebSocket interception but don't enable it just yet. + // It will be enabled when the server starts. + handleWebSocketEvent({ + getUnhandledRequestStrategy: () => { + return this.resolvedOptions.onUnhandledRequest + }, + getHandlers: () => { + return this.handlersController.currentHandlers() + }, + onMockedConnection: () => {}, + onPassthroughConnection: () => {}, + }) } public listen(options: Partial = {}): void { @@ -100,10 +119,11 @@ export class SetupServerCommonApi // Apply the interceptor when starting the server. this.interceptor.apply() + this.subscriptions.push(() => this.interceptor.dispose()) - this.subscriptions.push(() => { - this.interceptor.dispose() - }) + // Apply the WebSocket interception. + webSocketInterceptor.apply() + this.subscriptions.push(() => webSocketInterceptor.dispose()) // Assert that the interceptor has been applied successfully. // Also guards us from forgetting to call "interceptor.apply()" diff --git a/src/node/glossary.ts b/src/node/glossary.ts index 895418d87..7f52c9f91 100644 --- a/src/node/glossary.ts +++ b/src/node/glossary.ts @@ -1,8 +1,6 @@ import type { PartialDeep } from 'type-fest' -import type { - RequestHandler, - RequestHandlerDefaultInfo, -} from '~/core/handlers/RequestHandler' +import type { RequestHandler } from '~/core/handlers/RequestHandler' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import type { LifeCycleEventEmitter, LifeCycleEventsMap, @@ -29,7 +27,7 @@ export interface SetupServerCommon { * * @see {@link https://mswjs.io/docs/api/setup-server/use `server.use()` API reference} */ - use(...handlers: Array): void + use(...handlers: Array): void /** * Marks all request handlers that respond using `res.once()` as unused. @@ -43,14 +41,14 @@ export interface SetupServerCommon { * * @see {@link https://mswjs.io/docs/api/setup-server/reset-handlers `server.reset-handlers()` API reference} */ - resetHandlers(...nextHandlers: Array): void + resetHandlers(...nextHandlers: Array): void /** * Returns a readonly list of currently active request handlers. * * @see {@link https://mswjs.io/docs/api/setup-server/list-handlers `server.listHandlers()` API reference} */ - listHandlers(): ReadonlyArray> + listHandlers(): ReadonlyArray /** * Life-cycle events. diff --git a/src/node/setupServer.ts b/src/node/setupServer.ts index 9fb3102e7..cb2ee7ec4 100644 --- a/src/node/setupServer.ts +++ b/src/node/setupServer.ts @@ -1,4 +1,5 @@ import type { RequestHandler } from '~/core/handlers/RequestHandler' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { SetupServerApi } from './SetupServerApi' /** @@ -8,7 +9,7 @@ import { SetupServerApi } from './SetupServerApi' * @see {@link https://mswjs.io/docs/api/setup-server `setupServer()` API reference} */ export const setupServer = ( - ...handlers: Array + ...handlers: Array ): SetupServerApi => { return new SetupServerApi(handlers) } diff --git a/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.mocks.ts b/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.mocks.ts index 8204158dc..f3f46a72d 100644 --- a/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.mocks.ts +++ b/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.mocks.ts @@ -4,8 +4,8 @@ import { setupWorker } from 'msw/browser' const worker = setupWorker() worker.start() -// @ts-ignore window.msw = { + // @ts-ignore worker, http, } diff --git a/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.test.ts b/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.test.ts index ef97469a6..bcb25543e 100644 --- a/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.test.ts +++ b/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.test.ts @@ -7,7 +7,7 @@ const staticMiddleware = (router: express.Router) => { router.use(express.static(__dirname)) } -function getFrameById(id: string, page: Page): Frame { +export function getFrameById(id: string, page: Page): Frame { const frame = page .mainFrame() .childFrames() diff --git a/test/browser/msw-api/setup-worker/scenarios/iframe/multiple-workers/child.mocks.ts b/test/browser/msw-api/setup-worker/scenarios/iframe/multiple-workers/child.mocks.ts new file mode 100644 index 000000000..ae748687e --- /dev/null +++ b/test/browser/msw-api/setup-worker/scenarios/iframe/multiple-workers/child.mocks.ts @@ -0,0 +1,10 @@ +import { http } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker( + http.get('/resource', () => { + return new Response('hello world') + }), +) + +worker.start() diff --git a/test/browser/msw-api/setup-worker/scenarios/iframe/multiple-workers/iframe-multiple-workers.test.ts b/test/browser/msw-api/setup-worker/scenarios/iframe/multiple-workers/iframe-multiple-workers.test.ts new file mode 100644 index 000000000..fc7907621 --- /dev/null +++ b/test/browser/msw-api/setup-worker/scenarios/iframe/multiple-workers/iframe-multiple-workers.test.ts @@ -0,0 +1,33 @@ +import { test, expect } from '../../../../../playwright.extend' + +test('intercepts a request issued by child frame when both child and parent have MSW', async ({ + webpackServer, + page, +}) => { + const parentCompilation = await webpackServer.compile([ + require.resolve('./parent.mocks.ts'), + ]) + const childCompilation = await webpackServer.compile([ + require.resolve('./child.mocks.ts'), + ]) + + await page.goto(parentCompilation.previewUrl, { waitUntil: 'networkidle' }) + + await page.evaluate((childFrameUrl) => { + const iframe = document.createElement('iframe') + iframe.setAttribute('id', 'child-frame') + iframe.setAttribute('src', childFrameUrl) + document.body.appendChild(iframe) + }, childCompilation.previewUrl) + + const childFrameElement = await page.locator('#child-frame').elementHandle() + const childFrame = await childFrameElement!.contentFrame() + await childFrame!.waitForLoadState('networkidle') + + const responseText = await childFrame!.evaluate(async () => { + const response = await fetch('/resource') + return response.text() + }) + + expect(responseText).toBe('hello world') +}) diff --git a/test/browser/msw-api/setup-worker/scenarios/iframe/multiple-workers/parent.mocks.ts b/test/browser/msw-api/setup-worker/scenarios/iframe/multiple-workers/parent.mocks.ts new file mode 100644 index 000000000..45e1b3f71 --- /dev/null +++ b/test/browser/msw-api/setup-worker/scenarios/iframe/multiple-workers/parent.mocks.ts @@ -0,0 +1,7 @@ +import { setupWorker } from 'msw/browser' + +// The parent frame has a worker without any handlers. +const worker = setupWorker() + +// This registration is awaited by the `loadExample` command in the test. +worker.start() diff --git a/test/browser/ws-api/ws.apply.browser.test.ts b/test/browser/ws-api/ws.apply.browser.test.ts new file mode 100644 index 000000000..478c92597 --- /dev/null +++ b/test/browser/ws-api/ws.apply.browser.test.ts @@ -0,0 +1,44 @@ +import type { ws } from 'msw' +import type { SetupWorker, setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + worker: SetupWorker + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('does not apply the interceptor until "worker.start()" is called', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(() => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + window.worker = setupWorker(api.addEventListener('connection', () => {})) + }) + + await expect( + page.evaluate(() => { + return new WebSocket('wss://example.com').constructor.name + }), + ).resolves.toBe('WebSocket') + + await page.evaluate(async () => { + await window.worker.start() + }) + + await expect( + page.evaluate(() => { + return new WebSocket('wss://example.com').constructor.name + }), + ).resolves.not.toBe('WebSocket') +}) diff --git a/test/browser/ws-api/ws.client.send.test.ts b/test/browser/ws-api/ws.client.send.test.ts new file mode 100644 index 000000000..84a30b226 --- /dev/null +++ b/test/browser/ws-api/ws.client.send.test.ts @@ -0,0 +1,132 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import type { Page } from '@playwright/test' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('sends data to a single client on connection', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + // Send a message to the client as soon as it connects. + client.send('hello world') + }), + ) + await worker.start() + }) + + const clientMessage = await page.evaluate(async () => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onmessage = (event) => resolve(event.data) + socket.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) + }) + + expect(clientMessage).toBe('hello world') +}) + +test('sends data to multiple clients on connection', async ({ + loadExample, + browser, + page, +}) => { + const { compilation } = await loadExample( + require.resolve('./ws.runtime.js'), + { + skipActivation: true, + }, + ) + + async function createSocketAndGetFirstMessage(page: Page) { + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + // Send a message to the client as soon as it connects. + client.send('hello world') + }), + ) + await worker.start() + }) + + return page.evaluate(async () => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onmessage = (event) => resolve(event.data) + socket.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) + }) + } + + const secondPage = await browser.newPage() + await secondPage.goto(compilation.previewUrl) + + const [firstClientMessage, secondClientMessage] = await Promise.all([ + createSocketAndGetFirstMessage(page), + createSocketAndGetFirstMessage(secondPage), + ]) + + expect(firstClientMessage).toBe('hello world') + expect(secondClientMessage).toBe('hello world') +}) + +test('sends data in response to a client message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (typeof event.data === 'string' && event.data === 'hello') { + client.send('hello world') + } + }) + }), + ) + await worker.start() + }) + + const clientMessage = await page.evaluate(async () => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => { + socket.send('ignore this') + socket.send('hello') + } + + return new Promise((resolve, reject) => { + socket.onmessage = (event) => resolve(event.data) + socket.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) + }) + + expect(clientMessage).toBe('hello world') +}) diff --git a/test/browser/ws-api/ws.clients.browser.test.ts b/test/browser/ws-api/ws.clients.browser.test.ts new file mode 100644 index 000000000..098fed76c --- /dev/null +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -0,0 +1,253 @@ +import type { WebSocketLink, ws } from 'msw' +import type { SetupWorker, setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + worker: SetupWorker + link: WebSocketLink + ws: WebSocket + messages: string[] + } +} + +test('returns the number of active clients in the same runtime', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.addEventListener('connection', () => {})) + window.link = api + await worker.start() + }) + + // Must return 0 when no clients are present. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(0) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return 1 after a single client joined. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(1) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return 2 now that another client has joined. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(2) +}) + +test('returns the number of active clients across different runtimes', async ({ + loadExample, + context, +}) => { + const { compilation } = await loadExample( + require.resolve('./ws.runtime.js'), + { + skipActivation: true, + }, + ) + + const pageOne = await context.newPage() + const pageTwo = await context.newPage() + + for (const page of [pageOne, pageTwo]) { + await page.goto(compilation.previewUrl) + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.addEventListener('connection', () => {})) + window.link = api + await worker.start() + }) + } + + await pageOne.bringToFront() + await pageOne.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + expect(await pageOne.evaluate(() => window.link.clients.size)).toBe(1) + expect(await pageTwo.evaluate(() => window.link.clients.size)).toBe(1) + + await pageTwo.bringToFront() + await pageTwo.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + expect(await pageTwo.evaluate(() => window.link.clients.size)).toBe(2) + expect(await pageOne.evaluate(() => window.link.clients.size)).toBe(2) +}) + +test('broadcasts messages across runtimes', async ({ + loadExample, + context, + page, +}) => { + const { compilation } = await loadExample( + require.resolve('./ws.runtime.js'), + { + skipActivation: true, + }, + ) + + const pageOne = await context.newPage() + const pageTwo = await context.newPage() + + for (const page of [pageOne, pageTwo]) { + await page.goto(compilation.previewUrl) + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + + // @ts-ignore + window.api = api + + const worker = setupWorker( + api.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + api.broadcast(event.data) + }) + }), + ) + await worker.start() + + window.worker = worker + }) + + await page.evaluate(() => { + window.messages = [] + const ws = new WebSocket('wss://example.com') + window.ws = ws + ws.onmessage = (event) => { + window.messages.push(event.data) + } + }) + } + + await page.pause() + + await pageOne.evaluate(() => { + window.ws.send('hi from one') + }) + expect(await pageOne.evaluate(() => window.messages)).toEqual(['hi from one']) + expect(await pageTwo.evaluate(() => window.messages)).toEqual(['hi from one']) + + await pageTwo.evaluate(() => { + window.ws.send('hi from two') + }) + + expect(await pageTwo.evaluate(() => window.messages)).toEqual([ + 'hi from one', + 'hi from two', + ]) + expect(await pageOne.evaluate(() => window.messages)).toEqual([ + 'hi from one', + 'hi from two', + ]) +}) + +test('clears the list of clients when the worker is stopped', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.addEventListener('connection', () => {})) + window.link = api + window.worker = worker + await worker.start() + }) + + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return the number of joined clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(1) + + await page.evaluate(() => { + window.worker.stop() + }) + + // Must purge the local storage on reload. + // The worker has been started as a part of the test, not runtime, + // so it will start with empty clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) +}) + +test('clears the list of clients when the page is reloaded', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + const enableMocking = async () => { + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.addEventListener('connection', () => {})) + window.link = api + window.worker = worker + await worker.start() + }) + } + + await enableMocking() + + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return the number of joined clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(1) + + await page.reload() + await enableMocking() + + // Must purge the local storage on reload. + // The worker has been started as a part of the test, not runtime, + // so it will start with empty clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) +}) diff --git a/test/browser/ws-api/ws.intercept.client.browser.test.ts b/test/browser/ws-api/ws.intercept.client.browser.test.ts new file mode 100644 index 000000000..8fddd4e3d --- /dev/null +++ b/test/browser/ws-api/ws.intercept.client.browser.test.ts @@ -0,0 +1,146 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('does not throw on connecting to a non-existing host', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + queueMicrotask(() => client.close()) + }), + ) + await worker.start() + }) + + const clientClosePromise = page.evaluate(() => { + const socket = new WebSocket('ws://non-existing-host.com') + + return new Promise((resolve, reject) => { + socket.onclose = () => resolve() + socket.onerror = reject + }) + }) + + await expect(clientClosePromise).resolves.toBeUndefined() +}) + +test('intercepts outgoing client text message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + const clientMessagePromise = page.evaluate(() => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (typeof event.data === 'string') { + resolve(event.data) + } + }) + }), + ) + await worker.start() + }) + }) + + await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => socket.send('hello world') + }) + + expect(await clientMessagePromise).toBe('hello world') +}) + +test('intercepts outgoing client Blob message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + const clientMessagePromise = page.evaluate(() => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data instanceof Blob) { + resolve(event.data.text()) + } + }) + }), + ) + await worker.start() + }) + }) + + await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => socket.send(new Blob(['hello world'])) + }) + + expect(await clientMessagePromise).toBe('hello world') +}) + +test('intercepts outgoing client ArrayBuffer message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + const clientMessagePromise = page.evaluate(() => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data instanceof Uint8Array) { + resolve(new TextDecoder().decode(event.data)) + } + }) + }), + ) + await worker.start() + }) + }) + + await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => socket.send(new TextEncoder().encode('hello world')) + }) + + expect(await clientMessagePromise).toBe('hello world') +}) diff --git a/test/browser/ws-api/ws.intercept.server.browser.test.ts b/test/browser/ws-api/ws.intercept.server.browser.test.ts new file mode 100644 index 000000000..34dd7c9e5 --- /dev/null +++ b/test/browser/ws-api/ws.intercept.server.browser.test.ts @@ -0,0 +1,166 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' +import { WebSocketServer } from '../../support/WebSocketServer' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +const server = new WebSocketServer() + +test.beforeAll(async () => { + await server.listen() +}) + +test.afterAll(async () => { + await server.close() +}) + +test('intercepts incoming server text message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.on('connection', (client) => { + client.send('hello') + }) + + const serverMessagePromise = page.evaluate((serverUrl) => { + const { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + if (typeof event.data === 'string') { + resolve(event.data) + } + }) + }), + ) + await worker.start() + }) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onerror = () => reject(new Error('Socket error')) + socket.addEventListener('message', (event) => { + resolve(event.data) + }) + }) + }, server.url) + + expect(clientMessage).toBe('hello') + expect(await serverMessagePromise).toBe('hello') +}) + +test('intercepts incoming server Blob message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.on('connection', async (client) => { + /** + * `ws` doesn't support sending Blobs. + * @see https://github.com/websockets/ws/issues/2206 + */ + client.send(await new Blob(['hello']).arrayBuffer()) + }) + + const serverMessagePromise = page.evaluate((serverUrl) => { + const { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + if (event.data instanceof Blob) { + resolve(event.data.text()) + } + }) + }), + ) + await worker.start() + }) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onerror = () => reject(new Error('Socket error')) + socket.addEventListener('message', (event) => { + resolve(event.data.text()) + }) + }) + }, server.url) + + expect(clientMessage).toBe('hello') + expect(await serverMessagePromise).toBe('hello') +}) + +test('intercepts outgoing server ArrayBuffer message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + const encoder = new TextEncoder() + server.on('connection', async (client) => { + client.binaryType = 'arraybuffer' + client.send(encoder.encode('hello')) + }) + + const serverMessagePromise = page.evaluate((serverUrl) => { + const { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + resolve(new TextDecoder().decode(event.data as Uint8Array)) + }) + }), + ) + await worker.start() + }) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + socket.binaryType = 'arraybuffer' + + return new Promise((resolve, reject) => { + socket.onerror = () => reject(new Error('Socket error')) + socket.addEventListener('message', (event) => { + resolve(new TextDecoder().decode(event.data)) + }) + }) + }, server.url) + + expect(clientMessage).toBe('hello') + expect(await serverMessagePromise).toBe('hello') +}) diff --git a/test/browser/ws-api/ws.logging.browser.test.ts b/test/browser/ws-api/ws.logging.browser.test.ts new file mode 100644 index 000000000..383f0330b --- /dev/null +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -0,0 +1,694 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' +import { WebSocketServer } from '../../support/WebSocketServer' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +const server = new WebSocketServer() + +test.beforeAll(async () => { + await server.listen() +}) + +test.afterEach(async () => { + server.resetState() +}) + +test.afterAll(async () => { + await server.close() +}) + +test('does not log anything if "quiet" was set to "true"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start({ quiet: true }) + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/path') + ws.onopen = () => { + ws.send('hello') + ws.send('world') + ws.close() + } + + return new Promise((resolve, reject) => { + ws.onclose = () => resolve() + ws.onerror = () => reject(new Error('Client connection closed')) + }) + }) + + expect(consoleSpy.get('startGroupCollapsed')).toBeUndefined() +}) + +test('logs the open event', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://localhost/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2} %c▶%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the client', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/path') + ws.onopen = () => ws.close() + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the original server', async ({ + loadExample, + spyOnConsole, + page, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.on('connection', (ws) => { + ws.close(1003) + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.addEventListener('connection', ({ server }) => { + server.connect() + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c ws:\/\/(.+):\d{4,}\/ color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the event handler', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker( + api.addEventListener('connection', ({ client }) => { + client.close() + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://localhost/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending text', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/path') + ws.onopen = () => ws.send('hello world') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c hello world %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending long text', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/path') + ws.onopen = () => ws.send('this is an extremely long sentence to log out') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c this is an extremely lon… %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending Blob', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/path') + ws.onopen = () => ws.send(new Blob(['hello world'])) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c Blob\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending long Blob', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/path') + ws.onopen = () => + ws.send(new Blob(['this is an extremely long sentence to log out'])) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c Blob\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending ArrayBuffer data', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/path') + ws.onopen = () => ws.send(new TextEncoder().encode('hello world')) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c ArrayBuffer\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending long ArrayBuffer', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/path') + ws.onopen = () => + ws.send( + new TextEncoder().encode( + 'this is an extremely long sentence to log out', + ), + ) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c ArrayBuffer\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs incoming server messages', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.addListener('connection', (ws) => { + ws.send('hello from server') + + ws.addEventListener('message', (event) => { + if (event.data === 'how are you, server?') { + ws.send('thanks, not bad') + } + }) + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.addEventListener('connection', ({ client, server }) => { + server.connect() + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + const ws = new WebSocket(url) + ws.addEventListener('message', (event) => { + if (event.data === 'hello from server') { + ws.send('how are you, server?') + } + }) + }, server.url) + + // Initial message sent to every connected client. + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬇%c hello from server %c17%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) + + // Message sent in response to a client message. + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬇%c thanks, not bad %c15%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs raw incoming server events', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.addListener('connection', (ws) => { + ws.send('hello from server') + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('message', (event) => { + event.preventDefault() + // This is the only data the client will receive + // but we should still print the raw server message. + client.send('intercepted server event') + }) + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + // The actual (raw) message recieved from the server. + // The arrow is dotted because the message's default has been prevented. + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + + // The mocked message sent from the event handler (client.send()). + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬇%c intercepted server event %c24%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs mocked outgoing client message (server.send)', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.addEventListener('connection', ({ server }) => { + server.connect() + server.send('hello from handler') + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs mocked incoming server message (client.send)', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.addEventListener('connection', ({ client }) => { + client.send('hello from handler') + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬇%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('marks the prevented outgoing client event as dashed', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + event.preventDefault() + }) + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + const socket = new WebSocket(url) + socket.onopen = () => socket.send('hello world') + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇡%c hello world %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('marks the prevented incoming server event as dashed', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.addListener('connection', (ws) => { + ws.send('hello from server') + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + event.preventDefault() + }) + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) diff --git a/test/browser/ws-api/ws.runtime.js b/test/browser/ws-api/ws.runtime.js new file mode 100644 index 000000000..59b41ee87 --- /dev/null +++ b/test/browser/ws-api/ws.runtime.js @@ -0,0 +1,7 @@ +import { ws } from 'msw' +import { setupWorker } from 'msw/browser' + +window.msw = { + ws, + setupWorker, +} diff --git a/test/browser/ws-api/ws.server.connect.browser.test.ts b/test/browser/ws-api/ws.server.connect.browser.test.ts new file mode 100644 index 000000000..c9f070cb5 --- /dev/null +++ b/test/browser/ws-api/ws.server.connect.browser.test.ts @@ -0,0 +1,128 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' +import { WebSocketServer } from '../../support/WebSocketServer' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +const server = new WebSocketServer() + +test.beforeAll(async () => { + await server.listen() +}) + +test.afterAll(async () => { + await server.close() +}) + +test('does not connect to the actual server by default', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.once('connection', (client) => { + client.send('must not receive this') + }) + + await page.evaluate(async (serverUrl) => { + const { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + queueMicrotask(() => client.send('mock')) + }), + ) + await worker.start() + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onmessage = (event) => resolve(event.data) + socket.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) + }, server.url) + + expect(clientMessage).toBe('mock') +}) + +test('forwards incoming server events to the client once connected', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.once('connection', (client) => { + client.send('hello from server') + }) + + await page.evaluate(async (serverUrl) => { + const { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + const worker = setupWorker( + service.addEventListener('connection', ({ server }) => { + // Calling "connect()" establishes the connection + // to the actual WebSocket server. + server.connect() + }), + ) + await worker.start() + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onmessage = (event) => { + resolve(event.data) + socket.close() + } + socket.onerror = reject + }) + }, server.url) + + expect(clientMessage).toBe('hello from server') +}) + +test('throws an error when connecting to a non-existing server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + const error = await page.evaluate((serverUrl) => { + const { setupWorker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise(async (resolve) => { + const worker = setupWorker( + service.addEventListener('connection', ({ server }) => { + server.connect() + }), + ) + await worker.start() + + const socket = new WebSocket(serverUrl) + socket.onerror = () => resolve('Connection failed') + }) + }, 'ws://non-existing-websocket-address.com') + + expect(error).toMatch('Connection failed') +}) diff --git a/test/browser/ws-api/ws.use.browser.test.ts b/test/browser/ws-api/ws.use.browser.test.ts new file mode 100644 index 000000000..d323192b7 --- /dev/null +++ b/test/browser/ws-api/ws.use.browser.test.ts @@ -0,0 +1,257 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('resolves outgoing events using initial handlers', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello from mock') + } + }) + }), + ) + await worker.start() + }) + + const clientMessage = await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => resolve(event.data) + socket.onerror = reject + }) + }) + + expect(clientMessage).toBe('hello from mock') +}) + +test('overrides an outgoing event listener', async ({ loadExample, page }) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('must not be sent') + } + }) + }), + ) + await worker.start() + + worker.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + }) + + const clientMessage = await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => resolve(event.data) + socket.onerror = reject + }) + }) + + expect(clientMessage).toBe('howdy, client!') +}) + +test('combines initial and override listeners', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // This will be sent the last since the initial + // event listener is attached the first. + client.send('hello from mock') + client.close() + } + }) + }), + ) + await worker.start() + + worker.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // This will be sent first since the override listener + // is attached the last. + client.send('override data') + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => messages.push(event.data) + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['override data', 'hello from mock']) +}) + +test('combines initial and override listeners in the opposite order', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello from mock') + } + }) + }), + ) + await worker.start() + + worker.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Queue this send to the next tick so it + // happens after the initial listener's send. + queueMicrotask(() => { + client.send('override data') + client.close() + }) + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => messages.push(event.data) + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['hello from mock', 'override data']) +}) + +test('does not affect unrelated events', async ({ loadExample, page }) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('must not be sent') + } + + if (event.data === 'fallthrough') { + client.send('ok') + client.close() + } + }) + }), + ) + await worker.start() + + worker.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => { + messages.push(event.data) + if (event.data === 'howdy, client!') { + socket.send('fallthrough') + } + } + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['howdy, client!', 'ok']) +}) diff --git a/test/modules/node/esm-node.test.ts b/test/modules/node/esm-node.test.ts index 303a19e7f..5ecf123bc 100644 --- a/test/modules/node/esm-node.test.ts +++ b/test/modules/node/esm-node.test.ts @@ -77,8 +77,8 @@ console.log('msw/node:', require.resolve('msw/node')) console.log('msw/native:', require.resolve('msw/native')) `, 'runtime.cjs': ` -import { http } from 'msw' -import { setupServer } from 'msw/node' +const { http } = require('msw') +const { setupServer } = require('msw/node') const server = setupServer( http.get('/resource', () => new Response()) ) diff --git a/test/node/vitest.config.mts b/test/node/vitest.config.mts index fe4b6c3bf..0557df44e 100644 --- a/test/node/vitest.config.mts +++ b/test/node/vitest.config.mts @@ -1,33 +1,30 @@ -import * as path from 'node:path' import { defineConfig } from 'vitest/config' - -const LIB_DIR = path.resolve(__dirname, '../../lib') +import { mswExports, customViteEnvironments } from '../support/alias' export default defineConfig({ test: { - /** - * @note Paths are resolved against CWD. - */ dir: './test/node', globals: true, alias: { - 'msw/node': path.resolve(LIB_DIR, 'node/index.mjs'), - 'msw/native': path.resolve(LIB_DIR, 'native/index.mjs'), - 'msw/browser': path.resolve(LIB_DIR, 'browser/index.mjs'), - msw: path.resolve(LIB_DIR, 'core/index.mjs'), + ...mswExports, + ...customViteEnvironments, }, environmentOptions: { jsdom: { url: 'http://localhost/', }, }, - /** - * @note Run Node.js integration tests in sequence. - * There's a test that involves building the library, - * which results in the "lib" directory being deleted. - * If any tests attempt to run during that window, - * they will fail, unable to resolve the "msw" import alias. - */ - poolOptions: { threads: { singleThread: true } }, + poolOptions: { + threads: { + /** + * @note Run Node.js integration tests in sequence. + * There's a test that involves building the library, + * which results in the "lib" directory being deleted. + * If any tests attempt to run during that window, + * they will fail, unable to resolve the "msw" import alias. + */ + singleThread: true, + }, + }, }, }) diff --git a/test/node/ws-api/on-unhandled-request/callback.test.ts b/test/node/ws-api/on-unhandled-request/callback.test.ts new file mode 100644 index 000000000..e49c9ed9f --- /dev/null +++ b/test/node/ws-api/on-unhandled-request/callback.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const service = ws.link('wss://localhost:4321') +const server = setupServer() + +const onUnhandledRequest = vi.fn() + +beforeAll(() => { + server.listen({ onUnhandledRequest }) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + vi.resetAllMocks() +}) + +afterAll(() => { + server.close() + vi.restoreAllMocks() +}) + +it('calls a custom callback on an unhandled WebSocket connection', async () => { + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(onUnhandledRequest).toHaveBeenCalledOnce() + + const [request] = onUnhandledRequest.mock.calls[0] + expect(request).toBeInstanceOf(Request) + expect(request.method).toBe('GET') + expect(request.url).toBe('wss://localhost:4321/') + expect(Array.from(request.headers)).toEqual([ + ['connection', 'upgrade'], + ['upgrade', 'websocket'], + ]) +}) + +it('does not call a custom callback for a handled WebSocket connection', async () => { + server.use(service.addEventListener('connection', () => {})) + + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(onUnhandledRequest).not.toHaveBeenCalled() +}) diff --git a/test/node/ws-api/on-unhandled-request/error.test.ts b/test/node/ws-api/on-unhandled-request/error.test.ts new file mode 100644 index 000000000..5b34d2b1b --- /dev/null +++ b/test/node/ws-api/on-unhandled-request/error.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { InternalError } from '../../../../src/core/utils/internal/devUtils' + +const service = ws.link('wss://localhost:4321') +const server = setupServer() + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'error' }) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + vi.resetAllMocks() +}) + +afterAll(() => { + server.close() + vi.restoreAllMocks() +}) + +it( + 'errors on unhandled WebSocket connection', + server.boundary(async () => { + const socket = new WebSocket('wss://localhost:4321') + const errorListener = vi.fn() + + await vi.waitUntil(() => { + return new Promise((resolve, reject) => { + // These are intentionally swapped. The connection MUST error. + socket.addEventListener('error', errorListener) + socket.addEventListener('error', resolve) + socket.onopen = () => { + reject(new Error('WebSocket connection opened unexpectedly')) + } + }) + }) + + expect(console.error).toHaveBeenCalledWith( + `\ +[MSW] Error: intercepted a request without a matching request handler: + + • GET wss://localhost:4321/ + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/getting-started/mocks`, + ) + + expect(errorListener).toHaveBeenCalledOnce() + + // Must forward the original `onUnhandledRequest` error as the + // `cause` property of the error event emitted on the connection. + const [event] = errorListener.mock.calls[0] + expect(event).toBeInstanceOf(Event) + expect(event.type).toBe('error') + expect(event.cause).toEqual( + new InternalError( + '[MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.', + ), + ) + }), +) + +it( + 'does not error on handled WebSocket connection', + server.boundary(async () => { + server.use(service.addEventListener('connection', () => {})) + + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(console.error).not.toHaveBeenCalled() + }), +) diff --git a/test/node/ws-api/on-unhandled-request/warn.test.ts b/test/node/ws-api/on-unhandled-request/warn.test.ts new file mode 100644 index 000000000..5473692a3 --- /dev/null +++ b/test/node/ws-api/on-unhandled-request/warn.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const service = ws.link('wss://localhost:4321') +const server = setupServer() + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'warn' }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + vi.resetAllMocks() +}) + +afterAll(() => { + server.close() + vi.restoreAllMocks() +}) + +it( + 'warns on unhandled WebSocket connection', + server.boundary(async () => { + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(console.warn).toHaveBeenCalledWith( + `\ +[MSW] Warning: intercepted a request without a matching request handler: + + • GET wss://localhost:4321/ + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/getting-started/mocks`, + ) + }), +) + +it( + 'does not warn on handled WebSocket connection', + server.boundary(async () => { + server.use(service.addEventListener('connection', () => {})) + + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(console.warn).not.toHaveBeenCalled() + }), +) diff --git a/test/node/ws-api/ws.apply.test.ts b/test/node/ws-api/ws.apply.test.ts new file mode 100644 index 000000000..22b750334 --- /dev/null +++ b/test/node/ws-api/ws.apply.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer() + +afterEach(() => { + server.close() +}) + +it('patches WebSocket class even if no event handlers were defined', () => { + server.listen() + + const raw = new WebSocket('wss://example.com') + expect(raw.constructor.name).toBe('WebSocketOverride') + expect(raw).toBeInstanceOf(EventTarget) +}) + +it('does not patch WebSocket class until server.listen() is called', () => { + const api = ws.link('wss://example.com') + server.use(api.addEventListener('connection', () => {})) + + const raw = new WebSocket('wss://example.com') + expect(raw.constructor.name).toBe('WebSocket') + expect(raw).toBeInstanceOf(EventTarget) + + server.listen() + + const mocked = new WebSocket('wss://example.com') + expect(mocked.constructor.name).not.toBe('WebSocket') + expect(mocked).toBeInstanceOf(EventTarget) +}) diff --git a/test/node/ws-api/ws.event-patching.test.ts b/test/node/ws-api/ws.event-patching.test.ts new file mode 100644 index 000000000..1027caa41 --- /dev/null +++ b/test/node/ws-api/ws.event-patching.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const service = ws.link('ws://*') +const originalServer = new WebSocketServer() + +const server = setupServer( + service.addEventListener('connection', ({ server }) => { + server.connect() + }), +) + +beforeAll(async () => { + server.listen() + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('patches incoming server message', async () => { + originalServer.once('connection', (client) => { + client.send('hi from John') + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + /** + * @note Since the initial handler connects to the server, + * there's no need to call `server.connect()` again. + */ + server.addEventListener('message', (event) => { + // Preventing the default stops the server-to-client forwarding. + // It means that the WebSocket client won't receive the + // actual server message. + event.preventDefault() + client.send(event.data.replace('John', 'Sarah')) + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hi from Sarah') + expect(messageListener).toHaveBeenCalledTimes(1) + }) +}) + +it('combines original and mock server messages', async () => { + originalServer.once('connection', (client) => { + client.send('original message') + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.addEventListener('message', () => { + client.send('mocked message') + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onopen = () => ws.send('hello') + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + /** + * @note That the server will send the message as soon as the client + * connects. This happens before the event handler is called. + */ + expect(messageListener).toHaveBeenNthCalledWith(1, 'original message') + expect(messageListener).toHaveBeenNthCalledWith(2, 'mocked message') + expect(messageListener).toHaveBeenCalledTimes(2) + }) +}) + +it('combines original and mock server messages in the different order', async () => { + originalServer.once('connection', (client) => { + client.send('original message') + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.addEventListener('message', (event) => { + /** + * @note To change the incoming server events order, + * prevent the default, send a mocked message, and + * then send the original message as-is. + */ + event.preventDefault() + client.send('mocked message') + client.send(event.data) + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'mocked message') + expect(messageListener).toHaveBeenNthCalledWith(2, 'original message') + expect(messageListener).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/node/ws-api/ws.intercept.client.test.ts b/test/node/ws-api/ws.intercept.client.test.ts new file mode 100644 index 000000000..f511da037 --- /dev/null +++ b/test/node/ws-api/ws.intercept.client.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const server = setupServer() +const wsServer = new WebSocketServer() + +const service = ws.link('ws://*') + +beforeAll(async () => { + server.listen() + await wsServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + wsServer.resetState() +}) + +afterAll(async () => { + server.close() + await wsServer.close() +}) + +it('intercepts outgoing client text message', async () => { + const mockMessageListener = vi.fn() + const realConnectionListener = vi.fn() + + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const socket = new WebSocket(wsServer.url) + socket.onopen = () => socket.send('hello') + + await vi.waitFor(() => { + // Must intercept the outgoing client message event. + expect(mockMessageListener).toHaveBeenCalledTimes(1) + + const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent + expect(messageEvent.type).toBe('message') + expect(messageEvent.data).toBe('hello') + expect(messageEvent.target).toBe(socket) + + // Must not connect to the actual server by default. + expect(realConnectionListener).not.toHaveBeenCalled() + }) +}) + +it('intercepts outgoing client Blob message', async () => { + const mockMessageListener = vi.fn() + const realConnectionListener = vi.fn() + + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const socket = new WebSocket(wsServer.url) + socket.onopen = () => socket.send(new Blob(['hello'])) + + await vi.waitFor(() => { + expect(mockMessageListener).toHaveBeenCalledTimes(1) + + const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent + expect(messageEvent.type).toBe('message') + expect(messageEvent.data.size).toBe(5) + expect(messageEvent.target).toEqual(socket) + + // Must not connect to the actual server by default. + expect(realConnectionListener).not.toHaveBeenCalled() + }) +}) + +it('intercepts outgoing client ArrayBuffer message', async () => { + const mockMessageListener = vi.fn() + const realConnectionListener = vi.fn() + + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const socket = new WebSocket(wsServer.url) + socket.binaryType = 'arraybuffer' + socket.onopen = () => socket.send(new TextEncoder().encode('hello')) + + await vi.waitFor(() => { + expect(mockMessageListener).toHaveBeenCalledTimes(1) + + const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent + expect(messageEvent.type).toBe('message') + expect(messageEvent.data).toEqual(new TextEncoder().encode('hello')) + expect(messageEvent.target).toEqual(socket) + + // Must not connect to the actual server by default. + expect(realConnectionListener).not.toHaveBeenCalled() + }) +}) diff --git a/test/node/ws-api/ws.intercept.server.test.ts b/test/node/ws-api/ws.intercept.server.test.ts new file mode 100644 index 000000000..a67c3884b --- /dev/null +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const server = setupServer() +const originalServer = new WebSocketServer() + +const service = ws.link('ws://*') + +beforeAll(async () => { + server.listen() + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('intercepts incoming server text message', async () => { + const serverMessageListener = vi.fn() + const clientMessageListener = vi.fn() + + originalServer.on('connection', (client) => { + client.send('hello') + }) + server.use( + service.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const socket = new WebSocket(originalServer.url) + socket.addEventListener('message', clientMessageListener) + + await vi.waitFor(() => { + expect(serverMessageListener).toHaveBeenCalledTimes(1) + + const serverMessage = serverMessageListener.mock.calls[0][0] as MessageEvent + expect(serverMessage.type).toBe('message') + expect(serverMessage.data).toBe('hello') + + expect(clientMessageListener).toHaveBeenCalledTimes(1) + + const clientMessage = clientMessageListener.mock.calls[0][0] as MessageEvent + expect(clientMessage.type).toBe('message') + expect(clientMessage.data).toBe('hello') + }) +}) + +it('intercepts incoming server Blob message', async () => { + const serverMessageListener = vi.fn() + const clientMessageListener = vi.fn() + + originalServer.on('connection', async (client) => { + /** + * @note You should use plain `Blob` instead. + * For some reason, the "ws" package has trouble accepting + * it as an input (expects a Buffer). + */ + client.send(await new Blob(['hello']).arrayBuffer()) + }) + server.use( + service.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const socket = new WebSocket(originalServer.url) + socket.addEventListener('message', clientMessageListener) + + await vi.waitFor(() => { + expect(serverMessageListener).toHaveBeenCalledTimes(1) + + const serverMessage = serverMessageListener.mock.calls[0][0] as MessageEvent + expect(serverMessage.type).toBe('message') + expect(serverMessage.data).toEqual(new Blob(['hello'])) + + expect(clientMessageListener).toHaveBeenCalledTimes(1) + + const clientMessage = clientMessageListener.mock.calls[0][0] as MessageEvent + expect(clientMessage.type).toBe('message') + expect(clientMessage.data).toEqual(new Blob(['hello'])) + }) +}) + +it('intercepts incoming ArrayBuffer message', async () => { + const encoder = new TextEncoder() + const serverMessageListener = vi.fn() + const clientMessageListener = vi.fn() + + originalServer.on('connection', async (client) => { + client.binaryType = 'arraybuffer' + client.send(encoder.encode('hello world')) + }) + server.use( + service.addEventListener('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const socket = new WebSocket(originalServer.url) + socket.binaryType = 'arraybuffer' + socket.addEventListener('message', clientMessageListener) + + await vi.waitFor(() => { + expect(serverMessageListener).toHaveBeenCalledTimes(1) + + const serverMessage = serverMessageListener.mock.calls[0][0] as MessageEvent + expect(serverMessage.type).toBe('message') + expect(new TextDecoder().decode(serverMessage.data)).toBe('hello world') + + expect(clientMessageListener).toHaveBeenCalledTimes(1) + + const clientMessage = clientMessageListener.mock.calls[0][0] as MessageEvent + expect(clientMessage.type).toBe('message') + expect(new TextDecoder().decode(clientMessage.data)).toBe('hello world') + }) +}) diff --git a/test/node/ws-api/ws.server.connect.test.ts b/test/node/ws-api/ws.server.connect.test.ts new file mode 100644 index 000000000..468b43ae6 --- /dev/null +++ b/test/node/ws-api/ws.server.connect.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const service = ws.link('ws://*') +const originalServer = new WebSocketServer() + +const server = setupServer() + +beforeAll(async () => { + server.listen() + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('does not connect to the actual server by default', async () => { + const serverConnectionListener = vi.fn() + const mockConnectionListener = vi.fn() + + originalServer.once('connection', serverConnectionListener) + server.use(service.addEventListener('connection', mockConnectionListener)) + + new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(mockConnectionListener).toHaveBeenCalledTimes(1) + expect(serverConnectionListener).not.toHaveBeenCalled() + }) +}) + +it('connects to the actual server after calling "server.connect()"', async () => { + const serverConnectionListener = vi.fn() + const mockConnectionListener = vi.fn() + + originalServer.once('connection', serverConnectionListener) + + server.use( + service.addEventListener('connection', ({ server }) => { + mockConnectionListener() + server.connect() + }), + ) + + new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(mockConnectionListener).toHaveBeenCalledTimes(1) + expect(serverConnectionListener).toHaveBeenCalledTimes(1) + }) +}) + +it('forwards incoming server events to the client once connected', async () => { + originalServer.once('connection', (client) => client.send('hello')) + + server.use( + service.addEventListener('connection', ({ server }) => { + server.connect() + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hello') + expect(messageListener).toHaveBeenCalledTimes(1) + }) +}) + +it('throws an error when connecting to a non-existing server', async () => { + server.use( + service.addEventListener('connection', ({ server }) => { + server.connect() + }), + ) + + const errorListener = vi.fn() + const ws = new WebSocket('wss://localhost:9876') + ws.onerror = errorListener + + await vi.waitFor(() => { + expect(errorListener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/node/ws-api/ws.stop-propagation.test.ts b/test/node/ws-api/ws.stop-propagation.test.ts new file mode 100644 index 000000000..b7cbb3cf6 --- /dev/null +++ b/test/node/ws-api/ws.stop-propagation.test.ts @@ -0,0 +1,493 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const server = setupServer() +const service = ws.link('ws://*') + +const originalServer = new WebSocketServer() + +beforeAll(async () => { + server.listen({ + // We are intentionally connecting to non-existing WebSocket URLs. + // Skip the unhandled request warnings, they are intentional. + onUnhandledRequest: 'bypass', + }) + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('stops propagation for client "message" event', async () => { + const clientMessageListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `client` beloning to a different event handler. + event.stopPropagation() + clientMessageListener(1) + }) + + client.addEventListener('message', () => { + clientMessageListener(2) + }) + }), + + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', () => { + clientMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', () => { + clientMessageListener(4) + }) + + process.nextTick(() => { + client.close() + }) + }), + ) + + const ws = new WebSocket('ws://localhost') + ws.onopen = () => ws.send('hello world') + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(clientMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(clientMessageListener).toHaveBeenNthCalledWith(2, 2) + expect(clientMessageListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for client "message" event', async () => { + const clientMessageListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `client` beloning to a different event handler. + event.stopImmediatePropagation() + clientMessageListener(1) + }) + + client.addEventListener('message', () => { + clientMessageListener(2) + }) + + client.addEventListener('message', () => { + clientMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', () => { + clientMessageListener(4) + }) + + process.nextTick(() => { + client.close() + }) + }), + ) + + const ws = new WebSocket('ws://localhost') + ws.onopen = () => ws.send('hello world') + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(clientMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(clientMessageListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "open" event', async () => { + const serverOpenListener = vi.fn<[number]>() + + originalServer.addListener('connection', () => {}) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('open', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `server` beloning to a different event handler. + event.stopPropagation() + serverOpenListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('open', () => { + serverOpenListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverOpenListener).toHaveBeenNthCalledWith(1, 1) + expect(serverOpenListener).toHaveBeenNthCalledWith(2, 2) + expect(serverOpenListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "open" event', async () => { + const serverOpenListener = vi.fn<[number]>() + + originalServer.addListener('connection', () => {}) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('open', (event) => { + event.stopImmediatePropagation() + serverOpenListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('open', () => { + serverOpenListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverOpenListener).toHaveBeenNthCalledWith(1, 1) + expect(serverOpenListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "message" event', async () => { + const serverMessageListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + // Send data from the original server to trigger the "message" event. + ws.send('hello') + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('message', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `server` beloning to a different event handler. + event.stopPropagation() + serverMessageListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('message', () => { + serverMessageListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(serverMessageListener).toHaveBeenNthCalledWith(2, 2) + expect(serverMessageListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "message" event', async () => { + const serverMessageListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + // Send data from the original server to trigger the "message" event. + ws.send('hello') + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('message', (event) => { + event.stopImmediatePropagation() + serverMessageListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('message', () => { + serverMessageListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(serverMessageListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "error" event', async () => { + const serverErrorListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('error', (event) => { + event.stopPropagation() + serverErrorListener(1) + }) + + server.addEventListener('error', () => { + serverErrorListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(4) + }) + }), + ) + + const ws = new WebSocket('ws://localhost/non-existing-path') + + await vi.waitFor(() => { + /** + * @note Ideally, await the "CLOSED" ready state, + * but Node.js doesn't dispatch it correctly. + * @see https://github.com/nodejs/undici/issues/3697 + */ + return new Promise((resolve) => { + ws.onerror = () => resolve() + }) + }) + + expect(serverErrorListener).toHaveBeenNthCalledWith(1, 1) + expect(serverErrorListener).toHaveBeenNthCalledWith(2, 2) + expect(serverErrorListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "error" event', async () => { + const serverErrorListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('error', (event) => { + event.stopImmediatePropagation() + serverErrorListener(1) + }) + + server.addEventListener('error', () => { + serverErrorListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(4) + }) + }), + ) + + const ws = new WebSocket('ws://localhost/non-existing-path') + + await vi.waitFor(() => { + /** + * @note Ideally, await the "CLOSED" ready state, + * but Node.js doesn't dispatch it correctly. + * @see https://github.com/nodejs/undici/issues/3697 + */ + return new Promise((resolve) => { + ws.onerror = () => resolve() + }) + }) + + expect(serverErrorListener).toHaveBeenNthCalledWith(1, 1) + expect(serverErrorListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "close" event', async () => { + const serverCloseListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + ws.close() + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('close', (event) => { + event.stopPropagation() + serverCloseListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('close', () => { + serverCloseListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverCloseListener).toHaveBeenNthCalledWith(1, 1) + expect(serverCloseListener).toHaveBeenNthCalledWith(2, 2) + expect(serverCloseListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "close" event', async () => { + const serverCloseListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + ws.close() + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('close', (event) => { + event.stopImmediatePropagation() + serverCloseListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('close', () => { + serverCloseListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverCloseListener).toHaveBeenNthCalledWith(1, 1) + expect(serverCloseListener).toHaveBeenCalledOnce() +}) diff --git a/test/node/ws-api/ws.use.test.ts b/test/node/ws-api/ws.use.test.ts new file mode 100644 index 000000000..83405a414 --- /dev/null +++ b/test/node/ws-api/ws.use.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const service = ws.link('wss://*') + +const server = setupServer( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello, client!') + } + + if (event.data === 'fallthrough') { + client.send('ok') + } + }) + }), +) + +beforeAll(() => { + server.listen() +}) + +afterAll(() => { + server.close() +}) + +it.concurrent( + 'resolves outgoing events using initial handlers', + server.boundary(async () => { + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenCalledWith('hello, client!') + expect(messageListener).toHaveBeenCalledTimes(1) + }) + }), +) + +it.concurrent( + 'overrides an outgoing event listener', + server.boundary(async () => { + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Stopping immediate event propagation will prevent + // the same message listener in the initial handler + // from being called. + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenCalledWith('howdy, client!') + expect(messageListener).toHaveBeenCalledTimes(1) + }) + }), +) + +it.concurrent( + 'combines initial and override listeners', + server.boundary(async () => { + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Not stopping the event propagation will result in both + // the override handler and the runtime handler sending + // data to the client in order. The override handler is + // prepended, so it will send data first. + client.send('override data') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + // The runtime handler is executed first, so it sends its message first. + expect(messageListener).toHaveBeenNthCalledWith(1, 'override data') + // The initial handler will send its message next. + expect(messageListener).toHaveBeenNthCalledWith(2, 'hello, client!') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }), +) + +it.concurrent( + 'combines initial and override listeners in the opposite order', + async () => { + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Queuing the send to the next tick will ensure + // that the initial handler sends data first, + // and this override handler sends data next. + queueMicrotask(() => { + client.send('override data') + }) + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hello, client!') + expect(messageListener).toHaveBeenNthCalledWith(2, 'override data') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }, +) + +it.concurrent( + 'does not affect unrelated events', + server.boundary(async () => { + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Stopping immediate event propagation will prevent + // the same message listener in the initial handler + // from being called. + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => { + messageListener(event.data) + + if (event.data === 'howdy, client!') { + ws.send('fallthrough') + } + } + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'howdy, client!') + }) + + await vi.waitFor(() => { + // The initial handler still sends data to unrelated events. + expect(messageListener).toHaveBeenNthCalledWith(2, 'ok') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }), +) diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts new file mode 100644 index 000000000..8995fb8d7 --- /dev/null +++ b/test/support/WebSocketServer.ts @@ -0,0 +1,65 @@ +import { invariant } from 'outvariant' +import { Emitter } from 'strict-event-emitter' +import fastify, { FastifyInstance } from 'fastify' +import fastifyWebSocket, { SocketStream } from '@fastify/websocket' + +type FastifySocket = SocketStream['socket'] + +type WebSocketEventMap = { + connection: [client: FastifySocket] +} + +export class WebSocketServer extends Emitter { + private _url?: string + private app: FastifyInstance + private clients: Set + + constructor() { + super() + this.clients = new Set() + + this.app = fastify() + this.app.register(fastifyWebSocket) + this.app.register(async (fastify) => { + fastify.get('/', { websocket: true }, ({ socket }) => { + this.clients.add(socket) + socket.once('close', () => this.clients.delete(socket)) + + this.emit('connection', socket) + }) + }) + } + + get url(): string { + invariant( + this._url, + 'Failed to get "url" on WebSocketServer: server is not running. Did you forget to "await server.listen()"?', + ) + return this._url + } + + public async listen(port = 0): Promise { + const address = await this.app.listen({ + host: '127.0.0.1', + port, + }) + const url = new URL(address) + url.protocol = url.protocol.replace(/^http/, 'ws') + this._url = url.href + } + + public resetState(): void { + this.closeAllClients() + this.removeAllListeners() + } + + public closeAllClients(): void { + this.clients.forEach((client) => { + client.close() + }) + } + + public async close(): Promise { + return this.app.close() + } +} diff --git a/test/support/alias.ts b/test/support/alias.ts new file mode 100644 index 000000000..7b8b49433 --- /dev/null +++ b/test/support/alias.ts @@ -0,0 +1,20 @@ +import * as path from 'node:path' + +const ROOT = path.resolve(__dirname, '../..') + +export function fromRoot(...paths: Array): string { + return path.resolve(ROOT, ...paths) +} + +export const mswExports = { + 'msw/node': fromRoot('/lib/node/index.mjs'), + 'msw/native': fromRoot('/lib/native/index.mjs'), + 'msw/browser': fromRoot('/lib/browser/index.mjs'), + msw: fromRoot('lib/core/index.mjs'), +} + +export const customViteEnvironments = { + 'vitest-environment-node-websocket': fromRoot( + '/test/support/environments/vitest-environment-node-websocket', + ), +} diff --git a/test/support/environments/vitest-environment-node-websocket.ts b/test/support/environments/vitest-environment-node-websocket.ts new file mode 100644 index 000000000..16d616f7d --- /dev/null +++ b/test/support/environments/vitest-environment-node-websocket.ts @@ -0,0 +1,25 @@ +/** + * Node.js environment superset that has a global WebSocket API. + */ +import type { Environment } from 'vitest' +import { builtinEnvironments } from 'vitest/environments' +import { WebSocket } from 'undici' + +export default { + name: 'node-with-websocket', + transformMode: 'ssr', + async setup(global, options) { + /** + * @note It's crucial this extend the Node.js environment. + * JSDOM polyfills the global "Event", making it unusable + * with Node's "EventTarget". + */ + const { teardown } = await builtinEnvironments.node.setup(global, options) + + Reflect.set(globalThis, 'WebSocket', WebSocket) + + return { + teardown, + } + }, +} diff --git a/test/typings/ws.test-d.ts b/test/typings/ws.test-d.ts new file mode 100644 index 000000000..612369390 --- /dev/null +++ b/test/typings/ws.test-d.ts @@ -0,0 +1,156 @@ +import { it, expectTypeOf } from 'vitest' +import { + WebSocketData, + WebSocketLink, + WebSocketHandlerConnection, + ws, +} from 'msw' +import { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/WebSocket' + +it('supports URL as the link argument', () => { + expectTypeOf(ws.link('ws://localhost')).toEqualTypeOf() +}) + +it('supports RegExp as the link argument', () => { + expectTypeOf(ws.link(/\/ws$/)).toEqualTypeOf() +}) + +it('exposes root-level link APIs', () => { + const link = ws.link('ws://localhost') + + expectTypeOf(link.addEventListener).toBeFunction() + expectTypeOf(link.broadcast).toBeFunction() + expectTypeOf(link.broadcastExcept).toBeFunction() + expectTypeOf(link.clients).toEqualTypeOf< + Set + >() +}) + +it('supports "connection" event listener', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', (connection) => { + expectTypeOf(connection).toEqualTypeOf() + }) +}) + +it('errors on arbitrary event names passed to the link', () => { + const link = ws.link('ws://localhost') + + link.addEventListener( + // @ts-expect-error Unknown event name "abc". + 'abc', + () => {}, + ) +}) + +/** + * Client API. + */ + +it('exposes root-level "client" APIs', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + expectTypeOf(client.id).toBeString() + expectTypeOf(client.socket).toEqualTypeOf() + expectTypeOf(client.url).toEqualTypeOf() + + expectTypeOf(client.addEventListener).toBeFunction() + expectTypeOf(client.send).toBeFunction() + expectTypeOf(client.removeEventListener).toBeFunction() + expectTypeOf(client.close).toBeFunction() + }) +}) + +it('supports "message" event listener on the client', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + expectTypeOf(event).toEqualTypeOf>() + }) + }) +}) + +it('supports "close" event listener on the client', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + client.addEventListener('close', (event) => { + expectTypeOf(event).toMatchTypeOf() + }) + }) +}) + +it('errors on arbitrary event names passed to the client', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + client.addEventListener( + // @ts-expect-error Unknown event name "abc". + 'abc', + () => {}, + ) + }) +}) + +/** + * Server API. + */ + +it('exposes root-level "server" APIs', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + expectTypeOf(server.socket).toEqualTypeOf() + + expectTypeOf(server.connect).toEqualTypeOf<() => void>() + expectTypeOf(server.addEventListener).toBeFunction() + expectTypeOf(server.send).toBeFunction() + expectTypeOf(server.removeEventListener).toBeFunction() + expectTypeOf(server.close).toBeFunction() + }) +}) + +it('supports "message" event listener on the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener('message', (event) => { + expectTypeOf(event).toEqualTypeOf>() + }) + }) +}) + +it('supports "open" event listener on the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener('open', (event) => { + expectTypeOf(event).toMatchTypeOf() + }) + }) +}) + +it('supports "close" event listener on the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener('close', (event) => { + expectTypeOf(event).toMatchTypeOf() + }) + }) +}) + +it('errors on arbitrary event names passed to the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener( + // @ts-expect-error Unknown event name "abc". + 'abc', + () => {}, + ) + }) +}) diff --git a/vitest.config.mts b/vitest.config.mts index f007e4c53..43406fdc1 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,5 +1,9 @@ -import * as path from 'node:path' import { defineConfig } from 'vitest/config' +import { + mswExports, + customViteEnvironments, + fromRoot, +} from './test/support/alias' export default defineConfig({ test: { @@ -8,7 +12,9 @@ export default defineConfig({ // they are located next to the source code they are testing. dir: './src', alias: { - '~/core': path.resolve(__dirname, 'src/core'), + ...mswExports, + ...customViteEnvironments, + '~/core': fromRoot('src/core'), }, typecheck: { // Load the TypeScript configuration to the unit tests.