diff --git a/client/src/components/HostView.test.jsx b/client/src/components/HostView.test.jsx
new file mode 100644
index 0000000..9b59af5
--- /dev/null
+++ b/client/src/components/HostView.test.jsx
@@ -0,0 +1,311 @@
+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 link navigates to /", async () => {
+ const user = userEvent.setup();
+ renderHostView();
+ await user.click(screen.getByRole("link", { 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/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
+
+ ) : (
+
+ 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/ListenView.jsx b/client/src/components/ListenView.jsx
new file mode 100644
index 0000000..8f634bc
--- /dev/null
+++ b/client/src/components/ListenView.jsx
@@ -0,0 +1,274 @@
+/**
+ * ListenView
+ *
+ * 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";
+import { useParams, Link } 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" },
+];
+
+const SIGNALING_URL =
+ import.meta.env.VITE_SIGNALING_URL || "ws://localhost:8080";
+
+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);
+ const [audioSuspended, setAudioSuspended] = useState(false);
+
+ const pcRef = useRef(null);
+ const gainNodeRef = useRef(null);
+ const audioCtxRef = useRef(null);
+ const audioElRef = useRef(null);
+ const autoJoinedRef = useRef(false);
+
+ // ── Signaling message handler ──────────────────────────────────────────────
+
+ const handleSignalingMessage = useCallback(async (msg) => {
+ switch (msg.type) {
+ case "room-joined": {
+ setConnectionState("joining");
+ break;
+ }
+
+ case "error": {
+ setError(msg.message || "An error occurred.");
+ setConnectionState("error");
+ break;
+ }
+
+ case "offer": {
+ await handleOffer(msg.sdp);
+ break;
+ }
+
+ case "ice-candidate": {
+ if (pcRef.current && msg.candidate) {
+ await pcRef.current.addIceCandidate(
+ new RTCIceCandidate(msg.candidate)
+ );
+ }
+ break;
+ }
+
+ case "host-left": {
+ setConnectionState("disconnected");
+ setRemoteStream(null);
+ setError("The host ended the session.");
+ pcRef.current?.close();
+ break;
+ }
+
+ default:
+ break;
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const { send, connected } = useSignaling(
+ SIGNALING_URL,
+ handleSignalingMessage
+ );
+
+ // ── Cleanup ────────────────────────────────────────────────────────────────
+
+ useEffect(() => {
+ return () => {
+ pcRef.current?.close();
+ audioCtxRef.current?.close();
+ };
+ }, []);
+
+ // ── Auto-join on mount ─────────────────────────────────────────────────────
+
+ useEffect(() => {
+ if (connected && !autoJoinedRef.current) {
+ autoJoinedRef.current = true;
+ send({ type: "join-room", roomId });
+ }
+ }, [connected, roomId, send]);
+
+ // ── Volume control ─────────────────────────────────────────────────────────
+
+ useEffect(() => {
+ if (gainNodeRef.current) gainNodeRef.current.gain.value = volume / 100;
+ if (audioElRef.current) audioElRef.current.volume = volume / 100;
+ }, [volume]);
+
+ // ── Handle WebRTC offer ────────────────────────────────────────────────────
+
+ const handleOffer = useCallback(
+ async (sdp) => {
+ const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
+ pcRef.current = pc;
+
+ pc.ontrack = (event) => {
+ if (event.streams && event.streams[0]) {
+ const incomingStream = event.streams[0];
+ setRemoteStream(incomingStream);
+ playStream(incomingStream);
+ }
+ };
+
+ pc.onicecandidate = ({ candidate }) => {
+ if (candidate) send({ type: "ice-candidate", candidate });
+ };
+
+ pc.onconnectionstatechange = () => {
+ 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
+ },
+ [send]
+ );
+
+ // ── Audio playback via Web Audio API ──────────────────────────────────────
+
+ const playStream = useCallback((stream) => {
+ 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;
+
+ ctx.resume().then(() => setAudioSuspended(false));
+ if (ctx.state !== "running") setAudioSuspended(true);
+
+ let el = audioElRef.current;
+ if (!el) {
+ el = new Audio();
+ audioElRef.current = el;
+ }
+ el.srcObject = stream;
+ el.volume = volume / 100;
+ el.play().catch(() => {});
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // ── UI helpers ─────────────────────────────────────────────────────────────
+
+ const stateConfig = {
+ idle: { label: "Not connected", cls: "idle" },
+ joining: { label: "Joining room…", cls: "connecting" },
+ new: { label: "Connecting…", cls: "connecting" },
+ checking: { label: "Negotiating…", cls: "connecting" },
+ connecting: { label: "Connecting…", cls: "connecting" },
+ connected: { label: "Connected · Live", cls: "active" },
+ disconnected: { label: "Disconnected", cls: "idle" },
+ failed: { label: "Connection failed", cls: "error" },
+ error: { label: "Error", cls: "error" },
+ closed: { label: "Closed", cls: "idle" },
+ };
+
+ const status = stateConfig[connectionState] ?? {
+ label: connectionState,
+ cls: "idle",
+ };
+ const isConnected = connectionState === "connected";
+
+ // ── Render ─────────────────────────────────────────────────────────────────
+
+ return (
+
+
+ ← Back
+
+
+
+
+ Listener
+
+
+ {status.label}
+
+
+
+
+
+
+ Room
+
+
+ {roomId}
+
+
+ {isConnected && (
+
+
+ Audio live
+
+ )}
+
+
+ {error &&
{error}
}
+
+ {!isConnected &&
+ connectionState !== "error" &&
+ connectionState !== "disconnected" && (
+
+ Waiting for the host to offer a connection…
+
+ )}
+
+
+ {isConnected && remoteStream && (
+
+
Audio Playback
+ {audioSuspended ? (
+
+ audioCtxRef.current?.resume().then(() => {
+ setAudioSuspended(false);
+ audioElRef.current?.play().catch(() => {});
+ })
+ }
+ >
+ 🔊 Click to enable audio
+
+ ) : (
+
+ )}
+
+
+ Volume
+ setVolume(Number(e.target.value))}
+ />
+ {volume}%
+
+
+ )}
+
+
+ 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/ListenView.test.jsx b/client/src/components/ListenView.test.jsx
new file mode 100644
index 0000000..7a085e9
--- /dev/null
+++ b/client/src/components/ListenView.test.jsx
@@ -0,0 +1,199 @@
+import { render, screen } 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 ListenView from "./ListenView.jsx";
+
+vi.mock("../hooks/useSignaling.js", () => ({
+ useSignaling: vi.fn(),
+}));
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+let mockSend;
+let capturedOnMessage;
+
+function renderListenView(roomId = "ABCDEF") {
+ 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,
+ createAnswer: vi.fn(async () => FAKE_ANSWER_SDP),
+ setLocalDescription: vi.fn(async (desc) => {
+ pc.localDescription = desc;
+ }),
+ setRemoteDescription: vi.fn(async () => {}),
+ addIceCandidate: vi.fn(async () => {}),
+ 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("ListenView — signaling and UI", () => {
+ test("auto-sends join-room with roomId from URL once connected", () => {
+ renderListenView("ABCXYZ");
+ expect(mockSend).toHaveBeenCalledWith({
+ type: "join-room",
+ roomId: "ABCXYZ",
+ });
+ });
+
+ test("displays the room code from the URL", () => {
+ renderListenView("ABCXYZ");
+ expect(screen.getByText("ABCXYZ")).toBeInTheDocument();
+ });
+
+ test('shows "Joining room…" after room-joined message', async () => {
+ renderListenView();
+ await act(async () => {
+ await capturedOnMessage({ type: "room-joined", roomId: "ABCDEF" });
+ });
+ expect(screen.getByText("Joining room…")).toBeInTheDocument();
+ });
+
+ test("shows error alert after error message", async () => {
+ renderListenView();
+ 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 () => {
+ renderListenView();
+ 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 link navigates to /", async () => {
+ const user = userEvent.setup();
+ renderListenView();
+ await user.click(screen.getByRole("link", { name: /← Back/i }));
+ expect(screen.getByTestId("landing")).toBeInTheDocument();
+ });
+});
+
+// ── WebRTC tests ──────────────────────────────────────────────────────────────
+
+describe("ListenView — WebRTC", () => {
+ beforeEach(() => {
+ setupFakeRTC();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ test("after receiving an offer, component sends an answer with valid SDP", async () => {
+ renderListenView();
+ 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 () => {
+ renderListenView();
+ 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("connectionState becomes connected in UI after state change", async () => {
+ renderListenView();
+ await act(async () => {
+ await capturedOnMessage({ type: "room-joined", roomId: "ABCDEF" });
+ });
+ await act(async () => {
+ await capturedOnMessage({ type: "offer", sdp: FAKE_OFFER_SDP });
+ });
+
+ await act(async () => {
+ fakePC._fireStateChange("connected");
+ });
+
+ expect(screen.getByText("Connected · Live")).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/PeerView.jsx b/client/src/components/PeerView.jsx
deleted file mode 100644
index 0d28041..0000000
--- a/client/src/components/PeerView.jsx
+++ /dev/null
@@ -1,326 +0,0 @@
-/**
- * PeerView
- *
- * 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
- */
-
-import { useState, useCallback, useRef, useEffect } from 'react';
-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' },
-];
-
-const SIGNALING_URL =
- import.meta.env.VITE_SIGNALING_URL || 'ws://localhost:8080';
-
-export default function PeerView({ onBack, initialRoomCode = null }) {
- 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 [remoteStream, setRemoteStream] = useState(null);
- const [volume, setVolume] = useState(80);
- const [error, setError] = useState(null);
- const [audioSuspended, setAudioSuspended] = useState(false);
-
- const pcRef = useRef(null);
- const gainNodeRef = useRef(null);
- const audioCtxRef = useRef(null);
- const audioElRef = useRef(null);
-
- // ── Signaling message handler (must be declared before useSignaling) ────────
-
- const handleSignalingMessage = useCallback(async (msg) => {
- switch (msg.type) {
-
- case 'room-joined': {
- setJoinedRoom(msg.roomId);
- setConnectionState('joining');
- break;
- }
-
- case 'error': {
- setError(msg.message || 'An error occurred.');
- setConnectionState('error');
- break;
- }
-
- // Host sent us a WebRTC offer — create an answer
- case 'offer': {
- const { sdp } = msg;
- await handleOffer(sdp);
- break;
- }
-
- // ICE candidate from the host
- case 'ice-candidate': {
- const { candidate } = msg;
- if (pcRef.current && candidate) {
- await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate));
- }
- break;
- }
-
- case 'host-left': {
- setConnectionState('disconnected');
- setRemoteStream(null);
- setError('The host ended the session.');
- pcRef.current?.close();
- break;
- }
-
- default:
- break;
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const { send, connected } = useSignaling(SIGNALING_URL, handleSignalingMessage);
-
- // ── Cleanup ────────────────────────────────────────────────────────────────
-
- useEffect(() => {
- return () => {
- pcRef.current?.close();
- audioCtxRef.current?.close();
- };
- }, []);
-
- // ── Auto-join when navigated to via URL ───────────────────────────────────
-
- useEffect(() => {
- if (initialRoomCode && connected && !autoJoinedRef.current) {
- autoJoinedRef.current = true;
- send({ type: 'join-room', roomId: initialRoomCode });
- }
- }, [initialRoomCode, connected, send]);
-
- // ── Volume control ─────────────────────────────────────────────────────────
-
- useEffect(() => {
- 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(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 });
- }
- };
-
- 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
- }, [send]);
-
- // ── 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
element as a fallback / for mobile autoplay
- let el = audioElRef.current;
- if (!el) {
- el = new Audio();
- audioElRef.current = el;
- }
- el.srcObject = stream;
- el.volume = volume / 100;
- el.play().catch(() => {}); // best-effort; may be blocked by autoplay policy
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- // ── UI helpers ─────────────────────────────────────────────────────────────
-
- const stateConfig = {
- idle: { label: 'Not connected', cls: 'idle' },
- joining: { label: 'Joining room…', cls: 'connecting' },
- new: { label: 'Connecting…', cls: 'connecting' },
- checking: { label: 'Negotiating…', cls: 'connecting' },
- connecting: { label: 'Connecting…', cls: 'connecting' },
- connected: { label: 'Connected · Live', cls: 'active' },
- disconnected: { label: 'Disconnected', cls: 'idle' },
- failed: { label: 'Connection failed',cls: 'error' },
- error: { label: 'Error', cls: 'error' },
- closed: { label: 'Closed', cls: 'idle' },
- };
-
- const status = stateConfig[connectionState] ?? { label: connectionState, cls: 'idle' };
- const isConnected = connectionState === 'connected';
-
- // ── Render ─────────────────────────────────────────────────────────────────
-
- return (
-
-
- ← Back
-
-
- {/* Connection status */}
-
-
- Listener
-
-
- {status.label}
-
-
-
- {/* Room code entry */}
- {!joinedRoom ? (
- <>
-
- Enter the 6-character room code shared by the host.
-
-
- setRoomCodeInput(e.target.value.toUpperCase())}
- onKeyDown={(e) => e.key === 'Enter' && joinRoom()}
- disabled={!connected}
- />
-
- Join
-
-
- {!connected && (
-
Connecting to signaling server…
- )}
- >
- ) : (
-
-
- {isConnected && (
-
-
- Audio live
-
- )}
-
- )}
-
- {error &&
{error}
}
-
- {joinedRoom && !isConnected && connectionState !== 'error' && (
-
- Waiting for the host to offer a connection…
-
- )}
-
-
- {/* Audio playback + visualizer */}
- {isConnected && remoteStream && (
-
-
Audio Playback
- {audioSuspended ? (
-
audioCtxRef.current?.resume().then(() => {
- setAudioSuspended(false);
- audioElRef.current?.play().catch(() => {});
- })}
- >
- 🔊 Click to enable audio
-
- ) : (
-
- )}
-
-
- Volume
- setVolume(Number(e.target.value))}
- />
- {volume}%
-
-
- )}
-
- {/* Info */}
-
- 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/hooks/useSignaling.test.js b/client/src/hooks/useSignaling.test.js
new file mode 100644
index 0000000..f9fac4d
--- /dev/null
+++ b/client/src/hooks/useSignaling.test.js
@@ -0,0 +1,182 @@
+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();
+ vi.restoreAllMocks();
+});
+
+// ── 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'],
+ },
});