From 102dade3f53636c7c9de60ca10feecb65b0dffc5 Mon Sep 17 00:00:00 2001 From: Adam Fraser Date: Tue, 3 Mar 2026 16:54:10 -0500 Subject: [PATCH 1/8] Add client test suite (44 tests) and migrate to React Router Test stack: vitest + @testing-library/react, covering routing, useSignaling hook, HostView/PeerView signaling + WebRTC flows, and AudioVisualizer lifecycle. Also migrates the client from prop-drilling navigation (onBack, initialRoomCode) to React Router v6 with HashRouter, which the tests depend on for proper route param and navigation testing. CI: adds a parallel test-client job to the test workflow. Pre-push: extends .githooks/pre-push to run client tests after the existing server tests. Co-Authored-By: Claude Sonnet 4.6 --- .githooks/pre-push | 26 +- .github/workflows/test.yml | 27 +- README.md | 35 +++ client/package.json | 15 +- client/pnpm-lock.yaml | 292 ++++++++++++++++++ client/src/App.jsx | 87 +++--- client/src/App.test.jsx | 53 ++++ .../src/components/AudioVisualizer.test.jsx | 36 +++ client/src/components/HostView.jsx | 8 +- client/src/components/HostView.test.jsx | 283 +++++++++++++++++ client/src/components/PeerView.jsx | 7 +- client/src/components/PeerView.test.jsx | 209 +++++++++++++ client/src/hooks/useSignaling.test.js | 146 +++++++++ client/src/main.jsx | 5 +- client/src/test-setup.js | 77 +++++ client/vite.config.js | 5 + 16 files changed, 1245 insertions(+), 66 deletions(-) create mode 100644 client/src/App.test.jsx create mode 100644 client/src/components/AudioVisualizer.test.jsx create mode 100644 client/src/components/HostView.test.jsx create mode 100644 client/src/components/PeerView.test.jsx create mode 100644 client/src/hooks/useSignaling.test.js create mode 100644 client/src/test-setup.js diff --git a/.githooks/pre-push b/.githooks/pre-push index 0b868f6..b065e1a 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,24 +1,24 @@ #!/bin/sh -# Run signaling server tests before push +# Run all tests before push -echo "Running signaling server tests..." - -cd "$(git rev-parse --show-toplevel)/signaling-server" || exit 1 +ROOT="$(git rev-parse --show-toplevel)" -# Load nvm so we get Node 24 as pinned in .nvmrc +# Load nvm so we get the Node version pinned in each directory's .nvmrc export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" # shellcheck disable=SC1091 [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" +# ── Signaling server ────────────────────────────────────────────────────────── +echo "Running signaling server tests..." +cd "$ROOT/signaling-server" || exit 1 nvm use --silent +npm test || { echo "Signaling server tests failed. Push aborted."; exit 1; } -npm test -STATUS=$? - -if [ $STATUS -ne 0 ]; then - echo "Tests failed. Push aborted." - exit 1 -fi +# ── Client ──────────────────────────────────────────────────────────────────── +echo "Running client tests..." +cd "$ROOT/client" || exit 1 +nvm use --silent +pnpm test || { echo "Client tests failed. Push aborted."; exit 1; } -echo "Tests passed." +echo "All tests passed." exit 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 79712df..4bafa5d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,8 @@ on: branches: [main] jobs: - test: + test-server: + name: Signaling server tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -23,3 +24,27 @@ jobs: - name: Run tests run: npm test working-directory: signaling-server + + test-client: + name: Client tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v3 + with: + version: latest + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: client/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install + working-directory: client + + - name: Run tests + run: pnpm test + working-directory: client diff --git a/README.md b/README.md index 07090da..733bdbb 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,41 @@ Open **http://localhost:3000** in your browser. --- +## Testing + +Both test suites require **Node 24** (pinned via `.nvmrc` in each directory — run `nvm use` if you use nvm). + +### Signaling server — integration tests + +```bash +cd signaling-server +npm test +``` + +22 integration tests covering room creation, peer join/leave, host-left notifications, and edge cases. Uses Node's built-in `node:test` runner. + +### Client — unit / component tests + +```bash +cd client +pnpm test # run once +pnpm test:watch # watch mode +``` + +44 tests across 5 files: + +| File | What it covers | +|------|----------------| +| `src/App.test.jsx` | Route rendering and navigation | +| `src/hooks/useSignaling.test.js` | WebSocket lifecycle, reconnect backoff | +| `src/components/HostView.test.jsx` | Signaling UI states, audio capture errors, WebRTC offer/ICE/answer flow | +| `src/components/PeerView.test.jsx` | Room join UI, auto-join from URL, WebRTC answer/ICE flow | +| `src/components/AudioVisualizer.test.jsx` | AudioContext setup and teardown | + +Stack: [Vitest](https://vitest.dev/) + [@testing-library/react](https://testing-library.com/docs/react-testing-library/intro/). + +--- + ## Project Structure ``` diff --git a/client/package.json b/client/package.json index aa71289..520b72c 100644 --- a/client/package.json +++ b/client/package.json @@ -5,14 +5,23 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^7.13.1" }, "devDependencies": { + "@testing-library/jest-dom": "*", + "@testing-library/react": "*", + "@testing-library/user-event": "*", "@vitejs/plugin-react": "^4.2.0", - "vite": "^5.0.0" + "jsdom": "*", + "mockrtc": "*", + "vite": "^5.0.0", + "vitest": "^2" } } diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index c7429ae..283c55c 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^7.13.1 + version: 7.13.1(react-dom@18.3.1)(react@18.3.1) devDependencies: '@vitejs/plugin-react': @@ -19,6 +22,9 @@ devDependencies: vite: specifier: ^5.0.0 version: 5.4.21 + vitest: + specifier: ^2 + version: 2.1.9 packages: @@ -689,6 +695,72 @@ packages: - supports-color dev: true + /@vitest/expect@2.1.9: + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + dev: true + + /@vitest/mocker@2.1.9(vite@5.4.21): + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + vite: 5.4.21 + dev: true + + /@vitest/pretty-format@2.1.9: + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + dependencies: + tinyrainbow: 1.2.0 + dev: true + + /@vitest/runner@2.1.9: + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + dev: true + + /@vitest/snapshot@2.1.9: + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + dev: true + + /@vitest/spy@2.1.9: + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + dependencies: + tinyspy: 3.0.2 + dev: true + + /@vitest/utils@2.1.9: + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + dev: true + + /assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + dev: true + /baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -707,14 +779,40 @@ packages: update-browserslist-db: 1.2.3(browserslist@4.28.1) dev: true + /cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + dev: true + /caniuse-lite@1.0.30001774: resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} dev: true + /chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + dev: true + + /check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + dev: true + /convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + dev: false + /debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -727,10 +825,19 @@ packages: ms: 2.1.3 dev: true + /deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + dev: true + /electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} dev: true + /es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + dev: true + /esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -767,6 +874,17 @@ packages: engines: {node: '>=6'} dev: true + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.8 + dev: true + + /expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + dev: true + /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -802,12 +920,22 @@ packages: js-tokens: 4.0.0 dev: false + /loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + dev: true + /lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} dependencies: yallist: 3.1.1 dev: true + /magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + dev: true + /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true @@ -822,6 +950,15 @@ packages: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} dev: true + /pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + dev: true + + /pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + dev: true + /picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} dev: true @@ -850,6 +987,34 @@ packages: engines: {node: '>=0.10.0'} dev: true + /react-router-dom@7.13.1(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 7.13.1(react-dom@18.3.1)(react@18.3.1) + dev: false + + /react-router@7.13.1(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + dependencies: + cookie: 1.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + set-cookie-parser: 2.7.2 + dev: false + /react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -903,11 +1068,50 @@ packages: hasBin: true dev: true + /set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + dev: false + + /siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + dev: true + /source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} dev: true + /stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + dev: true + + /std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + dev: true + + /tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + dev: true + + /tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + dev: true + + /tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + dev: true + + /tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + dev: true + + /tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + dev: true + /update-browserslist-db@1.2.3(browserslist@4.28.1): resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -919,6 +1123,28 @@ packages: picocolors: 1.1.1 dev: true + /vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + dev: true + /vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -957,6 +1183,72 @@ packages: fsevents: 2.3.3 dev: true + /vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21 + vite-node: 2.1.9 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + dev: true + + /why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + dev: true + /yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} dev: true diff --git a/client/src/App.jsx b/client/src/App.jsx index f821974..fcd2c72 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,22 +1,53 @@ -import { useState } from "react"; +import { Routes, Route, useNavigate } from "react-router-dom"; import HostView from "./components/HostView.jsx"; import PeerView from "./components/PeerView.jsx"; -function getInitialRoomCode() { - return new URLSearchParams(window.location.search).get("room")?.toUpperCase() || null; +function Landing() { + const navigate = useNavigate(); + return ( +
+

