/* @vitest-environment happy-dom */ import { Call } from '../../Call'; import { StreamClient } from '../../coordinator/connection/client'; import { CallingState, StreamVideoWriteableStateStore } from '../../store'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createLocalStorageMock, emitDeviceIds, LocalStorageMock, mockBrowserPermission, mockCall, mockDeviceIds$, MockTrack, mockVideoDevices, mockVideoStream, } from './mocks'; import { DeviceManager } from '../DeviceManager'; import { DeviceManagerState } from '../DeviceManagerState'; import { of } from 'rxjs'; import { TrackType } from '../../gen/video/sfu/models/models'; import { PermissionsContext } from '../../permissions'; import { readPreferences } from '../devicePersistence'; vi.mock('../../Call.ts', () => { console.log('MOCKING Call'); return { Call: vi.fn(() => mockCall()), }; }); vi.mock('../devices.ts', () => { console.log('MOCKING devices API'); return { getAudioBrowserPermission: () => mockBrowserPermission, getVideoBrowserPermission: () => mockBrowserPermission, deviceIds$: mockDeviceIds$(), resolveDeviceId: (deviceId) => deviceId, }; }); class TestInputMediaDeviceManagerState extends DeviceManagerState { public getDeviceIdFromStream = vi.fn( (stream) => stream.getVideoTracks()[0].getSettings().deviceId, ); } class TestInputMediaDeviceManager extends DeviceManager { public getDevices = vi.fn(() => of(mockVideoDevices)); public getStream = vi.fn(() => Promise.resolve(mockVideoStream())); public publishStream = vi.fn(); public stopPublishStream = vi.fn(); public getTracks = () => this.state.mediaStream?.getTracks() ?? []; constructor( call: Call, devicePersistence = { enabled: false, storageKey: '' }, ) { super( call, new TestInputMediaDeviceManagerState( 'stop-tracks', mockBrowserPermission, ), TrackType.VIDEO, devicePersistence, ); } } describe('Device Manager', () => { let manager: TestInputMediaDeviceManager; let localStorageMock: LocalStorageMock; let storageKey: string; beforeEach(() => { storageKey = '@test/device-preferences'; localStorageMock = createLocalStorageMock(); vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( of('granted'), ); Object.defineProperty(window, 'localStorage', { configurable: true, value: localStorageMock, }); manager = new TestInputMediaDeviceManager( new Call({ id: '', type: '', streamClient: new StreamClient('abc123'), clientStore: new StreamVideoWriteableStateStore(), }), { enabled: false, storageKey }, ); }); it('list devices', () => { const spy = vi.fn(); manager.listDevices().subscribe(spy); expect(spy).toHaveBeenCalledWith(mockVideoDevices); }); it('enable device - before joined to call', async () => { vi.spyOn(manager, 'getStream'); await manager.enable(); expect(manager.getStream).toHaveBeenCalledWith({ deviceId: undefined, }); expect(manager.state.mediaStream).toBeDefined(); expect(manager.state.status).toBe('enabled'); }); it('enable device - after joined to call', async () => { manager['call'].state.setCallingState(CallingState.JOINED); await manager.enable(); expect(manager.publishStream).toHaveBeenCalledWith( manager.state.mediaStream, ); }); it('enable device should set device id', async () => { expect(manager.state.selectedDevice).toBeUndefined(); await manager.enable(); expect(manager.state.selectedDevice).toBeDefined(); }); it('disable device - before joined to call', async () => { await manager.disable(); expect(manager.state.mediaStream).toBeUndefined(); expect(manager.state.status).toBe('disabled'); }); it('disable device - after joined to call', async () => { manager['call'].state.setCallingState(CallingState.JOINED); await manager.enable(); await manager.disable(); expect(manager.stopPublishStream).toHaveBeenCalled(); }); it('disable device with forceStop', async () => { manager['call'].state.setCallingState(CallingState.JOINED); await manager.enable(); expect(manager.state.mediaStream).toBeDefined(); await manager.disable(true); expect(manager.stopPublishStream).toHaveBeenCalled(); expect(manager.state.mediaStream).toBeUndefined(); expect(manager.state.status).toBe('disabled'); }); it('toggle device', async () => { vi.spyOn(manager, 'disable'); vi.spyOn(manager, 'enable'); await manager.toggle(); expect(manager.enable).toHaveBeenCalled(); await manager.toggle(); expect(manager.disable).toHaveBeenCalled(); }); it('select device when status is disabled', async () => { const deviceId = mockVideoDevices[0].deviceId; await manager.select(deviceId); expect(manager.state.selectedDevice).toBe(deviceId); expect(manager.getStream).not.toHaveBeenCalledWith(); expect(manager.publishStream).not.toHaveBeenCalled(); }); it('select device when status is enabled', async () => { await manager.enable(); const prevStream = manager.state.mediaStream; vi.spyOn(prevStream!.getVideoTracks()[0], 'stop'); const deviceId = mockVideoDevices[1].deviceId; await manager.select(deviceId); expect(prevStream!.getVideoTracks()[0].stop).toHaveBeenCalledWith(); }); it('select device when status is enabled and in call', async () => { manager['call'].state.setCallingState(CallingState.JOINED); await manager.enable(); const deviceId = mockVideoDevices[1].deviceId; await manager.select(deviceId); expect(manager.stopPublishStream).toHaveBeenCalled(); expect(manager.getStream).toHaveBeenCalledWith({ deviceId: { exact: deviceId }, }); expect(manager.publishStream).toHaveBeenCalled(); }); it(`changing media stream constraints shouldn't toggle optimistic status`, async () => { await manager.enable(); const spy = vi.fn(); manager.state.optimisticStatus$.subscribe(spy); expect(spy.mock.calls.length).toBe(1); const deviceId = mockVideoDevices[1].deviceId; await manager.select(deviceId); expect(spy.mock.calls.length).toBe(1); }); it('should resume previously enabled state', async () => { vi.spyOn(manager, 'enable'); await manager.enable(); expect(manager.enable).toHaveBeenCalledTimes(1); await manager.disable(); await manager.resume(); expect(manager.enable).toHaveBeenCalledTimes(2); }); it(`shouldn't resume if previous state is disabled`, async () => { vi.spyOn(manager, 'enable'); await manager.disable(); expect(manager.enable).not.toHaveBeenCalled(); await manager.resume(); expect(manager.enable).not.toHaveBeenCalled(); }); it(`shouldn't resume if it were disabled while in pause`, async () => { vi.spyOn(manager, 'enable'); await manager.enable(); expect(manager.enable).toHaveBeenCalledOnce(); // first call is pause await manager.disable(); // second call is for example mute from call admin await manager.disable(); await manager.resume(); expect(manager.enable).toHaveBeenCalledOnce(); }); it(`should resume if enable was cancelled due to disable call`, async () => { vi.spyOn(manager, 'enable'); manager.enable(); expect(manager.enable).toHaveBeenCalledOnce(); // enable was not awaited so cancelled by disabled await manager.disable(); manager.resume(); expect(manager.enable).toBeCalledTimes(2); // this disable is not awaited, but will cancel the enable anyway // so resume must work here too manager.disable(); manager.resume(); expect(manager.enable).toBeCalledTimes(3); }); it('should provide default constraints to `getStream` method', () => { manager.setDefaultConstraints({ echoCancellation: true, autoGainControl: false, }); manager.enable(); expect(manager.getStream).toHaveBeenCalledWith({ deviceId: undefined, echoCancellation: true, autoGainControl: false, }); }); it('should set status to disabled if track ends', async () => { vi.useFakeTimers(); await manager.enable(); vi.spyOn(manager, 'enable'); vi.spyOn(manager, 'listDevices').mockImplementationOnce(() => of(mockVideoDevices.slice(1)), ); await ( (manager.state.mediaStream?.getTracks()[0] as MockTrack).eventHandlers[ 'ended' ] as Function )(); await vi.runAllTimersAsync(); expect(manager.state.status).toBe('disabled'); expect(manager.enable).not.toHaveBeenCalled(); vi.useRealTimers(); }); it('should restart track if the default device is replaced and status is enabled', async () => { vi.useFakeTimers(); emitDeviceIds(mockVideoDevices); await manager.enable(); const device = mockVideoDevices[0]; await manager.select(device.deviceId); // @ts-expect-error - private method vi.spyOn(manager, 'applySettingsToStream'); emitDeviceIds([ { ...device, groupId: device.groupId + 'new' }, ...mockVideoDevices.slice(1), ]); await vi.runAllTimersAsync(); expect(manager['applySettingsToStream']).toHaveBeenCalledOnce(); expect(manager.state.status).toBe('enabled'); vi.useRealTimers(); }); it('should do nothing if default device is replaced and status is disabled', async () => { vi.useFakeTimers(); emitDeviceIds(mockVideoDevices); const device = mockVideoDevices[0]; await manager.select(device.deviceId); await manager.disable(); emitDeviceIds([ { ...device, groupId: device.groupId + 'new' }, ...mockVideoDevices.slice(1), ]); await vi.runAllTimersAsync(); expect(manager.state.status).toBe('disabled'); expect(manager.state.optimisticStatus).toBe('disabled'); expect(manager.state.selectedDevice).toBe(device.deviceId); vi.useRealTimers(); }); it('should disable stream and deselect device if selected device is disconnected', async () => { vi.useFakeTimers(); emitDeviceIds(mockVideoDevices); await manager.enable(); const device = mockVideoDevices[0]; await manager.select(device.deviceId); emitDeviceIds(mockVideoDevices.slice(1)); await vi.runAllTimersAsync(); expect(manager.state.selectedDevice).toBe(undefined); expect(manager.state.status).toBe('disabled'); expect(manager['call'].streamClient.dispatchEvent).toHaveBeenCalledWith({ type: 'device.disconnected', call_cid: manager['call'].cid, status: 'enabled', deviceId: device.deviceId, label: device.label, kind: device.kind, }); vi.useRealTimers(); }); describe('persistPreference', () => { it('stores selected device and muted state', () => { const persistenceEnabledManager = new TestInputMediaDeviceManager( manager['call'], { enabled: true, storageKey }, ); persistenceEnabledManager.state.setDevice(mockVideoDevices[1].deviceId); persistenceEnabledManager.state.setStatus('enabled'); const preferences = readPreferences(storageKey); expect(preferences.camera).toEqual([ { selectedDeviceId: mockVideoDevices[1].deviceId, selectedDeviceLabel: mockVideoDevices[1].label, muted: false, }, ]); }); it('stores default device when selection is cleared', () => { const persistenceEnabledManager = new TestInputMediaDeviceManager( manager['call'], { enabled: true, storageKey }, ); persistenceEnabledManager.state.setDevice(undefined); persistenceEnabledManager.state.setStatus('disabled'); const preferences = readPreferences(storageKey); expect(preferences.camera).toEqual([ { selectedDeviceId: 'default', selectedDeviceLabel: '', muted: true, }, ]); }); it('persists device history when selection changes', () => { const persistenceEnabledManager = new TestInputMediaDeviceManager( manager['call'], { enabled: true, storageKey }, ); persistenceEnabledManager.state.setDevice(mockVideoDevices[0].deviceId); persistenceEnabledManager.state.setStatus('enabled'); persistenceEnabledManager.state.setDevice(mockVideoDevices[1].deviceId); persistenceEnabledManager.state.setStatus('enabled'); const preferences = readPreferences(storageKey); expect(preferences.camera).toEqual([ { selectedDeviceId: mockVideoDevices[1].deviceId, selectedDeviceLabel: mockVideoDevices[1].label, muted: false, }, { selectedDeviceId: mockVideoDevices[0].deviceId, selectedDeviceLabel: mockVideoDevices[0].label, muted: false, }, ]); }); it('stores preferences when permission is granted', async () => { const persistenceEnabledManager = new TestInputMediaDeviceManager( manager['call'], { enabled: true, storageKey }, ); const listDevicesSpy = vi.spyOn(persistenceEnabledManager, 'listDevices'); emitDeviceIds(mockVideoDevices); persistenceEnabledManager.state.setDevice(mockVideoDevices[0].deviceId); persistenceEnabledManager.state.setStatus('enabled'); expect(readPreferences(storageKey).camera).toBeDefined(); expect(listDevicesSpy).toHaveBeenCalled(); expect(readPreferences(storageKey).camera).toEqual([ { selectedDeviceId: mockVideoDevices[0].deviceId, selectedDeviceLabel: mockVideoDevices[0].label, muted: false, }, ]); }); it('does not store preferences when permission is not granted', async () => { vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( of('prompt'), ); const persistenceEnabledManager = new TestInputMediaDeviceManager( manager['call'], { enabled: true, storageKey }, ); const listDevicesSpy = vi.spyOn(persistenceEnabledManager, 'listDevices'); emitDeviceIds(mockVideoDevices); persistenceEnabledManager.state.setDevice(mockVideoDevices[0].deviceId); persistenceEnabledManager.state.setStatus('enabled'); expect(readPreferences(storageKey).camera).toBeUndefined(); expect(listDevicesSpy).not.toHaveBeenCalled(); }); it('does not overwrite preferences when track ends unexpectedly', async () => { const persistenceEnabledManager = new TestInputMediaDeviceManager( manager['call'], { enabled: true, storageKey }, ); await persistenceEnabledManager.enable(); expect(readPreferences(storageKey).camera).toEqual([ { selectedDeviceId: mockVideoDevices[0].deviceId, selectedDeviceLabel: mockVideoDevices[0].label, muted: false, }, ]); const [track] = persistenceEnabledManager.state.mediaStream!.getTracks(); await ((track as MockTrack).eventHandlers['ended'] as Function)(); expect(readPreferences(storageKey).camera).toEqual([ { selectedDeviceId: mockVideoDevices[0].deviceId, selectedDeviceLabel: mockVideoDevices[0].label, muted: false, }, ]); }); }); describe('applyPersistedPreferences', () => { beforeEach(() => { manager.dispose(); manager = new TestInputMediaDeviceManager(manager['call'], { enabled: true, storageKey, }); // @ts-expect-error - read only property manager['call'].permissionsContext = new PermissionsContext(); manager['call'].permissionsContext.canPublish = vi .fn() .mockReturnValue(true); }); it('returns false when no preferences exist', async () => { // @ts-expect-error - private api const result = await manager.applyPersistedPreferences(true); expect(result).toBe(false); }); it('selects device by id and applies muted state', async () => { localStorageMock.setItem( storageKey, JSON.stringify({ camera: [ { selectedDeviceId: mockVideoDevices[0].deviceId, selectedDeviceLabel: mockVideoDevices[0].label, muted: true, }, ], }), ); const selectSpy = vi.spyOn(manager, 'select'); const disableSpy = vi.spyOn(manager, 'disable'); // @ts-expect-error - private API const result = await manager.applyPersistedPreferences(true); expect(result).toBe(true); expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[0].deviceId); expect(disableSpy).toHaveBeenCalled(); }); it('selects device by label when device id is not found', async () => { localStorageMock.setItem( storageKey, JSON.stringify({ camera: [ { selectedDeviceId: 'missing-device', selectedDeviceLabel: mockVideoDevices[1].label, muted: false, }, ], }), ); const selectSpy = vi.spyOn(manager, 'select'); // @ts-expect-error private api const result = await manager.applyPersistedPreferences(true); expect(result).toBe(true); expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[1].deviceId); }); it('applies muted state without selecting when default device is stored', async () => { localStorageMock.setItem( storageKey, JSON.stringify({ camera: [ { selectedDeviceId: 'default', selectedDeviceLabel: '', muted: true, }, ], }), ); const selectSpy = vi.spyOn(manager, 'select'); const disableSpy = vi.spyOn(manager, 'disable'); // @ts-expect-error private api const result = await manager.applyPersistedPreferences(true); expect(result).toBe(true); expect(selectSpy).not.toHaveBeenCalled(); expect(disableSpy).toHaveBeenCalled(); }); }); afterEach(() => { manager.dispose(); vi.clearAllMocks(); vi.resetModules(); Object.defineProperty(window, 'localStorage', { configurable: true, value: undefined, }); }); });