diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000000..0c642080ad --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,64 @@ +name: E2E + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + e2e: + name: Signal e2e (browser / ubuntu) + runs-on: ubuntu-latest + + services: + testserver: + image: livekit/test-server:latest + ports: + - 9999:9999 + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + + - name: Use Node.js 24 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Cache Playwright browsers + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run signal e2e tests + run: pnpm test:e2e 2>&1 | tee e2e.log + env: + # Connect to the test-server service above (validate + WS on 9999). + LK_TEST_SERVER_URL: ws://127.0.0.1:9999 + + - name: Collect test-server logs + if: failure() + run: docker logs ${{ job.services.testserver.id }} > test-server.log 2>&1 || true + + - name: Upload logs + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: e2e-logs + path: | + e2e.log + test-server.log + retention-days: 7 diff --git a/package.json b/package.json index dda202ea07..b9326a37e1 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "dev": "pnpm examples:demo", "lint": "eslint src", "test": "vitest run src", + "test:e2e": "vitest run --config vitest.e2e.config.mts", "deploy": "gh-pages -d examples/demo/dist", "format": "prettier --write src examples/**/*.ts", "format:check": "prettier --check src examples/**/*.ts", @@ -105,6 +106,8 @@ "@types/ua-parser-js": "0.7.39", "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", + "@vitest/browser": "^4.1.10", + "@vitest/browser-playwright": "^4.1.10", "downlevel-dts": "^0.11.0", "eslint": "10.4.0", "eslint-config-airbnb-extended": "^2.3.2", @@ -116,6 +119,7 @@ "glob": "^13.0.6", "happy-dom": "^20.0.0", "jsdom": "^26.1.0", + "playwright": "^1.61.1", "prettier": "^3.4.2", "publint": "^0.3.21", "rollup": "4.60.4", @@ -125,10 +129,10 @@ "tsx": "^4.21.0", "typedoc": "0.28.19", "typedoc-plugin-no-inherit": "1.6.1", - "typescript": "5.9.3", + "typescript": "catalog:", "typescript-eslint": "^8.47.0", "vite": "7.3.5", - "vitest": "^4.0.0" + "vitest": "^4.1.10" }, "packageManager": "pnpm@10.33.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e02bbc55ac..247d0397cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,15 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + typescript: + specifier: 5.9.3 + version: 5.9.3 + +overrides: + downlevel-dts>typescript: 5.9.3 + importers: .: @@ -102,6 +111,12 @@ importers: '@typescript-eslint/parser': specifier: 8.59.4 version: 8.59.4(eslint@10.4.0(jiti@2.4.2))(typescript@5.9.3) + '@vitest/browser': + specifier: ^4.1.10 + version: 4.1.10(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4))(vitest@4.1.10) + '@vitest/browser-playwright': + specifier: ^4.1.10 + version: 4.1.10(playwright@1.61.1)(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4))(vitest@4.1.10) downlevel-dts: specifier: ^0.11.0 version: 0.11.0 @@ -135,6 +150,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + playwright: + specifier: ^1.61.1 + version: 1.61.1 prettier: specifier: ^3.4.2 version: 3.8.3 @@ -163,7 +181,7 @@ importers: specifier: 1.6.1 version: 1.6.1(typedoc@0.28.19(typescript@5.9.3)) typescript: - specifier: 5.9.3 + specifier: 'catalog:' version: 5.9.3 typescript-eslint: specifier: ^8.47.0 @@ -172,8 +190,8 @@ importers: specifier: 7.3.5 version: 7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4) vitest: - specifier: ^4.0.0 - version: 4.1.7(@types/node@25.6.0)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4)) + specifier: ^4.1.10 + version: 4.1.10(@types/node@25.6.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4)) packages: @@ -685,6 +703,9 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@bufbuild/protobuf@1.10.1': resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} @@ -1249,6 +1270,9 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@publint/pack@0.1.4': resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=18'} @@ -1752,11 +1776,22 @@ packages: cpu: [x64] os: [win32] - '@vitest/expect@4.1.7': - resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + '@vitest/browser-playwright@4.1.10': + resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==} + peerDependencies: + playwright: '*' + vitest: 4.1.10 - '@vitest/mocker@4.1.7': - resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + '@vitest/browser@4.1.10': + resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==} + peerDependencies: + vitest: 4.1.10 + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1766,20 +1801,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.7': - resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/runner@4.1.7': - resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.1.7': - resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - '@vitest/spy@4.1.7': - resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - '@vitest/utils@4.1.7': - resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2539,6 +2574,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3023,6 +3063,10 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3198,6 +3242,20 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3427,6 +3485,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + size-limit@11.2.0: resolution: {integrity: sha512-2kpQq2DD/pRpx3Tal/qRW1SYwcIeQ0iq8li5CJHQgOC+FtPn2BVmuDtzUCgNnpCrbgtfEHqh+iWzxK+Tq6C+RQ==} engines: {node: ^18.0.0 || >=20.0.0} @@ -3591,6 +3653,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -3680,11 +3746,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20260416: - resolution: {integrity: sha512-64CCow5rQc6+oBAPqLtvZ8f3iz4WVCLwMVkg6/kOfMQlGshVOMkBhCXn+MMP4SX4Xtnz48VpD98FkptirMEuEQ==} - engines: {node: '>=14.17'} - hasBin: true - uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} @@ -3771,20 +3832,20 @@ packages: yaml: optional: true - vitest@4.1.7: - resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.7 - '@vitest/browser-preview': 4.1.7 - '@vitest/browser-webdriverio': 4.1.7 - '@vitest/coverage-istanbul': 4.1.7 - '@vitest/coverage-v8': 4.1.7 - '@vitest/ui': 4.1.7 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -4602,6 +4663,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@blazediff/core@1.9.1': {} + '@bufbuild/protobuf@1.10.1': {} '@bufbuild/protoc-gen-es@1.10.1(@bufbuild/protobuf@1.10.1)': @@ -5110,6 +5173,8 @@ snapshots: '@pkgr/core@0.2.9': {} + '@polka/url@1.0.0-next.29': {} + '@publint/pack@0.1.4': {} '@rollup/plugin-babel@7.0.0(@babel/core@7.29.7)(rollup@4.60.4)': @@ -5541,44 +5606,74 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitest/expect@4.1.7': + '@vitest/browser-playwright@4.1.10(playwright@1.61.1)(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4))(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4)) + playwright: 1.61.1 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@25.6.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.10(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@25.6.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4)) + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.2 - '@vitest/spy': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4))': + '@vitest/mocker@4.1.10(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4))': dependencies: - '@vitest/spy': 4.1.7 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4) - '@vitest/pretty-format@4.1.7': + '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.7': + '@vitest/runner@4.1.10': dependencies: - '@vitest/utils': 4.1.7 + '@vitest/utils': 4.1.10 pathe: 2.0.3 - '@vitest/snapshot@4.1.7': + '@vitest/snapshot@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.7': {} + '@vitest/spy@4.1.10': {} - '@vitest/utils@4.1.7': + '@vitest/utils@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.7 + '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -5995,7 +6090,7 @@ snapshots: dependencies: semver: 7.8.2 shelljs: 0.8.5 - typescript: 6.0.0-dev.20260416 + typescript: 5.9.3 dunder-proto@1.0.1: dependencies: @@ -6531,6 +6626,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7069,6 +7167,8 @@ snapshots: mri@1.2.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -7227,6 +7327,16 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + possible-typed-array-names@1.1.0: optional: true @@ -7515,6 +7625,12 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + size-limit@11.2.0: dependencies: bytes-iec: 3.1.1 @@ -7646,6 +7762,8 @@ snapshots: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -7759,8 +7877,6 @@ snapshots: typescript@5.9.3: {} - typescript@6.0.0-dev.20260416: {} - uc.micro@2.1.0: {} unbox-primitive@1.1.0: @@ -7841,15 +7957,15 @@ snapshots: tsx: 4.22.3 yaml: 2.8.4 - vitest@4.1.7(@types/node@25.6.0)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4)): + vitest@4.1.10(@types/node@25.6.0)(@vitest/browser-playwright@4.1.10)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4)): dependencies: - '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4)) - '@vitest/pretty-format': 4.1.7 - '@vitest/runner': 4.1.7 - '@vitest/snapshot': 4.1.7 - '@vitest/spy': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -7865,6 +7981,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.0 + '@vitest/browser-playwright': 4.1.10(playwright@1.61.1)(vite@7.3.5(@types/node@25.6.0)(jiti@2.4.2)(terser@5.39.2)(tsx@4.22.3)(yaml@2.8.4))(vitest@4.1.10) happy-dom: 20.9.0 jsdom: 26.1.0 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d55410c3dc..c74617fce0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,11 @@ +catalog: + typescript: 5.9.3 + +# downlevel-dts depends on `typescript@next`, which now resolves to the TS 7 native +# preview (no `.` export -> ERR_PACKAGE_PATH_NOT_EXPORTED). Pin it to our canonical TS. +overrides: + downlevel-dts>typescript: 'catalog:' + minimumReleaseAge: 2880 minimumReleaseAgeExclude: - '@livekit/*' diff --git a/src/api/SignalClient.e2e.test.ts b/src/api/SignalClient.e2e.test.ts new file mode 100644 index 0000000000..ed64a56f55 --- /dev/null +++ b/src/api/SignalClient.e2e.test.ts @@ -0,0 +1,284 @@ +import { type LeaveRequest, LeaveRequest_Action } from '@livekit/protocol'; +import { afterEach, beforeEach, describe, expect, inject, it, vi } from 'vitest'; +import { ConnectionErrorReason } from '../room/errors'; +import { createInvalidToken, createToken } from '../test/signalToken'; +import { SignalClient, SignalConnectionState, type SignalOptions } from './SignalClient'; + +// Provided by src/test/signalServerSetup.ts (spawns the livekit-server test-server). +const serverUrl = inject('serverUrl'); +const unavailable = inject('e2eUnavailable'); + +const defaultOpts = (): SignalOptions => ({ + autoSubscribe: true, + maxRetries: 0, + e2eeEnabled: false, + websocketTimeout: 5_000, +}); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function withTimeout(p: Promise, ms: number, label: string): Promise { + return Promise.race([ + p, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms), + ), + ]); +} + +/** Resolves with the reason passed to onClose (register before the event can fire). */ +function captureClose(client: SignalClient): Promise { + return new Promise((resolve) => { + client.onClose = (reason) => resolve(reason); + }); +} + +/** Resolves with the LeaveRequest passed to onLeave. */ +function captureLeave(client: SignalClient): Promise { + return new Promise((resolve) => { + client.onLeave = (leave) => resolve(leave); + }); +} + +describe.skipIf(!!unavailable)('SignalClient e2e', () => { + // Behaviors that depend on the signal path (v0 legacy vs v1). + describe.each([ + { label: 'v0', useV0: true }, + { label: 'v1', useV0: false }, + ])('$label path', ({ useV0 }) => { + let client: SignalClient; + + beforeEach(() => { + client = new SignalClient(false); + }); + + afterEach(async () => { + await client.close().catch(() => {}); + }); + + /** Mint a token whose metadata selects the server behavior mode, then join. */ + const join = (mode: string, opts: SignalOptions = defaultOpts(), abortSignal?: AbortSignal) => + createToken({ signal: mode }).then((token) => + client.join(serverUrl, token, opts, abortSignal, useV0), + ); + + it('connects and reports CONNECTED with a join response', async () => { + const res = await join('happy'); + expect(res).toBeTruthy(); + expect(res.pingInterval).toBeGreaterThan(0); + expect(client.currentState).toBe(SignalConnectionState.CONNECTED); + }); + + it('stays connected past the ping timeout while the server pongs', async () => { + const onClose = vi.fn(); + await join('happy'); + client.onClose = onClose; + // Server ping timeout is 3s; a healthy pong loop must keep us alive. + await sleep(4_000); + expect(onClose).not.toHaveBeenCalled(); + expect(client.currentState).toBe(SignalConnectionState.CONNECTED); + }); + + it('closes with a ping timeout when the server stops ponging', async () => { + const closed = captureClose(client); + await join('no_pong'); + expect(client.currentState).toBe(SignalConnectionState.CONNECTED); + const reason = await withTimeout(closed, 8_000, 'onClose (ping timeout)'); + expect(reason).toContain('ping timeout'); + expect(client.currentState).toBe(SignalConnectionState.DISCONNECTED); + }); + + it('surfaces an unexpected transport close while connected', async () => { + const closed = captureClose(client); + await join('close_when_connected'); + const reason = await withTimeout(closed, 5_000, 'onClose (transport closed)'); + expect(reason).toBeTruthy(); + expect(client.currentState).toBe(SignalConnectionState.DISCONNECTED); + }); + + it('surfaces an abnormal socket drop (1006) while connected', async () => { + // Server sends the join, then drops the TCP connection with no close + // handshake -> the browser reports an abnormal 1006 closure, exercising + // WebSocketStream's error->close path (distinct from the clean 1011 above). + const closed = captureClose(client); + await join('drop_when_connected'); + expect(client.currentState).toBe(SignalConnectionState.CONNECTED); + const reason = await withTimeout(closed, 5_000, 'onClose (abnormal drop)'); + + // KNOWN BUG (documented, not desired): an abnormal 1006 close carries no + // reason, and handleOnClose's `closeInfo.reason ?? 'Unexpected WS error'` + // fallback only fires for null/undefined — not the empty string a 1006 + // yields. So onClose gets '' instead of a descriptive reason. It SHOULD + // report 'Unexpected WS error'. When the client is fixed, flip this to + // expect(reason).toBe('Unexpected WS error'); + expect(reason).toBe(''); // <-- buggy current behavior; see above + expect(client.currentState).toBe(SignalConnectionState.DISCONNECTED); + }); + + it('rejects the join when the socket closes before the first message', async () => { + // Upgrade succeeds, then the server closes the WS before sending a join. + // The client is still establishing, so it rejects rather than hanging. + const err = await join('close_before_join').then( + () => undefined, + (e) => e as Error, + ); + expect(err).toBeInstanceOf(Error); + expect((err as { reason?: ConnectionErrorReason }).reason).toBe( + ConnectionErrorReason.InternalError, + ); + }); + + it('delivers a server-initiated leave while connected', async () => { + const left = captureLeave(client); + await join('leave_when_connected'); + const leave = await withTimeout(left, 5_000, 'onLeave'); + expect(leave).toBeTruthy(); + expect(leave.action).toBe(LeaveRequest_Action.DISCONNECT); + }); + + it('forwards a non-DISCONNECT leave action verbatim', async () => { + // SignalClient does not branch on the leave action (RTCEngine does); it + // must pass it through unchanged. Guards the FSM swap's leave_received + // effect, which carries leaveAction. Uses RECONNECT to prove it's not + // defaulted to DISCONNECT. + const left = captureLeave(client); + const token = await createToken({ + signal: 'leave_when_connected', + leaveAction: LeaveRequest_Action.RECONNECT, + }); + await client.join(serverUrl, token, defaultOpts(), undefined, useV0); + const leave = await withTimeout(left, 5_000, 'onLeave'); + expect(leave.action).toBe(LeaveRequest_Action.RECONNECT); + }); + + it('rejects the join when a leave arrives as the first message', async () => { + const err = await join('leave_first_message').then( + () => undefined, + (e) => e as Error, + ); + expect(err).toBeInstanceOf(Error); + }); + + it('closes gracefully via close() without firing onClose', async () => { + const onClose = vi.fn(); + await join('happy'); + client.onClose = onClose; + await client.close(); + expect(client.isDisconnected).toBe(true); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('reconnects (resume) back to CONNECTED', async () => { + await join('happy'); + const token = await createToken({ signal: 'happy' }); + await client.reconnect(serverUrl, token, 'RM_session'); + expect(client.currentState).toBe(SignalConnectionState.CONNECTED); + }); + + it('rejects a reconnect when a leave arrives as the first message', async () => { + // Reconnecting into a room that sends leave-first exercises the client's + // first-message validation while in RECONNECTING (path-independent: it + // doesn't rely on the mock detecting reconnect, which v1 hides inside the + // gzipped join_request the mock ignores). + await join('happy'); + const token = await createToken({ signal: 'leave_first_message' }); + const err = await client.reconnect(serverUrl, token, 'RM_session').then( + () => undefined, + (e) => e as Error, + ); + expect(err).toBeInstanceOf(Error); + }); + + // --- validate-endpoint classification ------------------------------- + it('classifies an invalid token as NotAllowed', async () => { + const token = await createInvalidToken(); + const err = await client + .join(serverUrl, token, defaultOpts(), undefined, useV0) + .catch((e) => e); + expect(err.reason).toBe(ConnectionErrorReason.NotAllowed); + }); + + it('classifies room-not-found as NotAllowed', async () => { + const err = await join('room_not_found').catch((e) => e); + expect(err.reason).toBe(ConnectionErrorReason.NotAllowed); + }); + + it('classifies a wrong-path 404 as ServiceNotFound', async () => { + const err = await join('validate_service_not_found').catch((e) => e); + expect(err.reason).toBe(ConnectionErrorReason.ServiceNotFound); + }); + + it('surfaces a 5xx validate as a WebSocket error (ws error shadows 5xx→internal)', async () => { + // Current behavior: handleConnectionError only maps a 5xx to InternalError + // when the ws rejection is NOT a ConnectionError. WebSocketStream always + // rejects with one, so for a refused upgrade the WS error wins. Only + // 401/403/404 (handled before that check) override it. + const err = await join('validate_500').catch((e) => e); + expect(err.reason).toBe(ConnectionErrorReason.WebSocket); + }); + }); + + // Path-independent transport-level failures (run once, via v0). + describe('transport failures', () => { + let client: SignalClient; + + beforeEach(() => { + client = new SignalClient(false); + }); + + afterEach(async () => { + await client.close().catch(() => {}); + }); + + it('classifies an unreachable server as ServerUnreachable', async () => { + const token = await createToken({ signal: 'happy' }); + // Nothing listening on this port -> ws fails, validate fetch fails. + const err = await client + .join('ws://127.0.0.1:59999', token, defaultOpts(), undefined, true) + .catch((e) => e); + expect(err.reason).toBe(ConnectionErrorReason.ServerUnreachable); + }); + + it('cancels the connect when the AbortSignal fires mid-connection', async () => { + const controller = new AbortController(); + const token = await createToken({ signal: 'no_first_message' }); + const p = client.join(serverUrl, token, defaultOpts(), controller.signal, true); + setTimeout(() => controller.abort('user requested abort'), 300); + const err = await p.catch((e) => e); + expect(err.reason).toBe(ConnectionErrorReason.Cancelled); + }); + + it('hangs with no read-timeout when the socket opens but no first message arrives', async () => { + // FIXME KNOWN GAP (documented, not desired): wsTimeout guards only the upgrade + // and is cleared as soon as ws.opened resolves. If the server accepts the + // socket then stays silent, `await signalReader.read()` blocks forever — + // join() never settles and websocketTimeout does NOT apply post-upgrade. + const controller = new AbortController(); + const token = await createToken({ signal: 'no_first_message' }); + const joinPromise = client.join( + serverUrl, + token, + { ...defaultOpts(), websocketTimeout: 1_000 }, + controller.signal, + true, + ); + + const STILL_PENDING = Symbol('still-pending'); + const outcome = await Promise.race([ + joinPromise.then( + () => 'resolved', + () => 'rejected', + ), + // Well past websocketTimeout (1s): a real read-timeout would have fired. + new Promise((resolve) => + setTimeout(() => resolve(STILL_PENDING), 3_000), + ), + ]); + expect(outcome).toBe(STILL_PENDING); + + // Cleanup: abort so the dangling join settles instead of leaking. + controller.abort('test cleanup'); + await joinPromise.catch(() => {}); + }); + }); +}); diff --git a/src/test/signalServerSetup.ts b/src/test/signalServerSetup.ts new file mode 100644 index 0000000000..49bb767a04 --- /dev/null +++ b/src/test/signalServerSetup.ts @@ -0,0 +1,151 @@ +import { type ChildProcess, execFileSync, spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { Socket, createServer } from 'node:net'; +import { join } from 'node:path'; +import { createToken } from './signalToken'; + +/** + * vitest globalSetup for the browser signal e2e suite. It provides the tests a + * ws:// base URL for the LiveKit mock test-server, via one of two paths: + * + * - LK_TEST_SERVER_URL — connect to an already-running server (CI runs the + * published test-server Docker image as a service; see .github/workflows/e2e.yaml). + * - LK_SERVER_DIR — a local livekit-server checkout: build cmd/test-server + * and spawn it on an ephemeral port (dev convenience). + * + * If neither is set (or the toolchain is unavailable) the suite is skipped. + */ + +const HOST = '127.0.0.1'; + +let child: ChildProcess | undefined; + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.unref(); + srv.on('error', reject); + srv.listen(0, HOST, () => { + const addr = srv.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + srv.close(() => resolve(port)); + }); + }); +} + +// Poll the validate endpoint (from Node — no CORS here) until it answers 200. +async function waitReady(serverUrl: string): Promise { + const token = await createToken({ signal: 'happy' }); + const httpBase = serverUrl.replace(/^ws/, 'http'); + const validateUrl = `${httpBase}/rtc/validate?access_token=${encodeURIComponent(token)}`; + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + try { + if ((await fetch(validateUrl)).status === 200) { + return true; + } + } catch { + // not up yet + } + await new Promise((r) => setTimeout(r, 200)); + } + return false; +} + +export default async function setup(project: { provide: (name: string, value: unknown) => void }) { + const provide = (serverUrl: string) => { + project.provide('serverUrl', serverUrl); + project.provide('e2eUnavailable', ''); + }; + const skip = (reason: string) => { + // eslint-disable-next-line no-console + console.warn(`\n[e2e] signal-connection e2e suite SKIPPED: ${reason}\n`); + project.provide('serverUrl', ''); + project.provide('e2eUnavailable', reason); + return () => {}; + }; + + // CI / Docker: connect to an externally-managed server. + const externalUrl = process.env.LK_TEST_SERVER_URL; + if (externalUrl) { + if (!(await waitReady(externalUrl))) { + return skip(`test-server at ${externalUrl} did not become ready`); + } + provide(externalUrl); + return () => {}; // container lifecycle is owned by CI + } + + // Local dev: build + spawn cmd/test-server from a livekit-server checkout. + const serverDir = process.env.LK_SERVER_DIR; + if (!serverDir || !existsSync(serverDir)) { + return skip( + 'set LK_TEST_SERVER_URL (a running server) or LK_SERVER_DIR (a livekit-server checkout)', + ); + } + + const binary = join(serverDir, 'bin', 'test-server'); + if (!process.env.LK_E2E_SKIP_BUILD) { + try { + execFileSync('go', ['build', '-o', 'bin/test-server', './cmd/test-server'], { + cwd: serverDir, + stdio: 'pipe', + }); + } catch (e) { + return skip(`failed to build test-server: ${(e as Error).message}`); + } + } + if (!existsSync(binary)) { + return skip(`test-server binary missing at ${binary}`); + } + + // Fresh ephemeral port each run so a leftover server from a crashed run can + // never be mistaken for ours (vitest browser-mode teardown is unreliable, so + // a spawned server may outlive the run; the ephemeral port makes that + // harmless, at the cost of leaving at most one idle process per run). + const port = Number(process.env.LK_TEST_SERVER_PORT) || (await getFreePort()); + const serverUrl = `ws://${HOST}:${port}`; + + child = spawn(binary, ['--ports', String(port), '--bind', HOST], { + cwd: serverDir, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let serverLog = ''; + child.stdout?.on('data', (d: unknown) => { + serverLog += String(d); + }); + child.stderr?.on('data', (d: unknown) => { + serverLog += String(d); + }); + // With stdio: 'pipe' these are net.Sockets (typed as Readable, which lacks unref); + // unref the pipe handles so their 'data' listeners don't keep the parent alive. + if (child.stdout instanceof Socket) child.stdout.unref(); + if (child.stderr instanceof Socket) child.stderr.unref(); + child.unref(); + + const kill = () => { + try { + child?.kill('SIGKILL'); + } catch { + // already gone + } + }; + process.once('exit', kill); + process.once('SIGINT', () => { + kill(); + process.exit(130); + }); + process.once('SIGTERM', () => { + kill(); + process.exit(143); + }); + + if (!(await waitReady(serverUrl))) { + kill(); + return skip( + `test-server did not become ready on ${serverUrl}\n--- server output ---\n${serverLog}`, + ); + } + + provide(serverUrl); + return kill; +} diff --git a/src/test/signalToken.ts b/src/test/signalToken.ts new file mode 100644 index 0000000000..5d89d34bef --- /dev/null +++ b/src/test/signalToken.ts @@ -0,0 +1,59 @@ +import { SignJWT } from 'jose'; + +/** + * Mint a LiveKit access token for the mock test-server (HS256, dev secret). + * + * The mock's signal behavior mode is selected via a participant attribute + * under the reserved key `lk.mock` (dot notation, matching LiveKit's internal + * attribute convention), whose value is the JSON control object + * `{"signal":""}` — mirroring the X-Lk-Mock header protocol. Attributes + * are a standard AccessToken field (a string map), so a dedicated `lk-mock` + * key coexists with any real metadata/attributes and needs no bespoke token + * construction. The room name is just a room; it no longer encodes behavior. + */ +export interface TokenOptions { + /** Mock behavior mode (e.g. 'no_pong'); omitted → the mock defaults to 'happy'. */ + signal?: string; + /** LeaveRequest action the leave modes should send (0=DISCONNECT,1=RESUME,2=RECONNECT). */ + leaveAction?: number; + room?: string; + identity?: string; + apiKey?: string; + secret?: string; + ttlSeconds?: number; +} + +export async function createToken(opts: TokenOptions = {}): Promise { + const { + signal, + leaveAction, + room = 'e2e-room', + identity = `test-${Math.random().toString(36).slice(2, 8)}`, + apiKey = 'devkey', + secret = 'secret', + ttlSeconds = 600, + } = opts; + const key = new TextEncoder().encode(secret); + const payload: Record = { + video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true }, + }; + if (signal) { + const control: Record = { signal }; + if (leaveAction !== undefined) { + control.leaveAction = leaveAction; + } + payload.attributes = { 'lk.mock': JSON.stringify(control) }; + } + return new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuer(apiKey) + .setSubject(identity) + .setIssuedAt() + .setExpirationTime(`${ttlSeconds}s`) + .sign(key); +} + +/** A syntactically-valid token signed with the WRONG secret — for 401 tests. */ +export async function createInvalidToken(): Promise { + return createToken({ secret: 'not-the-secret' }); +} diff --git a/src/test/vitest-context.d.ts b/src/test/vitest-context.d.ts new file mode 100644 index 0000000000..725edc3aba --- /dev/null +++ b/src/test/vitest-context.d.ts @@ -0,0 +1,10 @@ +import 'vitest'; + +declare module 'vitest' { + interface ProvidedContext { + /** ws:// base URL of the spawned mock test-server, or '' when unavailable. */ + serverUrl: string; + /** Non-empty reason string when the e2e suite must be skipped. */ + e2eUnavailable: string; + } +} diff --git a/tsconfig.json b/tsconfig.json index 4486b144b4..edcc6cb45e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,7 +31,7 @@ "ignoreDeprecations": "5.0", "plugins": [{ "name": "@livekit/throws-transformer" }] }, - "exclude": ["dist", "**/*.test.ts", "test/**", "src/room/token-source/test-tokens.ts"], + "exclude": ["dist", "**/*.test.ts", "test/**", "src/room/token-source/test-tokens.ts", "src/test/signalServerSetup.ts"], "include": ["src/**/*.ts"], "typedocOptions": { "entryPoints": ["src/index.ts"], diff --git a/vite.config.mjs b/vite.config.mjs index a5fd71986a..7cfadd307f 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -3,6 +3,7 @@ import { babel } from '@rollup/plugin-babel'; import dns from 'dns'; import { resolve } from 'path'; import { defineConfig } from 'vite'; +import { configDefaults } from 'vitest/config'; dns.setDefaultResultOrder('verbatim'); @@ -41,5 +42,8 @@ export default defineConfig({ }, test: { environment: 'happy-dom', + // e2e tests need a real server + node WebSocket; run them via the + // dedicated `pnpm test:e2e` config (vitest.e2e.config.mts), not the unit run. + exclude: [...configDefaults.exclude, '**/*.e2e.test.ts'], }, }); diff --git a/vitest.e2e.config.mts b/vitest.e2e.config.mts new file mode 100644 index 0000000000..68f1bf6f7e --- /dev/null +++ b/vitest.e2e.config.mts @@ -0,0 +1,24 @@ +import { playwright } from '@vitest/browser-playwright'; +import { defineConfig } from 'vitest/config'; + +// End-to-end signal-connection tests. Unlike the unit suite (happy-dom, mocked +// transport), these run in a REAL browser (Chromium via Playwright) so they +// exercise the actual browser WebSocket + fetch the SDK ships against, driving +// a live mock server (../livekit-server/cmd/test-server) spawned by globalSetup. +export default defineConfig({ + test: { + include: ['src/**/*.e2e.test.ts'], + // globalSetup runs in Node (spawns the Go mock); tests run in the browser. + globalSetup: ['./src/test/signalServerSetup.ts'], + testTimeout: 20_000, + hookTimeout: 120_000, + // One browser context; scenarios isolate via unique-per-mode room names. + fileParallelism: false, + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{ browser: 'chromium' }], + }, + }, +});