Stream Audio, Peer-to-Peer

+

+ Share music from Spotify, Deezer, or any browser tab directly to + your friends — no middle server, no delay, no accounts required. +

+ +
+
navigate("/host")}> +
📡
+

Host a Session

+

Capture and broadcast tab audio to connected listeners

+
+ +
navigate("/join-room")}> +
🎧
+

Listen In

+

Join a session with a room code and hear the stream live

+
+
+ +
+ Browser audio capture works best when + Spotify/Deezer is open in a browser tab (Web Player). For + the native desktop app, an Electron wrapper with system audio + access is required. +
+
+ ); } export default function App() { - const initialRoomCode = getInitialRoomCode(); - // 'landing' | 'host' | 'peer' - const [mode, setMode] = useState(initialRoomCode ? "peer" : "landing"); + const navigate = useNavigate(); return (
setMode("landing")} + onClick={() => navigate("/")} style={{ cursor: "pointer" }} >
🎵
@@ -25,42 +56,12 @@ export default function App() {
- {mode === "landing" && ( -
-

Stream Audio, Peer-to-Peer

-

- Share music from Spotify, Deezer, or any browser tab directly to - your friends — no middle server, no delay, no accounts required. -

- -
-
setMode("host")}> -
📡
-

Host a Session

-

Capture and broadcast tab audio to connected listeners

-
- -
setMode("peer")}> -
🎧
-

Listen In

-

Join a session with a room code and hear the stream live

-
-
- -
- Browser audio capture works best when - Spotify/Deezer is open in a browser tab (Web Player). For - the native desktop app, an Electron wrapper with system audio - access is required. -
-
- )} - - {mode === "host" && setMode("landing")} />} - {mode === "peer" && setMode("landing")} initialRoomCode={initialRoomCode} />} + + } /> + } /> + } /> + } /> +
); diff --git a/client/src/App.test.jsx b/client/src/App.test.jsx new file mode 100644 index 0000000..ad05892 --- /dev/null +++ b/client/src/App.test.jsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import App from './App.jsx'; + +// Mock useSignaling so child views don't open real WebSocket connections. +vi.mock('./hooks/useSignaling.js', () => ({ + useSignaling: vi.fn(() => ({ send: vi.fn(), connected: false })), +})); + +function renderAt(path) { + return render( + + + + ); +} + +describe('App routing', () => { + test('landing renders at /', () => { + renderAt('/'); + expect(screen.getByText('Stream Audio, Peer-to-Peer')).toBeInTheDocument(); + }); + + test('clicking "Host a Session" navigates to HostView', async () => { + const user = userEvent.setup(); + renderAt('/'); + await user.click(screen.getByText('Host a Session')); + // HostView shows this title in its card + expect(screen.getByText('Host Session')).toBeInTheDocument(); + }); + + test('clicking "Listen In" navigates to PeerView', async () => { + const user = userEvent.setup(); + renderAt('/'); + await user.click(screen.getByText('Listen In')); + // PeerView shows this title in its card + expect(screen.getByText('Listener')).toBeInTheDocument(); + }); + + test('logo click from /host returns to landing', async () => { + const user = userEvent.setup(); + renderAt('/host'); + await user.click(screen.getByText('JamLink')); + expect(screen.getByText('Stream Audio, Peer-to-Peer')).toBeInTheDocument(); + }); + + test('navigating to /listen/ABCXYZ renders PeerView with room code pre-filled', () => { + renderAt('/listen/ABCXYZ'); + expect(screen.getByText('Listener')).toBeInTheDocument(); + expect(screen.getByDisplayValue('ABCXYZ')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/AudioVisualizer.test.jsx b/client/src/components/AudioVisualizer.test.jsx new file mode 100644 index 0000000..bd69875 --- /dev/null +++ b/client/src/components/AudioVisualizer.test.jsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react'; +import AudioVisualizer from './AudioVisualizer.jsx'; + +// A minimal fake MediaStream — AudioVisualizer only calls +// createMediaStreamSource(stream), so the object doesn't need real tracks. +const fakeStream = { getTracks: () => [] }; + +describe('AudioVisualizer', () => { + test('renders a element', () => { + render(); + expect(document.querySelector('canvas')).toBeInTheDocument(); + }); + + test('creates an AudioContext and connects the stream source on mount', () => { + render(); + + expect(window.AudioContext).toHaveBeenCalled(); + const ctx = window.AudioContext.mock.results[0].value; + expect(ctx.createMediaStreamSource).toHaveBeenCalledWith(fakeStream); + // source is connected to the analyser + const source = ctx.createMediaStreamSource.mock.results[0].value; + expect(source.connect).toHaveBeenCalled(); + }); + + test('disconnects source and closes AudioContext on unmount', () => { + const { unmount } = render(); + + const ctx = window.AudioContext.mock.results[0].value; + const source = ctx.createMediaStreamSource.mock.results[0].value; + + unmount(); + + expect(source.disconnect).toHaveBeenCalled(); + expect(ctx.close).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/HostView.jsx b/client/src/components/HostView.jsx index 3ef1ea5..288690a 100644 --- a/client/src/components/HostView.jsx +++ b/client/src/components/HostView.jsx @@ -11,6 +11,7 @@ */ import { useState, useCallback, useRef, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; import { useSignaling } from "../hooks/useSignaling.js"; import AudioVisualizer from "./AudioVisualizer.jsx"; @@ -23,7 +24,8 @@ const ICE_SERVERS = [ const SIGNALING_URL = import.meta.env.VITE_SIGNALING_URL || "ws://localhost:8080"; -export default function HostView({ onBack }) { +export default function HostView() { + const navigate = useNavigate(); const [roomId, setRoomId] = useState(null); const [stream, setStream] = useState(null); const [peers, setPeers] = useState({}); // peerId -> { pc, state } @@ -234,7 +236,7 @@ export default function HostView({ onBack }) { const copyRoomCode = () => { if (roomId) { - const url = `${window.location.origin}${window.location.pathname}?room=${roomId}`; + const url = `${window.location.origin}${window.location.pathname}#/listen/${roomId}`; navigator.clipboard.writeText(url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); @@ -264,7 +266,7 @@ export default function HostView({ onBack }) { return (
- diff --git a/client/src/components/HostView.test.jsx b/client/src/components/HostView.test.jsx new file mode 100644 index 0000000..3b355bc --- /dev/null +++ b/client/src/components/HostView.test.jsx @@ -0,0 +1,283 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { act } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { useSignaling } from '../hooks/useSignaling.js'; +import HostView from './HostView.jsx'; + +vi.mock('../hooks/useSignaling.js', () => ({ + useSignaling: vi.fn(), +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +let mockSend; +let capturedOnMessage; + +function renderHostView() { + return render( + + + } /> + } /> + + + ); +} + +beforeEach(() => { + mockSend = vi.fn(); + useSignaling.mockImplementation((_url, onMessage) => { + capturedOnMessage = onMessage; + return { send: mockSend, connected: true }; + }); +}); + +// ── Fake RTCPeerConnection for WebRTC tests ─────────────────────────────────── + +const FAKE_OFFER_SDP = { type: 'offer', sdp: 'v=0\r\no=fake 0 0 IN IP4 0.0.0.0\r\n' }; +const FAKE_ANSWER_SDP = { type: 'answer', sdp: 'v=0\r\no=fake 1 0 IN IP4 0.0.0.0\r\n' }; + +function makeFakePC() { + const pc = { + localDescription: null, + connectionState: 'new', + onicecandidate: null, + onconnectionstatechange: null, + ontrack: null, + createOffer: vi.fn(async () => FAKE_OFFER_SDP), + setLocalDescription: vi.fn(async (desc) => { pc.localDescription = desc; }), + setRemoteDescription: vi.fn(async () => {}), + createAnswer: vi.fn(async () => FAKE_ANSWER_SDP), + addIceCandidate: vi.fn(async () => {}), + addTrack: vi.fn(), + close: vi.fn(), + // Test helper: fire an ICE candidate event + _fireIce: (candidate) => pc.onicecandidate?.({ candidate }), + // Test helper: change connection state + _fireStateChange: (state) => { + pc.connectionState = state; + pc.onconnectionstatechange?.(); + }, + }; + return pc; +} + +let fakePC; + +function setupFakeRTC() { + fakePC = makeFakePC(); + vi.stubGlobal('RTCPeerConnection', vi.fn(() => fakePC)); +} + +// ── Signaling / UI tests ────────────────────────────────────────────────────── + +describe('HostView — signaling and UI', () => { + test('shows "Connecting to server…" when connected=false', () => { + useSignaling.mockReturnValue({ send: mockSend, connected: false }); + renderHostView(); + expect(screen.getByText('Connecting to server…')).toBeInTheDocument(); + }); + + test('sends create-room once connected=true', () => { + renderHostView(); + expect(mockSend).toHaveBeenCalledWith({ type: 'create-room' }); + }); + + test('shows "Creating room…" after connected but before room-created', () => { + renderHostView(); + expect(screen.getByText('Creating room…')).toBeInTheDocument(); + }); + + test('shows room code and listener count after room-created', async () => { + renderHostView(); + await act(async () => { + await capturedOnMessage({ type: 'room-created', roomId: 'ABCDEF' }); + }); + expect(screen.getByText('ABCDEF')).toBeInTheDocument(); + expect(screen.getByText(/Room ready · 0 listeners/)).toBeInTheDocument(); + }); + + test('copy button writes listen URL to clipboard', async () => { + const user = userEvent.setup(); + const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + renderHostView(); + await act(async () => { + await capturedOnMessage({ type: 'room-created', roomId: 'ABCDEF' }); + }); + await user.click(screen.getByRole('button', { name: /Copy link/i })); + expect(writeTextSpy).toHaveBeenCalledWith( + expect.stringContaining('#/listen/ABCDEF') + ); + }); + + test('"Start Capturing Tab Audio" is disabled until roomId is set', () => { + renderHostView(); + expect( + screen.getByRole('button', { name: /Start Capturing Tab Audio/i }) + ).toBeDisabled(); + }); + + test('"Start Capturing Tab Audio" enabled after room-created', async () => { + renderHostView(); + await act(async () => { + await capturedOnMessage({ type: 'room-created', roomId: 'XYZABC' }); + }); + expect( + screen.getByRole('button', { name: /Start Capturing Tab Audio/i }) + ).toBeEnabled(); + }); + + test('clicking start capture calls navigator.mediaDevices.getDisplayMedia', async () => { + const user = userEvent.setup(); + navigator.mediaDevices.getDisplayMedia.mockResolvedValue({ + getAudioTracks: () => [{ addEventListener: vi.fn() }], + getTracks: () => [], + }); + renderHostView(); + await act(async () => { + await capturedOnMessage({ type: 'room-created', roomId: 'XYZABC' }); + }); + await user.click(screen.getByRole('button', { name: /Start Capturing Tab Audio/i })); + expect(navigator.mediaDevices.getDisplayMedia).toHaveBeenCalled(); + }); + + test('shows error alert when capture is rejected with NotAllowedError', async () => { + const user = userEvent.setup(); + const err = Object.assign(new Error('denied'), { name: 'NotAllowedError' }); + navigator.mediaDevices.getDisplayMedia.mockRejectedValue(err); + renderHostView(); + await act(async () => { + await capturedOnMessage({ type: 'room-created', roomId: 'XYZABC' }); + }); + await user.click(screen.getByRole('button', { name: /Start Capturing Tab Audio/i })); + await waitFor(() => + expect(screen.getByText(/Permission denied/i)).toBeInTheDocument() + ); + }); + + test('back button navigates to /', async () => { + const user = userEvent.setup(); + renderHostView(); + await user.click(screen.getByRole('button', { name: /← Back/i })); + expect(screen.getByTestId('landing')).toBeInTheDocument(); + }); +}); + +// ── WebRTC tests ────────────────────────────────────────────────────────────── + +describe('HostView — WebRTC', () => { + beforeEach(() => { + setupFakeRTC(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('after peer-joined, sends an offer message with valid SDP', async () => { + renderHostView(); + await act(async () => { + await capturedOnMessage({ type: 'room-created', roomId: 'ROOM01' }); + }); + await act(async () => { + await capturedOnMessage({ type: 'peer-joined', peerId: 'peer-1' }); + }); + + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'offer', + sdp: FAKE_OFFER_SDP, + targetPeerId: 'peer-1', + }) + ); + }); + + test('after peer-joined, sends ICE candidates via signaling', async () => { + renderHostView(); + await act(async () => { + await capturedOnMessage({ type: 'room-created', roomId: 'ROOM01' }); + }); + await act(async () => { + await capturedOnMessage({ type: 'peer-joined', peerId: 'peer-1' }); + }); + + const fakeCandidate = { candidate: 'candidate:1 1 UDP 2113667327 192.168.1.1 54321 typ host' }; + await act(async () => { + fakePC._fireIce(fakeCandidate); + }); + + expect(mockSend).toHaveBeenCalledWith({ + type: 'ice-candidate', + candidate: fakeCandidate, + targetPeerId: 'peer-1', + }); + }); + + test('peer appears in listeners list after peer-joined', async () => { + renderHostView(); + await act(async () => { + await capturedOnMessage({ type: 'room-created', roomId: 'ROOM01' }); + }); + await act(async () => { + await capturedOnMessage({ type: 'peer-joined', peerId: 'peer-abc-123-xyz-qrs' }); + }); + + // peer-id is displayed truncated to 16 chars + '…' + expect(screen.getByText(/peer-abc-123-xyz/)).toBeInTheDocument(); + }); + + test('after peer-left, peer is removed from listeners list', async () => { + renderHostView(); + await act(async () => { + await capturedOnMessage({ type: 'room-created', roomId: 'ROOM01' }); + }); + await act(async () => { + await capturedOnMessage({ type: 'peer-joined', peerId: 'peer-abc-123-xyz-qrs' }); + }); + expect(screen.getByText(/peer-abc-123-xyz/)).toBeInTheDocument(); + + await act(async () => { + await capturedOnMessage({ type: 'peer-left', peerId: 'peer-abc-123-xyz-qrs' }); + }); + expect(screen.queryByText(/peer-abc-123-xyz/)).not.toBeInTheDocument(); + }); + + test('after answer message, setRemoteDescription is called on the peer connection', async () => { + renderHostView(); + await act(async () => { + await capturedOnMessage({ type: 'room-created', roomId: 'ROOM01' }); + }); + await act(async () => { + await capturedOnMessage({ type: 'peer-joined', peerId: 'peer-1' }); + }); + await act(async () => { + await capturedOnMessage({ + type: 'answer', + fromPeerId: 'peer-1', + sdp: FAKE_ANSWER_SDP, + }); + }); + + expect(fakePC.setRemoteDescription).toHaveBeenCalled(); + }); + + test('connection state shown in listener list reflects RTCPeerConnection state', async () => { + renderHostView(); + await act(async () => { + await capturedOnMessage({ type: 'room-created', roomId: 'ROOM01' }); + }); + await act(async () => { + await capturedOnMessage({ type: 'peer-joined', peerId: 'peer-1' }); + }); + + await act(async () => { + fakePC._fireStateChange('connected'); + }); + + // The listener row shows "connected" state + expect(screen.getByText('connected')).toBeInTheDocument(); + // And the room status updates to "1 listener" + expect(screen.getByText(/Room ready · 1 listener/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/PeerView.jsx b/client/src/components/PeerView.jsx index 0d28041..7302a5d 100644 --- a/client/src/components/PeerView.jsx +++ b/client/src/components/PeerView.jsx @@ -9,6 +9,7 @@ */ import { useState, useCallback, useRef, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; import { useSignaling } from '../hooks/useSignaling.js'; import AudioVisualizer from './AudioVisualizer.jsx'; @@ -20,7 +21,9 @@ const ICE_SERVERS = [ const SIGNALING_URL = import.meta.env.VITE_SIGNALING_URL || 'ws://localhost:8080'; -export default function PeerView({ onBack, initialRoomCode = null }) { +export default function PeerView() { + const { roomId: initialRoomCode = null } = useParams(); + const navigate = useNavigate(); const [roomCodeInput, setRoomCodeInput] = useState(initialRoomCode || ''); const [joinedRoom, setJoinedRoom] = useState(null); const autoJoinedRef = useRef(false); @@ -218,7 +221,7 @@ export default function PeerView({ onBack, initialRoomCode = null }) { return (
- diff --git a/client/src/components/PeerView.test.jsx b/client/src/components/PeerView.test.jsx new file mode 100644 index 0000000..f275cbf --- /dev/null +++ b/client/src/components/PeerView.test.jsx @@ -0,0 +1,209 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { act } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { useSignaling } from '../hooks/useSignaling.js'; +import PeerView from './PeerView.jsx'; + +vi.mock('../hooks/useSignaling.js', () => ({ + useSignaling: vi.fn(), +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +let mockSend; +let capturedOnMessage; + +function renderPeerView(path = '/join-room') { + return render( + + + } /> + } /> + } /> + + + ); +} + +beforeEach(() => { + mockSend = vi.fn(); + useSignaling.mockImplementation((_url, onMessage) => { + capturedOnMessage = onMessage; + return { send: mockSend, connected: true }; + }); +}); + +// ── Fake RTCPeerConnection for WebRTC tests ─────────────────────────────────── + +const FAKE_OFFER_SDP = { type: 'offer', sdp: 'v=0\r\no=fake 0 0 IN IP4 0.0.0.0\r\n' }; +const FAKE_ANSWER_SDP = { type: 'answer', sdp: 'v=0\r\no=fake 1 0 IN IP4 0.0.0.0\r\n' }; + +function makeFakePC() { + const pc = { + localDescription: null, + connectionState: 'new', + onicecandidate: null, + onconnectionstatechange: null, + ontrack: null, + createOffer: vi.fn(async () => FAKE_OFFER_SDP), + setLocalDescription: vi.fn(async (desc) => { pc.localDescription = desc; }), + setRemoteDescription: vi.fn(async () => {}), + createAnswer: vi.fn(async () => FAKE_ANSWER_SDP), + addIceCandidate: vi.fn(async () => {}), + addTrack: vi.fn(), + close: vi.fn(), + _fireIce: (candidate) => pc.onicecandidate?.({ candidate }), + _fireStateChange: (state) => { + pc.connectionState = state; + pc.onconnectionstatechange?.(); + }, + _fireTrack: (track, stream) => { + pc.ontrack?.({ track, streams: [stream] }); + }, + }; + return pc; +} + +let fakePC; + +function setupFakeRTC() { + fakePC = makeFakePC(); + vi.stubGlobal('RTCPeerConnection', vi.fn(() => fakePC)); +} + +// ── Signaling / UI tests ────────────────────────────────────────────────────── + +describe('PeerView — signaling and UI', () => { + test('on /join-room: room code input renders empty', () => { + renderPeerView('/join-room'); + expect(screen.getByPlaceholderText('XXXXXX')).toHaveValue(''); + }); + + test('on /listen/ABCXYZ: input is pre-filled with ABCXYZ', () => { + renderPeerView('/listen/ABCXYZ'); + expect(screen.getByDisplayValue('ABCXYZ')).toBeInTheDocument(); + }); + + test('on /listen/ABCXYZ: auto-sends join-room once connected=true', () => { + renderPeerView('/listen/ABCXYZ'); + expect(mockSend).toHaveBeenCalledWith({ type: 'join-room', roomId: 'ABCXYZ' }); + }); + + test('Join button is disabled when input has fewer than 4 chars', async () => { + const user = userEvent.setup(); + renderPeerView('/join-room'); + const input = screen.getByPlaceholderText('XXXXXX'); + await user.type(input, 'AB'); + expect(screen.getByRole('button', { name: 'Join' })).toBeDisabled(); + }); + + test('typing a code and clicking Join sends join-room message', async () => { + const user = userEvent.setup(); + renderPeerView('/join-room'); + await user.type(screen.getByPlaceholderText('XXXXXX'), 'ABCDEF'); + await user.click(screen.getByRole('button', { name: 'Join' })); + expect(mockSend).toHaveBeenCalledWith({ type: 'join-room', roomId: 'ABCDEF' }); + }); + + test('shows "Joining room…" after room-joined message', async () => { + renderPeerView('/join-room'); + await act(async () => { + await capturedOnMessage({ type: 'room-joined', roomId: 'ABCDEF' }); + }); + expect(screen.getByText('Joining room…')).toBeInTheDocument(); + }); + + test('shows error alert after error message', async () => { + renderPeerView('/join-room'); + await act(async () => { + await capturedOnMessage({ type: 'error', message: 'Room not found.' }); + }); + expect(screen.getByText('Room not found.')).toBeInTheDocument(); + }); + + test('shows "The host ended the session." after host-left message', async () => { + renderPeerView('/join-room'); + await act(async () => { + await capturedOnMessage({ type: 'room-joined', roomId: 'ABCDEF' }); + }); + await act(async () => { + await capturedOnMessage({ type: 'host-left' }); + }); + expect(screen.getByText('The host ended the session.')).toBeInTheDocument(); + }); + + test('back button navigates to /', async () => { + const user = userEvent.setup(); + renderPeerView('/join-room'); + await user.click(screen.getByRole('button', { name: /← Back/i })); + expect(screen.getByTestId('landing')).toBeInTheDocument(); + }); +}); + +// ── WebRTC tests ────────────────────────────────────────────────────────────── + +describe('PeerView — WebRTC', () => { + beforeEach(() => { + setupFakeRTC(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('after receiving an offer, component sends an answer with valid SDP', async () => { + renderPeerView('/join-room'); + await act(async () => { + await capturedOnMessage({ type: 'room-joined', roomId: 'ABCDEF' }); + }); + await act(async () => { + await capturedOnMessage({ type: 'offer', sdp: FAKE_OFFER_SDP }); + }); + + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'answer', + sdp: FAKE_ANSWER_SDP, + }) + ); + }); + + test('component sends ICE candidates back to host via signaling', async () => { + renderPeerView('/join-room'); + await act(async () => { + await capturedOnMessage({ type: 'room-joined', roomId: 'ABCDEF' }); + }); + await act(async () => { + await capturedOnMessage({ type: 'offer', sdp: FAKE_OFFER_SDP }); + }); + + const fakeCandidate = { candidate: 'candidate:1 1 UDP 2113667327 192.168.1.1 12345 typ host' }; + await act(async () => { + fakePC._fireIce(fakeCandidate); + }); + + expect(mockSend).toHaveBeenCalledWith({ + type: 'ice-candidate', + candidate: fakeCandidate, + }); + }); + + test('ontrack fires → connectionState becomes connected in UI', async () => { + renderPeerView('/join-room'); + await act(async () => { + await capturedOnMessage({ type: 'room-joined', roomId: 'ABCDEF' }); + }); + await act(async () => { + await capturedOnMessage({ type: 'offer', sdp: FAKE_OFFER_SDP }); + }); + + // Simulate the RTCPeerConnection reaching 'connected' state + await act(async () => { + fakePC._fireStateChange('connected'); + }); + + // Status badge should now show "Connected · Live" + expect(screen.getByText('Connected · Live')).toBeInTheDocument(); + }); +}); diff --git a/client/src/hooks/useSignaling.test.js b/client/src/hooks/useSignaling.test.js new file mode 100644 index 0000000..6db3f1d --- /dev/null +++ b/client/src/hooks/useSignaling.test.js @@ -0,0 +1,146 @@ +import { renderHook, act } from '@testing-library/react'; +import { useSignaling } from './useSignaling.js'; + +// ── Fake WebSocket ──────────────────────────────────────────────────────────── + +let wsInstances = []; + +class FakeWebSocket { + static OPEN = 1; + static CONNECTING = 0; + static CLOSING = 2; + static CLOSED = 3; + + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.CONNECTING; + this._sent = []; + this.closeCalled = false; + wsInstances.push(this); + } + + send(data) { this._sent.push(data); } + + close() { + this.readyState = FakeWebSocket.CLOSED; + this.closeCalled = true; + } + + // Test helpers + simulateOpen() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + simulateMessage(data) { + this.onmessage?.({ data: typeof data === 'string' ? data : JSON.stringify(data) }); + } + simulateClose() { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.(); + } +} + +beforeEach(() => { + wsInstances = []; + vi.stubGlobal('WebSocket', FakeWebSocket); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +test('opens a WebSocket to the given URL on mount', () => { + renderHook(() => useSignaling('ws://test.local', vi.fn())); + expect(wsInstances).toHaveLength(1); + expect(wsInstances[0].url).toBe('ws://test.local'); +}); + +test('connected starts false, becomes true after onopen fires', async () => { + const { result } = renderHook(() => useSignaling('ws://test.local', vi.fn())); + expect(result.current.connected).toBe(false); + + await act(async () => { wsInstances[0].simulateOpen(); }); + + expect(result.current.connected).toBe(true); +}); + +test('onMessage callback called with parsed object when onmessage fires', async () => { + const onMessage = vi.fn(); + renderHook(() => useSignaling('ws://test.local', onMessage)); + + await act(async () => { wsInstances[0].simulateOpen(); }); + await act(async () => { + wsInstances[0].simulateMessage({ type: 'room-created', roomId: 'ABC' }); + }); + + expect(onMessage).toHaveBeenCalledWith({ type: 'room-created', roomId: 'ABC' }); +}); + +test('connected becomes false when onclose fires', async () => { + const { result } = renderHook(() => useSignaling('ws://test.local', vi.fn())); + + await act(async () => { wsInstances[0].simulateOpen(); }); + expect(result.current.connected).toBe(true); + + await act(async () => { wsInstances[0].simulateClose(); }); + expect(result.current.connected).toBe(false); +}); + +test('send() calls ws.send(JSON.stringify(payload)) when connected', async () => { + const { result } = renderHook(() => useSignaling('ws://test.local', vi.fn())); + await act(async () => { wsInstances[0].simulateOpen(); }); + + act(() => { result.current.send({ type: 'create-room' }); }); + + expect(wsInstances[0]._sent).toContain(JSON.stringify({ type: 'create-room' })); +}); + +test('send() logs a console warning when not connected', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { result } = renderHook(() => useSignaling('ws://test.local', vi.fn())); + // Don't open — readyState stays CONNECTING + + act(() => { result.current.send({ type: 'test' }); }); + + expect(warnSpy).toHaveBeenCalled(); +}); + +test('after onclose, schedules a reconnect (advance fake timers)', async () => { + vi.useFakeTimers(); + + const { result } = renderHook(() => useSignaling('ws://test.local', vi.fn())); + await act(async () => { wsInstances[0].simulateOpen(); }); + await act(async () => { wsInstances[0].simulateClose(); }); + + // Not yet reconnected + expect(wsInstances).toHaveLength(1); + + // First retry delay is 1000ms + await act(async () => { vi.advanceTimersByTime(1100); }); + + expect(wsInstances).toHaveLength(2); + expect(wsInstances[1].url).toBe('ws://test.local'); + + // Stop fake timers to avoid bleeding into other tests + vi.useRealTimers(); +}); + +test('WebSocket is closed and reconnect timer cleared on unmount', async () => { + vi.useFakeTimers(); + + const { result, unmount } = renderHook(() => useSignaling('ws://test.local', vi.fn())); + await act(async () => { wsInstances[0].simulateOpen(); }); + + unmount(); + + expect(wsInstances[0].closeCalled).toBe(true); + + // Advance past the retry delay — no new connection should appear + await act(async () => { vi.advanceTimersByTime(5000); }); + expect(wsInstances).toHaveLength(1); + + vi.useRealTimers(); +}); diff --git a/client/src/main.jsx b/client/src/main.jsx index 220dcc4..d8dee3e 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -1,10 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { HashRouter } from 'react-router-dom'; import App from './App.jsx'; import './App.css'; ReactDOM.createRoot(document.getElementById('root')).render( - + + + ); diff --git a/client/src/test-setup.js b/client/src/test-setup.js new file mode 100644 index 0000000..c05fa4a --- /dev/null +++ b/client/src/test-setup.js @@ -0,0 +1,77 @@ +import '@testing-library/jest-dom'; + +// ── Canvas stub ─────────────────────────────────────────────────────────────── +// AudioVisualizer calls canvas.getContext('2d') — jsdom doesn't implement it. +HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ + clearRect: vi.fn(), + fillRect: vi.fn(), + beginPath: vi.fn(), + closePath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fill: vi.fn(), + fillStyle: '', + strokeStyle: '', + lineWidth: 0, + shadowBlur: 0, + shadowColor: '', +})); + +// ── AudioContext stub ───────────────────────────────────────────────────────── +const makeFakeSource = () => ({ connect: vi.fn(), disconnect: vi.fn() }); +const makeFakeGain = () => ({ gain: { value: 1 }, connect: vi.fn() }); +const makeFakeAnalyser = () => ({ + fftSize: 1024, + smoothingTimeConstant: 0.8, + frequencyBinCount: 512, + getFloatTimeDomainData: vi.fn(), + connect: vi.fn(), +}); + +const makeFakeAudioCtx = () => { + const source = makeFakeSource(); + const gain = makeFakeGain(); + const analyser = makeFakeAnalyser(); + return { + createMediaStreamSource: vi.fn(() => source), + createGain: vi.fn(() => gain), + createAnalyser: vi.fn(() => analyser), + resume: vi.fn(() => Promise.resolve()), + close: vi.fn(() => Promise.resolve()), + destination: {}, + state: 'running', + }; +}; + +window.AudioContext = vi.fn(makeFakeAudioCtx); +window.webkitAudioContext = vi.fn(makeFakeAudioCtx); + +// ── navigator.mediaDevices stub ─────────────────────────────────────────────── +Object.defineProperty(navigator, 'mediaDevices', { + value: { getDisplayMedia: vi.fn() }, + writable: true, + configurable: true, +}); + +// ── requestAnimationFrame / cancelAnimationFrame stubs ─────────────────────── +// Use a no-op: return a handle but never fire the callback. This prevents the +// AudioVisualizer draw loop from running asynchronously and leaking timers. +window.requestAnimationFrame = vi.fn(() => 0); +window.cancelAnimationFrame = vi.fn(); + +// ── RTCSessionDescription / RTCIceCandidate stubs ──────────────────────────── +// Used in component code when passing SDP/ICE to RTCPeerConnection. +// WebRTC tests stub RTCPeerConnection per-test; these ensure constructor calls +// don't throw in non-WebRTC tests. +global.RTCSessionDescription = class RTCSessionDescription { + constructor(init) { Object.assign(this, init); } +}; +global.RTCIceCandidate = class RTCIceCandidate { + constructor(init) { Object.assign(this, init); } +}; + +// ── Reset mock state between tests ─────────────────────────────────────────── +afterEach(() => { + vi.clearAllMocks(); +}); diff --git a/client/vite.config.js b/client/vite.config.js index bf86dfa..5b3a06f 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -15,4 +15,9 @@ export default defineConfig({ }, }, }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test-setup.js'], + }, }); From 7db13547a5d7b57774e078be1a2aa15e6c9a4acc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:11:51 +0000 Subject: [PATCH 2/8] Initial plan From ab874be3df8c57ca90a254ae711659a48c1f5d3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:13:26 +0000 Subject: [PATCH 3/8] Pin pnpm to version 8 and use --frozen-lockfile in CI Co-authored-by: afraser <1179515+afraser@users.noreply.github.com> --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4bafa5d..a582a7f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: - uses: pnpm/action-setup@v3 with: - version: latest + version: 8 - uses: actions/setup-node@v4 with: @@ -42,7 +42,7 @@ jobs: cache-dependency-path: client/pnpm-lock.yaml - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile working-directory: client - name: Run tests From 93112ee6f7759393612b7841c523c4f274649c8c Mon Sep 17 00:00:00 2001 From: Adam Fraser Date: Tue, 3 Mar 2026 17:21:17 -0500 Subject: [PATCH 4/8] fix lousy accessibility --- client/src/App.css | 5 + client/src/App.jsx | 32 ++--- client/src/components/HostView.jsx | 9 +- client/src/components/PeerView.jsx | 181 ++++++++++++++++------------- 4 files changed, 120 insertions(+), 107 deletions(-) diff --git a/client/src/App.css b/client/src/App.css index ad950b6..c89b0b7 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -32,6 +32,11 @@ html, body { -webkit-font-smoothing: antialiased; } +a { + color: inherit; + text-decoration: none; +} + /* ── Layout ─────────────────────────────────────────────────────────────────── */ .app { diff --git a/client/src/App.jsx b/client/src/App.jsx index fcd2c72..61a133e 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,58 +1,50 @@ -import { Routes, Route, useNavigate } from "react-router-dom"; +import { Routes, Route, Link } from "react-router-dom"; import HostView from "./components/HostView.jsx"; import PeerView from "./components/PeerView.jsx"; function Landing() { - const navigate = useNavigate(); return (

Stream Audio, Peer-to-Peer

- Share music from Spotify, Deezer, or any browser tab directly to - your friends — no middle server, no delay, no accounts required. + Share music from Spotify, Deezer, or any browser tab directly to your + friends — no middle server, no delay, no accounts required.

-
navigate("/host")}> +
📡

