import '../../rtc/__tests__/mocks/webrtc.mocks'; import { describe, expect, it, vi } from 'vitest'; import { anyNumber } from 'vitest-mock-extended'; import { fromPartial } from '@total-typescript/shoehorn'; import { StreamVideoParticipant, VisibilityState } from '../../types'; import { CallingState } from '../CallingState'; import { CallState } from '../CallState'; import { TrackType } from '../../gen/video/sfu/models/models'; import { combineComparators, conditional, descending, dominantSpeaker, name, noopComparator, publishingAudio, publishingVideo, screenSharing, } from '../../sorting'; import { CallAcceptedEvent, CallEndedEvent, CallUpdatedEvent, MemberResponse, OwnCapability, } from '../../gen/coordinator'; import * as TestData from '../../sorting/__tests__/participant-data'; describe('CallState', () => { describe('API assertions', () => { it('every exposed observable$ should have a getter', () => { const state = new CallState(); const observables = Object.keys( Object.getOwnPropertyDescriptors(state), ).filter((key) => key.endsWith('$')); // @ts-expect-error - __proto__ const getters = Object.getOwnPropertyDescriptors(state.__proto__); for (const observable of observables) { const key = observable.slice(0, -1); // remove $ const getter = getters[key]; expect( getter, `A getter for ${observable} is missing. Please define it like this: get ${key}() { return this.getCurrentValue(this.${observable}); } `, ).toBeDefined(); expect(typeof getter.get).toEqual('function'); } }); }); describe('distinctUntilChanged', () => { it(`shouldn't emit when primitive (backstage) values didn't change`, () => { const state = new CallState(); const updateWith = (value: boolean) => { state.updateFromCallResponse( fromPartial({ backstage: value, egress: {} }), ); }; updateWith(false); const subscriber = vi.fn(); const subscription = state.backstage$.subscribe(subscriber); expect(subscriber).toBeCalledTimes(1); // initial updateWith(false); updateWith(false); expect(subscriber).toBeCalledTimes(1); // still initial updateWith(true); expect(subscriber).toBeCalledTimes(2); // false -> true updateWith(true); expect(subscriber).toBeCalledTimes(2); // true -> true updateWith(false); expect(subscriber).toBeCalledTimes(3); // true -> false subscription.unsubscribe(); }); it(`shouldn't emit when primitive arrays (ownCapabilities) values didn't change`, () => { const state = new CallState(); state.setOwnCapabilities([OwnCapability.SEND_AUDIO]); const subscriber = vi.fn(); const subscription = state.ownCapabilities$.subscribe(subscriber); expect(subscriber).toBeCalledTimes(1); // initial state.setOwnCapabilities([OwnCapability.SEND_AUDIO]); state.setOwnCapabilities([OwnCapability.SEND_AUDIO]); expect(subscriber).toBeCalledTimes(1); // initial state.setOwnCapabilities([ OwnCapability.SEND_AUDIO, OwnCapability.SEND_VIDEO, ]); expect(subscriber).toBeCalledTimes(2); // AUDIO, VIDEO state.setOwnCapabilities([ OwnCapability.SEND_VIDEO, OwnCapability.SEND_AUDIO, ]); expect(subscriber).toBeCalledTimes(2); // VIDEO, AUDIO (order changed) state.setOwnCapabilities([OwnCapability.SEND_VIDEO]); expect(subscriber).toBeCalledTimes(3); // VIDEO subscription.unsubscribe(); }); it(`shouldn't emit when enums (callingState) value didn't change`, () => { const state = new CallState(); const subscriber = vi.fn(); const subscription = state.callingState$.subscribe(subscriber); state.setCallingState(CallingState.JOINING); expect(subscriber).toBeCalledTimes(2); state.setCallingState(CallingState.JOINING); state.setCallingState(CallingState.JOINING); expect(subscriber).toBeCalledTimes(2); state.setCallingState(CallingState.JOINED); expect(subscriber).toBeCalledTimes(3); state.setCallingState(CallingState.JOINED); expect(subscriber).toBeCalledTimes(3); state.setCallingState(CallingState.LEFT); expect(subscriber).toBeCalledTimes(4); subscription.unsubscribe(); }); it(`shouldn't emit when string arrays (blockedUserIds) value didn't change`, () => { const state = new CallState(); const updateWith = (value: string[]) => { state.updateFromCallResponse( fromPartial({ blocked_user_ids: value, egress: {} }), ); }; updateWith(['a', 'b']); const subscriber = vi.fn(); const subscription = state.blockedUserIds$.subscribe(subscriber); expect(subscriber).toBeCalledTimes(1); updateWith(['a', 'b', 'b']); expect(subscriber).toBeCalledTimes(2); updateWith(['a', 'b', 'c']); expect(subscriber).toBeCalledTimes(3); updateWith(['a', 'b']); expect(subscriber).toBeCalledTimes(4); updateWith(['a', 'b', 'c']); expect(subscriber).toBeCalledTimes(5); updateWith(['b', 'c', 'a']); expect(subscriber).toBeCalledTimes(5); subscription.unsubscribe(); }); }); describe('updateOrAddParticipant', () => { it('updates an existing participant if session_id matches', () => { const state = new CallState(); // @ts-expect-error - incomplete data state.setParticipants([{ sessionId: '123', userId: 'alice' }]); // @ts-expect-error - incomplete data state.updateOrAddParticipant('123', { userId: 'bob' }); const lookupBySessionId = state.getParticipantLookupBySessionId(); expect(lookupBySessionId['123']?.userId).toEqual('bob'); }); it('appends the participant to the participants array if the session_id is unknown', () => { const state = new CallState(); state.setSortParticipantsBy(noopComparator()); // @ts-expect-error - incomplete data state.setParticipants([{ sessionId: '123', userId: 'alice' }]); // @ts-expect-error - incomplete data state.updateOrAddParticipant('12345', { userId: 'bob' }); expect(state.participants.length).toBe(2); expect(state.participants[0].userId).toBe('alice'); expect(state.participants[1].userId).toBe('bob'); }); }); describe('updateParticipants', () => { it('does nothing when the patch is empty', () => { const state = new CallState(); // @ts-expect-error - incomplete data state.setParticipants([{ sessionId: '123', userId: 'alice' }]); const p1Ref = state.participants; state.updateParticipants({}); const p2Ref = state.participants; expect(p1Ref === p2Ref).toBeTruthy(); }); it('applies participant patches', () => { const state = new CallState(); state.setSortParticipantsBy(noopComparator()); state.setParticipants([ // @ts-expect-error - incomplete data { sessionId: '123', userId: 'alice' }, // @ts-expect-error - incomplete data { sessionId: '1234', userId: 'charlie ' }, ]); const p1Ref = state.participants; state.updateParticipants({ '123': { userId: 'bob' } }); const p2Ref = state.participants; expect(p1Ref === p2Ref).toBeFalsy(); expect(p1Ref[0].userId).toBe('alice'); expect(p2Ref[0].userId).toBe('bob'); }); }); describe('sorting', () => { it('should emit sorted participants', () => { const state = new CallState(); state.setSortParticipantsBy(noopComparator()); state.setParticipants(TestData.participants()); // initial sort criteria const ps = state.participants; expect(ps.map((p) => p.name)).toEqual(['A', 'B', 'C', 'D', 'E', 'F']); // update sort criteria state.setSortParticipantsBy( combineComparators( dominantSpeaker, publishingAudio, publishingVideo, screenSharing, ), ); const ps2 = state.participants; expect(ps2.map((p) => p.name)).toEqual(['D', 'B', 'A', 'F', 'E', 'C']); }); it('should be able to disable sorting', () => { const participants = TestData.participants(); const state = new CallState(); state.setParticipants(TestData.participants()); // initial sort criteria const ps = state.participants; expect(ps.map((p) => p.name)).toMatchInlineSnapshot(` [ "B", "E", "F", "A", "C", "D", ] `); // disable sorting state.setSortParticipantsBy(noopComparator()); // update the dominant speaker -> in this case, no sorting should be applied const [A] = participants; state.updateParticipant(A.sessionId, { isDominantSpeaker: true, }); const ps2 = state.participants; // should resolve in initial - non-mutated state as set at the beginning expect(ps2.map((p) => p.name)).toMatchInlineSnapshot(` [ "B", "E", "F", "A", "C", "D", ] `); }); it('should support custom sorting', () => { const state = new CallState(); state.setSortParticipantsBy(descending(name)); state.setParticipants(TestData.participants()); const ps = state.participants; expect(ps.map((p) => p.name)).toEqual(['F', 'E', 'D', 'C', 'B', 'A']); }); it('should consider participant visibility', () => { const [A, B, C, D] = TestData.participants(); const state = new CallState(); state.setSortParticipantsBy(name); state.setParticipants([A, B, C, D]); expect(state.participants).toEqual([A, B, C, D]); const Z = { ...A, name: 'Z', }; // normal mode: Z is pushed to the end state.setParticipants([Z, B, C, D]); expect(state.participants).toEqual([B, C, D, Z]); const ifInvisibleBy = conditional( (a: StreamVideoParticipant, b: StreamVideoParticipant) => a.viewportVisibilityState?.videoTrack === VisibilityState.INVISIBLE || b.viewportVisibilityState?.videoTrack === VisibilityState.INVISIBLE, ); state.setSortParticipantsBy(ifInvisibleBy(name)); // Z is visible, so it is kept in the same position state.setParticipants([Z, B, C, D]); expect(state.participants).toEqual([Z, B, C, D]); // Z is invisible, so, the normal sorting is applied and Z is pushed to the end Z.viewportVisibilityState!.videoTrack = VisibilityState.INVISIBLE; state.setParticipants([Z, B, C, D]); expect(state.participants).toEqual([B, C, D, Z]); }); }); describe('pinning', () => { it('should update the pinned state of participants in the call', () => { const state = new CallState(); state.setSortParticipantsBy(noopComparator()); // @ts-expect-error - incomplete data state.setParticipants([{ sessionId: '123' }, { sessionId: '456' }]); state.setServerSidePins([{ sessionId: '123', userId: 'user-id' }]); expect(state.participants).toEqual([ { sessionId: '123', pin: { isLocalPin: false, pinnedAt: anyNumber() } }, { sessionId: '456' }, ]); }); it('should unpin participants that are no longer pinned', () => { const state = new CallState(); state.setSortParticipantsBy(noopComparator()); state.setParticipants([ // @ts-expect-error - incomplete data { sessionId: '123', pin: { isLocalPin: false, pinnedAt: 1000 } }, // @ts-expect-error - incomplete data { sessionId: '456' }, ]); state.setServerSidePins([]); expect(state.participants).toEqual([ { sessionId: '123', pin: undefined }, { sessionId: '456' }, ]); }); it('should not unpin participants that are pinned locally', () => { const state = new CallState(); state.setSortParticipantsBy(noopComparator()); state.setParticipants([ // @ts-expect-error - incomplete data { sessionId: '123', pin: { isLocalPin: true, pinnedAt: 1000 } }, // @ts-expect-error - incomplete data { sessionId: '456' }, ]); state.setServerSidePins([]); expect(state.participants).toEqual([ { sessionId: '123', pin: { isLocalPin: true, pinnedAt: 1000 } }, { sessionId: '456' }, ]); }); }); describe('events', () => { describe('call.live and backstage events', () => { it('handles call.live_started events', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.live_started', call: fromPartial({ backstage: false, egress: {} }), }), ); expect(state.backstage).toBe(false); }); }); describe('Call ringing events', () => { describe('call.updated', () => { it(`will update the call's metadata`, () => { const state = new CallState(); const event: CallUpdatedEvent = fromPartial({ type: 'call.updated', call_cid: 'development:12345', call: fromPartial({ cid: 'development:12345', egress: {}, custom: { test: 'value' }, }), }); // @ts-expect-error incomplete data state.updateFromEvent(event); expect(state.custom).toEqual(event.call.custom); }); }); describe(`call.accepted`, () => { it(`will update state`, () => { const state = new CallState(); const event: CallAcceptedEvent = fromPartial({ type: 'call.accepted', call: fromPartial({ egress: {}, custom: { test: 'value' } }), }); // @ts-expect-error incomplete data state.updateFromEvent(event); expect(state.custom).toEqual(event.call.custom); }); }); describe(`call.rejected`, () => { it(`will update state`, () => { const state = new CallState(); const event: CallEndedEvent = fromPartial({ type: 'call.rejected', call: fromPartial({ egress: {}, custom: { test: 'value' } }), }); // @ts-expect-error incomplete data state.updateFromEvent(event); expect(state.custom).toEqual(event.call.custom); }); }); describe(`call.ended`, () => { it(`will update state`, () => { const state = new CallState(); const event: CallEndedEvent = fromPartial({ type: 'call.ended', call: fromPartial({ egress: {}, custom: { test: 'value' } }), }); // @ts-expect-error incomplete data state.updateFromEvent(event); expect(state.custom).toEqual(event.call.custom); }); }); }); describe('Call Permission Events', () => { it('handles call.permissions_updated', () => { const state = new CallState(); // @ts-expect-error incomplete data state.setParticipants([{ userId: 'test', isLocalParticipant: true }]); state.updateFromEvent({ type: 'call.permissions_updated', own_capabilities: [ OwnCapability.SEND_AUDIO, OwnCapability.SEND_VIDEO, ], // @ts-expect-error incomplete data user: { id: 'test' }, }); expect(state.ownCapabilities).toEqual([ OwnCapability.SEND_AUDIO, OwnCapability.SEND_VIDEO, ]); state.updateFromEvent({ type: 'call.permissions_updated', created_at: '', call_cid: 'development:12345', own_capabilities: [OwnCapability.SEND_VIDEO], // @ts-expect-error incomplete data user: { id: 'test' }, }); expect(state.ownCapabilities).toEqual([OwnCapability.SEND_VIDEO]); }); }); describe('member events', () => { it('handles call.member_added events', () => { const state = new CallState(); const initialMembers: MemberResponse[] = [ { user_id: 'user0', } as MemberResponse, ]; state.setMembers(initialMembers); state.updateFromEvent({ type: 'call.member_added', // @ts-expect-error incomplete data members: [{ user_id: 'user1' }, { user_id: 'user2' }], // @ts-expect-error incomplete data call: { egress: {} }, }); const updatedMembers = state.members; updatedMembers.forEach((member, index) => expect(member.user_id).toBe(`user${index}`), ); }); it('handles call.member_removed events', () => { const state = new CallState(); const initialMembers: MemberResponse[] = [ // @ts-expect-error incomplete data { user_id: 'user0' }, // @ts-expect-error incomplete data { user_id: 'user1' }, // @ts-expect-error incomplete data { user_id: 'user2' }, ]; state.setMembers(initialMembers); const removedMembers = ['user1']; state.updateFromEvent({ type: 'call.member_removed', members: removedMembers, // @ts-expect-error incomplete data call: { egress: {} }, }); const updatedMembers = state.members; expect(updatedMembers[0].user_id).toBe('user0'); expect(updatedMembers[1].user_id).toBe('user2'); expect(updatedMembers.length).toBe( initialMembers.length - removedMembers.length, ); }); it('handles call.member_updated_permission events', () => { const state = new CallState(); const user0: MemberResponse = { user_id: 'user0', user: { role: 'viewer' }, } as MemberResponse; const user1 = { user_id: 'user1', user: { role: 'host' }, } as MemberResponse; const user2 = { user_id: 'user2', user: { role: 'viewer' }, } as MemberResponse; const initialMembers: MemberResponse[] = [user0, user1, user2]; state.setMembers(initialMembers); state.updateFromEvent({ type: 'call.member_updated_permission', members: [ { user_id: user1.user_id, // @ts-expect-error incomplete data user: { ...user1, role: 'viewer' }, role: 'viewer', }, { user_id: user0.user_id, // @ts-expect-error incomplete data user: { ...user0, role: 'host' }, role: 'host', }, ], // @ts-expect-error incomplete data call: { egress: {} }, }); const updatedMembers = state.members; expect(updatedMembers[0].user.role).toBe('host'); expect(updatedMembers[1].user.role).toBe('viewer'); expect(updatedMembers[2].user.role).toBe('viewer'); }); it('handles call.member_updated events', () => { const state = new CallState(); const user0 = { user_id: 'user0', user: { name: 'Jane' }, } as MemberResponse; const user1 = { user_id: 'user1', user: { name: 'Jack' }, } as MemberResponse; const user2 = { user_id: 'user2', user: { name: 'Adam' }, } as MemberResponse; const initialMembers: MemberResponse[] = [user0, user1, user2]; state.setMembers(initialMembers); state.updateFromEvent({ type: 'call.member_updated', // @ts-expect-error incomplete data members: [{ ...user1, user: { name: 'John' } }], // @ts-expect-error incomplete data call: { egress: {} }, }); const updatedMembers = state.members; expect(updatedMembers[0].user.name).toBe('Jane'); expect(updatedMembers[1].user.name).toBe('John'); expect(updatedMembers[2].user.name).toBe('Adam'); }); }); describe('recording and broadcasting events', () => { it('handles call.recording_started events', () => { const state = new CallState(); state.updateFromEvent(fromPartial({ type: 'call.recording_started' })); expect(state.recording).toBe(true); }); it('handles call.recording_stopped events', () => { const state = new CallState(); state.updateFromEvent(fromPartial({ type: 'call.recording_stopped' })); expect(state.recording).toBe(false); }); it('handles call.recording_failed events', () => { const state = new CallState(); state.updateFromEvent(fromPartial({ type: 'call.recording_started' })); expect(state.recording).toBe(true); state.updateFromEvent(fromPartial({ type: 'call.recording_failed' })); expect(state.recording).toBe(false); }); it('handles call.hls_broadcasting_started events', () => { const state = new CallState(); state.updateFromCallResponse({ // @ts-expect-error incomplete data egress: { broadcasting: false, hls: { playlist_url: '', status: 'starting' }, }, }); state.updateFromEvent({ type: 'call.hls_broadcasting_started', // @ts-expect-error incomplete data call: { egress: { broadcasting: true, hls: { playlist_url: 'https://example.com/playlist.m3u8', status: 'running', }, rtmps: [], }, }, }); expect(state.egress?.broadcasting).toBe(true); expect(state.egress?.hls?.playlist_url).toBe( 'https://example.com/playlist.m3u8', ); expect(state.egress?.hls?.status).toBe('running'); }); it('handles call.hls_broadcasting_stopped events', () => { const state = new CallState(); // @ts-expect-error incomplete data state.updateFromCallResponse({ egress: {} }); // @ts-expect-error incomplete data state.updateFromEvent({ type: 'call.hls_broadcasting_stopped', }); expect(state.egress?.broadcasting).toBe(false); }); it('handles call.hls_broadcasting_failed events', () => { const state = new CallState(); // @ts-expect-error incomplete data state.updateFromCallResponse({ egress: { broadcasting: true } }); // @ts-expect-error incomplete data state.updateFromEvent({ type: 'call.hls_broadcasting_failed' }); expect(state.egress?.broadcasting).toBe(false); }); }); describe('updateFromRecordingEvent', () => { describe('COMPOSITE recording type', () => { it('should set recording to true when composite recording starts', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'composite', }), ); expect(state.recording).toBe(true); expect(state.individualRecording).toBe(false); expect(state.rawRecording).toBe(false); }); it('should set recording to false when composite recording stops', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'composite', }), ); expect(state.recording).toBe(true); state.updateFromEvent( fromPartial({ type: 'call.recording_stopped', recording_type: 'composite', }), ); expect(state.recording).toBe(false); }); it('should set recording to false when composite recording fails', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'composite', }), ); expect(state.recording).toBe(true); state.updateFromEvent( fromPartial({ type: 'call.recording_failed', recording_type: 'composite', }), ); expect(state.recording).toBe(false); }); }); describe('INDIVIDUAL recording type', () => { it('should set individualRecording to true when individual recording starts', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'individual', }), ); expect(state.individualRecording).toBe(true); expect(state.recording).toBe(false); expect(state.rawRecording).toBe(false); }); it('should set individualRecording to false when individual recording stops', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'individual', }), ); expect(state.individualRecording).toBe(true); state.updateFromEvent( fromPartial({ type: 'call.recording_stopped', recording_type: 'individual', }), ); expect(state.individualRecording).toBe(false); }); it('should set individualRecording to false when individual recording fails', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'individual', }), ); expect(state.individualRecording).toBe(true); state.updateFromEvent( fromPartial({ type: 'call.recording_failed', recording_type: 'individual', }), ); expect(state.individualRecording).toBe(false); }); }); describe('RAW recording type', () => { it('should set rawRecording to true when raw recording starts', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'raw', }), ); expect(state.rawRecording).toBe(true); expect(state.recording).toBe(false); expect(state.individualRecording).toBe(false); }); it('should set rawRecording to false when raw recording stops', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'raw', }), ); expect(state.rawRecording).toBe(true); state.updateFromEvent( fromPartial({ type: 'call.recording_stopped', recording_type: 'raw', }), ); expect(state.rawRecording).toBe(false); }); it('should set rawRecording to false when raw recording fails', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'raw', }), ); expect(state.rawRecording).toBe(true); state.updateFromEvent( fromPartial({ type: 'call.recording_failed', recording_type: 'raw', }), ); expect(state.rawRecording).toBe(false); }); }); describe('Legacy (undefined) recording type', () => { it('should set recording to true when recording starts with undefined type (legacy)', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: undefined, }), ); expect(state.recording).toBe(true); expect(state.individualRecording).toBe(false); expect(state.rawRecording).toBe(false); }); it('should set recording to false when recording stops with undefined type (legacy)', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: undefined, }), ); expect(state.recording).toBe(true); state.updateFromEvent( fromPartial({ type: 'call.recording_stopped', recording_type: undefined, }), ); expect(state.recording).toBe(false); }); it('should set recording to false when recording fails with undefined type (legacy)', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: undefined, }), ); expect(state.recording).toBe(true); state.updateFromEvent( fromPartial({ type: 'call.recording_failed', recording_type: undefined, }), ); expect(state.recording).toBe(false); }); }); describe('Recording type isolation', () => { it('should not affect other recording types when composite recording changes', () => { const state = new CallState(); // Start all three types state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'composite', }), ); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'individual', }), ); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'raw', }), ); expect(state.recording).toBe(true); expect(state.individualRecording).toBe(true); expect(state.rawRecording).toBe(true); // Stop composite recording only state.updateFromEvent( fromPartial({ type: 'call.recording_stopped', recording_type: 'composite', }), ); expect(state.recording).toBe(false); expect(state.individualRecording).toBe(true); expect(state.rawRecording).toBe(true); }); it('should not affect other recording types when individual recording changes', () => { const state = new CallState(); // Start all three types state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'composite', }), ); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'individual', }), ); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'raw', }), ); // Stop individual recording only state.updateFromEvent( fromPartial({ type: 'call.recording_stopped', recording_type: 'individual', }), ); expect(state.recording).toBe(true); expect(state.individualRecording).toBe(false); expect(state.rawRecording).toBe(true); }); it('should not affect other recording types when raw recording changes', () => { const state = new CallState(); // Start all three types state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'composite', }), ); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'individual', }), ); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'raw', }), ); // Stop raw recording only state.updateFromEvent( fromPartial({ type: 'call.recording_stopped', recording_type: 'raw', }), ); expect(state.recording).toBe(true); expect(state.individualRecording).toBe(true); expect(state.rawRecording).toBe(false); }); it('should handle multiple recording types being active simultaneously', () => { const state = new CallState(); // Start composite and individual state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'composite', }), ); state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'individual', }), ); expect(state.recording).toBe(true); expect(state.individualRecording).toBe(true); expect(state.rawRecording).toBe(false); // Add raw recording state.updateFromEvent( fromPartial({ type: 'call.recording_started', recording_type: 'raw', }), ); expect(state.recording).toBe(true); expect(state.individualRecording).toBe(true); expect(state.rawRecording).toBe(true); }); }); }); describe('call.session events', () => { it('should update the call metadata when a session starts', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.session_started', call: { egress: {}, session: fromPartial({ id: 'session-id', participants: [], participants_count_by_role: {}, }), }, }), ); expect(state.session).toEqual({ id: 'session-id', participants: [], participants_count_by_role: {}, }); }); it('should update the call metadata when a session ends', () => { const state = new CallState(); state.updateFromEvent( fromPartial({ type: 'call.session_ended', call: { egress: {}, session: fromPartial({ id: 'session-id', participants: [], participants_count_by_role: {}, }), }, }), ); expect(state.session).toEqual({ id: 'session-id', participants: [], participants_count_by_role: {}, }); }); it('should update the call metadata when a participant joins', () => { const state = new CallState(); state.updateFromCallResponse( fromPartial({ session: { participants: [], participants_count_by_role: {} }, egress: {}, }), ); state.updateFromEvent({ type: 'call.session_participant_joined', participant: { // @ts-expect-error incomplete data user: { id: 'user-id', role: 'user' }, user_session_id: '123', }, }); expect(state.session).toEqual({ participants: [ { user: { id: 'user-id', role: 'user' }, user_session_id: '123', }, ], participants_count_by_role: { user: 1 }, }); }); it('should update the call metadata when a participant leaves', () => { const state = new CallState(); state.updateFromCallResponse( fromPartial({ egress: {}, session: { participants: [ { joined_at: '2021-01-01T00:00:00.000Z', user: { id: 'user-id', role: 'user' }, user_session_id: '123', }, ], participants_count_by_role: { user: 1 }, }, }), ); state.updateFromEvent({ type: 'call.session_participant_left', participant: { // @ts-expect-error incomplete data user: { id: 'user-id', role: 'user' }, user_session_id: '123', }, }); expect(state.session).toEqual({ participants: [], participants_count_by_role: { user: 0 }, }); }); it('should update existing participant', () => { const state = new CallState(); state.updateFromCallResponse( fromPartial({ egress: {}, session: { participants: [ { user: { id: 'user-id', role: 'user' }, user_session_id: '123', }, ], participants_count_by_role: { user: 1 }, }, }), ); state.updateFromEvent({ type: 'call.session_participant_joined', participant: { // @ts-expect-error incomplete data user: { id: 'user-id', role: 'user', name: 'Updated user' }, user_session_id: '123', }, }); expect(state.session).toEqual({ participants: [ { user: { id: 'user-id', role: 'user', name: 'Updated user' }, user_session_id: '123', }, ], participants_count_by_role: { user: 1 }, }); }); it('should handle call.session_participant_updated events', () => { const state = new CallState(); state.updateFromCallResponse( fromPartial({ session: { participants: [], participants_count_by_role: {} }, egress: {}, }), ); // @ts-expect-error incomplete data state.updateFromEvent({ type: 'call.session_participant_count_updated', anonymous_participant_count: 10, participants_count_by_role: { user: 5, host: 3, admin: 1 }, }); expect(state.session?.anonymous_participant_count).toBe(10); expect(state.session?.participants_count_by_role).toEqual({ user: 5, host: 3, admin: 1, }); expect(state.participantCount).toBe(9); expect(state.anonymousParticipantCount).toBe(10); }); it('should not update the participant counts when call is joined', () => { const state = new CallState(); state.updateFromCallResponse( fromPartial({ session: { participants: [], participants_count_by_role: {} }, egress: {}, }), ); state.setCallingState(CallingState.JOINED); // @ts-expect-error incomplete data state.updateFromEvent({ type: 'call.session_participant_count_updated', anonymous_participant_count: 10, participants_count_by_role: { user: 5, host: 3, admin: 1 }, }); expect(state.session?.anonymous_participant_count).toBe(10); expect(state.session?.participants_count_by_role).toEqual({ user: 5, host: 3, admin: 1, }); expect(state.participantCount).toBe(0); expect(state.anonymousParticipantCount).toBe(0); // simulate SFU heartbeat state.setParticipantCount(3); state.setAnonymousParticipantCount(2); expect(state.participantCount).toBe(3); expect(state.anonymousParticipantCount).toBe(2); }); }); }); describe('orphaned tracks', () => { it('registers orphaned tracks', () => { const state = new CallState(); state.registerOrphanedTrack({ id: '123:TRACK_TYPE_VIDEO', track: new MediaStream(), trackLookupPrefix: '123', trackType: TrackType.AUDIO, }); expect(state['orphanedTracks'].length).toBe(1); }); it('removes orphaned tracks once assigned', () => { const state = new CallState(); state.registerOrphanedTrack({ id: '123:TRACK_TYPE_VIDEO', track: new MediaStream(), trackLookupPrefix: '123', trackType: TrackType.VIDEO, }); const orphans = state.takeOrphanedTracks('123'); expect(orphans.length).toBe(1); expect(state['orphanedTracks'].length).toBe(0); }); it('removes orphaned tracks', () => { const state = new CallState(); const id = '123:TRACK_TYPE_VIDEO'; state.registerOrphanedTrack({ id, track: new MediaStream(), trackLookupPrefix: '123', trackType: TrackType.VIDEO, }); expect(state['orphanedTracks'].length).toBe(1); state.removeOrphanedTrack(id); expect(state['orphanedTracks'].length).toBe(0); }); }); describe('closed captions', () => { it('should add closed captions to the queue', () => { const state = new CallState(); state.updateFromEvent({ type: 'call.closed_caption', // @ts-expect-error incomplete data closed_caption: { speaker_id: '123', text: 'Hello world', start_time: '2021-01-01T00:00:00.000Z', end_time: '2021-01-01T00:02:00.000Z', }, }); expect(state.closedCaptions.length).toBe(1); }); it('should maintain predefined queue size', () => { const state = new CallState(); state.updateClosedCaptionSettings({ maxVisibleCaptions: 2 }); for (let i = 0; i < 5; i++) { state.updateFromEvent({ type: 'call.closed_caption', // @ts-expect-error incomplete data closed_caption: { speaker_id: `123-${i}`, text: `Hello world ${i}`, start_time: '2021-01-01T00:00:00.000Z', end_time: '2021-01-01T00:02:00.000Z', }, }); } expect(state.closedCaptions.length).toBe(2); expect(state['closedCaptionsTasks'].size).toBe(2); expect(state.closedCaptions.map((cc) => cc.text)).toEqual([ 'Hello world 3', 'Hello world 4', ]); }); it('should remove stale captions from the queue', () => { const state = new CallState(); vi.useFakeTimers(); state.updateFromEvent({ type: 'call.closed_caption', // @ts-expect-error incomplete data closed_caption: { speaker_id: `123`, text: `Hello world`, start_time: '2021-01-01T00:00:00.000Z', end_time: '2021-01-01T00:02:00.000Z', }, }); expect(state.closedCaptions.length).toBe(1); expect(state['closedCaptionsTasks'].size).toBe(1); vi.runAllTimers(); expect(state.closedCaptions.length).toBe(0); expect(state['closedCaptionsTasks'].size).toBe(0); }); it('should remove stale captions from the queue after timer runs', () => { const state = new CallState(); state.updateClosedCaptionSettings({ visibilityDurationMs: 100 }); vi.useFakeTimers(); state.updateFromEvent({ type: 'call.closed_caption', // @ts-expect-error incomplete data closed_caption: { speaker_id: `123`, text: `Hello world`, start_time: '2021-01-01T00:00:00.000Z', end_time: '2021-01-01T00:02:00.000Z', }, }); expect(state.closedCaptions.length).toBe(1); expect(state['closedCaptionsTasks'].size).toBe(1); vi.advanceTimersByTime(101); expect(state.closedCaptions.length).toBe(0); expect(state['closedCaptionsTasks'].size).toBe(0); }); it('dispose cancels all cleanup tasks', () => { const state = new CallState(); state.updateFromEvent({ type: 'call.closed_caption', // @ts-expect-error incomplete data closed_caption: { speaker_id: `123`, text: `Hello world`, start_time: '2021-01-01T00:00:00.000Z', }, }); expect(state.closedCaptions.length).toBe(1); expect(state['closedCaptionsTasks'].size).toBe(1); state.dispose(); expect(state['closedCaptionsTasks'].size).toBe(0); }); }); });