From f2b51542bc05552abfac2a8bd46fbb877c9aa639 Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Wed, 3 Jun 2026 10:28:08 +0200 Subject: [PATCH 1/7] add tests --- packages/react-client/package.json | 3 +- .../react-client/src/tests/camera.spec.ts | 131 +++++++++ .../react-client/src/tests/connection.spec.ts | 88 ++++++ .../src/tests/customSource.spec.ts | 69 +++++ .../src/tests/dataChannel.spec.ts | 95 +++++++ .../src/tests/initializeDevices.spec.ts | 71 +++++ .../react-client/src/tests/livestream.spec.ts | 95 +++++++ .../react-client/src/tests/microphone.spec.ts | 72 +++++ packages/react-client/src/tests/misc.spec.ts | 24 ++ packages/react-client/src/tests/peers.spec.ts | 91 ++++++ .../react-client/src/tests/sandbox.spec.ts | 58 ++++ .../src/tests/screenShare.spec.ts | 87 ++++++ .../src/tests/support/fakeFishjamClient.ts | 261 ++++++++++++++++++ .../src/tests/support/fakeMediaDevices.ts | 115 ++++++++ .../src/tests/support/fakeMediaStream.ts | 95 +++++++ .../src/tests/support/renderWithProvider.tsx | 44 +++ .../react-client/src/tests/support/setup.ts | 24 ++ packages/react-client/src/tests/vad.spec.ts | 61 ++++ packages/react-client/tsconfig.build.json | 4 + packages/react-client/vitest.config.ts | 1 + 20 files changed, 1488 insertions(+), 1 deletion(-) create mode 100644 packages/react-client/src/tests/camera.spec.ts create mode 100644 packages/react-client/src/tests/connection.spec.ts create mode 100644 packages/react-client/src/tests/customSource.spec.ts create mode 100644 packages/react-client/src/tests/dataChannel.spec.ts create mode 100644 packages/react-client/src/tests/initializeDevices.spec.ts create mode 100644 packages/react-client/src/tests/livestream.spec.ts create mode 100644 packages/react-client/src/tests/microphone.spec.ts create mode 100644 packages/react-client/src/tests/misc.spec.ts create mode 100644 packages/react-client/src/tests/peers.spec.ts create mode 100644 packages/react-client/src/tests/sandbox.spec.ts create mode 100644 packages/react-client/src/tests/screenShare.spec.ts create mode 100644 packages/react-client/src/tests/support/fakeFishjamClient.ts create mode 100644 packages/react-client/src/tests/support/fakeMediaDevices.ts create mode 100644 packages/react-client/src/tests/support/fakeMediaStream.ts create mode 100644 packages/react-client/src/tests/support/renderWithProvider.tsx create mode 100644 packages/react-client/src/tests/support/setup.ts create mode 100644 packages/react-client/src/tests/vad.spec.ts create mode 100644 packages/react-client/tsconfig.build.json diff --git a/packages/react-client/package.json b/packages/react-client/package.json index bdc58b2fe..f316be45c 100644 --- a/packages/react-client/package.json +++ b/packages/react-client/package.json @@ -32,9 +32,10 @@ } }, "scripts": { - "build": "tsc", + "build": "tsc -p tsconfig.build.json", "e2e": "NODE_OPTIONS=--dns-result-order=ipv4first playwright test", "test": "vitest run", + "typecheck": "tsc --noEmit", "docs": "typedoc src src/experimental", "format": "prettier --write . --ignore-path ./.eslintignore", "format:check": "prettier --check . --ignore-path ./.eslintignore", diff --git a/packages/react-client/src/tests/camera.spec.ts b/packages/react-client/src/tests/camera.spec.ts new file mode 100644 index 000000000..aca8f68a9 --- /dev/null +++ b/packages/react-client/src/tests/camera.spec.ts @@ -0,0 +1,131 @@ +import { act } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { useCamera } from "../hooks/devices/useCamera"; +import { FakeFishjamClient } from "./support/fakeFishjamClient"; +import { createFakeStream } from "./support/fakeMediaStream"; +import { renderHookWithProvider } from "./support/renderWithProvider"; +import { media } from "./support/setup"; + +const videoStream = () => createFakeStream([{ kind: "video", deviceId: "cam-1" }]); + +describe("useCamera", () => { + it("is off before the device is started", () => { + const { result } = renderHookWithProvider(() => useCamera()); + expect(result.current.isCameraOn).toBe(false); + expect(result.current.cameraStream).toBeNull(); + }); + + it("startCamera acquires the device and exposes the stream without publishing", async () => { + media().setUserMediaStream(videoStream()); + const { result, client } = renderHookWithProvider(() => useCamera()); + + await act(async () => { + await result.current.startCamera(); + }); + + expect(media().devices.getUserMedia).toHaveBeenCalledTimes(1); + expect(result.current.isCameraOn).toBe(true); + expect(result.current.cameraStream?.getVideoTracks()).toHaveLength(1); + // Not connected → no track published to the SFU. + expect(client.addTrack).not.toHaveBeenCalled(); + }); + + it("stopCamera tears the stream down", async () => { + media().setUserMediaStream(videoStream()); + const { result } = renderHookWithProvider(() => useCamera()); + + await act(async () => { + await result.current.startCamera(); + }); + act(() => result.current.stopCamera()); + + expect(result.current.isCameraOn).toBe(false); + expect(result.current.cameraStream).toBeNull(); + }); + + it("toggleCamera while connected publishes a camera track with metadata", async () => { + media().setUserMediaStream(videoStream()); + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useCamera(), { client }); + + act(() => client.simulateJoined()); // peerStatus → connected + + await act(async () => { + await result.current.toggleCamera(); + }); + + expect(result.current.isCameraOn).toBe(true); + expect(client.addTrack).toHaveBeenCalledTimes(1); + const meta = client.addTrack.mock.calls[0][1]; + expect(meta).toMatchObject({ type: "camera", paused: false }); + }); + + it("auto-publishes the running device when the room is joined", async () => { + media().setUserMediaStream(videoStream()); + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useCamera(), { client }); + + await act(async () => { + await result.current.startCamera(); + }); + expect(client.addTrack).not.toHaveBeenCalled(); + + await act(async () => { + client.simulateJoined(); + }); + + expect(client.addTrack).toHaveBeenCalledTimes(1); + expect(client.addTrack.mock.calls[0][1]).toMatchObject({ type: "camera" }); + }); + + it("toggleCamera off while connected pauses the published track", async () => { + media().setUserMediaStream(videoStream()); + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useCamera(), { client }); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.toggleCamera(); // on + publish + }); + await act(async () => { + await result.current.toggleCamera(); // off + }); + + expect(result.current.isCameraOn).toBe(false); + // pauseStreaming replaces with null and flips metadata paused=true. + expect(client.replaceTrack).toHaveBeenCalledWith(expect.any(String), null); + const lastMeta = client.updateTrackMetadata.mock.calls.at(-1)?.[1]; + expect(lastMeta).toMatchObject({ type: "camera", paused: true }); + }); + + it("setCameraTrackMiddleware applies the middleware to the device track", async () => { + media().setUserMediaStream(videoStream()); + const { result } = renderHookWithProvider(() => useCamera()); + + await act(async () => { + await result.current.startCamera(); + }); + + const processed = createFakeStream([{ kind: "video", deviceId: "processed" }]).getVideoTracks()[0]; + const onClear = vi.fn(); + await act(async () => { + await result.current.setCameraTrackMiddleware((_track) => ({ track: processed, onClear })); + }); + + expect(result.current.currentCameraMiddleware).toBeTypeOf("function"); + expect(result.current.cameraStream?.getVideoTracks()[0]).toBe(processed); + }); + + it("surfaces a device error when getUserMedia is denied", async () => { + media().failUserMediaAlways("NotAllowedError"); + const { result } = renderHookWithProvider(() => useCamera()); + + await act(async () => { + await result.current.startCamera(); + }); + + expect(result.current.cameraDeviceError).toEqual({ name: "NotAllowedError" }); + expect(result.current.isCameraOn).toBe(false); + }); +}); diff --git a/packages/react-client/src/tests/connection.spec.ts b/packages/react-client/src/tests/connection.spec.ts new file mode 100644 index 000000000..6f7e384df --- /dev/null +++ b/packages/react-client/src/tests/connection.spec.ts @@ -0,0 +1,88 @@ +import { act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useConnection } from "../hooks/useConnection"; +import { renderHookWithProvider } from "./support/renderWithProvider"; + +describe("useConnection", () => { + it("starts idle", () => { + const { result } = renderHookWithProvider(() => useConnection()); + expect(result.current.peerStatus).toBe("idle"); + expect(result.current.reconnectionStatus).toBe("idle"); + }); + + it("joinRoom connects the client with a ws url, token and metadata", async () => { + const { result, client } = renderHookWithProvider(() => useConnection()); + + await act(async () => { + await result.current.joinRoom({ peerToken: "tok-123", peerMetadata: { name: "alice" } }); + }); + + expect(client.connect).toHaveBeenCalledTimes(1); + const config = client.connect.mock.calls[0][0] as { url: string; token: string; peerMetadata: unknown }; + expect(config.token).toBe("tok-123"); + expect(config.peerMetadata).toEqual({ name: "alice" }); + expect(config.url.startsWith("wss://")).toBe(true); + expect(config.url).toContain("test-fishjam-id"); + }); + + it("defaults peerMetadata to an empty object", async () => { + const { result, client } = renderHookWithProvider(() => useConnection()); + await act(async () => { + await result.current.joinRoom({ peerToken: "tok" }); + }); + expect((client.connect.mock.calls[0][0] as { peerMetadata: unknown }).peerMetadata).toEqual({}); + }); + + it("transitions peerStatus connecting → connected on the client events", () => { + const { result, client } = renderHookWithProvider(() => useConnection()); + + act(() => client.simulateConnectionStarted()); + expect(result.current.peerStatus).toBe("connecting"); + + act(() => client.simulateJoined()); + expect(result.current.peerStatus).toBe("connected"); + }); + + it("sets peerStatus to error on auth / join / connection errors", () => { + const { result, client } = renderHookWithProvider(() => useConnection()); + act(() => client.simulateAuthError()); + expect(result.current.peerStatus).toBe("error"); + }); + + it("leaveRoom disconnects and returns to idle", () => { + const { result, client } = renderHookWithProvider(() => useConnection()); + act(() => client.simulateJoined()); + expect(result.current.peerStatus).toBe("connected"); + + act(() => result.current.leaveRoom()); + expect(client.disconnect).toHaveBeenCalledTimes(1); + expect(result.current.peerStatus).toBe("idle"); + }); + + it("tracks reconnectionStatus across the reconnect lifecycle", () => { + const { result, client } = renderHookWithProvider(() => useConnection()); + + act(() => client.simulateReconnectionStarted()); + expect(result.current.reconnectionStatus).toBe("reconnecting"); + + act(() => client.simulateReconnected()); + expect(result.current.reconnectionStatus).toBe("idle"); + + act(() => client.simulateReconnectionStarted()); + act(() => client.simulateReconnectionRetriesLimitReached()); + expect(result.current.reconnectionStatus).toBe("error"); + }); + + it("promotes a joinError to a reconnection error only while reconnecting", () => { + const { result, client } = renderHookWithProvider(() => useConnection()); + + // Not reconnecting → joinError must not flip reconnectionStatus. + act(() => client.simulateJoinError()); + expect(result.current.reconnectionStatus).toBe("idle"); + + act(() => client.simulateReconnectionStarted()); + act(() => client.simulateJoinError()); + expect(result.current.reconnectionStatus).toBe("error"); + }); +}); diff --git a/packages/react-client/src/tests/customSource.spec.ts b/packages/react-client/src/tests/customSource.spec.ts new file mode 100644 index 000000000..93e395c57 --- /dev/null +++ b/packages/react-client/src/tests/customSource.spec.ts @@ -0,0 +1,69 @@ +import { act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useCustomSource } from "../hooks/useCustomSource"; +import { FakeFishjamClient } from "./support/fakeFishjamClient"; +import { createFakeStream } from "./support/fakeMediaStream"; +import { renderHookWithProvider } from "./support/renderWithProvider"; + +const avStream = () => + createFakeStream([ + { kind: "video", deviceId: "v" }, + { kind: "audio", deviceId: "a" }, + ]); + +describe("useCustomSource", () => { + it("exposes no stream until one is set", () => { + const { result } = renderHookWithProvider(() => useCustomSource("cam-feed")); + expect(result.current.stream).toBeUndefined(); + }); + + it("publishes custom video + audio tracks when connected", async () => { + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useCustomSource("feed"), { client }); + + act(() => client.simulateJoined()); + + const stream = avStream(); + await act(async () => { + await result.current.setStream(stream); + }); + + expect(result.current.stream).toBe(stream); + const metas = client.addTrack.mock.calls.map((c) => (c[1] as { type: string }).type); + expect(metas).toContain("customVideo"); + expect(metas).toContain("customAudio"); + }); + + it("defers publishing until the room is joined when set before connecting", async () => { + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useCustomSource("feed"), { client }); + + await act(async () => { + await result.current.setStream(avStream()); + }); + expect(client.addTrack).not.toHaveBeenCalled(); + + await act(async () => { + client.simulateJoined(); + }); + + expect(client.addTrack).toHaveBeenCalled(); + }); + + it("removes tracks when the stream is cleared", async () => { + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useCustomSource("feed"), { client }); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.setStream(avStream()); + }); + await act(async () => { + await result.current.setStream(null); + }); + + expect(client.removeTrack).toHaveBeenCalled(); + expect(result.current.stream).toBeUndefined(); + }); +}); diff --git a/packages/react-client/src/tests/dataChannel.spec.ts b/packages/react-client/src/tests/dataChannel.spec.ts new file mode 100644 index 000000000..eadf0357b --- /dev/null +++ b/packages/react-client/src/tests/dataChannel.spec.ts @@ -0,0 +1,95 @@ +import { act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useDataChannel } from "../hooks/useDataChannel"; +import { FakeFishjamClient } from "./support/fakeFishjamClient"; +import { renderHookWithProvider } from "./support/renderWithProvider"; + +describe("useDataChannel", () => { + it("starts not ready / not loading / no error", () => { + const { result } = renderHookWithProvider(() => useDataChannel()); + expect(result.current.dataChannelReady).toBe(false); + expect(result.current.dataChannelLoading).toBe(false); + expect(result.current.dataChannelError).toBeNull(); + }); + + it("errors when initializing before the peer is connected", async () => { + const { result } = renderHookWithProvider(() => useDataChannel()); + + await act(async () => { + await result.current.initializeDataChannel(); + }); + + expect(result.current.dataChannelError?.message).toBe("Peer is not connected"); + expect(result.current.dataChannelReady).toBe(false); + }); + + it("creates channels and becomes ready when connected", async () => { + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useDataChannel(), { client }); + + act(() => client.simulateJoined()); + + await act(async () => { + await result.current.initializeDataChannel(); + }); + + expect(client.createDataChannels).toHaveBeenCalledTimes(1); + expect(result.current.dataChannelReady).toBe(true); + expect(result.current.dataChannelError).toBeNull(); + }); + + it("publishData forwards the payload and options to the client", () => { + const { result, client } = renderHookWithProvider(() => useDataChannel()); + const payload = new Uint8Array([1, 2, 3]); + + act(() => result.current.publishData(payload, { reliable: true })); + + expect(client.publishData).toHaveBeenCalledWith(payload, { reliable: true }); + }); + + it("subscribeData delivers incoming data and unsubscribes", () => { + const { result, client } = renderHookWithProvider(() => useDataChannel()); + const received: Uint8Array[] = []; + + let unsub = () => {}; + act(() => { + unsub = result.current.subscribeData((d) => received.push(d), { reliable: false }); + }); + + act(() => client.simulateIncomingData(new Uint8Array([9]))); + expect(received).toHaveLength(1); + + act(() => unsub()); + act(() => client.simulateIncomingData(new Uint8Array([10]))); + expect(received).toHaveLength(1); + }); + + it("surfaces a data-channel error and drops readiness", async () => { + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useDataChannel(), { client }); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.initializeDataChannel(); + }); + expect(result.current.dataChannelReady).toBe(true); + + act(() => client.simulateDataChannelsError(new Error("boom"))); + expect(result.current.dataChannelError?.message).toBe("boom"); + expect(result.current.dataChannelReady).toBe(false); + }); + + it("resets readiness on disconnect", async () => { + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useDataChannel(), { client }); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.initializeDataChannel(); + }); + act(() => client.simulateDisconnected()); + + expect(result.current.dataChannelReady).toBe(false); + }); +}); diff --git a/packages/react-client/src/tests/initializeDevices.spec.ts b/packages/react-client/src/tests/initializeDevices.spec.ts new file mode 100644 index 000000000..442b8000a --- /dev/null +++ b/packages/react-client/src/tests/initializeDevices.spec.ts @@ -0,0 +1,71 @@ +import { act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useCamera } from "../hooks/devices/useCamera"; +import { useInitializeDevices } from "../hooks/devices/useInitializeDevices"; +import { createFakeStream } from "./support/fakeMediaStream"; +import { renderHookWithProvider } from "./support/renderWithProvider"; +import { media } from "./support/setup"; + +const fullStream = () => + createFakeStream([ + { kind: "video", deviceId: "cam-1" }, + { kind: "audio", deviceId: "mic-1" }, + ]); + +const devices = [ + { deviceId: "cam-1", kind: "videoinput" as const, label: "Cam 1" }, + { deviceId: "mic-1", kind: "audioinput" as const, label: "Mic 1" }, +]; + +describe("useInitializeDevices", () => { + it("acquires media and reports initialized", async () => { + media().setUserMediaStream(fullStream()); + media().setEnumeratedDevices(devices); + + const { result } = renderHookWithProvider(() => useInitializeDevices()); + + let outcome: Awaited>; + await act(async () => { + outcome = await result.current.initializeDevices(); + }); + + expect(outcome!.status).toBe("initialized"); + expect(outcome!.stream).not.toBeNull(); + expect(media().devices.enumerateDevices).toHaveBeenCalled(); + }); + + it("is idempotent: a second call reports already_initialized", async () => { + media().setUserMediaStream(fullStream()); + media().setEnumeratedDevices(devices); + + const { result } = renderHookWithProvider(() => useInitializeDevices()); + + await act(async () => { + await result.current.initializeDevices(); + }); + let second: Awaited>; + await act(async () => { + second = await result.current.initializeDevices(); + }); + + expect(second!.status).toBe("already_initialized"); + }); + + it("populates the device lists exposed by useCamera", async () => { + media().setUserMediaStream(fullStream()); + media().setEnumeratedDevices(devices); + + const { result } = renderHookWithProvider(() => ({ + init: useInitializeDevices(), + camera: useCamera(), + })); + + await act(async () => { + await result.current.init.initializeDevices(); + }); + + expect(result.current.camera.cameraDevices).toHaveLength(1); + expect(result.current.camera.cameraDevices[0]).toMatchObject({ deviceId: "cam-1", label: "Cam 1" }); + }); +}); diff --git a/packages/react-client/src/tests/livestream.spec.ts b/packages/react-client/src/tests/livestream.spec.ts new file mode 100644 index 000000000..b4bb06c60 --- /dev/null +++ b/packages/react-client/src/tests/livestream.spec.ts @@ -0,0 +1,95 @@ +import { LivestreamError, publishLivestream, receiveLivestream } from "@fishjam-cloud/ts-client"; +import { act } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useLivestreamStreamer } from "../hooks/useLivestreamStreamer"; +import { useLivestreamViewer } from "../hooks/useLivestreamViewer"; +import type { FakeMediaStream } from "./support/fakeMediaStream"; +import { createFakeStream } from "./support/fakeMediaStream"; +import { renderHookWithProvider } from "./support/renderWithProvider"; + +// Keep FishjamClient / getLogger / LivestreamError real; stub only the WHIP/WHEP fns. +vi.mock("@fishjam-cloud/ts-client", async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, publishLivestream: vi.fn(), receiveLivestream: vi.fn() }; +}); + +const asConnected = { connectionState: "connected" } as RTCPeerConnection; + +type ConnCb = (pc: RTCPeerConnection) => void; + +describe("useLivestreamStreamer", () => { + beforeEach(() => vi.mocked(publishLivestream).mockClear()); + + it("publishes the given video stream and tracks connection state", async () => { + let onChange: ConnCb = () => {}; + const stopPublishing = vi.fn(); + // The 4th arg carries the connection-state callback. Read it defensively: + // testing-library's unmount can invoke the spy again during teardown. + vi.mocked(publishLivestream).mockImplementation(async (...args) => { + const cbs = args[3] as { onConnectionStateChange?: ConnCb } | undefined; + if (cbs?.onConnectionStateChange) onChange = cbs.onConnectionStateChange; + return { stopPublishing } as never; + }); + + const { result } = renderHookWithProvider(() => useLivestreamStreamer()); + + await act(async () => { + await result.current.connect({ inputs: { video: createFakeStream([{ kind: "video" }]) }, token: "t" }); + }); + + expect(publishLivestream).toHaveBeenCalledTimes(1); + const publishedStream = vi.mocked(publishLivestream).mock.calls[0][0] as FakeMediaStream; + expect(publishedStream.getVideoTracks()).toHaveLength(1); + expect(result.current.isConnected).toBe(false); + + act(() => onChange(asConnected)); + expect(result.current.isConnected).toBe(true); + + act(() => result.current.disconnect()); + expect(stopPublishing).toHaveBeenCalledTimes(1); + }); + + it("captures a LivestreamError thrown by publishLivestream", async () => { + const err = Object.values(LivestreamError)[0]; + // Reject only the real connect call; resolve any teardown-time stray call so + // it doesn't surface as an unhandled rejection. + vi.mocked(publishLivestream) + .mockRejectedValueOnce(err) + .mockResolvedValue({ stopPublishing: vi.fn() } as never); + + const { result } = renderHookWithProvider(() => useLivestreamStreamer()); + await act(async () => { + await result.current.connect({ inputs: { video: createFakeStream([{ kind: "video" }]) }, token: "t" }); + }); + + expect(result.current.error).toBe(err); + }); +}); + +describe("useLivestreamViewer", () => { + beforeEach(() => vi.mocked(receiveLivestream).mockClear()); + + it("connects, exposes the received stream and disconnects", async () => { + const stream = createFakeStream([{ kind: "video" }]); + const stop = vi.fn(); + vi.mocked(receiveLivestream).mockResolvedValue({ + stream, + stop, + getStatistics: async () => ({}) as RTCStatsReport, + } as never); + + const { result } = renderHookWithProvider(() => useLivestreamViewer()); + + await act(async () => { + await result.current.connect({ token: "viewer-token" }); + }); + + expect(receiveLivestream).toHaveBeenCalledTimes(1); + expect(result.current.stream).toBe(stream); + + act(() => result.current.disconnect()); + expect(stop).toHaveBeenCalledTimes(1); + expect(result.current.stream).toBeNull(); + }); +}); diff --git a/packages/react-client/src/tests/microphone.spec.ts b/packages/react-client/src/tests/microphone.spec.ts new file mode 100644 index 000000000..a126c99ab --- /dev/null +++ b/packages/react-client/src/tests/microphone.spec.ts @@ -0,0 +1,72 @@ +import { act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useMicrophone } from "../hooks/devices/useMicrophone"; +import { FakeFishjamClient } from "./support/fakeFishjamClient"; +import { createFakeStream } from "./support/fakeMediaStream"; +import { renderHookWithProvider } from "./support/renderWithProvider"; +import { media } from "./support/setup"; + +const audioStream = () => createFakeStream([{ kind: "audio", deviceId: "mic-1" }]); + +describe("useMicrophone", () => { + it("starts off and unmuted", () => { + const { result } = renderHookWithProvider(() => useMicrophone()); + expect(result.current.isMicrophoneOn).toBe(false); + expect(result.current.isMicrophoneMuted).toBe(false); + }); + + it("startMicrophone acquires an audio stream", async () => { + media().setUserMediaStream(audioStream()); + const { result } = renderHookWithProvider(() => useMicrophone()); + + await act(async () => { + await result.current.startMicrophone(); + }); + + expect(result.current.isMicrophoneOn).toBe(true); + expect(result.current.microphoneStream?.getAudioTracks()).toHaveLength(1); + }); + + it("toggleMicrophoneMute pauses the track and reports muted, keeping the device on", async () => { + media().setUserMediaStream(audioStream()); + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useMicrophone(), { client }); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.toggleMicrophone(); // device on + publish + }); + expect(client.addTrack).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.toggleMicrophoneMute(); + }); + + expect(result.current.isMicrophoneMuted).toBe(true); + // Still on (soft mute): the stream is not torn down. + expect(result.current.isMicrophoneOn).toBe(true); + expect(client.replaceTrack).toHaveBeenCalledWith(expect.any(String), null); + expect(client.updateTrackMetadata.mock.calls.at(-1)?.[1]).toMatchObject({ type: "microphone", paused: true }); + }); + + it("unmuting resumes the track", async () => { + media().setUserMediaStream(audioStream()); + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useMicrophone(), { client }); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.toggleMicrophone(); + }); + await act(async () => { + await result.current.toggleMicrophoneMute(); // mute + }); + await act(async () => { + await result.current.toggleMicrophoneMute(); // unmute + }); + + expect(result.current.isMicrophoneMuted).toBe(false); + expect(client.updateTrackMetadata.mock.calls.at(-1)?.[1]).toMatchObject({ type: "microphone", paused: false }); + }); +}); diff --git a/packages/react-client/src/tests/misc.spec.ts b/packages/react-client/src/tests/misc.spec.ts new file mode 100644 index 000000000..4a9525aec --- /dev/null +++ b/packages/react-client/src/tests/misc.spec.ts @@ -0,0 +1,24 @@ +import { act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useStatistics } from "../hooks/useStatistics"; +import { useUpdatePeerMetadata } from "../hooks/useUpdatePeerMetadata"; +import { renderHookWithProvider } from "./support/renderWithProvider"; + +describe("useUpdatePeerMetadata", () => { + it("forwards metadata to the client", () => { + const { result, client } = renderHookWithProvider(() => useUpdatePeerMetadata()); + act(() => result.current.updatePeerMetadata({ role: "host" })); + expect(client.updatePeerMetadata).toHaveBeenCalledWith({ role: "host" }); + }); +}); + +describe("useStatistics", () => { + it("delegates to client.getStatistics", async () => { + const { result, client } = renderHookWithProvider(() => useStatistics()); + await act(async () => { + await result.current.getStatistics(); + }); + expect(client.getStatistics).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-client/src/tests/peers.spec.ts b/packages/react-client/src/tests/peers.spec.ts new file mode 100644 index 000000000..8879f3026 --- /dev/null +++ b/packages/react-client/src/tests/peers.spec.ts @@ -0,0 +1,91 @@ +import { act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { usePeers } from "../hooks/usePeers"; +import { createFakeTrack } from "./support/fakeMediaStream"; +import { renderHookWithProvider } from "./support/renderWithProvider"; + +const cameraMeta = { type: "camera", paused: false } as const; +const micMeta = { type: "microphone", paused: false } as const; +const screenVideoMeta = { type: "screenShareVideo", paused: false } as const; +const customVideoMeta = { type: "customVideo", paused: false } as const; + +describe("usePeers", () => { + it("returns null localPeer and empty remotePeers before joining", () => { + const { result } = renderHookWithProvider(() => usePeers()); + expect(result.current.localPeer).toBeNull(); + expect(result.current.remotePeers).toEqual([]); + }); + + it("buckets local peer tracks by metadata type", () => { + const { result, client } = renderHookWithProvider(() => usePeers()); + + act(() => { + client.setLocalPeer({ + id: "local-peer", + metadata: { peer: { displayName: "me" }, server: {} }, + tracks: [ + { trackId: "c", metadata: cameraMeta, track: createFakeTrack({ kind: "video" }) }, + { trackId: "m", metadata: micMeta, track: createFakeTrack({ kind: "audio" }) }, + ], + }); + client.notifyStateChanged(); + }); + + const local = result.current.localPeer!; + expect(local.id).toBe("local-peer"); + expect(local.cameraTrack?.trackId).toBe("c"); + expect(local.microphoneTrack?.trackId).toBe("m"); + expect(local.tracks).toHaveLength(2); + }); + + it("buckets remote peer tracks including screen share and custom tracks", () => { + const { result, client } = renderHookWithProvider(() => usePeers()); + + act(() => { + client.addRemotePeer({ + id: "p1", + tracks: [ + { trackId: "rv", metadata: screenVideoMeta, track: createFakeTrack({ kind: "video" }) }, + { trackId: "cv", metadata: customVideoMeta, track: createFakeTrack({ kind: "video" }) }, + ], + }); + client.notifyStateChanged(); + }); + + const peer = result.current.remotePeers[0]; + expect(peer.id).toBe("p1"); + expect(peer.screenShareVideoTrack?.trackId).toBe("rv"); + expect(peer.customVideoTracks.map((t) => t.trackId)).toEqual(["cv"]); + }); + + it("keeps the deprecated `peers` alias equal to remotePeers", () => { + const { result, client } = renderHookWithProvider(() => usePeers()); + act(() => { + client.addRemotePeer({ id: "p1" }); + client.notifyStateChanged(); + }); + expect(result.current.peers).toEqual(result.current.remotePeers); + }); + + it("remote tracks expose setReceivedQuality wired to setTargetTrackEncoding", () => { + const { result, client } = renderHookWithProvider(() => usePeers()); + act(() => { + client.addRemotePeer({ + id: "p1", + tracks: [{ trackId: "rv", metadata: screenVideoMeta, track: createFakeTrack({ kind: "video" }) }], + }); + client.notifyStateChanged(); + }); + + act(() => result.current.remotePeers[0].screenShareVideoTrack!.setReceivedQuality("h" as never)); + expect(client.setTargetTrackEncoding).toHaveBeenCalledWith("rv", "h"); + }); + + it("setReceivedTracksQuality applies quality to every track id", () => { + const { result, client } = renderHookWithProvider(() => usePeers()); + act(() => result.current.setReceivedTracksQuality(["a", "b"], "m" as never)); + expect(client.setTargetTrackEncoding).toHaveBeenCalledWith("a", "m"); + expect(client.setTargetTrackEncoding).toHaveBeenCalledWith("b", "m"); + }); +}); diff --git a/packages/react-client/src/tests/sandbox.spec.ts b/packages/react-client/src/tests/sandbox.spec.ts new file mode 100644 index 000000000..475e925a0 --- /dev/null +++ b/packages/react-client/src/tests/sandbox.spec.ts @@ -0,0 +1,58 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useSandbox } from "../hooks/useSandbox"; + +const mockFetch = (response: { ok: boolean; status?: number; json?: () => Promise }) => { + const fetchSpy = vi.fn(async (_input: string | URL) => ({ + ok: response.ok, + status: response.status ?? (response.ok ? 200 : 500), + json: response.json ?? (async () => ({})), + })); + vi.stubGlobal("fetch", fetchSpy); + return fetchSpy; +}; + +describe("useSandbox", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("throws if no sandboxApiUrl is provided", async () => { + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "" })); + await expect(result.current.getSandboxPeerToken("room", "peer")).rejects.toThrow(/sandboxApiUrl/); + }); + + it("getSandboxPeerToken builds the query and returns the peer token", async () => { + const fetchSpy = mockFetch({ ok: true, json: async () => ({ peerToken: "pt-1" }) }); + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); + + const token = await result.current.getSandboxPeerToken("my-room", "alice", "audio_only"); + + expect(token).toBe("pt-1"); + const calledUrl = new URL(fetchSpy.mock.calls[0][0]); + expect(calledUrl.searchParams.get("roomName")).toBe("my-room"); + expect(calledUrl.searchParams.get("peerName")).toBe("alice"); + expect(calledUrl.searchParams.get("roomType")).toBe("audio_only"); + }); + + it("defaults roomType to conference", async () => { + const fetchSpy = mockFetch({ ok: true, json: async () => ({ peerToken: "pt" }) }); + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); + + await result.current.getSandboxPeerToken("room", "bob"); + const calledUrl = new URL(fetchSpy.mock.calls[0][0]); + expect(calledUrl.searchParams.get("roomType")).toBe("conference"); + }); + + it("getSandboxViewerToken gives a friendly error when the room is missing", async () => { + mockFetch({ ok: false, status: 404 }); + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); + await expect(result.current.getSandboxViewerToken("ghost")).rejects.toThrow(/does not exist/); + }); + + it("getSandboxLivestream returns the streamer token payload", async () => { + mockFetch({ ok: true, json: async () => ({ streamerToken: "st", room: { id: "r", name: "n" } }) }); + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); + const data = await result.current.getSandboxLivestream("room", true); + expect(data.streamerToken).toBe("st"); + }); +}); diff --git a/packages/react-client/src/tests/screenShare.spec.ts b/packages/react-client/src/tests/screenShare.spec.ts new file mode 100644 index 000000000..2ba943953 --- /dev/null +++ b/packages/react-client/src/tests/screenShare.spec.ts @@ -0,0 +1,87 @@ +import { act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useScreenShare } from "../hooks/useScreenShare"; +import { FakeFishjamClient } from "./support/fakeFishjamClient"; +import { createFakeStream } from "./support/fakeMediaStream"; +import { renderHookWithProvider } from "./support/renderWithProvider"; +import { media } from "./support/setup"; + +const screenStream = () => + createFakeStream([ + { kind: "video", deviceId: "screen" }, + { kind: "audio", deviceId: "tab" }, + ]); + +describe("useScreenShare", () => { + it("has no stream initially", () => { + const { result } = renderHookWithProvider(() => useScreenShare()); + expect(result.current.stream).toBeNull(); + expect(result.current.videoTrack).toBeNull(); + expect(result.current.audioTrack).toBeNull(); + }); + + it("startStreaming prompts getDisplayMedia and publishes video + audio tracks", async () => { + media().setDisplayMediaStream(screenStream()); + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useScreenShare(), { client }); + + await act(async () => { + await result.current.startStreaming(); + }); + + expect(media().devices.getDisplayMedia).toHaveBeenCalledTimes(1); + expect(result.current.videoTrack).not.toBeNull(); + expect(result.current.audioTrack).not.toBeNull(); + + const metas = client.addTrack.mock.calls.map((c) => (c[1] as { type: string }).type); + expect(metas).toContain("screenShareVideo"); + expect(metas).toContain("screenShareAudio"); + }); + + it("stopStreaming removes the SFU tracks while connected and clears the stream", async () => { + media().setDisplayMediaStream(screenStream()); + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useScreenShare(), { client }); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.startStreaming(); + }); + await act(async () => { + await result.current.stopStreaming(); + }); + + expect(client.removeTrack).toHaveBeenCalled(); + expect(result.current.stream).toBeNull(); + }); + + it("setTracksMiddleware processes both tracks and replaces them on the SFU", async () => { + media().setDisplayMediaStream(screenStream()); + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useScreenShare(), { client }); + + await act(async () => { + await result.current.startStreaming(); + }); + + const processedVideo = createFakeStream([{ kind: "video", deviceId: "pv" }]).getVideoTracks()[0]; + await act(async () => { + await result.current.setTracksMiddleware((video, audio) => ({ + videoTrack: processedVideo, + audioTrack: audio ?? video, + onClear: () => {}, + })); + }); + + // The middleware output is pushed to the SFU via replaceTrack. + expect(client.replaceTrack).toHaveBeenCalled(); + + // KNOWN QUIRK (FCE-XXXX): setTracksMiddleware never writes the middleware + // back into state, so `currentTracksMiddleware` stays null and the middleware + // is NOT re-applied on a subsequent startStreaming. Captured here so the + // rewrite has to make a deliberate decision to fix it (the assertion will + // flip when it does). See REWRITE_PLAN.md §5. + expect(result.current.currentTracksMiddleware).toBeNull(); + }); +}); diff --git a/packages/react-client/src/tests/support/fakeFishjamClient.ts b/packages/react-client/src/tests/support/fakeFishjamClient.ts new file mode 100644 index 000000000..1b59cdedc --- /dev/null +++ b/packages/react-client/src/tests/support/fakeFishjamClient.ts @@ -0,0 +1,261 @@ +import type { + DataCallback, + DataChannelOptions, + FishjamClient, + Peer, + TrackMetadata, + Variant, +} from "@fishjam-cloud/ts-client"; +import { EventEmitter } from "events"; +import { vi } from "vitest"; + +import { Deferred } from "../../utils/deferred"; +import { FakeMediaStream } from "./fakeMediaStream"; + +type VadStatus = "speech" | "silence"; + +/** + * The exact subset of `FishjamClient` command/query methods the React SDK + * depends on. Deriving it with `Pick` (rather than re-declaring signatures) is + * the point: if any of these is renamed, removed, or re-signed on the real + * client, this type — and the `asClient()` guard built on it — fails to compile, + * flagging that the fake has drifted. Add a key here whenever the SDK starts + * calling a new client method. + * + * `on`/`off`/`removeListener` are intentionally NOT included: they return `this` + * (chainable), so the real client's signatures would require the fake to BE a + * FishjamClient. They don't need re-guarding here anyway — `FishjamProvider` + * holds the real, fully-typed client, so any event rename or listener-signature + * change breaks the production hooks' `client.on(...)` calls directly. The fake's + * EventEmitter base is deliberately permissive so tests can drive those events. + */ +export type FishjamClientContract = Pick< + FishjamClient, + | "status" + | "connect" + | "disconnect" + | "addTrack" + | "replaceTrack" + | "removeTrack" + | "updateTrackMetadata" + | "updatePeerMetadata" + | "setTargetTrackEncoding" + | "createDataChannels" + | "publishData" + | "subscribeData" + | "getStatistics" + | "getLocalPeer" + | "getRemotePeers" + | "getRemoteComponents" + | "isReconnecting" + | "getDataChannelsReadiness" + | "getLocalTrackAudioLevel" +>; + +/** + * In-memory track context, mirroring the slice of `FishjamTrackContext` the SDK + * reads. It is an EventEmitter so `voiceActivityChanged` can be driven, and + * `vadStatus` is mutable in place (the real client mutates it the same way, + * which is why `useVAD` needs its own re-render trigger). + */ +export class FakeTrackContext extends EventEmitter { + vadStatus: VadStatus = "silence"; + + constructor( + public trackId: string, + public track: MediaStreamTrack | null, + public metadata: TrackMetadata | undefined, + public stream: MediaStream | null, + public simulcastConfig: unknown = null, + ) { + super(); + } + + simulateVad(status: VadStatus) { + this.vadStatus = status; + this.emit("voiceActivityChanged", this); + } +} + +export type FakePeerInit = { + id: string; + metadata?: unknown; + tracks?: { trackId: string; metadata: TrackMetadata; track?: MediaStreamTrack | null }[]; +}; + +const buildPeer = (init: FakePeerInit): Peer => { + const tracks = new Map(); + for (const t of init.tracks ?? []) { + tracks.set( + t.trackId, + new FakeTrackContext(t.trackId, t.track ?? null, t.metadata, new FakeMediaStream(t.track ? [t.track] : [])), + ); + } + return { id: init.id, type: "webrtc", metadata: init.metadata, tracks } as unknown as Peer; +}; + +/** + * Behavioral double for `FishjamClient`. Implements only the surface the SDK + * uses, with spies on every mutating method and `simulate*` helpers to drive + * the event-based state machine deterministically. + * + * Cast with `asClient()` when handing to `FishjamProvider`. + */ +export class FakeFishjamClient extends EventEmitter { + status: "new" | "initialized" = "initialized"; + + // ---- spies (assert call args / counts) ------------------------------- + connect = vi.fn(async (_config: unknown) => { + this.emit("connectionStarted"); + }); + disconnect = vi.fn(() => { + this.simulateDisconnected(); + }); + replaceTrack = vi.fn(async (trackId: string, newTrack: MediaStreamTrack | null) => { + const ctx = this.localPeer?.tracks.get(trackId) as FakeTrackContext | undefined; + if (ctx) ctx.track = newTrack; + }); + removeTrack = vi.fn(async (trackId: string) => { + this.localPeer?.tracks.delete(trackId); + }); + updateTrackMetadata = vi.fn((trackId: string, metadata: TrackMetadata) => { + const ctx = this.localPeer?.tracks.get(trackId) as FakeTrackContext | undefined; + if (ctx) ctx.metadata = metadata; + }); + updatePeerMetadata = vi.fn((metadata: unknown) => { + if (this.localPeer) (this.localPeer as { metadata?: unknown }).metadata = { peer: metadata, server: {} }; + }); + setTargetTrackEncoding = vi.fn((_trackId: string, _variant: Variant) => {}); + createDataChannels = vi.fn(async () => { + this.dataChannelsReady = true; + this.emit("dataChannelsReady"); + }); + publishData = vi.fn((_data: Uint8Array, _options: DataChannelOptions) => {}); + subscribeData = vi.fn((cb: DataCallback, _options: DataChannelOptions) => { + this.dataSubscribers.add(cb); + return () => this.dataSubscribers.delete(cb); + }); + getStatistics = vi.fn(async () => ({}) as RTCStatsReport); + + // ---- addTrack: controllable to exercise the local→remote id race ----- + addTrack = vi.fn((track: MediaStreamTrack, metadata?: TrackMetadata): Promise => { + const remoteId = `remote-${this.trackIdCounter++}`; + const register = () => { + if (!this.localPeer) this.localPeer = buildPeer({ id: "local-peer" }); + (this.localPeer.tracks as Map).set( + remoteId, + new FakeTrackContext(remoteId, track, metadata, new FakeMediaStream([track])), + ); + this.emit("localTrackAdded"); + }; + if (this.autoResolveAddTrack) { + register(); + return Promise.resolve(remoteId); + } + const deferred = new Deferred(); + this.pendingAddTracks.push(() => { + register(); + deferred.resolve(remoteId); + }); + return deferred.promise; + }); + + private trackIdCounter = 0; + private autoResolveAddTrack = true; + private pendingAddTracks: (() => void)[] = []; + private dataChannelsReady = false; + private dataSubscribers = new Set(); + private reconnecting = false; + private audioLevel: number | null = null; + + localPeer: Peer | null = null; + remotePeers: Record = {}; + + // ---- read methods ---------------------------------------------------- + getLocalPeer = () => this.localPeer; + getRemotePeers = () => this.remotePeers; + getRemoteComponents = () => ({}); + isReconnecting = () => this.reconnecting; + getDataChannelsReadiness = () => this.dataChannelsReady; + getLocalTrackAudioLevel = async (_trackId: string) => (this.audioLevel == null ? null : { level: this.audioLevel }); + + // EventEmitter compat: the SDK calls removeListener (alias of off). + + asClient(): FishjamClient { + // Drift tripwire: `this` must satisfy the real client's used surface + // (see FishjamClientContract). If FishjamClient renames/re-signs/removes any + // method the SDK relies on, this `satisfies` check stops compiling — the fake + // can never silently go out of sync. The final `as unknown` only erases the + // unused remainder of the (large, partly-private) FishjamClient surface. + return this satisfies FishjamClientContract as unknown as FishjamClient; + } + + // ---- test controls --------------------------------------------------- + + /** Hold addTrack promises until `flushAddTracks()`; exercises connectionPromiseRef. */ + deferAddTracks() { + this.autoResolveAddTrack = false; + } + flushAddTracks() { + const pending = this.pendingAddTracks; + this.pendingAddTracks = []; + pending.forEach((resolve) => resolve()); + } + + setLocalPeer(init: FakePeerInit) { + this.localPeer = buildPeer(init); + } + addRemotePeer(init: FakePeerInit) { + this.remotePeers[init.id] = buildPeer(init); + } + getRemoteTrackContext(peerId: string, trackId: string) { + return this.remotePeers[peerId]?.tracks.get(trackId) as FakeTrackContext | undefined; + } + setAudioLevel(level: number | null) { + this.audioLevel = level; + } + + simulateConnectionStarted() { + this.emit("connectionStarted"); + } + simulateJoined() { + if (!this.localPeer) this.localPeer = buildPeer({ id: "local-peer" }); + this.emit("joined"); + } + simulateReconnectionStarted() { + this.reconnecting = true; + this.emit("reconnectionStarted"); + } + simulateReconnected() { + this.reconnecting = false; + this.emit("reconnected"); + } + simulateReconnectionRetriesLimitReached() { + this.emit("reconnectionRetriesLimitReached"); + } + simulateAuthError() { + this.emit("authError"); + } + simulateJoinError() { + this.emit("joinError"); + } + simulateConnectionError() { + this.emit("connectionError"); + } + simulateDisconnected() { + this.localPeer = null; + this.remotePeers = {}; + this.dataChannelsReady = false; + this.emit("disconnected"); + } + /** Nudge `useFishjamClientState` to recompute its snapshot (peers/components). */ + notifyStateChanged() { + this.emit("peerUpdated"); + } + simulateDataChannelsError(error: Error) { + this.emit("dataChannelsError", error); + } + simulateIncomingData(data: Uint8Array) { + this.dataSubscribers.forEach((cb) => cb(data)); + } +} diff --git a/packages/react-client/src/tests/support/fakeMediaDevices.ts b/packages/react-client/src/tests/support/fakeMediaDevices.ts new file mode 100644 index 000000000..9dee2d24d --- /dev/null +++ b/packages/react-client/src/tests/support/fakeMediaDevices.ts @@ -0,0 +1,115 @@ +import { vi } from "vitest"; + +import { FakeMediaStream } from "./fakeMediaStream"; + +/** + * Controllable fake of the browser media layer. This is the exact surface the + * rewrite plans to hoist behind an injected `PlatformDeviceManager`, so keeping + * it isolated here means the same control knobs drive both the current + * (React-owned) tests and the future ts-client/core tests. + */ +export type FakeMediaDevices = { + getUserMedia: ReturnType; + getDisplayMedia: ReturnType; + enumerateDevices: ReturnType; + addEventListener: ReturnType; + removeEventListener: ReturnType; +}; + +export type MediaDevicesController = { + devices: FakeMediaDevices; + /** Queue/replace what the next getUserMedia returns, keyed by requested kind. */ + setUserMediaStream: (stream: MediaStream) => void; + /** Make the next getUserMedia reject with a DOMException of the given name. */ + failUserMediaOnce: (errorName: string) => void; + failUserMediaAlways: (errorName: string) => void; + setDisplayMediaStream: (stream: MediaStream) => void; + failDisplayMediaOnce: (errorName: string) => void; + setEnumeratedDevices: (devices: Partial[]) => void; + restore: () => void; +}; + +const makeError = (name: string) => { + const err = new Error(name); + err.name = name; + return err; +}; + +export const installFakeMediaDevices = (): MediaDevicesController => { + let userMediaStreamFactory: () => MediaStream = () => new FakeMediaStream(); + let userMediaError: { name: string; once: boolean } | null = null; + let displayMediaStreamFactory: () => MediaStream = () => new FakeMediaStream(); + let displayMediaError: string | null = null; + let enumerated: MediaDeviceInfo[] = []; + + const getUserMedia = vi.fn(async () => { + if (userMediaError) { + const name = userMediaError.name; + if (userMediaError.once) userMediaError = null; + throw makeError(name); + } + return userMediaStreamFactory(); + }); + + const getDisplayMedia = vi.fn(async () => { + if (displayMediaError) { + const name = displayMediaError; + displayMediaError = null; + throw makeError(name); + } + return displayMediaStreamFactory(); + }); + + const enumerateDevices = vi.fn(async () => enumerated); + + const devices: FakeMediaDevices = { + getUserMedia, + getDisplayMedia, + enumerateDevices, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + + // jsdom provides `navigator` but not `navigator.mediaDevices` / `MediaStream`. + const originalMediaDevices = Object.getOwnPropertyDescriptor(navigator, "mediaDevices"); + Object.defineProperty(navigator, "mediaDevices", { value: devices, configurable: true, writable: true }); + + const originalMediaStream = (globalThis as { MediaStream?: unknown }).MediaStream; + (globalThis as { MediaStream?: unknown }).MediaStream = FakeMediaStream; + + return { + devices, + setUserMediaStream: (stream) => { + userMediaError = null; + userMediaStreamFactory = () => stream; + }, + failUserMediaOnce: (errorName) => { + userMediaError = { name: errorName, once: true }; + }, + failUserMediaAlways: (errorName) => { + userMediaError = { name: errorName, once: false }; + }, + setDisplayMediaStream: (stream) => { + displayMediaError = null; + displayMediaStreamFactory = () => stream; + }, + failDisplayMediaOnce: (errorName) => { + displayMediaError = errorName; + }, + setEnumeratedDevices: (list) => { + enumerated = list.map((d) => { + const info = { + deviceId: d.deviceId ?? "", + kind: d.kind ?? "videoinput", + label: d.label ?? "", + groupId: d.groupId ?? "", + }; + return { ...info, toJSON: () => info } as MediaDeviceInfo; + }); + }, + restore: () => { + if (originalMediaDevices) Object.defineProperty(navigator, "mediaDevices", originalMediaDevices); + (globalThis as { MediaStream?: unknown }).MediaStream = originalMediaStream; + }, + }; +}; diff --git a/packages/react-client/src/tests/support/fakeMediaStream.ts b/packages/react-client/src/tests/support/fakeMediaStream.ts new file mode 100644 index 000000000..90ff47feb --- /dev/null +++ b/packages/react-client/src/tests/support/fakeMediaStream.ts @@ -0,0 +1,95 @@ +import { FakeMediaStreamTrack } from "fake-mediastreamtrack"; + +let streamCounter = 0; +let trackCounter = 0; + +/** + * Minimal `MediaStream` stand-in for jsdom (which ships none). Only the surface + * the SDK touches is implemented: id, the get*Tracks accessors, add/removeTrack. + * + * Framework-neutral by design — this kit must keep working once the logic under + * test moves from the React hooks down into ts-client/core. + */ +export class FakeMediaStream implements MediaStream { + readonly id = `fake-stream-${streamCounter++}`; + active = true; + onaddtrack = null; + onremovetrack = null; + onactive = null; + oninactive = null; + + private tracks: MediaStreamTrack[]; + + constructor(tracks: MediaStreamTrack[] | MediaStream = []) { + if (Array.isArray(tracks)) { + this.tracks = [...tracks]; + } else { + this.tracks = tracks.getTracks(); + } + } + + getTracks(): MediaStreamTrack[] { + return [...this.tracks]; + } + + getVideoTracks(): MediaStreamTrack[] { + return this.tracks.filter((t) => t.kind === "video"); + } + + getAudioTracks(): MediaStreamTrack[] { + return this.tracks.filter((t) => t.kind === "audio"); + } + + getTrackById(id: string): MediaStreamTrack | null { + return this.tracks.find((t) => t.id === id) ?? null; + } + + addTrack(track: MediaStreamTrack): void { + if (!this.tracks.includes(track)) this.tracks.push(track); + } + + removeTrack(track: MediaStreamTrack): void { + this.tracks = this.tracks.filter((t) => t !== track); + } + + clone(): MediaStream { + return new FakeMediaStream(this.tracks); + } + + // EventTarget surface — unused by the SDK, present for type-compatibility. + addEventListener(): void {} + removeEventListener(): void {} + dispatchEvent(): boolean { + return true; + } +} + +export type FakeTrackOptions = { + kind: "audio" | "video"; + deviceId?: string; + label?: string; + id?: string; + enabled?: boolean; +}; + +/** + * Create a fake track whose `getSettings().deviceId` reflects the device it was + * acquired from — the device-manager logic keys off exactly this. + */ +export const createFakeTrack = ({ + kind, + deviceId = `${kind}-device-default`, + label = `${kind} track`, + id, + enabled = true, +}: FakeTrackOptions): FakeMediaStreamTrack => + new FakeMediaStreamTrack({ + kind, + id: id ?? `${kind}-track-${trackCounter++}`, + label, + enabled, + settings: { deviceId }, + }); + +export const createFakeStream = (tracks: FakeTrackOptions[]): FakeMediaStream => + new FakeMediaStream(tracks.map(createFakeTrack)); diff --git a/packages/react-client/src/tests/support/renderWithProvider.tsx b/packages/react-client/src/tests/support/renderWithProvider.tsx new file mode 100644 index 000000000..792665b16 --- /dev/null +++ b/packages/react-client/src/tests/support/renderWithProvider.tsx @@ -0,0 +1,44 @@ +import { renderHook, type RenderHookOptions, type RenderHookResult } from "@testing-library/react"; +import { createElement, type PropsWithChildren } from "react"; + +import { FishjamProvider, type FishjamProviderProps } from "../../FishjamProvider"; +import { FakeFishjamClient } from "./fakeFishjamClient"; + +export type RenderHookWithProviderResult = RenderHookResult & { + client: FakeFishjamClient; +}; + +/** + * Render a hook inside a real `FishjamProvider` wired to a `FakeFishjamClient`. + * + * Tests assert on the hook's public return shape and on the spy calls recorded + * by the fake client. Both are part of the contract that must survive the move + * of logic into ts-client/core — so these specs are the regression harness for + * the rewrite, not throwaway scaffolding. + */ +export function renderHookWithProvider( + hook: (props: Props) => Result, + options?: RenderHookOptions & { + client?: FakeFishjamClient; + providerProps?: Partial; + }, +): RenderHookWithProviderResult { + const client = options?.client ?? new FakeFishjamClient(); + + const wrapper = ({ children }: PropsWithChildren) => + createElement( + FishjamProvider, + { + fishjamId: "test-fishjam-id", + fishjamClient: client.asClient(), + // Off by default so device-persistence localStorage isn't exercised + // unless a test opts in. + persistLastDevice: false, + ...options?.providerProps, + }, + children, + ); + + const result = renderHook(hook, { ...options, wrapper }); + return Object.assign(result, { client }); +} diff --git a/packages/react-client/src/tests/support/setup.ts b/packages/react-client/src/tests/support/setup.ts new file mode 100644 index 000000000..96e5b1686 --- /dev/null +++ b/packages/react-client/src/tests/support/setup.ts @@ -0,0 +1,24 @@ +import { afterEach, beforeEach } from "vitest"; + +import { installFakeMediaDevices, type MediaDevicesController } from "./fakeMediaDevices"; + +/** + * Installs the fake browser media layer (navigator.mediaDevices + MediaStream) + * before every test and exposes the controller via `globalThis.__media`. + * + * Registered as a vitest setupFile so every spec gets a clean media layer. + */ +declare global { + // eslint-disable-next-line no-var + var __media: MediaDevicesController; +} + +beforeEach(() => { + globalThis.__media = installFakeMediaDevices(); +}); + +afterEach(() => { + globalThis.__media?.restore(); +}); + +export const media = () => globalThis.__media; diff --git a/packages/react-client/src/tests/vad.spec.ts b/packages/react-client/src/tests/vad.spec.ts new file mode 100644 index 000000000..60c7def3c --- /dev/null +++ b/packages/react-client/src/tests/vad.spec.ts @@ -0,0 +1,61 @@ +import { act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useVAD } from "../hooks/useVAD"; +import type { PeerId } from "../types/public"; +import { FakeFishjamClient } from "./support/fakeFishjamClient"; +import { createFakeTrack } from "./support/fakeMediaStream"; +import { renderHookWithProvider } from "./support/renderWithProvider"; + +const micMeta = { type: "microphone", paused: false } as const; + +describe("useVAD (remote peers)", () => { + it("reports false until a peer starts speaking", () => { + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useVAD({ peerIds: ["p1" as PeerId] }), { client }); + + act(() => { + client.addRemotePeer({ + id: "p1", + tracks: [{ trackId: "mic", metadata: micMeta, track: createFakeTrack({ kind: "audio" }) }], + }); + client.notifyStateChanged(); + }); + + expect(result.current["p1" as PeerId]).toBe(false); + }); + + it("flips to true on a voiceActivityChanged → speech event", () => { + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useVAD({ peerIds: ["p1" as PeerId] }), { client }); + + act(() => { + client.addRemotePeer({ + id: "p1", + tracks: [{ trackId: "mic", metadata: micMeta, track: createFakeTrack({ kind: "audio" }) }], + }); + client.notifyStateChanged(); + }); + + act(() => client.getRemoteTrackContext("p1", "mic")!.simulateVad("speech")); + expect(result.current["p1" as PeerId]).toBe(true); + + act(() => client.getRemoteTrackContext("p1", "mic")!.simulateVad("silence")); + expect(result.current["p1" as PeerId]).toBe(false); + }); + + it("only tracks the requested peer ids", () => { + const client = new FakeFishjamClient(); + const { result } = renderHookWithProvider(() => useVAD({ peerIds: ["p1" as PeerId] }), { client }); + + act(() => { + client.addRemotePeer({ + id: "p2", + tracks: [{ trackId: "mic2", metadata: micMeta, track: createFakeTrack({ kind: "audio" }) }], + }); + client.notifyStateChanged(); + }); + + expect(result.current["p2" as PeerId]).toBeUndefined(); + }); +}); diff --git a/packages/react-client/tsconfig.build.json b/packages/react-client/tsconfig.build.json new file mode 100644 index 000000000..a0609ff85 --- /dev/null +++ b/packages/react-client/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/tests/**", "src/**/*.spec.ts", "src/**/*.spec.tsx"] +} diff --git a/packages/react-client/vitest.config.ts b/packages/react-client/vitest.config.ts index 9f6250a33..d839ceb80 100644 --- a/packages/react-client/vitest.config.ts +++ b/packages/react-client/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "jsdom", + setupFiles: ["./src/tests/support/setup.ts"], }, }); From 3c2cbc4d3c05c6dc7d6aaba45acd6ef47dc29458 Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Mon, 8 Jun 2026 16:22:27 +0200 Subject: [PATCH 2/7] use vitest mechanism for fixtures --- .../react-client/src/tests/camera.spec.ts | 98 +++++++++++++------ .../react-client/src/tests/connection.spec.ts | 35 ++++--- .../src/tests/customSource.spec.ts | 23 ++--- .../src/tests/dataChannel.spec.ts | 35 +++---- .../src/tests/initializeDevices.spec.ts | 30 +++--- .../react-client/src/tests/livestream.spec.ts | 16 +-- .../react-client/src/tests/microphone.spec.ts | 33 +++---- packages/react-client/src/tests/misc.spec.ts | 11 +-- packages/react-client/src/tests/peers.spec.ts | 27 +++-- .../src/tests/screenShare.spec.ts | 44 +++++---- .../src/tests/support/fixtures.ts | 48 +++++++++ .../react-client/src/tests/support/setup.ts | 24 ----- packages/react-client/src/tests/vad.spec.ts | 19 ++-- packages/react-client/vitest.config.ts | 1 - 14 files changed, 244 insertions(+), 200 deletions(-) create mode 100644 packages/react-client/src/tests/support/fixtures.ts delete mode 100644 packages/react-client/src/tests/support/setup.ts diff --git a/packages/react-client/src/tests/camera.spec.ts b/packages/react-client/src/tests/camera.spec.ts index aca8f68a9..784269f09 100644 --- a/packages/react-client/src/tests/camera.spec.ts +++ b/packages/react-client/src/tests/camera.spec.ts @@ -1,39 +1,40 @@ import { act } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; import { useCamera } from "../hooks/devices/useCamera"; -import { FakeFishjamClient } from "./support/fakeFishjamClient"; import { createFakeStream } from "./support/fakeMediaStream"; -import { renderHookWithProvider } from "./support/renderWithProvider"; -import { media } from "./support/setup"; +import { describe, expect, it, vi } from "./support/fixtures"; const videoStream = () => createFakeStream([{ kind: "video", deviceId: "cam-1" }]); describe("useCamera", () => { - it("is off before the device is started", () => { - const { result } = renderHookWithProvider(() => useCamera()); + it("is off before the device is started", ({ renderHook }) => { + const { result } = renderHook(() => useCamera()); expect(result.current.isCameraOn).toBe(false); expect(result.current.cameraStream).toBeNull(); }); - it("startCamera acquires the device and exposes the stream without publishing", async () => { - media().setUserMediaStream(videoStream()); - const { result, client } = renderHookWithProvider(() => useCamera()); + it("startCamera acquires the device and exposes the stream without publishing", async ({ + media, + client, + renderHook, + }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => useCamera()); await act(async () => { await result.current.startCamera(); }); - expect(media().devices.getUserMedia).toHaveBeenCalledTimes(1); + expect(media.devices.getUserMedia).toHaveBeenCalledTimes(1); expect(result.current.isCameraOn).toBe(true); expect(result.current.cameraStream?.getVideoTracks()).toHaveLength(1); // Not connected → no track published to the SFU. expect(client.addTrack).not.toHaveBeenCalled(); }); - it("stopCamera tears the stream down", async () => { - media().setUserMediaStream(videoStream()); - const { result } = renderHookWithProvider(() => useCamera()); + it("stopCamera tears the stream down", async ({ media, renderHook }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => useCamera()); await act(async () => { await result.current.startCamera(); @@ -44,10 +45,9 @@ describe("useCamera", () => { expect(result.current.cameraStream).toBeNull(); }); - it("toggleCamera while connected publishes a camera track with metadata", async () => { - media().setUserMediaStream(videoStream()); - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useCamera(), { client }); + it("toggleCamera while connected publishes a camera track with metadata", async ({ media, client, renderHook }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => useCamera()); act(() => client.simulateJoined()); // peerStatus → connected @@ -61,10 +61,9 @@ describe("useCamera", () => { expect(meta).toMatchObject({ type: "camera", paused: false }); }); - it("auto-publishes the running device when the room is joined", async () => { - media().setUserMediaStream(videoStream()); - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useCamera(), { client }); + it("auto-publishes the running device when the room is joined", async ({ media, client, renderHook }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => useCamera()); await act(async () => { await result.current.startCamera(); @@ -79,10 +78,9 @@ describe("useCamera", () => { expect(client.addTrack.mock.calls[0][1]).toMatchObject({ type: "camera" }); }); - it("toggleCamera off while connected pauses the published track", async () => { - media().setUserMediaStream(videoStream()); - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useCamera(), { client }); + it("toggleCamera off while connected pauses the published track", async ({ media, client, renderHook }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => useCamera()); act(() => client.simulateJoined()); await act(async () => { @@ -99,9 +97,9 @@ describe("useCamera", () => { expect(lastMeta).toMatchObject({ type: "camera", paused: true }); }); - it("setCameraTrackMiddleware applies the middleware to the device track", async () => { - media().setUserMediaStream(videoStream()); - const { result } = renderHookWithProvider(() => useCamera()); + it("setCameraTrackMiddleware applies the middleware to the device track", async ({ media, renderHook }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => useCamera()); await act(async () => { await result.current.startCamera(); @@ -117,9 +115,9 @@ describe("useCamera", () => { expect(result.current.cameraStream?.getVideoTracks()[0]).toBe(processed); }); - it("surfaces a device error when getUserMedia is denied", async () => { - media().failUserMediaAlways("NotAllowedError"); - const { result } = renderHookWithProvider(() => useCamera()); + it("surfaces a device error when getUserMedia is denied", async ({ media, renderHook }) => { + media.failUserMediaAlways("NotAllowedError"); + const { result } = renderHook(() => useCamera()); await act(async () => { await result.current.startCamera(); @@ -128,4 +126,42 @@ describe("useCamera", () => { expect(result.current.cameraDeviceError).toEqual({ name: "NotAllowedError" }); expect(result.current.isCameraOn).toBe(false); }); + + it("awaits the in-flight publish so an op racing addTrack targets the remote id", async ({ + media, + client, + renderHook, + }) => { + media.setUserMediaStream(videoStream()); + // Hold addTrack open: the remote track id won't exist until we flush. + client.deferAddTracks(); + const { result } = renderHook(() => useCamera()); + + await act(async () => { + await result.current.startCamera(); + }); + + // Join → auto-publish fires, but addTrack is suspended. At this point + // currentTrackIdRef still holds the LOCAL track id and the remote track is + // not yet registered on the local peer. + act(() => client.simulateJoined()); + expect(client.addTrack).toHaveBeenCalledTimes(1); + + await act(async () => { + // toggleCamera-off calls getCurrentTrackId() as its FIRST step, so the id + // read races the pending publish. getCurrentTrackId() must await + // connectionPromiseRef: we read the id while addTrack is still suspended, + // then resolve the publish before letting the toggle continue. + const toggling = result.current.toggleCamera(); + client.flushAddTracks(); // addTrack resolves → currentTrackIdRef advances to remote-0 + await toggling; + }); + + // Regression guard for the local→remote id race: because getCurrentTrackId + // awaited the in-flight publish, pauseStreaming targeted the REMOTE id. + // Without that await it reads the still-local id (whose track isn't + // registered yet), resolves to null, and skips replaceTrack entirely. + expect(client.replaceTrack).toHaveBeenCalledTimes(1); + expect(client.replaceTrack).toHaveBeenCalledWith("remote-0", null); + }); }); diff --git a/packages/react-client/src/tests/connection.spec.ts b/packages/react-client/src/tests/connection.spec.ts index 6f7e384df..7a4e883b2 100644 --- a/packages/react-client/src/tests/connection.spec.ts +++ b/packages/react-client/src/tests/connection.spec.ts @@ -1,18 +1,17 @@ import { act } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; import { useConnection } from "../hooks/useConnection"; -import { renderHookWithProvider } from "./support/renderWithProvider"; +import { describe, expect, it } from "./support/fixtures"; describe("useConnection", () => { - it("starts idle", () => { - const { result } = renderHookWithProvider(() => useConnection()); + it("starts idle", ({ renderHook }) => { + const { result } = renderHook(() => useConnection()); expect(result.current.peerStatus).toBe("idle"); expect(result.current.reconnectionStatus).toBe("idle"); }); - it("joinRoom connects the client with a ws url, token and metadata", async () => { - const { result, client } = renderHookWithProvider(() => useConnection()); + it("joinRoom connects the client with a ws url, token and metadata", async ({ client, renderHook }) => { + const { result } = renderHook(() => useConnection()); await act(async () => { await result.current.joinRoom({ peerToken: "tok-123", peerMetadata: { name: "alice" } }); @@ -26,16 +25,16 @@ describe("useConnection", () => { expect(config.url).toContain("test-fishjam-id"); }); - it("defaults peerMetadata to an empty object", async () => { - const { result, client } = renderHookWithProvider(() => useConnection()); + it("defaults peerMetadata to an empty object", async ({ client, renderHook }) => { + const { result } = renderHook(() => useConnection()); await act(async () => { await result.current.joinRoom({ peerToken: "tok" }); }); expect((client.connect.mock.calls[0][0] as { peerMetadata: unknown }).peerMetadata).toEqual({}); }); - it("transitions peerStatus connecting → connected on the client events", () => { - const { result, client } = renderHookWithProvider(() => useConnection()); + it("transitions peerStatus connecting → connected on the client events", ({ client, renderHook }) => { + const { result } = renderHook(() => useConnection()); act(() => client.simulateConnectionStarted()); expect(result.current.peerStatus).toBe("connecting"); @@ -44,14 +43,14 @@ describe("useConnection", () => { expect(result.current.peerStatus).toBe("connected"); }); - it("sets peerStatus to error on auth / join / connection errors", () => { - const { result, client } = renderHookWithProvider(() => useConnection()); + it("sets peerStatus to error on auth / join / connection errors", ({ client, renderHook }) => { + const { result } = renderHook(() => useConnection()); act(() => client.simulateAuthError()); expect(result.current.peerStatus).toBe("error"); }); - it("leaveRoom disconnects and returns to idle", () => { - const { result, client } = renderHookWithProvider(() => useConnection()); + it("leaveRoom disconnects and returns to idle", ({ client, renderHook }) => { + const { result } = renderHook(() => useConnection()); act(() => client.simulateJoined()); expect(result.current.peerStatus).toBe("connected"); @@ -60,8 +59,8 @@ describe("useConnection", () => { expect(result.current.peerStatus).toBe("idle"); }); - it("tracks reconnectionStatus across the reconnect lifecycle", () => { - const { result, client } = renderHookWithProvider(() => useConnection()); + it("tracks reconnectionStatus across the reconnect lifecycle", ({ client, renderHook }) => { + const { result } = renderHook(() => useConnection()); act(() => client.simulateReconnectionStarted()); expect(result.current.reconnectionStatus).toBe("reconnecting"); @@ -74,8 +73,8 @@ describe("useConnection", () => { expect(result.current.reconnectionStatus).toBe("error"); }); - it("promotes a joinError to a reconnection error only while reconnecting", () => { - const { result, client } = renderHookWithProvider(() => useConnection()); + it("promotes a joinError to a reconnection error only while reconnecting", ({ client, renderHook }) => { + const { result } = renderHook(() => useConnection()); // Not reconnecting → joinError must not flip reconnectionStatus. act(() => client.simulateJoinError()); diff --git a/packages/react-client/src/tests/customSource.spec.ts b/packages/react-client/src/tests/customSource.spec.ts index 93e395c57..dace22192 100644 --- a/packages/react-client/src/tests/customSource.spec.ts +++ b/packages/react-client/src/tests/customSource.spec.ts @@ -1,10 +1,8 @@ import { act } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; import { useCustomSource } from "../hooks/useCustomSource"; -import { FakeFishjamClient } from "./support/fakeFishjamClient"; import { createFakeStream } from "./support/fakeMediaStream"; -import { renderHookWithProvider } from "./support/renderWithProvider"; +import { describe, expect, it } from "./support/fixtures"; const avStream = () => createFakeStream([ @@ -13,14 +11,13 @@ const avStream = () => ]); describe("useCustomSource", () => { - it("exposes no stream until one is set", () => { - const { result } = renderHookWithProvider(() => useCustomSource("cam-feed")); + it("exposes no stream until one is set", ({ renderHook }) => { + const { result } = renderHook(() => useCustomSource("cam-feed")); expect(result.current.stream).toBeUndefined(); }); - it("publishes custom video + audio tracks when connected", async () => { - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useCustomSource("feed"), { client }); + it("publishes custom video + audio tracks when connected", async ({ client, renderHook }) => { + const { result } = renderHook(() => useCustomSource("feed")); act(() => client.simulateJoined()); @@ -35,9 +32,8 @@ describe("useCustomSource", () => { expect(metas).toContain("customAudio"); }); - it("defers publishing until the room is joined when set before connecting", async () => { - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useCustomSource("feed"), { client }); + it("defers publishing until the room is joined when set before connecting", async ({ client, renderHook }) => { + const { result } = renderHook(() => useCustomSource("feed")); await act(async () => { await result.current.setStream(avStream()); @@ -51,9 +47,8 @@ describe("useCustomSource", () => { expect(client.addTrack).toHaveBeenCalled(); }); - it("removes tracks when the stream is cleared", async () => { - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useCustomSource("feed"), { client }); + it("removes tracks when the stream is cleared", async ({ client, renderHook }) => { + const { result } = renderHook(() => useCustomSource("feed")); act(() => client.simulateJoined()); await act(async () => { diff --git a/packages/react-client/src/tests/dataChannel.spec.ts b/packages/react-client/src/tests/dataChannel.spec.ts index eadf0357b..2f4d60830 100644 --- a/packages/react-client/src/tests/dataChannel.spec.ts +++ b/packages/react-client/src/tests/dataChannel.spec.ts @@ -1,20 +1,18 @@ import { act } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; import { useDataChannel } from "../hooks/useDataChannel"; -import { FakeFishjamClient } from "./support/fakeFishjamClient"; -import { renderHookWithProvider } from "./support/renderWithProvider"; +import { describe, expect, it } from "./support/fixtures"; describe("useDataChannel", () => { - it("starts not ready / not loading / no error", () => { - const { result } = renderHookWithProvider(() => useDataChannel()); + it("starts not ready / not loading / no error", ({ renderHook }) => { + const { result } = renderHook(() => useDataChannel()); expect(result.current.dataChannelReady).toBe(false); expect(result.current.dataChannelLoading).toBe(false); expect(result.current.dataChannelError).toBeNull(); }); - it("errors when initializing before the peer is connected", async () => { - const { result } = renderHookWithProvider(() => useDataChannel()); + it("errors when initializing before the peer is connected", async ({ renderHook }) => { + const { result } = renderHook(() => useDataChannel()); await act(async () => { await result.current.initializeDataChannel(); @@ -24,9 +22,8 @@ describe("useDataChannel", () => { expect(result.current.dataChannelReady).toBe(false); }); - it("creates channels and becomes ready when connected", async () => { - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useDataChannel(), { client }); + it("creates channels and becomes ready when connected", async ({ client, renderHook }) => { + const { result } = renderHook(() => useDataChannel()); act(() => client.simulateJoined()); @@ -39,8 +36,8 @@ describe("useDataChannel", () => { expect(result.current.dataChannelError).toBeNull(); }); - it("publishData forwards the payload and options to the client", () => { - const { result, client } = renderHookWithProvider(() => useDataChannel()); + it("publishData forwards the payload and options to the client", ({ client, renderHook }) => { + const { result } = renderHook(() => useDataChannel()); const payload = new Uint8Array([1, 2, 3]); act(() => result.current.publishData(payload, { reliable: true })); @@ -48,8 +45,8 @@ describe("useDataChannel", () => { expect(client.publishData).toHaveBeenCalledWith(payload, { reliable: true }); }); - it("subscribeData delivers incoming data and unsubscribes", () => { - const { result, client } = renderHookWithProvider(() => useDataChannel()); + it("subscribeData delivers incoming data and unsubscribes", ({ client, renderHook }) => { + const { result } = renderHook(() => useDataChannel()); const received: Uint8Array[] = []; let unsub = () => {}; @@ -65,9 +62,8 @@ describe("useDataChannel", () => { expect(received).toHaveLength(1); }); - it("surfaces a data-channel error and drops readiness", async () => { - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useDataChannel(), { client }); + it("surfaces a data-channel error and drops readiness", async ({ client, renderHook }) => { + const { result } = renderHook(() => useDataChannel()); act(() => client.simulateJoined()); await act(async () => { @@ -80,9 +76,8 @@ describe("useDataChannel", () => { expect(result.current.dataChannelReady).toBe(false); }); - it("resets readiness on disconnect", async () => { - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useDataChannel(), { client }); + it("resets readiness on disconnect", async ({ client, renderHook }) => { + const { result } = renderHook(() => useDataChannel()); act(() => client.simulateJoined()); await act(async () => { diff --git a/packages/react-client/src/tests/initializeDevices.spec.ts b/packages/react-client/src/tests/initializeDevices.spec.ts index 442b8000a..e27585c56 100644 --- a/packages/react-client/src/tests/initializeDevices.spec.ts +++ b/packages/react-client/src/tests/initializeDevices.spec.ts @@ -1,11 +1,9 @@ import { act } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; import { useCamera } from "../hooks/devices/useCamera"; import { useInitializeDevices } from "../hooks/devices/useInitializeDevices"; import { createFakeStream } from "./support/fakeMediaStream"; -import { renderHookWithProvider } from "./support/renderWithProvider"; -import { media } from "./support/setup"; +import { describe, expect, it } from "./support/fixtures"; const fullStream = () => createFakeStream([ @@ -19,11 +17,11 @@ const devices = [ ]; describe("useInitializeDevices", () => { - it("acquires media and reports initialized", async () => { - media().setUserMediaStream(fullStream()); - media().setEnumeratedDevices(devices); + it("acquires media and reports initialized", async ({ media, renderHook }) => { + media.setUserMediaStream(fullStream()); + media.setEnumeratedDevices(devices); - const { result } = renderHookWithProvider(() => useInitializeDevices()); + const { result } = renderHook(() => useInitializeDevices()); let outcome: Awaited>; await act(async () => { @@ -32,14 +30,14 @@ describe("useInitializeDevices", () => { expect(outcome!.status).toBe("initialized"); expect(outcome!.stream).not.toBeNull(); - expect(media().devices.enumerateDevices).toHaveBeenCalled(); + expect(media.devices.enumerateDevices).toHaveBeenCalled(); }); - it("is idempotent: a second call reports already_initialized", async () => { - media().setUserMediaStream(fullStream()); - media().setEnumeratedDevices(devices); + it("is idempotent: a second call reports already_initialized", async ({ media, renderHook }) => { + media.setUserMediaStream(fullStream()); + media.setEnumeratedDevices(devices); - const { result } = renderHookWithProvider(() => useInitializeDevices()); + const { result } = renderHook(() => useInitializeDevices()); await act(async () => { await result.current.initializeDevices(); @@ -52,11 +50,11 @@ describe("useInitializeDevices", () => { expect(second!.status).toBe("already_initialized"); }); - it("populates the device lists exposed by useCamera", async () => { - media().setUserMediaStream(fullStream()); - media().setEnumeratedDevices(devices); + it("populates the device lists exposed by useCamera", async ({ media, renderHook }) => { + media.setUserMediaStream(fullStream()); + media.setEnumeratedDevices(devices); - const { result } = renderHookWithProvider(() => ({ + const { result } = renderHook(() => ({ init: useInitializeDevices(), camera: useCamera(), })); diff --git a/packages/react-client/src/tests/livestream.spec.ts b/packages/react-client/src/tests/livestream.spec.ts index b4bb06c60..71227d43c 100644 --- a/packages/react-client/src/tests/livestream.spec.ts +++ b/packages/react-client/src/tests/livestream.spec.ts @@ -1,12 +1,12 @@ import { LivestreamError, publishLivestream, receiveLivestream } from "@fishjam-cloud/ts-client"; import { act } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { vi } from "vitest"; import { useLivestreamStreamer } from "../hooks/useLivestreamStreamer"; import { useLivestreamViewer } from "../hooks/useLivestreamViewer"; import type { FakeMediaStream } from "./support/fakeMediaStream"; import { createFakeStream } from "./support/fakeMediaStream"; -import { renderHookWithProvider } from "./support/renderWithProvider"; +import { beforeEach, describe, expect, it } from "./support/fixtures"; // Keep FishjamClient / getLogger / LivestreamError real; stub only the WHIP/WHEP fns. vi.mock("@fishjam-cloud/ts-client", async (importOriginal) => { @@ -21,7 +21,7 @@ type ConnCb = (pc: RTCPeerConnection) => void; describe("useLivestreamStreamer", () => { beforeEach(() => vi.mocked(publishLivestream).mockClear()); - it("publishes the given video stream and tracks connection state", async () => { + it("publishes the given video stream and tracks connection state", async ({ renderHook }) => { let onChange: ConnCb = () => {}; const stopPublishing = vi.fn(); // The 4th arg carries the connection-state callback. Read it defensively: @@ -32,7 +32,7 @@ describe("useLivestreamStreamer", () => { return { stopPublishing } as never; }); - const { result } = renderHookWithProvider(() => useLivestreamStreamer()); + const { result } = renderHook(() => useLivestreamStreamer()); await act(async () => { await result.current.connect({ inputs: { video: createFakeStream([{ kind: "video" }]) }, token: "t" }); @@ -50,7 +50,7 @@ describe("useLivestreamStreamer", () => { expect(stopPublishing).toHaveBeenCalledTimes(1); }); - it("captures a LivestreamError thrown by publishLivestream", async () => { + it("captures a LivestreamError thrown by publishLivestream", async ({ renderHook }) => { const err = Object.values(LivestreamError)[0]; // Reject only the real connect call; resolve any teardown-time stray call so // it doesn't surface as an unhandled rejection. @@ -58,7 +58,7 @@ describe("useLivestreamStreamer", () => { .mockRejectedValueOnce(err) .mockResolvedValue({ stopPublishing: vi.fn() } as never); - const { result } = renderHookWithProvider(() => useLivestreamStreamer()); + const { result } = renderHook(() => useLivestreamStreamer()); await act(async () => { await result.current.connect({ inputs: { video: createFakeStream([{ kind: "video" }]) }, token: "t" }); }); @@ -70,7 +70,7 @@ describe("useLivestreamStreamer", () => { describe("useLivestreamViewer", () => { beforeEach(() => vi.mocked(receiveLivestream).mockClear()); - it("connects, exposes the received stream and disconnects", async () => { + it("connects, exposes the received stream and disconnects", async ({ renderHook }) => { const stream = createFakeStream([{ kind: "video" }]); const stop = vi.fn(); vi.mocked(receiveLivestream).mockResolvedValue({ @@ -79,7 +79,7 @@ describe("useLivestreamViewer", () => { getStatistics: async () => ({}) as RTCStatsReport, } as never); - const { result } = renderHookWithProvider(() => useLivestreamViewer()); + const { result } = renderHook(() => useLivestreamViewer()); await act(async () => { await result.current.connect({ token: "viewer-token" }); diff --git a/packages/react-client/src/tests/microphone.spec.ts b/packages/react-client/src/tests/microphone.spec.ts index a126c99ab..7ef3cc6a0 100644 --- a/packages/react-client/src/tests/microphone.spec.ts +++ b/packages/react-client/src/tests/microphone.spec.ts @@ -1,24 +1,21 @@ import { act } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; import { useMicrophone } from "../hooks/devices/useMicrophone"; -import { FakeFishjamClient } from "./support/fakeFishjamClient"; import { createFakeStream } from "./support/fakeMediaStream"; -import { renderHookWithProvider } from "./support/renderWithProvider"; -import { media } from "./support/setup"; +import { describe, expect, it } from "./support/fixtures"; const audioStream = () => createFakeStream([{ kind: "audio", deviceId: "mic-1" }]); describe("useMicrophone", () => { - it("starts off and unmuted", () => { - const { result } = renderHookWithProvider(() => useMicrophone()); + it("starts off and unmuted", ({ renderHook }) => { + const { result } = renderHook(() => useMicrophone()); expect(result.current.isMicrophoneOn).toBe(false); expect(result.current.isMicrophoneMuted).toBe(false); }); - it("startMicrophone acquires an audio stream", async () => { - media().setUserMediaStream(audioStream()); - const { result } = renderHookWithProvider(() => useMicrophone()); + it("startMicrophone acquires an audio stream", async ({ media, renderHook }) => { + media.setUserMediaStream(audioStream()); + const { result } = renderHook(() => useMicrophone()); await act(async () => { await result.current.startMicrophone(); @@ -28,10 +25,13 @@ describe("useMicrophone", () => { expect(result.current.microphoneStream?.getAudioTracks()).toHaveLength(1); }); - it("toggleMicrophoneMute pauses the track and reports muted, keeping the device on", async () => { - media().setUserMediaStream(audioStream()); - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useMicrophone(), { client }); + it("toggleMicrophoneMute pauses the track and reports muted, keeping the device on", async ({ + media, + client, + renderHook, + }) => { + media.setUserMediaStream(audioStream()); + const { result } = renderHook(() => useMicrophone()); act(() => client.simulateJoined()); await act(async () => { @@ -50,10 +50,9 @@ describe("useMicrophone", () => { expect(client.updateTrackMetadata.mock.calls.at(-1)?.[1]).toMatchObject({ type: "microphone", paused: true }); }); - it("unmuting resumes the track", async () => { - media().setUserMediaStream(audioStream()); - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useMicrophone(), { client }); + it("unmuting resumes the track", async ({ media, client, renderHook }) => { + media.setUserMediaStream(audioStream()); + const { result } = renderHook(() => useMicrophone()); act(() => client.simulateJoined()); await act(async () => { diff --git a/packages/react-client/src/tests/misc.spec.ts b/packages/react-client/src/tests/misc.spec.ts index 4a9525aec..9a68b2a85 100644 --- a/packages/react-client/src/tests/misc.spec.ts +++ b/packages/react-client/src/tests/misc.spec.ts @@ -1,21 +1,20 @@ import { act } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; import { useStatistics } from "../hooks/useStatistics"; import { useUpdatePeerMetadata } from "../hooks/useUpdatePeerMetadata"; -import { renderHookWithProvider } from "./support/renderWithProvider"; +import { describe, expect, it } from "./support/fixtures"; describe("useUpdatePeerMetadata", () => { - it("forwards metadata to the client", () => { - const { result, client } = renderHookWithProvider(() => useUpdatePeerMetadata()); + it("forwards metadata to the client", ({ client, renderHook }) => { + const { result } = renderHook(() => useUpdatePeerMetadata()); act(() => result.current.updatePeerMetadata({ role: "host" })); expect(client.updatePeerMetadata).toHaveBeenCalledWith({ role: "host" }); }); }); describe("useStatistics", () => { - it("delegates to client.getStatistics", async () => { - const { result, client } = renderHookWithProvider(() => useStatistics()); + it("delegates to client.getStatistics", async ({ client, renderHook }) => { + const { result } = renderHook(() => useStatistics()); await act(async () => { await result.current.getStatistics(); }); diff --git a/packages/react-client/src/tests/peers.spec.ts b/packages/react-client/src/tests/peers.spec.ts index 8879f3026..0ed74d9c1 100644 --- a/packages/react-client/src/tests/peers.spec.ts +++ b/packages/react-client/src/tests/peers.spec.ts @@ -1,9 +1,8 @@ import { act } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; import { usePeers } from "../hooks/usePeers"; import { createFakeTrack } from "./support/fakeMediaStream"; -import { renderHookWithProvider } from "./support/renderWithProvider"; +import { describe, expect, it } from "./support/fixtures"; const cameraMeta = { type: "camera", paused: false } as const; const micMeta = { type: "microphone", paused: false } as const; @@ -11,14 +10,14 @@ const screenVideoMeta = { type: "screenShareVideo", paused: false } as const; const customVideoMeta = { type: "customVideo", paused: false } as const; describe("usePeers", () => { - it("returns null localPeer and empty remotePeers before joining", () => { - const { result } = renderHookWithProvider(() => usePeers()); + it("returns null localPeer and empty remotePeers before joining", ({ renderHook }) => { + const { result } = renderHook(() => usePeers()); expect(result.current.localPeer).toBeNull(); expect(result.current.remotePeers).toEqual([]); }); - it("buckets local peer tracks by metadata type", () => { - const { result, client } = renderHookWithProvider(() => usePeers()); + it("buckets local peer tracks by metadata type", ({ client, renderHook }) => { + const { result } = renderHook(() => usePeers()); act(() => { client.setLocalPeer({ @@ -39,8 +38,8 @@ describe("usePeers", () => { expect(local.tracks).toHaveLength(2); }); - it("buckets remote peer tracks including screen share and custom tracks", () => { - const { result, client } = renderHookWithProvider(() => usePeers()); + it("buckets remote peer tracks including screen share and custom tracks", ({ client, renderHook }) => { + const { result } = renderHook(() => usePeers()); act(() => { client.addRemotePeer({ @@ -59,8 +58,8 @@ describe("usePeers", () => { expect(peer.customVideoTracks.map((t) => t.trackId)).toEqual(["cv"]); }); - it("keeps the deprecated `peers` alias equal to remotePeers", () => { - const { result, client } = renderHookWithProvider(() => usePeers()); + it("keeps the deprecated `peers` alias equal to remotePeers", ({ client, renderHook }) => { + const { result } = renderHook(() => usePeers()); act(() => { client.addRemotePeer({ id: "p1" }); client.notifyStateChanged(); @@ -68,8 +67,8 @@ describe("usePeers", () => { expect(result.current.peers).toEqual(result.current.remotePeers); }); - it("remote tracks expose setReceivedQuality wired to setTargetTrackEncoding", () => { - const { result, client } = renderHookWithProvider(() => usePeers()); + it("remote tracks expose setReceivedQuality wired to setTargetTrackEncoding", ({ client, renderHook }) => { + const { result } = renderHook(() => usePeers()); act(() => { client.addRemotePeer({ id: "p1", @@ -82,8 +81,8 @@ describe("usePeers", () => { expect(client.setTargetTrackEncoding).toHaveBeenCalledWith("rv", "h"); }); - it("setReceivedTracksQuality applies quality to every track id", () => { - const { result, client } = renderHookWithProvider(() => usePeers()); + it("setReceivedTracksQuality applies quality to every track id", ({ client, renderHook }) => { + const { result } = renderHook(() => usePeers()); act(() => result.current.setReceivedTracksQuality(["a", "b"], "m" as never)); expect(client.setTargetTrackEncoding).toHaveBeenCalledWith("a", "m"); expect(client.setTargetTrackEncoding).toHaveBeenCalledWith("b", "m"); diff --git a/packages/react-client/src/tests/screenShare.spec.ts b/packages/react-client/src/tests/screenShare.spec.ts index 2ba943953..029f1f3c0 100644 --- a/packages/react-client/src/tests/screenShare.spec.ts +++ b/packages/react-client/src/tests/screenShare.spec.ts @@ -1,11 +1,8 @@ import { act } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; import { useScreenShare } from "../hooks/useScreenShare"; -import { FakeFishjamClient } from "./support/fakeFishjamClient"; import { createFakeStream } from "./support/fakeMediaStream"; -import { renderHookWithProvider } from "./support/renderWithProvider"; -import { media } from "./support/setup"; +import { describe, expect, it } from "./support/fixtures"; const screenStream = () => createFakeStream([ @@ -14,23 +11,26 @@ const screenStream = () => ]); describe("useScreenShare", () => { - it("has no stream initially", () => { - const { result } = renderHookWithProvider(() => useScreenShare()); + it("has no stream initially", ({ renderHook }) => { + const { result } = renderHook(() => useScreenShare()); expect(result.current.stream).toBeNull(); expect(result.current.videoTrack).toBeNull(); expect(result.current.audioTrack).toBeNull(); }); - it("startStreaming prompts getDisplayMedia and publishes video + audio tracks", async () => { - media().setDisplayMediaStream(screenStream()); - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useScreenShare(), { client }); + it("startStreaming prompts getDisplayMedia and publishes video + audio tracks", async ({ + media, + client, + renderHook, + }) => { + media.setDisplayMediaStream(screenStream()); + const { result } = renderHook(() => useScreenShare()); await act(async () => { await result.current.startStreaming(); }); - expect(media().devices.getDisplayMedia).toHaveBeenCalledTimes(1); + expect(media.devices.getDisplayMedia).toHaveBeenCalledTimes(1); expect(result.current.videoTrack).not.toBeNull(); expect(result.current.audioTrack).not.toBeNull(); @@ -39,10 +39,13 @@ describe("useScreenShare", () => { expect(metas).toContain("screenShareAudio"); }); - it("stopStreaming removes the SFU tracks while connected and clears the stream", async () => { - media().setDisplayMediaStream(screenStream()); - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useScreenShare(), { client }); + it("stopStreaming removes the SFU tracks while connected and clears the stream", async ({ + media, + client, + renderHook, + }) => { + media.setDisplayMediaStream(screenStream()); + const { result } = renderHook(() => useScreenShare()); act(() => client.simulateJoined()); await act(async () => { @@ -56,10 +59,13 @@ describe("useScreenShare", () => { expect(result.current.stream).toBeNull(); }); - it("setTracksMiddleware processes both tracks and replaces them on the SFU", async () => { - media().setDisplayMediaStream(screenStream()); - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useScreenShare(), { client }); + it("setTracksMiddleware processes both tracks and replaces them on the SFU", async ({ + media, + client, + renderHook, + }) => { + media.setDisplayMediaStream(screenStream()); + const { result } = renderHook(() => useScreenShare()); await act(async () => { await result.current.startStreaming(); diff --git a/packages/react-client/src/tests/support/fixtures.ts b/packages/react-client/src/tests/support/fixtures.ts new file mode 100644 index 000000000..b48b11d95 --- /dev/null +++ b/packages/react-client/src/tests/support/fixtures.ts @@ -0,0 +1,48 @@ +import { test as base } from "vitest"; + +import { FakeFishjamClient } from "./fakeFishjamClient"; +import { installFakeMediaDevices, type MediaDevicesController } from "./fakeMediaDevices"; +import { renderHookWithProvider } from "./renderWithProvider"; + +interface Fixtures { + /** + * The fake browser media layer (navigator.mediaDevices + global MediaStream). + * + * Declared `auto` so it is installed and torn down for EVERY test using this + * `it`, whether or not the test destructures it — no global setup file or + * `globalThis` channel needed. + */ + media: MediaDevicesController; + /** A fresh fake client, shared with whatever `renderHook` mounts. */ + client: FakeFishjamClient; + /** + * `renderHookWithProvider` pre-wired to the `client` fixture, so a test can + * mount a hook and drive the same client's events without threading it by hand. + * A test may still override `client`/`providerProps` per call. + */ + renderHook: typeof renderHookWithProvider; +} + +// `provide` is vitest's fixture-injection callback (positionally the 2nd arg); +// it is named `provide` rather than the docs' `use` only to dodge the +// react-hooks/rules-of-hooks lint, which mistakes a call to `use(...)` for React. +export const it = base.extend({ + media: [ + // eslint-disable-next-line no-empty-pattern + async ({}, provide) => { + const controller = installFakeMediaDevices(); + await provide(controller); + controller.restore(); + }, + { auto: true }, + ], + // eslint-disable-next-line no-empty-pattern + client: async ({}, provide) => { + await provide(new FakeFishjamClient()); + }, + renderHook: async ({ client }, provide) => { + await provide((hook, options) => renderHookWithProvider(hook, { client, ...options })); + }, +}); + +export { afterEach, beforeEach, describe, expect, vi } from "vitest"; diff --git a/packages/react-client/src/tests/support/setup.ts b/packages/react-client/src/tests/support/setup.ts deleted file mode 100644 index 96e5b1686..000000000 --- a/packages/react-client/src/tests/support/setup.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { afterEach, beforeEach } from "vitest"; - -import { installFakeMediaDevices, type MediaDevicesController } from "./fakeMediaDevices"; - -/** - * Installs the fake browser media layer (navigator.mediaDevices + MediaStream) - * before every test and exposes the controller via `globalThis.__media`. - * - * Registered as a vitest setupFile so every spec gets a clean media layer. - */ -declare global { - // eslint-disable-next-line no-var - var __media: MediaDevicesController; -} - -beforeEach(() => { - globalThis.__media = installFakeMediaDevices(); -}); - -afterEach(() => { - globalThis.__media?.restore(); -}); - -export const media = () => globalThis.__media; diff --git a/packages/react-client/src/tests/vad.spec.ts b/packages/react-client/src/tests/vad.spec.ts index 60c7def3c..324dc9518 100644 --- a/packages/react-client/src/tests/vad.spec.ts +++ b/packages/react-client/src/tests/vad.spec.ts @@ -1,18 +1,15 @@ import { act } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; import { useVAD } from "../hooks/useVAD"; import type { PeerId } from "../types/public"; -import { FakeFishjamClient } from "./support/fakeFishjamClient"; import { createFakeTrack } from "./support/fakeMediaStream"; -import { renderHookWithProvider } from "./support/renderWithProvider"; +import { describe, expect, it } from "./support/fixtures"; const micMeta = { type: "microphone", paused: false } as const; describe("useVAD (remote peers)", () => { - it("reports false until a peer starts speaking", () => { - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useVAD({ peerIds: ["p1" as PeerId] }), { client }); + it("reports false until a peer starts speaking", ({ client, renderHook }) => { + const { result } = renderHook(() => useVAD({ peerIds: ["p1" as PeerId] })); act(() => { client.addRemotePeer({ @@ -25,9 +22,8 @@ describe("useVAD (remote peers)", () => { expect(result.current["p1" as PeerId]).toBe(false); }); - it("flips to true on a voiceActivityChanged → speech event", () => { - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useVAD({ peerIds: ["p1" as PeerId] }), { client }); + it("flips to true on a voiceActivityChanged → speech event", ({ client, renderHook }) => { + const { result } = renderHook(() => useVAD({ peerIds: ["p1" as PeerId] })); act(() => { client.addRemotePeer({ @@ -44,9 +40,8 @@ describe("useVAD (remote peers)", () => { expect(result.current["p1" as PeerId]).toBe(false); }); - it("only tracks the requested peer ids", () => { - const client = new FakeFishjamClient(); - const { result } = renderHookWithProvider(() => useVAD({ peerIds: ["p1" as PeerId] }), { client }); + it("only tracks the requested peer ids", ({ client, renderHook }) => { + const { result } = renderHook(() => useVAD({ peerIds: ["p1" as PeerId] })); act(() => { client.addRemotePeer({ diff --git a/packages/react-client/vitest.config.ts b/packages/react-client/vitest.config.ts index d839ceb80..9f6250a33 100644 --- a/packages/react-client/vitest.config.ts +++ b/packages/react-client/vitest.config.ts @@ -3,6 +3,5 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "jsdom", - setupFiles: ["./src/tests/support/setup.ts"], }, }); From c1a458f7b0218b8c55b514a3028a9910ab899daf Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Wed, 8 Jul 2026 15:35:56 +0200 Subject: [PATCH 3/7] fix found issues --- packages/react-client/package.json | 4 +- .../react-client/src/tests/connection.spec.ts | 24 +++++++++- packages/react-client/src/tests/peers.spec.ts | 4 -- .../src/tests/screenShare.spec.ts | 5 ++ .../src/tests/support/fakeFishjamClient.ts | 48 ++++++++++++++++--- .../src/tests/support/fakeMediaDevices.ts | 11 ++++- .../react-client/src/tests/support/setup.ts | 11 +++++ packages/react-client/src/tests/vad.spec.ts | 3 -- packages/react-client/vitest.config.ts | 1 + yarn.lock | 20 ++++++++ 10 files changed, 114 insertions(+), 17 deletions(-) create mode 100644 packages/react-client/src/tests/support/setup.ts diff --git a/packages/react-client/package.json b/packages/react-client/package.json index f316be45c..7db9b31e2 100644 --- a/packages/react-client/package.json +++ b/packages/react-client/package.json @@ -35,7 +35,7 @@ "build": "tsc -p tsconfig.build.json", "e2e": "NODE_OPTIONS=--dns-result-order=ipv4first playwright test", "test": "vitest run", - "typecheck": "tsc --noEmit", + "tsc": "tsc --noEmit", "docs": "typedoc src src/experimental", "format": "prettier --write . --ignore-path ./.eslintignore", "format:check": "prettier --check . --ignore-path ./.eslintignore", @@ -56,6 +56,8 @@ "@types/react": "19.1.0", "fake-mediastreamtrack": "^2.0.0", "jsdom": "^26.0.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", "typed-emitter": "^2.1.0", "typedoc": "^0.28.2", "typedoc-plugin-mdn-links": "^5.0.1", diff --git a/packages/react-client/src/tests/connection.spec.ts b/packages/react-client/src/tests/connection.spec.ts index 7a4e883b2..d76b0dd47 100644 --- a/packages/react-client/src/tests/connection.spec.ts +++ b/packages/react-client/src/tests/connection.spec.ts @@ -14,7 +14,10 @@ describe("useConnection", () => { const { result } = renderHook(() => useConnection()); await act(async () => { - await result.current.joinRoom({ peerToken: "tok-123", peerMetadata: { name: "alice" } }); + // connect() resolves only once joined fires (matching the real client). + const joining = result.current.joinRoom({ peerToken: "tok-123", peerMetadata: { name: "alice" } }); + client.simulateJoined(); + await joining; }); expect(client.connect).toHaveBeenCalledTimes(1); @@ -28,7 +31,9 @@ describe("useConnection", () => { it("defaults peerMetadata to an empty object", async ({ client, renderHook }) => { const { result } = renderHook(() => useConnection()); await act(async () => { - await result.current.joinRoom({ peerToken: "tok" }); + const joining = result.current.joinRoom({ peerToken: "tok" }); + client.simulateJoined(); + await joining; }); expect((client.connect.mock.calls[0][0] as { peerMetadata: unknown }).peerMetadata).toEqual({}); }); @@ -45,8 +50,23 @@ describe("useConnection", () => { it("sets peerStatus to error on auth / join / connection errors", ({ client, renderHook }) => { const { result } = renderHook(() => useConnection()); + + // Each error source is subscribed independently; disconnect resets to idle + // between them so every one is proven to flip peerStatus to "error". act(() => client.simulateAuthError()); expect(result.current.peerStatus).toBe("error"); + + act(() => client.simulateDisconnected()); + expect(result.current.peerStatus).toBe("idle"); + + act(() => client.simulateJoinError()); + expect(result.current.peerStatus).toBe("error"); + + act(() => client.simulateDisconnected()); + expect(result.current.peerStatus).toBe("idle"); + + act(() => client.simulateConnectionError()); + expect(result.current.peerStatus).toBe("error"); }); it("leaveRoom disconnects and returns to idle", ({ client, renderHook }) => { diff --git a/packages/react-client/src/tests/peers.spec.ts b/packages/react-client/src/tests/peers.spec.ts index 0ed74d9c1..c4d166227 100644 --- a/packages/react-client/src/tests/peers.spec.ts +++ b/packages/react-client/src/tests/peers.spec.ts @@ -28,7 +28,6 @@ describe("usePeers", () => { { trackId: "m", metadata: micMeta, track: createFakeTrack({ kind: "audio" }) }, ], }); - client.notifyStateChanged(); }); const local = result.current.localPeer!; @@ -49,7 +48,6 @@ describe("usePeers", () => { { trackId: "cv", metadata: customVideoMeta, track: createFakeTrack({ kind: "video" }) }, ], }); - client.notifyStateChanged(); }); const peer = result.current.remotePeers[0]; @@ -62,7 +60,6 @@ describe("usePeers", () => { const { result } = renderHook(() => usePeers()); act(() => { client.addRemotePeer({ id: "p1" }); - client.notifyStateChanged(); }); expect(result.current.peers).toEqual(result.current.remotePeers); }); @@ -74,7 +71,6 @@ describe("usePeers", () => { id: "p1", tracks: [{ trackId: "rv", metadata: screenVideoMeta, track: createFakeTrack({ kind: "video" }) }], }); - client.notifyStateChanged(); }); act(() => result.current.remotePeers[0].screenShareVideoTrack!.setReceivedQuality("h" as never)); diff --git a/packages/react-client/src/tests/screenShare.spec.ts b/packages/react-client/src/tests/screenShare.spec.ts index 029f1f3c0..469f49d0d 100644 --- a/packages/react-client/src/tests/screenShare.spec.ts +++ b/packages/react-client/src/tests/screenShare.spec.ts @@ -26,6 +26,8 @@ describe("useScreenShare", () => { media.setDisplayMediaStream(screenStream()); const { result } = renderHook(() => useScreenShare()); + // Publishing to the SFU only happens once connected (status "initialized"). + act(() => client.simulateJoined()); await act(async () => { await result.current.startStreaming(); }); @@ -67,6 +69,9 @@ describe("useScreenShare", () => { media.setDisplayMediaStream(screenStream()); const { result } = renderHook(() => useScreenShare()); + // replaceTrack is only reached when tracks were published (status + // "initialized"), which requires being connected. + act(() => client.simulateJoined()); await act(async () => { await result.current.startStreaming(); }); diff --git a/packages/react-client/src/tests/support/fakeFishjamClient.ts b/packages/react-client/src/tests/support/fakeFishjamClient.ts index 1b59cdedc..60b5cde7f 100644 --- a/packages/react-client/src/tests/support/fakeFishjamClient.ts +++ b/packages/react-client/src/tests/support/fakeFishjamClient.ts @@ -102,11 +102,39 @@ const buildPeer = (init: FakePeerInit): Peer => { * Cast with `asClient()` when handing to `FishjamProvider`. */ export class FakeFishjamClient extends EventEmitter { - status: "new" | "initialized" = "initialized"; + // Mirrors the real client: starts `"new"` and only becomes `"initialized"` + // inside connect(). Publishing paths gated on `status === "initialized"` + // (e.g. screenshare) therefore behave as they do in production. + status: "new" | "initialized" = "new"; // ---- spies (assert call args / counts) ------------------------------- - connect = vi.fn(async (_config: unknown) => { + // Faithful to the real connect(): emits `connectionStarted`, flips status to + // `initialized`, and only resolves once `joined` fires (rejects on + // join/auth/socket errors), mirroring connectEventsHandler. A test that + // awaits joinRoom() must drive `simulateJoined()` for the await to settle. + connect = vi.fn((_config: unknown) => { this.emit("connectionStarted"); + this.status = "initialized"; + return new Promise((resolve, reject) => { + const onSuccess = () => { + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject(); + }; + const cleanup = () => { + this.off("joined", onSuccess); + this.off("joinError", onError); + this.off("authError", onError); + this.off("socketError", onError); + }; + this.on("joined", onSuccess); + this.on("joinError", onError); + this.on("authError", onError); + this.on("socketError", onError); + }); }); disconnect = vi.fn(() => { this.simulateDisconnected(); @@ -114,16 +142,20 @@ export class FakeFishjamClient extends EventEmitter { replaceTrack = vi.fn(async (trackId: string, newTrack: MediaStreamTrack | null) => { const ctx = this.localPeer?.tracks.get(trackId) as FakeTrackContext | undefined; if (ctx) ctx.track = newTrack; + this.emit("localTrackReplaced", { trackId, track: newTrack }); }); removeTrack = vi.fn(async (trackId: string) => { this.localPeer?.tracks.delete(trackId); + this.emit("localTrackRemoved", { trackId }); }); updateTrackMetadata = vi.fn((trackId: string, metadata: TrackMetadata) => { const ctx = this.localPeer?.tracks.get(trackId) as FakeTrackContext | undefined; if (ctx) ctx.metadata = metadata; + this.emit("localTrackMetadataChanged", { trackId, metadata }); }); updatePeerMetadata = vi.fn((metadata: unknown) => { if (this.localPeer) (this.localPeer as { metadata?: unknown }).metadata = { peer: metadata, server: {} }; + this.emit("localPeerMetadataChanged", { metadata }); }); setTargetTrackEncoding = vi.fn((_trackId: string, _variant: Variant) => {}); createDataChannels = vi.fn(async () => { @@ -204,9 +236,13 @@ export class FakeFishjamClient extends EventEmitter { setLocalPeer(init: FakePeerInit) { this.localPeer = buildPeer(init); + // `joined` is the real event that first surfaces the local peer; emitting it + // invalidates useFishjamClientState's snapshot the same way production does. + this.emit("joined"); } addRemotePeer(init: FakePeerInit) { this.remotePeers[init.id] = buildPeer(init); + this.emit("peerJoined", this.remotePeers[init.id]); } getRemoteTrackContext(peerId: string, trackId: string) { return this.remotePeers[peerId]?.tracks.get(trackId) as FakeTrackContext | undefined; @@ -219,6 +255,10 @@ export class FakeFishjamClient extends EventEmitter { this.emit("connectionStarted"); } simulateJoined() { + // You cannot be joined without having connected, so status must already be + // `initialized` here (connect() sets it; this covers tests that jump + // straight to the joined state without awaiting connect()). + this.status = "initialized"; if (!this.localPeer) this.localPeer = buildPeer({ id: "local-peer" }); this.emit("joined"); } @@ -248,10 +288,6 @@ export class FakeFishjamClient extends EventEmitter { this.dataChannelsReady = false; this.emit("disconnected"); } - /** Nudge `useFishjamClientState` to recompute its snapshot (peers/components). */ - notifyStateChanged() { - this.emit("peerUpdated"); - } simulateDataChannelsError(error: Error) { this.emit("dataChannelsError", error); } diff --git a/packages/react-client/src/tests/support/fakeMediaDevices.ts b/packages/react-client/src/tests/support/fakeMediaDevices.ts index 9dee2d24d..7e3cb9e3b 100644 --- a/packages/react-client/src/tests/support/fakeMediaDevices.ts +++ b/packages/react-client/src/tests/support/fakeMediaDevices.ts @@ -108,7 +108,16 @@ export const installFakeMediaDevices = (): MediaDevicesController => { }); }, restore: () => { - if (originalMediaDevices) Object.defineProperty(navigator, "mediaDevices", originalMediaDevices); + // jsdom ships no `navigator.mediaDevices`, so `originalMediaDevices` is + // usually undefined — in that case the fake must be DELETED, not left in + // place. Leaving it lets the next `install()` capture this test's fake as + // its "original" and re-install stale fakes (with queued streams / error + // state) on later restores. + if (originalMediaDevices) { + Object.defineProperty(navigator, "mediaDevices", originalMediaDevices); + } else { + delete (navigator as { mediaDevices?: unknown }).mediaDevices; + } (globalThis as { MediaStream?: unknown }).MediaStream = originalMediaStream; }, }; diff --git a/packages/react-client/src/tests/support/setup.ts b/packages/react-client/src/tests/support/setup.ts new file mode 100644 index 000000000..a3a5c56ee --- /dev/null +++ b/packages/react-client/src/tests/support/setup.ts @@ -0,0 +1,11 @@ +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +// Testing Library only auto-registers its cleanup when a global `afterEach` +// exists (which it does not here — no `globals: true`). Without this, every +// `renderHook` tree leaks for the whole spec file: listeners stay attached to +// fake clients and references to already-restored media fakes linger, turning +// into order-dependent spy-count pollution and act() flakes as specs grow. +afterEach(() => { + cleanup(); +}); diff --git a/packages/react-client/src/tests/vad.spec.ts b/packages/react-client/src/tests/vad.spec.ts index 324dc9518..b5efeab84 100644 --- a/packages/react-client/src/tests/vad.spec.ts +++ b/packages/react-client/src/tests/vad.spec.ts @@ -16,7 +16,6 @@ describe("useVAD (remote peers)", () => { id: "p1", tracks: [{ trackId: "mic", metadata: micMeta, track: createFakeTrack({ kind: "audio" }) }], }); - client.notifyStateChanged(); }); expect(result.current["p1" as PeerId]).toBe(false); @@ -30,7 +29,6 @@ describe("useVAD (remote peers)", () => { id: "p1", tracks: [{ trackId: "mic", metadata: micMeta, track: createFakeTrack({ kind: "audio" }) }], }); - client.notifyStateChanged(); }); act(() => client.getRemoteTrackContext("p1", "mic")!.simulateVad("speech")); @@ -48,7 +46,6 @@ describe("useVAD (remote peers)", () => { id: "p2", tracks: [{ trackId: "mic2", metadata: micMeta, track: createFakeTrack({ kind: "audio" }) }], }); - client.notifyStateChanged(); }); expect(result.current["p2" as PeerId]).toBeUndefined(); diff --git a/packages/react-client/vitest.config.ts b/packages/react-client/vitest.config.ts index 9f6250a33..d839ceb80 100644 --- a/packages/react-client/vitest.config.ts +++ b/packages/react-client/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "jsdom", + setupFiles: ["./src/tests/support/setup.ts"], }, }); diff --git a/yarn.lock b/yarn.lock index 105cd8771..c72f09c7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4520,6 +4520,8 @@ __metadata: fake-mediastreamtrack: "npm:^2.0.0" jsdom: "npm:^26.0.0" lodash.isequal: "npm:4.5.0" + react: "npm:^19.1.0" + react-dom: "npm:^19.1.0" typed-emitter: "npm:^2.1.0" typedoc: "npm:^0.28.2" typedoc-plugin-mdn-links: "npm:^5.0.1" @@ -16424,6 +16426,17 @@ __metadata: languageName: node linkType: hard +"react-dom@npm:^19.1.0": + version: 19.2.7 + resolution: "react-dom@npm:19.2.7" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.7 + checksum: 10c0/970ff600f6e80d47d39e2f226f12f226173b3cba3382efc97c5f0cd663de9af38c7a4c11c213fb936094faeac83060d660247accaa96b752180d5b951b9cfecb + languageName: node + linkType: hard + "react-fast-compare@npm:^3.2.2": version: 3.2.2 resolution: "react-fast-compare@npm:3.2.2" @@ -16768,6 +16781,13 @@ __metadata: languageName: node linkType: hard +"react@npm:^19.1.0": + version: 19.2.7 + resolution: "react@npm:19.2.7" + checksum: 10c0/0bd0e2f1bbd4ba97561c6597bf8a5fec05e6476fe61e165c1065598d16668efc6715205599c94d3ddd49d36cb0f21cbf1b9bcc18ee840b805ce222c3e8d558ac + languageName: node + linkType: hard + "readable-stream@npm:^2.0.5": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" From 2d50009920c2b7b38f1d83f7b65ed473067d4c05 Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Thu, 9 Jul 2026 16:27:08 +0200 Subject: [PATCH 4/7] de-choreograph seam tests, cover TrackTypeError, constraint-aware media fake --- .../react-client/src/tests/camera.spec.ts | 111 +++++++++++---- .../src/tests/customSource.spec.ts | 21 +++ .../src/tests/initializeDevices.spec.ts | 18 +++ .../react-client/src/tests/microphone.spec.ts | 32 +++-- .../react-client/src/tests/sandbox.spec.ts | 14 +- .../src/tests/screenShare.spec.ts | 23 ++++ .../src/tests/support/fakeFishjamClient.ts | 67 ++++++--- .../src/tests/support/fakeMediaDevices.ts | 127 +++++++++++++----- .../src/tests/support/fakeMediaStream.ts | 15 +-- .../src/tests/support/fixtures.ts | 12 +- .../src/tests/support/renderWithProvider.tsx | 21 ++- packages/react-client/tsconfig.build.json | 2 +- 12 files changed, 330 insertions(+), 133 deletions(-) diff --git a/packages/react-client/src/tests/camera.spec.ts b/packages/react-client/src/tests/camera.spec.ts index 784269f09..c79817e73 100644 --- a/packages/react-client/src/tests/camera.spec.ts +++ b/packages/react-client/src/tests/camera.spec.ts @@ -1,6 +1,7 @@ import { act } from "@testing-library/react"; import { useCamera } from "../hooks/devices/useCamera"; +import { usePeers } from "../hooks/usePeers"; import { createFakeStream } from "./support/fakeMediaStream"; import { describe, expect, it, vi } from "./support/fixtures"; @@ -45,6 +46,43 @@ describe("useCamera", () => { expect(result.current.cameraStream).toBeNull(); }); + it("restarting after stopCamera acquires a live track, not the stopped one", async ({ media, renderHook }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => useCamera()); + + await act(async () => { + await result.current.startCamera(); + }); + act(() => result.current.stopCamera()); + await act(async () => { + await result.current.startCamera(); + }); + + expect(result.current.isCameraOn).toBe(true); + expect(result.current.cameraStream?.getVideoTracks()[0]?.readyState).toBe("live"); + }); + + it("selecting a camera that does not exist surfaces OverconstrainedError", async ({ media, renderHook }) => { + media.setUserMediaStream(videoStream()); + // KNOWN QUIRK: getDeviceStream (useDeviceManager.ts) MUTATES the constraints + // object it is handed, baking `deviceId: { exact: ... }` into it — and the + // default is the module-level VIDEO_TRACK_CONSTRAINTS shared by every + // FishjamProvider, so without per-test constraints this test would poison + // every later getUserMedia call in the process with the nonexistent device. + const { result } = renderHook(() => useCamera(), { + providerProps: { constraints: { video: {}, audio: true } }, + }); + + await act(async () => { + await result.current.startCamera(); + }); + await act(async () => { + await result.current.selectCamera("nonexistent-device"); + }); + + expect(result.current.cameraDeviceError).toEqual({ name: "OverconstrainedError" }); + }); + it("toggleCamera while connected publishes a camera track with metadata", async ({ media, client, renderHook }) => { media.setUserMediaStream(videoStream()); const { result } = renderHook(() => useCamera()); @@ -80,21 +118,21 @@ describe("useCamera", () => { it("toggleCamera off while connected pauses the published track", async ({ media, client, renderHook }) => { media.setUserMediaStream(videoStream()); - const { result } = renderHook(() => useCamera()); + const { result } = renderHook(() => ({ camera: useCamera(), peers: usePeers() })); act(() => client.simulateJoined()); await act(async () => { - await result.current.toggleCamera(); // on + publish + await result.current.camera.toggleCamera(); // on + publish }); await act(async () => { - await result.current.toggleCamera(); // off + await result.current.camera.toggleCamera(); // off }); - expect(result.current.isCameraOn).toBe(false); - // pauseStreaming replaces with null and flips metadata paused=true. - expect(client.replaceTrack).toHaveBeenCalledWith(expect.any(String), null); - const lastMeta = client.updateTrackMetadata.mock.calls.at(-1)?.[1]; - expect(lastMeta).toMatchObject({ type: "camera", paused: true }); + expect(result.current.camera.isCameraOn).toBe(false); + // The published track is paused, not removed: no media flows, metadata says paused. + const published = result.current.peers.localPeer?.cameraTrack; + expect(published?.track).toBeNull(); + expect(published?.metadata).toMatchObject({ type: "camera", paused: true }); }); it("setCameraTrackMiddleware applies the middleware to the device track", async ({ media, renderHook }) => { @@ -127,41 +165,60 @@ describe("useCamera", () => { expect(result.current.isCameraOn).toBe(false); }); - it("awaits the in-flight publish so an op racing addTrack targets the remote id", async ({ + it("keeps the camera running locally when an audio-only room refuses the video track", async ({ + media, + client, + renderHook, + }) => { + media.setUserMediaStream(videoStream()); + client.simulateAudioOnlyRoom(); + const { result } = renderHook(() => ({ camera: useCamera(), peers: usePeers() })); + + await act(async () => { + await result.current.camera.startCamera(); + }); + // Joining triggers the auto-publish, which the audio-only room rejects + // with TrackTypeError. The hook must swallow it, not crash the tree. + await act(async () => { + client.simulateJoined(); + }); + + expect(client.addTrack).toHaveBeenCalledTimes(1); + expect(result.current.camera.isCameraOn).toBe(true); // device still on locally + expect(result.current.peers.localPeer?.cameraTrack).toBeUndefined(); // nothing published + }); + + it("toggling off while the publish is still in flight pauses the published track", async ({ media, client, renderHook, }) => { media.setUserMediaStream(videoStream()); - // Hold addTrack open: the remote track id won't exist until we flush. + // Hold addTrack open so the toggle below races the pending publish. client.deferAddTracks(); - const { result } = renderHook(() => useCamera()); + const { result } = renderHook(() => ({ camera: useCamera(), peers: usePeers() })); await act(async () => { - await result.current.startCamera(); + await result.current.camera.startCamera(); }); - // Join → auto-publish fires, but addTrack is suspended. At this point - // currentTrackIdRef still holds the LOCAL track id and the remote track is - // not yet registered on the local peer. + // Join → auto-publish fires, but the publish hasn't completed yet. act(() => client.simulateJoined()); expect(client.addTrack).toHaveBeenCalledTimes(1); await act(async () => { - // toggleCamera-off calls getCurrentTrackId() as its FIRST step, so the id - // read races the pending publish. getCurrentTrackId() must await - // connectionPromiseRef: we read the id while addTrack is still suspended, - // then resolve the publish before letting the toggle continue. - const toggling = result.current.toggleCamera(); - client.flushAddTracks(); // addTrack resolves → currentTrackIdRef advances to remote-0 + // Toggle off while the publish is pending; the publish completes mid-toggle. + const toggling = result.current.camera.toggleCamera(); + client.flushAddTracks(); await toggling; }); - // Regression guard for the local→remote id race: because getCurrentTrackId - // awaited the in-flight publish, pauseStreaming targeted the REMOTE id. - // Without that await it reads the still-local id (whose track isn't - // registered yet), resolves to null, and skips replaceTrack entirely. - expect(client.replaceTrack).toHaveBeenCalledTimes(1); - expect(client.replaceTrack).toHaveBeenCalledWith("remote-0", null); + // Regression guard: the toggle-off must land on the track that finished + // publishing — peers end up seeing a paused camera track with no media, + // not a still-live one the toggle failed to reach. + expect(result.current.camera.isCameraOn).toBe(false); + const published = result.current.peers.localPeer?.cameraTrack; + expect(published?.track).toBeNull(); + expect(published?.metadata).toMatchObject({ type: "camera", paused: true }); }); }); diff --git a/packages/react-client/src/tests/customSource.spec.ts b/packages/react-client/src/tests/customSource.spec.ts index dace22192..e4a274d9b 100644 --- a/packages/react-client/src/tests/customSource.spec.ts +++ b/packages/react-client/src/tests/customSource.spec.ts @@ -1,6 +1,7 @@ import { act } from "@testing-library/react"; import { useCustomSource } from "../hooks/useCustomSource"; +import { usePeers } from "../hooks/usePeers"; import { createFakeStream } from "./support/fakeMediaStream"; import { describe, expect, it } from "./support/fixtures"; @@ -47,6 +48,26 @@ describe("useCustomSource", () => { expect(client.addTrack).toHaveBeenCalled(); }); + it("publishes only the audio track when an audio-only room refuses the video track", async ({ + client, + renderHook, + }) => { + client.simulateAudioOnlyRoom(); + const { result } = renderHook(() => ({ source: useCustomSource("feed"), peers: usePeers() })); + + act(() => client.simulateJoined()); + const stream = avStream(); + // addTrack throws TrackTypeError for the video track; setStream must + // recover and still publish the audio track instead of rejecting. + await act(async () => { + await result.current.source.setStream(stream); + }); + + expect(result.current.source.stream).toBe(stream); + expect(result.current.peers.localPeer?.customVideoTracks).toHaveLength(0); + expect(result.current.peers.localPeer?.customAudioTracks).toHaveLength(1); + }); + it("removes tracks when the stream is cleared", async ({ client, renderHook }) => { const { result } = renderHook(() => useCustomSource("feed")); diff --git a/packages/react-client/src/tests/initializeDevices.spec.ts b/packages/react-client/src/tests/initializeDevices.spec.ts index e27585c56..e1ccdfedb 100644 --- a/packages/react-client/src/tests/initializeDevices.spec.ts +++ b/packages/react-client/src/tests/initializeDevices.spec.ts @@ -50,6 +50,24 @@ describe("useInitializeDevices", () => { expect(second!.status).toBe("already_initialized"); }); + it("falls back to audio-only when no camera is installed", async ({ media, renderHook }) => { + // Only a microphone exists: the combined audio+video request fails + // (NotFoundError, as in a real browser) and the audio-only retry succeeds. + media.setUserMediaStream(createFakeStream([{ kind: "audio", deviceId: "mic-1" }])); + media.setEnumeratedDevices([devices[1]]); + + const { result } = renderHook(() => useInitializeDevices()); + + let outcome: Awaited>; + await act(async () => { + outcome = await result.current.initializeDevices(); + }); + + expect(outcome!.status).toBe("initialized_with_errors"); + expect(outcome!.errors?.video).not.toBeNull(); + expect(outcome!.errors?.audio).toBeNull(); + }); + it("populates the device lists exposed by useCamera", async ({ media, renderHook }) => { media.setUserMediaStream(fullStream()); media.setEnumeratedDevices(devices); diff --git a/packages/react-client/src/tests/microphone.spec.ts b/packages/react-client/src/tests/microphone.spec.ts index 7ef3cc6a0..27963962a 100644 --- a/packages/react-client/src/tests/microphone.spec.ts +++ b/packages/react-client/src/tests/microphone.spec.ts @@ -1,6 +1,7 @@ import { act } from "@testing-library/react"; import { useMicrophone } from "../hooks/devices/useMicrophone"; +import { usePeers } from "../hooks/usePeers"; import { createFakeStream } from "./support/fakeMediaStream"; import { describe, expect, it } from "./support/fixtures"; @@ -31,41 +32,46 @@ describe("useMicrophone", () => { renderHook, }) => { media.setUserMediaStream(audioStream()); - const { result } = renderHook(() => useMicrophone()); + const { result } = renderHook(() => ({ mic: useMicrophone(), peers: usePeers() })); act(() => client.simulateJoined()); await act(async () => { - await result.current.toggleMicrophone(); // device on + publish + await result.current.mic.toggleMicrophone(); // device on + publish }); expect(client.addTrack).toHaveBeenCalledTimes(1); await act(async () => { - await result.current.toggleMicrophoneMute(); + await result.current.mic.toggleMicrophoneMute(); }); - expect(result.current.isMicrophoneMuted).toBe(true); + expect(result.current.mic.isMicrophoneMuted).toBe(true); // Still on (soft mute): the stream is not torn down. - expect(result.current.isMicrophoneOn).toBe(true); - expect(client.replaceTrack).toHaveBeenCalledWith(expect.any(String), null); - expect(client.updateTrackMetadata.mock.calls.at(-1)?.[1]).toMatchObject({ type: "microphone", paused: true }); + expect(result.current.mic.isMicrophoneOn).toBe(true); + // Peers see a paused microphone track with no media flowing. + const published = result.current.peers.localPeer?.microphoneTrack; + expect(published?.track).toBeNull(); + expect(published?.metadata).toMatchObject({ type: "microphone", paused: true }); }); it("unmuting resumes the track", async ({ media, client, renderHook }) => { media.setUserMediaStream(audioStream()); - const { result } = renderHook(() => useMicrophone()); + const { result } = renderHook(() => ({ mic: useMicrophone(), peers: usePeers() })); act(() => client.simulateJoined()); await act(async () => { - await result.current.toggleMicrophone(); + await result.current.mic.toggleMicrophone(); }); await act(async () => { - await result.current.toggleMicrophoneMute(); // mute + await result.current.mic.toggleMicrophoneMute(); // mute }); await act(async () => { - await result.current.toggleMicrophoneMute(); // unmute + await result.current.mic.toggleMicrophoneMute(); // unmute }); - expect(result.current.isMicrophoneMuted).toBe(false); - expect(client.updateTrackMetadata.mock.calls.at(-1)?.[1]).toMatchObject({ type: "microphone", paused: false }); + expect(result.current.mic.isMicrophoneMuted).toBe(false); + // Media flows again and the metadata no longer reports paused. + const published = result.current.peers.localPeer?.microphoneTrack; + expect(published?.track).not.toBeNull(); + expect(published?.metadata).toMatchObject({ type: "microphone", paused: false }); }); }); diff --git a/packages/react-client/src/tests/sandbox.spec.ts b/packages/react-client/src/tests/sandbox.spec.ts index 475e925a0..7b613a851 100644 --- a/packages/react-client/src/tests/sandbox.spec.ts +++ b/packages/react-client/src/tests/sandbox.spec.ts @@ -1,7 +1,5 @@ -import { renderHook } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - import { useSandbox } from "../hooks/useSandbox"; +import { afterEach, describe, expect, it, vi } from "./support/fixtures"; const mockFetch = (response: { ok: boolean; status?: number; json?: () => Promise }) => { const fetchSpy = vi.fn(async (_input: string | URL) => ({ @@ -16,12 +14,12 @@ const mockFetch = (response: { ok: boolean; status?: number; json?: () => Promis describe("useSandbox", () => { afterEach(() => vi.unstubAllGlobals()); - it("throws if no sandboxApiUrl is provided", async () => { + it("throws if no sandboxApiUrl is provided", async ({ renderHook }) => { const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "" })); await expect(result.current.getSandboxPeerToken("room", "peer")).rejects.toThrow(/sandboxApiUrl/); }); - it("getSandboxPeerToken builds the query and returns the peer token", async () => { + it("getSandboxPeerToken builds the query and returns the peer token", async ({ renderHook }) => { const fetchSpy = mockFetch({ ok: true, json: async () => ({ peerToken: "pt-1" }) }); const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); @@ -34,7 +32,7 @@ describe("useSandbox", () => { expect(calledUrl.searchParams.get("roomType")).toBe("audio_only"); }); - it("defaults roomType to conference", async () => { + it("defaults roomType to conference", async ({ renderHook }) => { const fetchSpy = mockFetch({ ok: true, json: async () => ({ peerToken: "pt" }) }); const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); @@ -43,13 +41,13 @@ describe("useSandbox", () => { expect(calledUrl.searchParams.get("roomType")).toBe("conference"); }); - it("getSandboxViewerToken gives a friendly error when the room is missing", async () => { + it("getSandboxViewerToken gives a friendly error when the room is missing", async ({ renderHook }) => { mockFetch({ ok: false, status: 404 }); const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); await expect(result.current.getSandboxViewerToken("ghost")).rejects.toThrow(/does not exist/); }); - it("getSandboxLivestream returns the streamer token payload", async () => { + it("getSandboxLivestream returns the streamer token payload", async ({ renderHook }) => { mockFetch({ ok: true, json: async () => ({ streamerToken: "st", room: { id: "r", name: "n" } }) }); const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); const data = await result.current.getSandboxLivestream("room", true); diff --git a/packages/react-client/src/tests/screenShare.spec.ts b/packages/react-client/src/tests/screenShare.spec.ts index 469f49d0d..8325330cf 100644 --- a/packages/react-client/src/tests/screenShare.spec.ts +++ b/packages/react-client/src/tests/screenShare.spec.ts @@ -1,5 +1,6 @@ import { act } from "@testing-library/react"; +import { usePeers } from "../hooks/usePeers"; import { useScreenShare } from "../hooks/useScreenShare"; import { createFakeStream } from "./support/fakeMediaStream"; import { describe, expect, it } from "./support/fixtures"; @@ -61,6 +62,28 @@ describe("useScreenShare", () => { expect(result.current.stream).toBeNull(); }); + it("publishes only the audio track when an audio-only room refuses the video track", async ({ + media, + client, + renderHook, + }) => { + media.setDisplayMediaStream(screenStream()); + client.simulateAudioOnlyRoom(); + const { result } = renderHook(() => ({ screenShare: useScreenShare(), peers: usePeers() })); + + act(() => client.simulateJoined()); + // addTrack throws TrackTypeError for the video track; startStreaming must + // recover and still publish the audio track instead of rejecting. + await act(async () => { + await result.current.screenShare.startStreaming(); + }); + + expect(result.current.screenShare.stream).not.toBeNull(); + expect(result.current.screenShare.videoTrack).not.toBeNull(); // still captured locally + expect(result.current.peers.localPeer?.screenShareVideoTrack).toBeUndefined(); + expect(result.current.peers.localPeer?.screenShareAudioTrack).toBeDefined(); + }); + it("setTracksMiddleware processes both tracks and replaces them on the SFU", async ({ media, client, diff --git a/packages/react-client/src/tests/support/fakeFishjamClient.ts b/packages/react-client/src/tests/support/fakeFishjamClient.ts index 60b5cde7f..37aea89af 100644 --- a/packages/react-client/src/tests/support/fakeFishjamClient.ts +++ b/packages/react-client/src/tests/support/fakeFishjamClient.ts @@ -1,10 +1,14 @@ -import type { - DataCallback, - DataChannelOptions, - FishjamClient, - Peer, - TrackMetadata, - Variant, +import { + type DataCallback, + type DataChannelOptions, + type FishjamClient, + type FishjamTrackContext, + type Peer, + type SimulcastConfig, + type TrackMetadata, + TrackTypeError, + type VadStatus, + type Variant, } from "@fishjam-cloud/ts-client"; import { EventEmitter } from "events"; import { vi } from "vitest"; @@ -12,8 +16,6 @@ import { vi } from "vitest"; import { Deferred } from "../../utils/deferred"; import { FakeMediaStream } from "./fakeMediaStream"; -type VadStatus = "speech" | "silence"; - /** * The exact subset of `FishjamClient` command/query methods the React SDK * depends on. Deriving it with `Pick` (rather than re-declaring signatures) is @@ -52,13 +54,23 @@ export type FishjamClientContract = Pick< | "getLocalTrackAudioLevel" >; +/** + * The slice of `FishjamTrackContext` the SDK reads — same drift-tripwire idea + * as `FishjamClientContract`: if the real context renames or re-types any of + * these fields, `FakeTrackContext` stops compiling. + */ +type TrackContextContract = Pick< + FishjamTrackContext, + "trackId" | "track" | "metadata" | "stream" | "simulcastConfig" | "vadStatus" +>; + /** * In-memory track context, mirroring the slice of `FishjamTrackContext` the SDK * reads. It is an EventEmitter so `voiceActivityChanged` can be driven, and * `vadStatus` is mutable in place (the real client mutates it the same way, * which is why `useVAD` needs its own re-render trigger). */ -export class FakeTrackContext extends EventEmitter { +export class FakeTrackContext extends EventEmitter implements TrackContextContract { vadStatus: VadStatus = "silence"; constructor( @@ -66,7 +78,7 @@ export class FakeTrackContext extends EventEmitter { public track: MediaStreamTrack | null, public metadata: TrackMetadata | undefined, public stream: MediaStream | null, - public simulcastConfig: unknown = null, + public simulcastConfig?: SimulcastConfig, ) { super(); } @@ -79,7 +91,7 @@ export class FakeTrackContext extends EventEmitter { export type FakePeerInit = { id: string; - metadata?: unknown; + metadata?: Peer["metadata"]; tracks?: { trackId: string; metadata: TrackMetadata; track?: MediaStreamTrack | null }[]; }; @@ -91,7 +103,14 @@ const buildPeer = (init: FakePeerInit): Peer => { new FakeTrackContext(t.trackId, t.track ?? null, t.metadata, new FakeMediaStream(t.track ? [t.track] : [])), ); } - return { id: init.id, type: "webrtc", metadata: init.metadata, tracks } as unknown as Peer; + return { + id: init.id, + type: "webrtc", + metadata: init.metadata, + // FakeTrackContext implements only the read surface of FishjamTrackContext + // (TrackContextContract); this cast erases the unimplemented remainder. + tracks: tracks as unknown as Peer["tracks"], + } satisfies Peer; }; /** @@ -154,7 +173,7 @@ export class FakeFishjamClient extends EventEmitter { this.emit("localTrackMetadataChanged", { trackId, metadata }); }); updatePeerMetadata = vi.fn((metadata: unknown) => { - if (this.localPeer) (this.localPeer as { metadata?: unknown }).metadata = { peer: metadata, server: {} }; + if (this.localPeer) this.localPeer.metadata = { peer: metadata, server: {} } as Peer["metadata"]; this.emit("localPeerMetadataChanged", { metadata }); }); setTargetTrackEncoding = vi.fn((_trackId: string, _variant: Variant) => {}); @@ -169,8 +188,12 @@ export class FakeFishjamClient extends EventEmitter { }); getStatistics = vi.fn(async () => ({}) as RTCStatsReport); - // ---- addTrack: controllable to exercise the local→remote id race ----- + // ---- addTrack: controllable to exercise races with in-flight publishes ---- addTrack = vi.fn((track: MediaStreamTrack, metadata?: TrackMetadata): Promise => { + // Faithful to the real addTrack, which throws TrackTypeError SYNCHRONOUSLY + // (not via a rejected promise) when a non-audio track is added to an + // audio-only room (FishjamClient.ts: `if (this.isAudioOnlyConnection && ...)`). + if (this.audioOnlyConnection && track.kind !== "audio") throw new TrackTypeError(); const remoteId = `remote-${this.trackIdCounter++}`; const register = () => { if (!this.localPeer) this.localPeer = buildPeer({ id: "local-peer" }); @@ -198,7 +221,7 @@ export class FakeFishjamClient extends EventEmitter { private dataChannelsReady = false; private dataSubscribers = new Set(); private reconnecting = false; - private audioLevel: number | null = null; + private audioOnlyConnection = false; localPeer: Peer | null = null; remotePeers: Record = {}; @@ -209,7 +232,7 @@ export class FakeFishjamClient extends EventEmitter { getRemoteComponents = () => ({}); isReconnecting = () => this.reconnecting; getDataChannelsReadiness = () => this.dataChannelsReady; - getLocalTrackAudioLevel = async (_trackId: string) => (this.audioLevel == null ? null : { level: this.audioLevel }); + getLocalTrackAudioLevel = async (_trackId: string) => null; // EventEmitter compat: the SDK calls removeListener (alias of off). @@ -224,7 +247,7 @@ export class FakeFishjamClient extends EventEmitter { // ---- test controls --------------------------------------------------- - /** Hold addTrack promises until `flushAddTracks()`; exercises connectionPromiseRef. */ + /** Hold addTrack promises until `flushAddTracks()`, so tests can race other ops against an in-flight publish. */ deferAddTracks() { this.autoResolveAddTrack = false; } @@ -234,6 +257,11 @@ export class FakeFishjamClient extends EventEmitter { pending.forEach((resolve) => resolve()); } + /** Marks the room audio-only, like an `authenticated` response with roomType AUDIO_ONLY does. */ + simulateAudioOnlyRoom() { + this.audioOnlyConnection = true; + } + setLocalPeer(init: FakePeerInit) { this.localPeer = buildPeer(init); // `joined` is the real event that first surfaces the local peer; emitting it @@ -247,9 +275,6 @@ export class FakeFishjamClient extends EventEmitter { getRemoteTrackContext(peerId: string, trackId: string) { return this.remotePeers[peerId]?.tracks.get(trackId) as FakeTrackContext | undefined; } - setAudioLevel(level: number | null) { - this.audioLevel = level; - } simulateConnectionStarted() { this.emit("connectionStarted"); diff --git a/packages/react-client/src/tests/support/fakeMediaDevices.ts b/packages/react-client/src/tests/support/fakeMediaDevices.ts index 7e3cb9e3b..cc0a63db1 100644 --- a/packages/react-client/src/tests/support/fakeMediaDevices.ts +++ b/packages/react-client/src/tests/support/fakeMediaDevices.ts @@ -1,12 +1,12 @@ import { vi } from "vitest"; -import { FakeMediaStream } from "./fakeMediaStream"; +import { createFakeTrack, FakeMediaStream } from "./fakeMediaStream"; /** * Controllable fake of the browser media layer. This is the exact surface the - * rewrite plans to hoist behind an injected `PlatformDeviceManager`, so keeping - * it isolated here means the same control knobs drive both the current - * (React-owned) tests and the future ts-client/core tests. + * rewrite plans to hoist behind an injected `IDeviceManager`, so keeping it + * isolated here (no react-client imports) means the same control knobs drive + * both the current (React-owned) tests and the future core tests. */ export type FakeMediaDevices = { getUserMedia: ReturnType; @@ -18,13 +18,16 @@ export type FakeMediaDevices = { export type MediaDevicesController = { devices: FakeMediaDevices; - /** Queue/replace what the next getUserMedia returns, keyed by requested kind. */ + /** + * Declare the tracks the "installed" devices produce. Each getUserMedia call + * hands out FRESH live tracks cut down to the requested kinds (an audio-only + * request never receives video), so a stop→restart sequence gets live tracks + * again instead of the previous call's ended ones. + */ setUserMediaStream: (stream: MediaStream) => void; - /** Make the next getUserMedia reject with a DOMException of the given name. */ - failUserMediaOnce: (errorName: string) => void; + /** Make every getUserMedia reject with a DOMException of the given name. */ failUserMediaAlways: (errorName: string) => void; setDisplayMediaStream: (stream: MediaStream) => void; - failDisplayMediaOnce: (errorName: string) => void; setEnumeratedDevices: (devices: Partial[]) => void; restore: () => void; }; @@ -35,31 +38,92 @@ const makeError = (name: string) => { return err; }; +// Real browsers report which constraint could not be satisfied. +const makeOverconstrainedError = () => { + const err = makeError("OverconstrainedError") as Error & { constraint: string }; + err.constraint = "deviceId"; + return err; +}; + +type TrackKind = "audio" | "video"; + +/** A device the fake has "installed", derived from the staged template tracks. */ +type InstalledDevice = { kind: TrackKind; deviceId: string; label: string }; + +/** `deviceId: { exact }` is mandatory; a plain string or `{ ideal }` is a preference. */ +const requestedDeviceId = (constraint: MediaTrackConstraints): { id: string; mandatory: boolean } | null => { + const deviceId = constraint.deviceId; + if (typeof deviceId === "string") return { id: deviceId, mandatory: false }; + if (deviceId && typeof deviceId === "object" && !Array.isArray(deviceId)) { + if (typeof deviceId.exact === "string") return { id: deviceId.exact, mandatory: true }; + if (typeof deviceId.ideal === "string") return { id: deviceId.ideal, mandatory: false }; + } + return null; +}; + +/** + * Resolve one kind's constraint against the installed devices, mirroring real + * getUserMedia semantics: unmatched `exact` deviceId → OverconstrainedError, + * no device of the kind at all → NotFoundError, otherwise a fresh live track. + * Pure constraint logic — safe to lift into a future IDeviceManager fake. + */ +const resolveTrack = (installed: InstalledDevice[], kind: TrackKind, constraint: MediaTrackConstraints | boolean) => { + const candidates = installed.filter((device) => device.kind === kind); + const requested = typeof constraint === "object" ? requestedDeviceId(constraint) : null; + + let device: InstalledDevice | undefined; + if (requested?.mandatory) { + device = candidates.find((candidate) => candidate.deviceId === requested.id); + if (!device) throw makeOverconstrainedError(); + } else { + device = (requested && candidates.find((candidate) => candidate.deviceId === requested.id)) || candidates[0]; + if (!device) throw makeError("NotFoundError"); + } + + return createFakeTrack({ kind, deviceId: device.deviceId, label: device.label }); +}; + export const installFakeMediaDevices = (): MediaDevicesController => { - let userMediaStreamFactory: () => MediaStream = () => new FakeMediaStream(); - let userMediaError: { name: string; once: boolean } | null = null; + let userMediaTemplate: MediaStream = new FakeMediaStream(); + let userMediaError: string | null = null; let displayMediaStreamFactory: () => MediaStream = () => new FakeMediaStream(); - let displayMediaError: string | null = null; let enumerated: MediaDeviceInfo[] = []; - const getUserMedia = vi.fn(async () => { - if (userMediaError) { - const name = userMediaError.name; - if (userMediaError.once) userMediaError = null; - throw makeError(name); - } - return userMediaStreamFactory(); - }); + // Installed devices = the staged template tracks, plus enumerated devices + // not already covered by them (so a test can select any enumerated device). + const installedDevices = (): InstalledDevice[] => { + const fromTemplate = userMediaTemplate.getTracks().map( + (track): InstalledDevice => ({ + kind: track.kind as TrackKind, + deviceId: track.getSettings().deviceId ?? "", + label: track.label, + }), + ); + const fromEnumerated = enumerated + .filter((device) => device.kind === "videoinput" || device.kind === "audioinput") + .map( + (device): InstalledDevice => ({ + kind: device.kind === "videoinput" ? "video" : "audio", + deviceId: device.deviceId, + label: device.label, + }), + ) + .filter((device) => !fromTemplate.some((t) => t.kind === device.kind && t.deviceId === device.deviceId)); + return [...fromTemplate, ...fromEnumerated]; + }; - const getDisplayMedia = vi.fn(async () => { - if (displayMediaError) { - const name = displayMediaError; - displayMediaError = null; - throw makeError(name); - } - return displayMediaStreamFactory(); + const getUserMedia = vi.fn(async (constraints: MediaStreamConstraints = {}) => { + if (userMediaError) throw makeError(userMediaError); + + const installed = installedDevices(); + const tracks = (["audio", "video"] as const) + .filter((kind) => Boolean(constraints[kind])) + .map((kind) => resolveTrack(installed, kind, constraints[kind]!)); + return new FakeMediaStream(tracks); }); + const getDisplayMedia = vi.fn(async () => displayMediaStreamFactory()); + const enumerateDevices = vi.fn(async () => enumerated); const devices: FakeMediaDevices = { @@ -81,21 +145,14 @@ export const installFakeMediaDevices = (): MediaDevicesController => { devices, setUserMediaStream: (stream) => { userMediaError = null; - userMediaStreamFactory = () => stream; - }, - failUserMediaOnce: (errorName) => { - userMediaError = { name: errorName, once: true }; + userMediaTemplate = stream; }, failUserMediaAlways: (errorName) => { - userMediaError = { name: errorName, once: false }; + userMediaError = errorName; }, setDisplayMediaStream: (stream) => { - displayMediaError = null; displayMediaStreamFactory = () => stream; }, - failDisplayMediaOnce: (errorName) => { - displayMediaError = errorName; - }, setEnumeratedDevices: (list) => { enumerated = list.map((d) => { const info = { diff --git a/packages/react-client/src/tests/support/fakeMediaStream.ts b/packages/react-client/src/tests/support/fakeMediaStream.ts index 90ff47feb..70bb28a72 100644 --- a/packages/react-client/src/tests/support/fakeMediaStream.ts +++ b/packages/react-client/src/tests/support/fakeMediaStream.ts @@ -20,12 +20,8 @@ export class FakeMediaStream implements MediaStream { private tracks: MediaStreamTrack[]; - constructor(tracks: MediaStreamTrack[] | MediaStream = []) { - if (Array.isArray(tracks)) { - this.tracks = [...tracks]; - } else { - this.tracks = tracks.getTracks(); - } + constructor(tracks: MediaStreamTrack[] = []) { + this.tracks = [...tracks]; } getTracks(): MediaStreamTrack[] { @@ -68,8 +64,6 @@ export type FakeTrackOptions = { kind: "audio" | "video"; deviceId?: string; label?: string; - id?: string; - enabled?: boolean; }; /** @@ -80,14 +74,11 @@ export const createFakeTrack = ({ kind, deviceId = `${kind}-device-default`, label = `${kind} track`, - id, - enabled = true, }: FakeTrackOptions): FakeMediaStreamTrack => new FakeMediaStreamTrack({ kind, - id: id ?? `${kind}-track-${trackCounter++}`, + id: `${kind}-track-${trackCounter++}`, label, - enabled, settings: { deviceId }, }); diff --git a/packages/react-client/src/tests/support/fixtures.ts b/packages/react-client/src/tests/support/fixtures.ts index b48b11d95..597b761ea 100644 --- a/packages/react-client/src/tests/support/fixtures.ts +++ b/packages/react-client/src/tests/support/fixtures.ts @@ -1,8 +1,9 @@ +import { type RenderHookResult } from "@testing-library/react"; import { test as base } from "vitest"; import { FakeFishjamClient } from "./fakeFishjamClient"; import { installFakeMediaDevices, type MediaDevicesController } from "./fakeMediaDevices"; -import { renderHookWithProvider } from "./renderWithProvider"; +import { renderHookWithProvider, type RenderHookWithProviderOptions } from "./renderWithProvider"; interface Fixtures { /** @@ -18,9 +19,12 @@ interface Fixtures { /** * `renderHookWithProvider` pre-wired to the `client` fixture, so a test can * mount a hook and drive the same client's events without threading it by hand. - * A test may still override `client`/`providerProps` per call. + * A test may still override `providerProps` per call. */ - renderHook: typeof renderHookWithProvider; + renderHook: ( + hook: (props: Props) => Result, + options?: RenderHookWithProviderOptions, + ) => RenderHookResult; } // `provide` is vitest's fixture-injection callback (positionally the 2nd arg); @@ -41,7 +45,7 @@ export const it = base.extend({ await provide(new FakeFishjamClient()); }, renderHook: async ({ client }, provide) => { - await provide((hook, options) => renderHookWithProvider(hook, { client, ...options })); + await provide((hook, options) => renderHookWithProvider(hook, client, options)); }, }); diff --git a/packages/react-client/src/tests/support/renderWithProvider.tsx b/packages/react-client/src/tests/support/renderWithProvider.tsx index 792665b16..5b593b278 100644 --- a/packages/react-client/src/tests/support/renderWithProvider.tsx +++ b/packages/react-client/src/tests/support/renderWithProvider.tsx @@ -2,10 +2,10 @@ import { renderHook, type RenderHookOptions, type RenderHookResult } from "@test import { createElement, type PropsWithChildren } from "react"; import { FishjamProvider, type FishjamProviderProps } from "../../FishjamProvider"; -import { FakeFishjamClient } from "./fakeFishjamClient"; +import type { FakeFishjamClient } from "./fakeFishjamClient"; -export type RenderHookWithProviderResult = RenderHookResult & { - client: FakeFishjamClient; +export type RenderHookWithProviderOptions = RenderHookOptions & { + providerProps?: Partial; }; /** @@ -18,12 +18,10 @@ export type RenderHookWithProviderResult = RenderHookResult( hook: (props: Props) => Result, - options?: RenderHookOptions & { - client?: FakeFishjamClient; - providerProps?: Partial; - }, -): RenderHookWithProviderResult { - const client = options?.client ?? new FakeFishjamClient(); + client: FakeFishjamClient, + options?: RenderHookWithProviderOptions, +): RenderHookResult { + const { providerProps, ...renderOptions } = options ?? {}; const wrapper = ({ children }: PropsWithChildren) => createElement( @@ -34,11 +32,10 @@ export function renderHookWithProvider( // Off by default so device-persistence localStorage isn't exercised // unless a test opts in. persistLastDevice: false, - ...options?.providerProps, + ...providerProps, }, children, ); - const result = renderHook(hook, { ...options, wrapper }); - return Object.assign(result, { client }); + return renderHook(hook, { ...renderOptions, wrapper }); } diff --git a/packages/react-client/tsconfig.build.json b/packages/react-client/tsconfig.build.json index a0609ff85..590f181ca 100644 --- a/packages/react-client/tsconfig.build.json +++ b/packages/react-client/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["src/tests/**", "src/**/*.spec.ts", "src/**/*.spec.tsx"] + "exclude": ["src/tests/**", "src/**/*.spec.ts", "src/**/*.spec.tsx", "tests/**"] } From a14f51539b0426faae2073b9180bda55f0f3c73f Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Thu, 9 Jul 2026 16:40:22 +0200 Subject: [PATCH 5/7] update ticket name --- packages/react-client/src/tests/screenShare.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-client/src/tests/screenShare.spec.ts b/packages/react-client/src/tests/screenShare.spec.ts index 8325330cf..7ccace70f 100644 --- a/packages/react-client/src/tests/screenShare.spec.ts +++ b/packages/react-client/src/tests/screenShare.spec.ts @@ -111,7 +111,7 @@ describe("useScreenShare", () => { // The middleware output is pushed to the SFU via replaceTrack. expect(client.replaceTrack).toHaveBeenCalled(); - // KNOWN QUIRK (FCE-XXXX): setTracksMiddleware never writes the middleware + // KNOWN QUIRK (FCE-3574): setTracksMiddleware never writes the middleware // back into state, so `currentTracksMiddleware` stays null and the middleware // is NOT re-applied on a subsequent startStreaming. Captured here so the // rewrite has to make a deliberate decision to fix it (the assertion will From 92d0148396abcdb59c06426355ff458e69ef1cab Mon Sep 17 00:00:00 2001 From: Milosz Filimowski Date: Thu, 16 Jul 2026 12:57:23 +0200 Subject: [PATCH 6/7] address review: named connect rejections, faithful clone, typed ConnectConfig Co-Authored-By: Claude Fable 5 --- .../react-client/src/tests/connection.spec.ts | 4 ++-- .../src/tests/support/fakeFishjamClient.ts | 24 ++++++++++--------- .../src/tests/support/fakeMediaStream.ts | 3 ++- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/react-client/src/tests/connection.spec.ts b/packages/react-client/src/tests/connection.spec.ts index d76b0dd47..8da1d035c 100644 --- a/packages/react-client/src/tests/connection.spec.ts +++ b/packages/react-client/src/tests/connection.spec.ts @@ -21,7 +21,7 @@ describe("useConnection", () => { }); expect(client.connect).toHaveBeenCalledTimes(1); - const config = client.connect.mock.calls[0][0] as { url: string; token: string; peerMetadata: unknown }; + const config = client.connect.mock.calls[0][0]; expect(config.token).toBe("tok-123"); expect(config.peerMetadata).toEqual({ name: "alice" }); expect(config.url.startsWith("wss://")).toBe(true); @@ -35,7 +35,7 @@ describe("useConnection", () => { client.simulateJoined(); await joining; }); - expect((client.connect.mock.calls[0][0] as { peerMetadata: unknown }).peerMetadata).toEqual({}); + expect(client.connect.mock.calls[0][0].peerMetadata).toEqual({}); }); it("transitions peerStatus connecting → connected on the client events", ({ client, renderHook }) => { diff --git a/packages/react-client/src/tests/support/fakeFishjamClient.ts b/packages/react-client/src/tests/support/fakeFishjamClient.ts index 37aea89af..18ac6a2aa 100644 --- a/packages/react-client/src/tests/support/fakeFishjamClient.ts +++ b/packages/react-client/src/tests/support/fakeFishjamClient.ts @@ -1,8 +1,10 @@ import { + type ConnectConfig, type DataCallback, type DataChannelOptions, type FishjamClient, type FishjamTrackContext, + type GenericMetadata, type Peer, type SimulcastConfig, type TrackMetadata, @@ -131,28 +133,28 @@ export class FakeFishjamClient extends EventEmitter { // `initialized`, and only resolves once `joined` fires (rejects on // join/auth/socket errors), mirroring connectEventsHandler. A test that // awaits joinRoom() must drive `simulateJoined()` for the await to settle. - connect = vi.fn((_config: unknown) => { + connect = vi.fn((_config: ConnectConfig) => { this.emit("connectionStarted"); this.status = "initialized"; return new Promise((resolve, reject) => { + const errorEvents = ["joinError", "authError", "socketError"] as const; const onSuccess = () => { cleanup(); resolve(); }; - const onError = () => { - cleanup(); - reject(); - }; + const errorHandlers = errorEvents.map((event) => { + const handler = () => { + cleanup(); + reject(new Error(`FakeFishjamClient: "${event}" emitted while connect() was pending`)); + }; + return [event, handler] as const; + }); const cleanup = () => { this.off("joined", onSuccess); - this.off("joinError", onError); - this.off("authError", onError); - this.off("socketError", onError); + for (const [event, handler] of errorHandlers) this.off(event, handler); }; this.on("joined", onSuccess); - this.on("joinError", onError); - this.on("authError", onError); - this.on("socketError", onError); + for (const [event, handler] of errorHandlers) this.on(event, handler); }); }); disconnect = vi.fn(() => { diff --git a/packages/react-client/src/tests/support/fakeMediaStream.ts b/packages/react-client/src/tests/support/fakeMediaStream.ts index 70bb28a72..7b5f75942 100644 --- a/packages/react-client/src/tests/support/fakeMediaStream.ts +++ b/packages/react-client/src/tests/support/fakeMediaStream.ts @@ -49,7 +49,8 @@ export class FakeMediaStream implements MediaStream { } clone(): MediaStream { - return new FakeMediaStream(this.tracks); + // Real MediaStream.clone() clones the tracks too (new ids). + return new FakeMediaStream(this.tracks.map((track) => track.clone())); } // EventTarget surface — unused by the SDK, present for type-compatibility. From b76bec8d36957ab10bf5b4ef67d635bfad0c45e6 Mon Sep 17 00:00:00 2001 From: Milosz Filimowski Date: Fri, 17 Jul 2026 12:05:54 +0200 Subject: [PATCH 7/7] remove leftover REWRITE_PLAN.md reference --- packages/react-client/src/tests/screenShare.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-client/src/tests/screenShare.spec.ts b/packages/react-client/src/tests/screenShare.spec.ts index 7ccace70f..7a6d9f9cd 100644 --- a/packages/react-client/src/tests/screenShare.spec.ts +++ b/packages/react-client/src/tests/screenShare.spec.ts @@ -115,7 +115,7 @@ describe("useScreenShare", () => { // back into state, so `currentTracksMiddleware` stays null and the middleware // is NOT re-applied on a subsequent startStreaming. Captured here so the // rewrite has to make a deliberate decision to fix it (the assertion will - // flip when it does). See REWRITE_PLAN.md §5. + // flip when it does). expect(result.current.currentTracksMiddleware).toBeNull(); }); });