Host a Session

Capture and broadcast tab audio to connected listeners

-
+ -
navigate("/join-room")}> +
🎧

Listen In

Join a session with a room code and hear the stream live

-
+
- Browser audio capture works best when - Spotify/Deezer is open in a browser tab (Web Player). For - the native desktop app, an Electron wrapper with system audio - access is required. + Browser audio capture works best when Spotify/Deezer is + open in a browser tab (Web Player). For the native desktop app, + an Electron wrapper with system audio access is required.
); } export default function App() { - const navigate = useNavigate(); - return (
-
navigate("/")} - style={{ cursor: "pointer" }} - > +
🎵
JamLink -
+
diff --git a/client/src/components/HostView.jsx b/client/src/components/HostView.jsx index 288690a..482ca1c 100644 --- a/client/src/components/HostView.jsx +++ b/client/src/components/HostView.jsx @@ -11,7 +11,7 @@ */ import { useState, useCallback, useRef, useEffect } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import { useSignaling } from "../hooks/useSignaling.js"; import AudioVisualizer from "./AudioVisualizer.jsx"; @@ -25,7 +25,6 @@ const SIGNALING_URL = import.meta.env.VITE_SIGNALING_URL || "ws://localhost:8080"; export default function HostView() { - const navigate = useNavigate(); const [roomId, setRoomId] = useState(null); const [stream, setStream] = useState(null); const [peers, setPeers] = useState({}); // peerId -> { pc, state } @@ -101,7 +100,7 @@ export default function HostView() { setRoomId(null); Object.keys(peerConnsRef.current).forEach(closePeerConnection); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [connected]); // ── Create the room once we're connected ────────────────────────────────── @@ -266,9 +265,9 @@ export default function HostView() { return (
- + {/* Status bar */}
diff --git a/client/src/components/PeerView.jsx b/client/src/components/PeerView.jsx index 7302a5d..a834dd9 100644 --- a/client/src/components/PeerView.jsx +++ b/client/src/components/PeerView.jsx @@ -8,26 +8,25 @@ * 4. Visualise the incoming audio waveform */ -import { useState, useCallback, useRef, useEffect } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { useSignaling } from '../hooks/useSignaling.js'; -import AudioVisualizer from './AudioVisualizer.jsx'; +import { useState, useCallback, useRef, useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { useSignaling } from "../hooks/useSignaling.js"; +import AudioVisualizer from "./AudioVisualizer.jsx"; const ICE_SERVERS = [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, ]; const SIGNALING_URL = - import.meta.env.VITE_SIGNALING_URL || 'ws://localhost:8080'; + import.meta.env.VITE_SIGNALING_URL || "ws://localhost:8080"; export default function PeerView() { const { roomId: initialRoomCode = null } = useParams(); - const navigate = useNavigate(); - const [roomCodeInput, setRoomCodeInput] = useState(initialRoomCode || ''); + const [roomCodeInput, setRoomCodeInput] = useState(initialRoomCode || ""); const [joinedRoom, setJoinedRoom] = useState(null); const autoJoinedRef = useRef(false); - const [connectionState, setConnectionState] = useState('idle'); // idle | joining | connected | disconnected | error + const [connectionState, setConnectionState] = useState("idle"); // idle | joining | connected | disconnected | error const [remoteStream, setRemoteStream] = useState(null); const [volume, setVolume] = useState(80); const [error, setError] = useState(null); @@ -42,28 +41,27 @@ export default function PeerView() { const handleSignalingMessage = useCallback(async (msg) => { switch (msg.type) { - - case 'room-joined': { + case "room-joined": { setJoinedRoom(msg.roomId); - setConnectionState('joining'); + setConnectionState("joining"); break; } - case 'error': { - setError(msg.message || 'An error occurred.'); - setConnectionState('error'); + case "error": { + setError(msg.message || "An error occurred."); + setConnectionState("error"); break; } // Host sent us a WebRTC offer — create an answer - case 'offer': { + case "offer": { const { sdp } = msg; await handleOffer(sdp); break; } // ICE candidate from the host - case 'ice-candidate': { + case "ice-candidate": { const { candidate } = msg; if (pcRef.current && candidate) { await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate)); @@ -71,10 +69,10 @@ export default function PeerView() { break; } - case 'host-left': { - setConnectionState('disconnected'); + case "host-left": { + setConnectionState("disconnected"); setRemoteStream(null); - setError('The host ended the session.'); + setError("The host ended the session."); pcRef.current?.close(); break; } @@ -82,10 +80,13 @@ export default function PeerView() { default: break; } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const { send, connected } = useSignaling(SIGNALING_URL, handleSignalingMessage); + const { send, connected } = useSignaling( + SIGNALING_URL, + handleSignalingMessage + ); // ── Cleanup ──────────────────────────────────────────────────────────────── @@ -101,7 +102,7 @@ export default function PeerView() { useEffect(() => { if (initialRoomCode && connected && !autoJoinedRef.current) { autoJoinedRef.current = true; - send({ type: 'join-room', roomId: initialRoomCode }); + send({ type: "join-room", roomId: initialRoomCode }); } }, [initialRoomCode, connected, send]); @@ -121,49 +122,52 @@ export default function PeerView() { const joinRoom = useCallback(() => { const code = roomCodeInput.trim().toUpperCase(); if (!code || code.length < 4) { - setError('Please enter a valid room code.'); + setError("Please enter a valid room code."); return; } setError(null); - setConnectionState('joining'); - send({ type: 'join-room', roomId: code }); + setConnectionState("joining"); + send({ type: "join-room", roomId: code }); }, [roomCodeInput, send]); // ── Handle WebRTC offer ──────────────────────────────────────────────────── - const handleOffer = useCallback(async (sdp) => { - const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); - pcRef.current = pc; - - // When we receive audio tracks from the host, set up playback - pc.ontrack = (event) => { - console.log('[Peer] Received remote track:', event.track.kind); - if (event.streams && event.streams[0]) { - const incomingStream = event.streams[0]; - setRemoteStream(incomingStream); - playStream(incomingStream); - } - }; + const handleOffer = useCallback( + async (sdp) => { + const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); + pcRef.current = pc; + + // When we receive audio tracks from the host, set up playback + pc.ontrack = (event) => { + console.log("[Peer] Received remote track:", event.track.kind); + if (event.streams && event.streams[0]) { + const incomingStream = event.streams[0]; + setRemoteStream(incomingStream); + playStream(incomingStream); + } + }; - // Send our ICE candidates back to the host - pc.onicecandidate = ({ candidate }) => { - if (candidate) { - send({ type: 'ice-candidate', candidate }); - } - }; + // Send our ICE candidates back to the host + pc.onicecandidate = ({ candidate }) => { + if (candidate) { + send({ type: "ice-candidate", candidate }); + } + }; - pc.onconnectionstatechange = () => { - console.log('[Peer] Connection state:', pc.connectionState); - setConnectionState(pc.connectionState); - }; + pc.onconnectionstatechange = () => { + console.log("[Peer] Connection state:", pc.connectionState); + setConnectionState(pc.connectionState); + }; - await pc.setRemoteDescription(new RTCSessionDescription(sdp)); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); + await pc.setRemoteDescription(new RTCSessionDescription(sdp)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); - send({ type: 'answer', sdp: pc.localDescription }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [send]); + send({ type: "answer", sdp: pc.localDescription }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, + [send] + ); // ── Audio playback via Web Audio API ────────────────────────────────────── @@ -185,7 +189,7 @@ export default function PeerView() { // context starts suspended. We attempt resume() here; if it stays // suspended the UI will show a "click to enable audio" button. ctx.resume().then(() => setAudioSuspended(false)); - if (ctx.state !== 'running') setAudioSuspended(true); + if (ctx.state !== "running") setAudioSuspended(true); // Also attach to an
diff --git a/client/src/App.test.jsx b/client/src/App.test.jsx index ad05892..6fe93b2 100644 --- a/client/src/App.test.jsx +++ b/client/src/App.test.jsx @@ -30,11 +30,11 @@ describe('App routing', () => { expect(screen.getByText('Host Session')).toBeInTheDocument(); }); - test('clicking "Listen In" navigates to PeerView', async () => { + test('clicking "Listen In" navigates to JoinView', async () => { const user = userEvent.setup(); renderAt('/'); await user.click(screen.getByText('Listen In')); - // PeerView shows this title in its card + // JoinView shows this title in its card expect(screen.getByText('Listener')).toBeInTheDocument(); }); @@ -45,9 +45,9 @@ describe('App routing', () => { expect(screen.getByText('Stream Audio, Peer-to-Peer')).toBeInTheDocument(); }); - test('navigating to /listen/ABCXYZ renders PeerView with room code pre-filled', () => { + test('navigating to /listen/ABCXYZ renders ListenView with room code visible', () => { renderAt('/listen/ABCXYZ'); expect(screen.getByText('Listener')).toBeInTheDocument(); - expect(screen.getByDisplayValue('ABCXYZ')).toBeInTheDocument(); + expect(screen.getByText('ABCXYZ')).toBeInTheDocument(); }); }); diff --git a/client/src/components/JoinView.jsx b/client/src/components/JoinView.jsx new file mode 100644 index 0000000..56d909f --- /dev/null +++ b/client/src/components/JoinView.jsx @@ -0,0 +1,61 @@ +/** + * JoinView + * + * Handles the /join-room route. + * Lets the user type a room code and navigate to /listen/:roomId. + * No signaling or WebRTC here — that all happens in ListenView. + */ + +import { useState } from "react"; +import { Link } from "react-router-dom"; + +export default function JoinView() { + const [code, setCode] = useState(""); + + const trimmedCode = code.trim().toUpperCase(); + const isValid = trimmedCode.length >= 4; + + return ( +
+ + ← Back + + +
+
+ Listener +
+ +

+ Enter the 6-character room code shared by the host. +

+ +
+ setCode(e.target.value.toUpperCase())} + /> + {isValid ? ( + + Join + + ) : ( + + )} +
+
+ +
+ Privacy note: Audio streams directly from the host's + browser to yours via WebRTC. The signaling server never relays audio + data. +
+
+ ); +} diff --git a/client/src/components/JoinView.test.jsx b/client/src/components/JoinView.test.jsx new file mode 100644 index 0000000..ee8f000 --- /dev/null +++ b/client/src/components/JoinView.test.jsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import JoinView from "./JoinView.jsx"; + +function renderJoinView() { + return render( + + + } /> + } /> + } + /> + + + ); +} + +describe("JoinView", () => { + test("input renders empty", () => { + renderJoinView(); + expect(screen.getByPlaceholderText("XXXXXX")).toHaveValue(""); + }); + + test("Join is a disabled button when input has fewer than 4 chars", async () => { + const user = userEvent.setup(); + renderJoinView(); + await user.type(screen.getByPlaceholderText("XXXXXX"), "AB"); + expect(screen.getByRole("button", { name: "Join" })).toBeDisabled(); + }); + + test("Join becomes a link when 4+ chars are entered", async () => { + const user = userEvent.setup(); + renderJoinView(); + await user.type(screen.getByPlaceholderText("XXXXXX"), "ABCD"); + expect(screen.getByRole("link", { name: "Join" })).toBeInTheDocument(); + }); + + test("clicking Join link navigates to /listen/CODE", async () => { + const user = userEvent.setup(); + renderJoinView(); + await user.type(screen.getByPlaceholderText("XXXXXX"), "ABCDEF"); + await user.click(screen.getByRole("link", { name: "Join" })); + expect(screen.getByTestId("listen-view")).toBeInTheDocument(); + }); + + test("back link navigates to /", async () => { + const user = userEvent.setup(); + renderJoinView(); + await user.click(screen.getByRole("link", { name: /← Back/i })); + expect(screen.getByTestId("landing")).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/PeerView.jsx b/client/src/components/ListenView.jsx similarity index 60% rename from client/src/components/PeerView.jsx rename to client/src/components/ListenView.jsx index 8e5ecc6..8f634bc 100644 --- a/client/src/components/PeerView.jsx +++ b/client/src/components/ListenView.jsx @@ -1,11 +1,9 @@ /** - * PeerView + * ListenView * - * Lets a listener: - * 1. Enter a room code and connect to the host's signaling room - * 2. Receive the WebRTC audio stream offered by the host (ANSWERER role) - * 3. Play the audio through the Web Audio API with volume control - * 4. Visualise the incoming audio waveform + * Handles the /listen/:roomId route. + * Auto-joins the signaling room and receives the WebRTC audio stream + * offered by the host (ANSWERER role). */ import { useState, useCallback, useRef, useEffect } from "react"; @@ -21,12 +19,9 @@ const ICE_SERVERS = [ const SIGNALING_URL = import.meta.env.VITE_SIGNALING_URL || "ws://localhost:8080"; -export default function PeerView() { - const { roomId: initialRoomCode = null } = useParams(); - const [roomCodeInput, setRoomCodeInput] = useState(initialRoomCode || ""); - const [joinedRoom, setJoinedRoom] = useState(null); - const autoJoinedRef = useRef(false); - const [connectionState, setConnectionState] = useState("idle"); // idle | joining | connected | disconnected | error +export default function ListenView() { + const { roomId } = useParams(); + const [connectionState, setConnectionState] = useState("idle"); const [remoteStream, setRemoteStream] = useState(null); const [volume, setVolume] = useState(80); const [error, setError] = useState(null); @@ -36,13 +31,13 @@ export default function PeerView() { const gainNodeRef = useRef(null); const audioCtxRef = useRef(null); const audioElRef = useRef(null); + const autoJoinedRef = useRef(false); - // ── Signaling message handler (must be declared before useSignaling) ──────── + // ── Signaling message handler ────────────────────────────────────────────── const handleSignalingMessage = useCallback(async (msg) => { switch (msg.type) { case "room-joined": { - setJoinedRoom(msg.roomId); setConnectionState("joining"); break; } @@ -53,18 +48,16 @@ export default function PeerView() { break; } - // Host sent us a WebRTC offer — create an answer case "offer": { - const { sdp } = msg; - await handleOffer(sdp); + await handleOffer(msg.sdp); break; } - // ICE candidate from the host case "ice-candidate": { - const { candidate } = msg; - if (pcRef.current && candidate) { - await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate)); + if (pcRef.current && msg.candidate) { + await pcRef.current.addIceCandidate( + new RTCIceCandidate(msg.candidate) + ); } break; } @@ -97,39 +90,22 @@ export default function PeerView() { }; }, []); - // ── Auto-join when navigated to via URL ─────────────────────────────────── + // ── Auto-join on mount ───────────────────────────────────────────────────── useEffect(() => { - if (initialRoomCode && connected && !autoJoinedRef.current) { + if (connected && !autoJoinedRef.current) { autoJoinedRef.current = true; - send({ type: "join-room", roomId: initialRoomCode }); + send({ type: "join-room", roomId }); } - }, [initialRoomCode, connected, send]); + }, [connected, roomId, send]); // ── Volume control ───────────────────────────────────────────────────────── useEffect(() => { - if (gainNodeRef.current) { - gainNodeRef.current.gain.value = volume / 100; - } - if (audioElRef.current) { - audioElRef.current.volume = volume / 100; - } + if (gainNodeRef.current) gainNodeRef.current.gain.value = volume / 100; + if (audioElRef.current) audioElRef.current.volume = volume / 100; }, [volume]); - // ── Join a room ──────────────────────────────────────────────────────────── - - const joinRoom = useCallback(() => { - const code = roomCodeInput.trim().toUpperCase(); - if (!code || code.length < 4) { - setError("Please enter a valid room code."); - return; - } - setError(null); - setConnectionState("joining"); - send({ type: "join-room", roomId: code }); - }, [roomCodeInput, send]); - // ── Handle WebRTC offer ──────────────────────────────────────────────────── const handleOffer = useCallback( @@ -137,9 +113,7 @@ export default function PeerView() { const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); pcRef.current = pc; - // When we receive audio tracks from the host, set up playback pc.ontrack = (event) => { - console.log("[Peer] Received remote track:", event.track.kind); if (event.streams && event.streams[0]) { const incomingStream = event.streams[0]; setRemoteStream(incomingStream); @@ -147,22 +121,17 @@ export default function PeerView() { } }; - // Send our ICE candidates back to the host pc.onicecandidate = ({ candidate }) => { - if (candidate) { - send({ type: "ice-candidate", candidate }); - } + if (candidate) send({ type: "ice-candidate", candidate }); }; pc.onconnectionstatechange = () => { - console.log("[Peer] Connection state:", pc.connectionState); setConnectionState(pc.connectionState); }; await pc.setRemoteDescription(new RTCSessionDescription(sdp)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); - send({ type: "answer", sdp: pc.localDescription }); // eslint-disable-next-line react-hooks/exhaustive-deps }, @@ -172,26 +141,19 @@ export default function PeerView() { // ── Audio playback via Web Audio API ────────────────────────────────────── const playStream = useCallback((stream) => { - // Create an AudioContext for volume control + visualisation const ctx = new (window.AudioContext || window.webkitAudioContext)(); const source = ctx.createMediaStreamSource(stream); const gainNode = ctx.createGain(); gainNode.gain.value = volume / 100; - source.connect(gainNode); gainNode.connect(ctx.destination); audioCtxRef.current = ctx; gainNodeRef.current = gainNode; - // Browsers require a user gesture before AudioContext can produce sound. - // If the peer arrived via a shared URL (no gesture on this page), the - // context starts suspended. We attempt resume() here; if it stays - // suspended the UI will show a "click to enable audio" button. ctx.resume().then(() => setAudioSuspended(false)); if (ctx.state !== "running") setAudioSuspended(true); - // Also attach to an