Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/react-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
167 changes: 167 additions & 0 deletions packages/react-client/src/tests/camera.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { act } from "@testing-library/react";

import { useCamera } from "../hooks/devices/useCamera";
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("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(() => useCamera());

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, 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("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);
});
});
87 changes: 87 additions & 0 deletions packages/react-client/src/tests/connection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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 () => {
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 ({ 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", ({ 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());
act(() => client.simulateAuthError());
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");
});
});
64 changes: 64 additions & 0 deletions packages/react-client/src/tests/customSource.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { act } from "@testing-library/react";

import { useCustomSource } from "../hooks/useCustomSource";
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("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();
});
});
Loading
Loading