From c055a771301d967cc2b072fff79f16b4de2e2e66 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Fri, 13 Feb 2026 10:59:23 +0000 Subject: [PATCH 1/3] core: add RoomConnectionClient tests --- packages/core/src/__mocks__/appMocks.ts | 24 +- .../__tests__/RoomConnection.spec.ts | 295 ++++++++++++++++++ packages/core/src/redux/tests/store.setup.ts | 3 +- 3 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/client/RoomConnection/__tests__/RoomConnection.spec.ts diff --git a/packages/core/src/__mocks__/appMocks.ts b/packages/core/src/__mocks__/appMocks.ts index e4f953ccb..c2d9a269c 100644 --- a/packages/core/src/__mocks__/appMocks.ts +++ b/packages/core/src/__mocks__/appMocks.ts @@ -1,6 +1,6 @@ import { Credentials } from "../api"; import Organization from "../api/models/Organization"; -import { SignalClient } from "@whereby.com/media"; +import { ChatMessage, SignalClient } from "@whereby.com/media"; import { LocalParticipantState } from "../redux/slices/localParticipant"; import { RemoteParticipant } from "../RoomParticipant"; import * as uuidPkg from "uuid"; @@ -104,6 +104,28 @@ export const randomSignalClient = ({ }; }; +export const randomChatMessage = ({ + text = randomString(), + id = randomString(), + messageType = "text", + senderId = randomString(), + sig = randomString(), + timestamp = new Date().toDateString(), + roomName = randomString(), + userId = randomString(), +}: Partial = {}): ChatMessage => { + return { + text, + id, + messageType, + senderId, + sig, + timestamp, + roomName, + userId, + }; +}; + export const randomLocalParticipant = ({ breakoutGroup = null, clientClaim = randomString(), diff --git a/packages/core/src/client/RoomConnection/__tests__/RoomConnection.spec.ts b/packages/core/src/client/RoomConnection/__tests__/RoomConnection.spec.ts new file mode 100644 index 000000000..b068dc7e9 --- /dev/null +++ b/packages/core/src/client/RoomConnection/__tests__/RoomConnection.spec.ts @@ -0,0 +1,295 @@ +import { ChatMessage, RtcStreamAddedPayload } from "@whereby.com/media"; +import { RoomConnectionClient } from ".."; +import { doHandleStreamingStarted, rtcEvents, setDisplayName, signalEvents } from "../../../redux"; +import { createStore } from "../../../redux/tests/store.setup"; +import { randomChatMessage, randomMediaStream, randomSignalClient, randomString } from "../../../__mocks__/appMocks"; + +describe("RoomConnection", () => { + let roomConnectionClient: RoomConnectionClient; + let store: ReturnType; + + beforeEach(() => { + store = createStore({ connectToRoom: true, withRtcManager: true }); + jest.spyOn(store, "dispatch"); + roomConnectionClient = new RoomConnectionClient(store); + }); + + describe("state subscriptions", () => { + describe("subscribeToChatMessages", () => { + it("triggers when chat messages are updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToChatMessages(callback); + + const client = randomSignalClient(); + const chatMessage: ChatMessage = randomChatMessage({ senderId: client.id }); + store.dispatch(signalEvents.newClient({ client })); + store.dispatch(signalEvents.chatMessage(chatMessage)); + + expect(callback).toHaveBeenCalledWith([ + { text: chatMessage.text, senderId: chatMessage.senderId, timestamp: chatMessage.timestamp }, + ]); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToChatMessages(callback); + unsubscribe(); + + const client = randomSignalClient(); + const chatMessage: ChatMessage = randomChatMessage({ senderId: client.id }); + store.dispatch(signalEvents.newClient({ client })); + store.dispatch(signalEvents.chatMessage(chatMessage)); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToCloudRecording", () => { + it("triggers when cloud recording state is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToCloudRecording(callback); + + const client = randomSignalClient({ role: { roleName: "recorder" } }); + store.dispatch(signalEvents.newClient({ client })); + + expect(callback).toHaveBeenCalledWith({ status: "recording" }); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToCloudRecording(callback); + unsubscribe(); + + const client = randomSignalClient({ role: { roleName: "recorder" } }); + store.dispatch(signalEvents.newClient({ client })); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToConnectionStatus", () => { + it("triggers when connection status is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToConnectionStatus(callback); + + store.dispatch(signalEvents.disconnect()); + + expect(callback).toHaveBeenCalledWith("disconnected"); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToConnectionStatus(callback); + unsubscribe(); + + store.dispatch(signalEvents.disconnect()); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToLiveStream", () => { + it("triggers when live stream state is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToLiveStream(callback); + + store.dispatch(doHandleStreamingStarted()); + + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ status: "streaming" })); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToLiveStream(callback); + unsubscribe(); + + store.dispatch(doHandleStreamingStarted()); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToLocalParticipant", () => { + it("triggers when local participant is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToLocalParticipant(callback); + + store.dispatch(setDisplayName({ displayName: "NewDisplayName" })); + + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ displayName: "NewDisplayName" })); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToLocalParticipant(callback); + unsubscribe(); + + store.dispatch(setDisplayName({ displayName: "NewDisplayName" })); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToRemoteParticipants", () => { + it("triggers when remote participants are updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToRemoteParticipants(callback); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: client.id })]), + ); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToRemoteParticipants(callback); + unsubscribe(); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToScreenshares", () => { + it("triggers when screenshares are updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToScreenshares(callback); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + const screenshareEvent: RtcStreamAddedPayload = { + clientId: client.id, + stream: randomMediaStream(), + streamId: randomString(), + streamType: "screenshare", + }; + store.dispatch(rtcEvents.streamAdded(screenshareEvent)); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: `pres-${client.id}` })]), + ); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToScreenshares(callback); + unsubscribe(); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + const screenshareEvent: RtcStreamAddedPayload = { + clientId: client.id, + stream: randomMediaStream(), + streamId: randomString(), + streamType: "screenshare", + }; + store.dispatch(rtcEvents.streamAdded(screenshareEvent)); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToWaitingParticipants", () => { + it("triggers when waiting participants are updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToWaitingParticipants(callback); + + const clientId = randomString(); + const displayName = randomString(); + store.dispatch( + signalEvents.roomKnocked({ clientId, displayName, imageUrl: randomString(), liveVideo: true }), + ); + + expect(callback).toHaveBeenCalledWith([{ id: clientId, displayName }]); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToWaitingParticipants(callback); + unsubscribe(); + + store.dispatch( + signalEvents.roomKnocked({ + clientId: randomString(), + displayName: randomString(), + imageUrl: randomString(), + liveVideo: true, + }), + ); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToBreakoutConfig", () => { + it("triggers when breakout config is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToBreakoutConfig(callback); + + store.dispatch( + signalEvents.breakoutSessionUpdated({ + groups: { "group-1": "Group 1" }, + startedAt: new Date(), + }), + ); + + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ isActive: true })); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToBreakoutConfig(callback); + unsubscribe(); + + store.dispatch( + signalEvents.breakoutSessionUpdated({ + groups: { "group-1": "Group 1" }, + startedAt: new Date(), + }), + ); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToSpotlightedParticipants", () => { + it("triggers when spotlighted participants are updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToSpotlightedParticipants(callback); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + store.dispatch( + signalEvents.spotlightAdded({ clientId: client.id, streamId: "0", requestedByClientId: client.id }), + ); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ clientId: client.id })]), + ); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToSpotlightedParticipants(callback); + unsubscribe(); + + const client = randomSignalClient(); + store.dispatch(signalEvents.newClient({ client })); + store.dispatch( + signalEvents.spotlightAdded({ + clientId: client.id, + streamId: "0", + requestedByClientId: randomString(), + }), + ); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/core/src/redux/tests/store.setup.ts b/packages/core/src/redux/tests/store.setup.ts index b647c30c3..5f8b8f944 100644 --- a/packages/core/src/redux/tests/store.setup.ts +++ b/packages/core/src/redux/tests/store.setup.ts @@ -99,8 +99,7 @@ export function createStore({ initialState, withSignalConnection, withRtcManager // eslint-disable-next-line @typescript-eslint/no-explicit-any rtcManagerDispatcher: createRtcDispatcher({ emitter: mockRtcEmitter }) as any, rtcManagerInitialized: true, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rtcManager: mockRtcManager as any, + rtcManager: mockRtcManager, isAcceptingStreams: false, ...initialState.rtcConnection, }; From f3e99770d872db5576ed6dd1aa8457e5fe7012ab Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Fri, 27 Feb 2026 13:51:12 +0000 Subject: [PATCH 2/3] core: fix mock rtc manager type --- packages/core/src/redux/tests/store.setup.ts | 17 ++++++++++------- .../src/redux/tests/store/rtcConnection.spec.ts | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/core/src/redux/tests/store.setup.ts b/packages/core/src/redux/tests/store.setup.ts index 5f8b8f944..bb7a11c88 100644 --- a/packages/core/src/redux/tests/store.setup.ts +++ b/packages/core/src/redux/tests/store.setup.ts @@ -1,4 +1,4 @@ -import { RtcEvents } from "@whereby.com/media"; +import { RtcEvents, RtcManager } from "@whereby.com/media"; import { RootState, createStore as createRealStore } from "../store"; export const mockSignalEmit = jest.fn(); @@ -12,16 +12,19 @@ export const mockServerSocket = { once: jest.fn(), emit: mockSignalEmit, }; -export const mockRtcManager = { - addNewStream: jest.fn(), +export const mockRtcManager: RtcManager = { acceptNewStream: jest.fn(), - replaceTrack: jest.fn(), - removeStream: jest.fn(), + addNewStream: jest.fn(), disconnect: jest.fn(), disconnectAll: jest.fn(), + hasClient: jest.fn(), + isInitializedWith: jest.fn(), + removeStream: jest.fn(), + replaceTrack: jest.fn(), rtcStatsDisconnect: jest.fn(), - updateStreamResolution: jest.fn(), + rtcStatsReconnect: jest.fn(), sendStatsCustomEvent: jest.fn(), + updateStreamResolution: jest.fn(), }; export const mockRtcEmitter = { emit: jest.fn(), @@ -33,7 +36,7 @@ const createRtcDispatcher = ({ emitter: { emit: (eventName: K, args: RtcEvents[K]) => void }; }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - emitter.emit("rtc_manager_created", { rtcManager: mockRtcManager as any }); + emitter.emit("rtc_manager_created", { rtcManager: mockRtcManager }); return { stopRtcManager: jest.fn(), diff --git a/packages/core/src/redux/tests/store/rtcConnection.spec.ts b/packages/core/src/redux/tests/store/rtcConnection.spec.ts index 1579b7869..1075bd5d5 100644 --- a/packages/core/src/redux/tests/store/rtcConnection.spec.ts +++ b/packages/core/src/redux/tests/store/rtcConnection.spec.ts @@ -45,7 +45,7 @@ describe("actions", () => { ]), ); - expect(JSON.stringify(mockRtcManager.acceptNewStream.mock.calls)).toStrictEqual( + expect(JSON.stringify((mockRtcManager.acceptNewStream as jest.Mock).mock.calls)).toStrictEqual( JSON.stringify([ [{ streamId: id1, clientId: participant1.id, shouldAddLocalVideo: false, activeBreakout: false }], [{ streamId: id3, clientId: participant2.id, shouldAddLocalVideo: false, activeBreakout: false }], From 2e9513006443ac79fe0b4a1efd0862ec8bbd1dc5 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Fri, 27 Feb 2026 13:51:25 +0000 Subject: [PATCH 3/3] core: expose connection error to RoomConnectionClient --- .changeset/better-waves-burn.md | 6 ++++ .../react/useRoomConnection/initialState.ts | 1 + .../__tests__/RoomConnection.spec.ts | 31 ++++++++++++++++++- .../core/src/client/RoomConnection/events.ts | 2 ++ .../core/src/client/RoomConnection/index.ts | 13 ++++++++ .../src/client/RoomConnection/selector.ts | 4 +++ .../core/src/client/RoomConnection/types.ts | 1 + 7 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 .changeset/better-waves-burn.md diff --git a/.changeset/better-waves-burn.md b/.changeset/better-waves-burn.md new file mode 100644 index 000000000..8fe007b9a --- /dev/null +++ b/.changeset/better-waves-burn.md @@ -0,0 +1,6 @@ +--- +"@whereby.com/core": minor +"@whereby.com/media": patch +--- + +Expose connection error to room connection client diff --git a/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts b/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts index 5ffa2fd57..967ac0329 100644 --- a/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts +++ b/packages/browser-sdk/src/lib/react/useRoomConnection/initialState.ts @@ -13,6 +13,7 @@ export const initialState: RoomConnectionState = { remoteParticipants: [], screenshares: [], connectionStatus: "ready", + connectionError: null, waitingParticipants: [], spotlightedParticipants: [], }; diff --git a/packages/core/src/client/RoomConnection/__tests__/RoomConnection.spec.ts b/packages/core/src/client/RoomConnection/__tests__/RoomConnection.spec.ts index b068dc7e9..63cc0e740 100644 --- a/packages/core/src/client/RoomConnection/__tests__/RoomConnection.spec.ts +++ b/packages/core/src/client/RoomConnection/__tests__/RoomConnection.spec.ts @@ -1,4 +1,4 @@ -import { ChatMessage, RtcStreamAddedPayload } from "@whereby.com/media"; +import { ChatMessage, RoomJoinedEvent, RtcStreamAddedPayload } from "@whereby.com/media"; import { RoomConnectionClient } from ".."; import { doHandleStreamingStarted, rtcEvents, setDisplayName, signalEvents } from "../../../redux"; import { createStore } from "../../../redux/tests/store.setup"; @@ -88,6 +88,35 @@ describe("RoomConnection", () => { }); }); + describe("subscribeToConnectionError", () => { + it("triggers when connection error is updated", () => { + const callback = jest.fn(); + roomConnectionClient.subscribeToConnectionError(callback); + + store.dispatch( + signalEvents.roomJoined({ + error: "room_full", + } as RoomJoinedEvent), + ); + + expect(callback).toHaveBeenCalledWith("room_full"); + }); + + it("stops triggering after unsubscribe", () => { + const callback = jest.fn(); + const unsubscribe = roomConnectionClient.subscribeToConnectionError(callback); + unsubscribe(); + + store.dispatch( + signalEvents.roomJoined({ + error: "room_full", + } as RoomJoinedEvent), + ); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + describe("subscribeToLiveStream", () => { it("triggers when live stream state is updated", () => { const callback = jest.fn(); diff --git a/packages/core/src/client/RoomConnection/events.ts b/packages/core/src/client/RoomConnection/events.ts index 5ea8e0279..e8c54de60 100644 --- a/packages/core/src/client/RoomConnection/events.ts +++ b/packages/core/src/client/RoomConnection/events.ts @@ -18,6 +18,7 @@ export const CHAT_NEW_MESSAGE = "chat:new-message"; export const CLOUD_RECORDING_STATUS_CHANGED = "cloud-recording:status-changed"; /* Connection Status Events */ export const CONNECTION_STATUS_CHANGED = "connection:status-changed"; +export const CONNECTION_ERROR_CHANGED = "connection:error-changed"; /* Local participant events */ export const LOCAL_PARTICIPANT_CHANGED = "local-participant:changed"; export const LOCAL_SCREENSHARE_STATUS_CHANGED = "local-screenshare:status-changed"; @@ -54,6 +55,7 @@ export type RoomConnectionEvents = { [CHAT_NEW_MESSAGE]: [message: ChatMessage]; [CLOUD_RECORDING_STATUS_CHANGED]: [status: CloudRecordingState | undefined]; [CONNECTION_STATUS_CHANGED]: [status: ConnectionStatus]; + [CONNECTION_ERROR_CHANGED]: [error: string | null]; [LOCAL_PARTICIPANT_CHANGED]: [participant?: LocalParticipantState]; [LOCAL_SCREENSHARE_STATUS_CHANGED]: [status?: LocalScreenshareStatus]; [REMOTE_PARTICIPANTS_CHANGED]: [participants: RemoteParticipantState[]]; diff --git a/packages/core/src/client/RoomConnection/index.ts b/packages/core/src/client/RoomConnection/index.ts index 80ad2df7f..476db2363 100644 --- a/packages/core/src/client/RoomConnection/index.ts +++ b/packages/core/src/client/RoomConnection/index.ts @@ -50,6 +50,7 @@ import { BREAKOUT_CONFIG_CHANGED, CHAT_NEW_MESSAGE, CLOUD_RECORDING_STATUS_CHANGED, + CONNECTION_ERROR_CHANGED, CONNECTION_STATUS_CHANGED, LOCAL_PARTICIPANT_CHANGED, LOCAL_SCREENSHARE_STATUS_CHANGED, @@ -79,6 +80,7 @@ export class RoomConnectionClient extends BaseClient void>(); private breakoutSubscribers = new Set<(config: BreakoutState) => void>(); private connectionStatusSubscribers = new Set<(status: ConnectionStatus) => void>(); + private connectionErrorSubscribers = new Set<(error: string | null) => void>(); private liveStreamSubscribers = new Set<(status: { status: "streaming" } | undefined) => void>(); private localScreenshareStatusSubscribers = new Set<(status?: LocalScreenshareStatus) => void>(); private localParticipantSubscribers = new Set<(participant?: LocalParticipantState) => void>(); @@ -115,6 +117,11 @@ export class RoomConnectionClient extends BaseClient cb(state.connectionError)); + this.emit(CONNECTION_ERROR_CHANGED, state.connectionError); + } + if (state.liveStream !== previousState.liveStream) { this.liveStreamSubscribers.forEach((cb) => cb(state.liveStream)); if (state.liveStream?.status === "streaming") { @@ -237,6 +244,11 @@ export class RoomConnectionClient extends BaseClient this.connectionStatusSubscribers.delete(callback); } + public subscribeToConnectionError(callback: (error: string | null) => void): () => void { + this.connectionErrorSubscribers.add(callback); + return () => this.connectionErrorSubscribers.delete(callback); + } + public subscribeToLiveStream(callback: (status: { status: "streaming" } | undefined) => void): () => void { this.liveStreamSubscribers.add(callback); return () => this.liveStreamSubscribers.delete(callback); @@ -627,6 +639,7 @@ export class RoomConnectionClient extends BaseClient