diff --git a/packages/react-client/package.json b/packages/react-client/package.json index bdc58b2fe..7db9b31e2 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", + "tsc": "tsc --noEmit", "docs": "typedoc src src/experimental", "format": "prettier --write . --ignore-path ./.eslintignore", "format:check": "prettier --check . --ignore-path ./.eslintignore", @@ -55,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/camera.spec.ts b/packages/react-client/src/tests/camera.spec.ts new file mode 100644 index 000000000..c79817e73 --- /dev/null +++ b/packages/react-client/src/tests/camera.spec.ts @@ -0,0 +1,224 @@ +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"; + +const videoStream = () => createFakeStream([{ kind: "video", deviceId: "cam-1" }]); + +describe("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, + client, + renderHook, + }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => 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, renderHook }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => useCamera()); + + await act(async () => { + await result.current.startCamera(); + }); + act(() => result.current.stopCamera()); + + expect(result.current.isCameraOn).toBe(false); + 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()); + + 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, client, renderHook }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => useCamera()); + + 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, client, renderHook }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => ({ camera: useCamera(), peers: usePeers() })); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.camera.toggleCamera(); // on + publish + }); + await act(async () => { + await result.current.camera.toggleCamera(); // off + }); + + 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 }) => { + media.setUserMediaStream(videoStream()); + const { result } = renderHook(() => 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, renderHook }) => { + media.failUserMediaAlways("NotAllowedError"); + const { result } = renderHook(() => useCamera()); + + await act(async () => { + await result.current.startCamera(); + }); + + expect(result.current.cameraDeviceError).toEqual({ name: "NotAllowedError" }); + expect(result.current.isCameraOn).toBe(false); + }); + + 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 so the toggle below races the pending publish. + client.deferAddTracks(); + const { result } = renderHook(() => ({ camera: useCamera(), peers: usePeers() })); + + await act(async () => { + await result.current.camera.startCamera(); + }); + + // Join → auto-publish fires, but the publish hasn't completed yet. + act(() => client.simulateJoined()); + expect(client.addTrack).toHaveBeenCalledTimes(1); + + await act(async () => { + // Toggle off while the publish is pending; the publish completes mid-toggle. + const toggling = result.current.camera.toggleCamera(); + client.flushAddTracks(); + await toggling; + }); + + // 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/connection.spec.ts b/packages/react-client/src/tests/connection.spec.ts new file mode 100644 index 000000000..8da1d035c --- /dev/null +++ b/packages/react-client/src/tests/connection.spec.ts @@ -0,0 +1,107 @@ +import { act } from "@testing-library/react"; + +import { useConnection } from "../hooks/useConnection"; +import { describe, expect, it } from "./support/fixtures"; + +describe("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 ({ client, renderHook }) => { + const { result } = renderHook(() => useConnection()); + + await act(async () => { + // 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); + 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); + expect(config.url).toContain("test-fishjam-id"); + }); + + it("defaults peerMetadata to an empty object", async ({ client, renderHook }) => { + const { result } = renderHook(() => useConnection()); + await act(async () => { + const joining = result.current.joinRoom({ peerToken: "tok" }); + client.simulateJoined(); + await joining; + }); + expect(client.connect.mock.calls[0][0].peerMetadata).toEqual({}); + }); + + it("transitions peerStatus connecting → connected on the client events", ({ client, renderHook }) => { + const { result } = renderHook(() => 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", ({ 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 }) => { + const { result } = renderHook(() => 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", ({ client, renderHook }) => { + const { result } = renderHook(() => 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", ({ client, renderHook }) => { + const { result } = renderHook(() => 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..e4a274d9b --- /dev/null +++ b/packages/react-client/src/tests/customSource.spec.ts @@ -0,0 +1,85 @@ +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"; + +const avStream = () => + createFakeStream([ + { kind: "video", deviceId: "v" }, + { kind: "audio", deviceId: "a" }, + ]); + +describe("useCustomSource", () => { + 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 ({ client, renderHook }) => { + const { result } = renderHook(() => useCustomSource("feed")); + + 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 ({ client, renderHook }) => { + const { result } = renderHook(() => useCustomSource("feed")); + + await act(async () => { + await result.current.setStream(avStream()); + }); + expect(client.addTrack).not.toHaveBeenCalled(); + + await act(async () => { + client.simulateJoined(); + }); + + 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")); + + 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..2f4d60830 --- /dev/null +++ b/packages/react-client/src/tests/dataChannel.spec.ts @@ -0,0 +1,90 @@ +import { act } from "@testing-library/react"; + +import { useDataChannel } from "../hooks/useDataChannel"; +import { describe, expect, it } from "./support/fixtures"; + +describe("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 ({ renderHook }) => { + const { result } = renderHook(() => 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 ({ client, renderHook }) => { + const { result } = renderHook(() => useDataChannel()); + + 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", ({ client, renderHook }) => { + const { result } = renderHook(() => 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", ({ client, renderHook }) => { + const { result } = renderHook(() => 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 ({ client, renderHook }) => { + const { result } = renderHook(() => useDataChannel()); + + 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 ({ client, renderHook }) => { + const { result } = renderHook(() => useDataChannel()); + + 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..e1ccdfedb --- /dev/null +++ b/packages/react-client/src/tests/initializeDevices.spec.ts @@ -0,0 +1,87 @@ +import { act } from "@testing-library/react"; + +import { useCamera } from "../hooks/devices/useCamera"; +import { useInitializeDevices } from "../hooks/devices/useInitializeDevices"; +import { createFakeStream } from "./support/fakeMediaStream"; +import { describe, expect, it } from "./support/fixtures"; + +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, renderHook }) => { + media.setUserMediaStream(fullStream()); + media.setEnumeratedDevices(devices); + + const { result } = renderHook(() => 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, renderHook }) => { + media.setUserMediaStream(fullStream()); + media.setEnumeratedDevices(devices); + + const { result } = renderHook(() => 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("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); + + const { result } = renderHook(() => ({ + 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..71227d43c --- /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 { 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 { 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) => { + 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 ({ renderHook }) => { + 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 } = renderHook(() => 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 ({ 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. + vi.mocked(publishLivestream) + .mockRejectedValueOnce(err) + .mockResolvedValue({ stopPublishing: vi.fn() } as never); + + const { result } = renderHook(() => 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 ({ renderHook }) => { + const stream = createFakeStream([{ kind: "video" }]); + const stop = vi.fn(); + vi.mocked(receiveLivestream).mockResolvedValue({ + stream, + stop, + getStatistics: async () => ({}) as RTCStatsReport, + } as never); + + const { result } = renderHook(() => 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..27963962a --- /dev/null +++ b/packages/react-client/src/tests/microphone.spec.ts @@ -0,0 +1,77 @@ +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"; + +const audioStream = () => createFakeStream([{ kind: "audio", deviceId: "mic-1" }]); + +describe("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, renderHook }) => { + media.setUserMediaStream(audioStream()); + const { result } = renderHook(() => 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, + client, + renderHook, + }) => { + media.setUserMediaStream(audioStream()); + const { result } = renderHook(() => ({ mic: useMicrophone(), peers: usePeers() })); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.mic.toggleMicrophone(); // device on + publish + }); + expect(client.addTrack).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.mic.toggleMicrophoneMute(); + }); + + expect(result.current.mic.isMicrophoneMuted).toBe(true); + // Still on (soft mute): the stream is not torn down. + 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(() => ({ mic: useMicrophone(), peers: usePeers() })); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.mic.toggleMicrophone(); + }); + await act(async () => { + await result.current.mic.toggleMicrophoneMute(); // mute + }); + await act(async () => { + await result.current.mic.toggleMicrophoneMute(); // unmute + }); + + 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/misc.spec.ts b/packages/react-client/src/tests/misc.spec.ts new file mode 100644 index 000000000..9a68b2a85 --- /dev/null +++ b/packages/react-client/src/tests/misc.spec.ts @@ -0,0 +1,23 @@ +import { act } from "@testing-library/react"; + +import { useStatistics } from "../hooks/useStatistics"; +import { useUpdatePeerMetadata } from "../hooks/useUpdatePeerMetadata"; +import { describe, expect, it } from "./support/fixtures"; + +describe("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 ({ client, renderHook }) => { + const { result } = renderHook(() => 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..c4d166227 --- /dev/null +++ b/packages/react-client/src/tests/peers.spec.ts @@ -0,0 +1,86 @@ +import { act } from "@testing-library/react"; + +import { usePeers } from "../hooks/usePeers"; +import { createFakeTrack } from "./support/fakeMediaStream"; +import { describe, expect, it } from "./support/fixtures"; + +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", ({ renderHook }) => { + const { result } = renderHook(() => usePeers()); + expect(result.current.localPeer).toBeNull(); + expect(result.current.remotePeers).toEqual([]); + }); + + it("buckets local peer tracks by metadata type", ({ client, renderHook }) => { + const { result } = renderHook(() => 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" }) }, + ], + }); + }); + + 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", ({ client, renderHook }) => { + const { result } = renderHook(() => usePeers()); + + act(() => { + client.addRemotePeer({ + id: "p1", + tracks: [ + { trackId: "rv", metadata: screenVideoMeta, track: createFakeTrack({ kind: "video" }) }, + { trackId: "cv", metadata: customVideoMeta, track: createFakeTrack({ kind: "video" }) }, + ], + }); + }); + + 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", ({ client, renderHook }) => { + const { result } = renderHook(() => usePeers()); + act(() => { + client.addRemotePeer({ id: "p1" }); + }); + expect(result.current.peers).toEqual(result.current.remotePeers); + }); + + it("remote tracks expose setReceivedQuality wired to setTargetTrackEncoding", ({ client, renderHook }) => { + const { result } = renderHook(() => usePeers()); + act(() => { + client.addRemotePeer({ + id: "p1", + tracks: [{ trackId: "rv", metadata: screenVideoMeta, track: createFakeTrack({ kind: "video" }) }], + }); + }); + + act(() => result.current.remotePeers[0].screenShareVideoTrack!.setReceivedQuality("h" as never)); + expect(client.setTargetTrackEncoding).toHaveBeenCalledWith("rv", "h"); + }); + + 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/sandbox.spec.ts b/packages/react-client/src/tests/sandbox.spec.ts new file mode 100644 index 000000000..7b613a851 --- /dev/null +++ b/packages/react-client/src/tests/sandbox.spec.ts @@ -0,0 +1,56 @@ +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) => ({ + 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 ({ 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 ({ renderHook }) => { + 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 ({ renderHook }) => { + 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 ({ 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 ({ 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); + 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..7a6d9f9cd --- /dev/null +++ b/packages/react-client/src/tests/screenShare.spec.ts @@ -0,0 +1,121 @@ +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"; + +const screenStream = () => + createFakeStream([ + { kind: "video", deviceId: "screen" }, + { kind: "audio", deviceId: "tab" }, + ]); + +describe("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, + client, + renderHook, + }) => { + 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(); + }); + + 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, + client, + renderHook, + }) => { + media.setDisplayMediaStream(screenStream()); + const { result } = renderHook(() => useScreenShare()); + + 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("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, + renderHook, + }) => { + 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(); + }); + + 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-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 + // flip when it does). + 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..18ac6a2aa --- /dev/null +++ b/packages/react-client/src/tests/support/fakeFishjamClient.ts @@ -0,0 +1,324 @@ +import { + type ConnectConfig, + type DataCallback, + type DataChannelOptions, + type FishjamClient, + type FishjamTrackContext, + type GenericMetadata, + type Peer, + type SimulcastConfig, + type TrackMetadata, + TrackTypeError, + type VadStatus, + type Variant, +} from "@fishjam-cloud/ts-client"; +import { EventEmitter } from "events"; +import { vi } from "vitest"; + +import { Deferred } from "../../utils/deferred"; +import { FakeMediaStream } from "./fakeMediaStream"; + +/** + * 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" +>; + +/** + * 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 implements TrackContextContract { + vadStatus: VadStatus = "silence"; + + constructor( + public trackId: string, + public track: MediaStreamTrack | null, + public metadata: TrackMetadata | undefined, + public stream: MediaStream | null, + public simulcastConfig?: SimulcastConfig, + ) { + super(); + } + + simulateVad(status: VadStatus) { + this.vadStatus = status; + this.emit("voiceActivityChanged", this); + } +} + +export type FakePeerInit = { + id: string; + metadata?: Peer["metadata"]; + 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, + // FakeTrackContext implements only the read surface of FishjamTrackContext + // (TrackContextContract); this cast erases the unimplemented remainder. + tracks: tracks as unknown as Peer["tracks"], + } satisfies 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 { + // 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) ------------------------------- + // 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: ConnectConfig) => { + this.emit("connectionStarted"); + this.status = "initialized"; + return new Promise((resolve, reject) => { + const errorEvents = ["joinError", "authError", "socketError"] as const; + const onSuccess = () => { + cleanup(); + resolve(); + }; + 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); + for (const [event, handler] of errorHandlers) this.off(event, handler); + }; + this.on("joined", onSuccess); + for (const [event, handler] of errorHandlers) this.on(event, handler); + }); + }); + 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; + 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.metadata = { peer: metadata, server: {} } as Peer["metadata"]; + this.emit("localPeerMetadataChanged", { metadata }); + }); + 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 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" }); + (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 audioOnlyConnection = false; + + 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) => null; + + // 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()`, so tests can race other ops against an in-flight publish. */ + deferAddTracks() { + this.autoResolveAddTrack = false; + } + flushAddTracks() { + const pending = this.pendingAddTracks; + this.pendingAddTracks = []; + 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 + // 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; + } + + simulateConnectionStarted() { + 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"); + } + 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"); + } + 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..cc0a63db1 --- /dev/null +++ b/packages/react-client/src/tests/support/fakeMediaDevices.ts @@ -0,0 +1,181 @@ +import { vi } from "vitest"; + +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 `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; + getDisplayMedia: ReturnType; + enumerateDevices: ReturnType; + addEventListener: ReturnType; + removeEventListener: ReturnType; +}; + +export type MediaDevicesController = { + devices: FakeMediaDevices; + /** + * 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 every getUserMedia reject with a DOMException of the given name. */ + failUserMediaAlways: (errorName: string) => void; + setDisplayMediaStream: (stream: MediaStream) => void; + setEnumeratedDevices: (devices: Partial[]) => void; + restore: () => void; +}; + +const makeError = (name: string) => { + const err = new Error(name); + err.name = name; + 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 userMediaTemplate: MediaStream = new FakeMediaStream(); + let userMediaError: string | null = null; + let displayMediaStreamFactory: () => MediaStream = () => new FakeMediaStream(); + let enumerated: MediaDeviceInfo[] = []; + + // 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 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 = { + 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; + userMediaTemplate = stream; + }, + failUserMediaAlways: (errorName) => { + userMediaError = errorName; + }, + setDisplayMediaStream: (stream) => { + displayMediaStreamFactory = () => stream; + }, + 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: () => { + // 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/fakeMediaStream.ts b/packages/react-client/src/tests/support/fakeMediaStream.ts new file mode 100644 index 000000000..7b5f75942 --- /dev/null +++ b/packages/react-client/src/tests/support/fakeMediaStream.ts @@ -0,0 +1,87 @@ +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[] = []) { + this.tracks = [...tracks]; + } + + 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 { + // 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. + addEventListener(): void {} + removeEventListener(): void {} + dispatchEvent(): boolean { + return true; + } +} + +export type FakeTrackOptions = { + kind: "audio" | "video"; + deviceId?: string; + label?: string; +}; + +/** + * 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`, +}: FakeTrackOptions): FakeMediaStreamTrack => + new FakeMediaStreamTrack({ + kind, + id: `${kind}-track-${trackCounter++}`, + label, + settings: { deviceId }, + }); + +export const createFakeStream = (tracks: FakeTrackOptions[]): FakeMediaStream => + new FakeMediaStream(tracks.map(createFakeTrack)); 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..597b761ea --- /dev/null +++ b/packages/react-client/src/tests/support/fixtures.ts @@ -0,0 +1,52 @@ +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, type RenderHookWithProviderOptions } 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 `providerProps` per call. + */ + renderHook: ( + hook: (props: Props) => Result, + options?: RenderHookWithProviderOptions, + ) => RenderHookResult; +} + +// `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/renderWithProvider.tsx b/packages/react-client/src/tests/support/renderWithProvider.tsx new file mode 100644 index 000000000..5b593b278 --- /dev/null +++ b/packages/react-client/src/tests/support/renderWithProvider.tsx @@ -0,0 +1,41 @@ +import { renderHook, type RenderHookOptions, type RenderHookResult } from "@testing-library/react"; +import { createElement, type PropsWithChildren } from "react"; + +import { FishjamProvider, type FishjamProviderProps } from "../../FishjamProvider"; +import type { FakeFishjamClient } from "./fakeFishjamClient"; + +export type RenderHookWithProviderOptions = RenderHookOptions & { + providerProps?: Partial; +}; + +/** + * 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, + client: FakeFishjamClient, + options?: RenderHookWithProviderOptions, +): RenderHookResult { + const { providerProps, ...renderOptions } = options ?? {}; + + 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, + ...providerProps, + }, + children, + ); + + return renderHook(hook, { ...renderOptions, wrapper }); +} 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 new file mode 100644 index 000000000..b5efeab84 --- /dev/null +++ b/packages/react-client/src/tests/vad.spec.ts @@ -0,0 +1,53 @@ +import { act } from "@testing-library/react"; + +import { useVAD } from "../hooks/useVAD"; +import type { PeerId } from "../types/public"; +import { createFakeTrack } from "./support/fakeMediaStream"; +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", ({ client, renderHook }) => { + const { result } = renderHook(() => useVAD({ peerIds: ["p1" as PeerId] })); + + act(() => { + client.addRemotePeer({ + id: "p1", + tracks: [{ trackId: "mic", metadata: micMeta, track: createFakeTrack({ kind: "audio" }) }], + }); + }); + + expect(result.current["p1" as PeerId]).toBe(false); + }); + + it("flips to true on a voiceActivityChanged → speech event", ({ client, renderHook }) => { + const { result } = renderHook(() => useVAD({ peerIds: ["p1" as PeerId] })); + + act(() => { + client.addRemotePeer({ + id: "p1", + tracks: [{ trackId: "mic", metadata: micMeta, track: createFakeTrack({ kind: "audio" }) }], + }); + }); + + 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", ({ client, renderHook }) => { + const { result } = renderHook(() => useVAD({ peerIds: ["p1" as PeerId] })); + + act(() => { + client.addRemotePeer({ + id: "p2", + tracks: [{ trackId: "mic2", metadata: micMeta, track: createFakeTrack({ kind: "audio" }) }], + }); + }); + + 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..590f181ca --- /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", "tests/**"] +} 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"