import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { MicrophoneManager, MicrophoneNotSupportedError, } from '../../src/card-controller/microphone-manager'; import type { MicrophoneState } from '../../src/card-controller/types'; import { createConfig } from '../config/test-utils'; import { createCardAPI } from '../test-utils'; const navigatorMock: Navigator = { ...mock(), mediaDevices: { ...mock(), getUserMedia: vi.fn(), }, }; const medialessNavigatorMock: Navigator = { ...navigatorMock, // Some browser will set mediaDevices to undefined when access over http. mediaDevices: undefined as unknown as MediaDevices, }; // @vitest-environment jsdom describe('MicrophoneManager', () => { beforeEach(() => { vi.stubGlobal('navigator', navigatorMock); vi.useRealTimers(); }); afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); }); const createMockStream = (mute?: boolean): MediaStream => { const stream = mock(); const track = mock(); track.enabled = !mute; stream.getTracks.mockImplementation(() => [track]); return stream; }; it('should be muted on creation', () => { const manager = new MicrophoneManager(createCardAPI()); expect(manager).toBeTruthy(); expect(manager.isMuted()).toBeTruthy(); }); it('should be undefined without creation', () => { const manager = new MicrophoneManager(createCardAPI()); expect(manager.getStream()).toBeUndefined(); }); it('should connect', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); const stream = createMockStream(); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream); await manager.connect(); expect(manager.isConnected()).toBeTruthy(); expect(manager.getStream()).toBe(stream); expect(manager.isMuted()).toBeTruthy(); expect(api.getCardElementManager().update).toHaveBeenCalled(); }); it('should be unsupported without browser support', () => { vi.stubGlobal('navigator', medialessNavigatorMock); const manager = new MicrophoneManager(createCardAPI()); expect(manager.isSupported()).toBeFalsy(); }); it('should not connect when not supported', async () => { vi.stubGlobal('navigator', medialessNavigatorMock); const api = createCardAPI(); const manager = new MicrophoneManager(api); const stream = createMockStream(); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream); await expect(manager.connect()).rejects.toThrow(MicrophoneNotSupportedError); expect(manager.isConnected()).toBeFalsy(); }); it('should be forbidden when permission denied', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error()); await expect(manager.connect()).rejects.toThrow(Error); expect(manager.isConnected()).toBeFalsy(); expect(manager.isForbidden()).toBeTruthy(); expect(api.getCardElementManager().update).toHaveBeenCalled(); }); it('should mute and unmute', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( createMockStream(), ); await manager.connect(); expect(manager.isMuted()).toBeTruthy(); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1); manager.mute(); expect(manager.isMuted()).toBeTruthy(); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2); await manager.unmute(); expect(manager.isMuted()).toBeFalsy(); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(3); }); it('should not unmute when microphone forbidden', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error()); await expect(manager.connect()).rejects.toThrow(Error); expect(manager.isMuted()).toBeTruthy(); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1); await manager.unmute(); expect(manager.isMuted()).toBeTruthy(); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1); }); it('should not unmute when not supported', async () => { vi.stubGlobal('navigator', medialessNavigatorMock); const manager = new MicrophoneManager(createCardAPI()); await manager.unmute(); expect(manager.isConnected()).toBeFalsy(); expect(manager.isMuted()).toBeTruthy(); }); it('should connect on unmute', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( createMockStream(), ); expect(manager.isConnected()).toBeFalsy(); await manager.unmute(); expect(manager.isConnected()).toBeTruthy(); expect(manager.isMuted()).toBeFalsy(); expect(api.getCardElementManager().update).toHaveBeenCalled(); }); it('should disconnect', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( createMockStream(), ); await manager.connect(); expect(manager.isConnected()).toBeTruthy(); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1); manager.disconnect(); expect(manager.isConnected()).toBeFalsy(); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2); }); it('should automatically disconnect', async () => { vi.useFakeTimers(); const disconnectSeconds = 10; const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( createMockStream(), ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ live: { microphone: { always_connected: false, disconnect_seconds: disconnectSeconds, }, }, }), ); await manager.connect(); expect(manager.isConnected()).toBeTruthy(); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(disconnectSeconds * 1000); expect(manager.isConnected()).toBeFalsy(); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2); }); it('should not automatically disconnect when always connected', async () => { vi.useFakeTimers(); const disconnectSeconds = 10; const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( createMockStream(), ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ live: { microphone: { always_connected: true, disconnect_seconds: disconnectSeconds, }, }, }), ); await manager.connect(); expect(manager.isConnected()).toBeTruthy(); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(disconnectSeconds * 1000); expect(manager.isConnected()).toBeTruthy(); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1); }); describe('should stay connected while in use', () => { const disconnectSeconds = 10; const createManagerWithDisconnectSeconds = (): MicrophoneManager => { const api = createCardAPI(); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( createMockStream(), ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ live: { microphone: { always_connected: false, disconnect_seconds: disconnectSeconds, }, }, }), ); return new MicrophoneManager(api); }; it('should not automatically disconnect while a user is registered', async () => { vi.useFakeTimers(); const manager = createManagerWithDisconnectSeconds(); await manager.connect(); manager.startUsing(); vi.advanceTimersByTime(disconnectSeconds * 1000); expect(manager.isConnected()).toBeTruthy(); }); it('should not automatically disconnect when a user is registered before connection', async () => { vi.useFakeTimers(); const manager = createManagerWithDisconnectSeconds(); manager.startUsing(); await manager.connect(); vi.advanceTimersByTime(disconnectSeconds * 1000); expect(manager.isConnected()).toBeTruthy(); }); it('should not automatically disconnect on mute or unmute while in use', async () => { vi.useFakeTimers(); const manager = createManagerWithDisconnectSeconds(); await manager.connect(); manager.startUsing(); await manager.unmute(); manager.mute(); vi.advanceTimersByTime(disconnectSeconds * 1000); expect(manager.isConnected()).toBeTruthy(); }); it('should restart the countdown in full when use stops', async () => { vi.useFakeTimers(); const manager = createManagerWithDisconnectSeconds(); await manager.connect(); manager.startUsing(); vi.advanceTimersByTime(disconnectSeconds * 1000); manager.stopUsing(); vi.advanceTimersByTime(disconnectSeconds * 1000 - 1); expect(manager.isConnected()).toBeTruthy(); vi.advanceTimersByTime(1); expect(manager.isConnected()).toBeFalsy(); }); it('should become idle on a single stop no matter how often use was started', async () => { vi.useFakeTimers(); const manager = createManagerWithDisconnectSeconds(); await manager.connect(); manager.startUsing(); manager.startUsing(); manager.stopUsing(); vi.advanceTimersByTime(disconnectSeconds * 1000); expect(manager.isConnected()).toBeFalsy(); }); it('should restart the countdown when stopped without having been in use', async () => { vi.useFakeTimers(); const manager = createManagerWithDisconnectSeconds(); await manager.connect(); vi.advanceTimersByTime((disconnectSeconds * 1000) / 2); manager.stopUsing(); vi.advanceTimersByTime((disconnectSeconds * 1000) / 2); expect(manager.isConnected()).toBeTruthy(); vi.advanceTimersByTime((disconnectSeconds * 1000) / 2); expect(manager.isConnected()).toBeFalsy(); }); it('should not reconnect after an explicit disconnect while in use', async () => { vi.useFakeTimers(); const manager = createManagerWithDisconnectSeconds(); await manager.connect(); manager.startUsing(); manager.disconnect(); vi.advanceTimersByTime(disconnectSeconds * 1000); expect(manager.isConnected()).toBeFalsy(); }); }); it('should not disconnect after being explicitly disconnected and reconnected', async () => { vi.useFakeTimers(); const disconnectSeconds = 10; const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( createMockStream(), ); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ live: { microphone: { always_connected: false, disconnect_seconds: disconnectSeconds, }, }, }), ); await manager.connect(); vi.advanceTimersByTime((disconnectSeconds * 1000) / 2); manager.disconnect(); await manager.connect(); // The countdown from the first connection must not survive the disconnect // and cut the second one short. vi.advanceTimersByTime((disconnectSeconds * 1000) / 2); expect(manager.isConnected()).toBeTruthy(); }); describe('should require initialization', async () => { it('should require when configured and supported', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ live: { microphone: { always_connected: true, }, }, }), ); await manager.connect(); expect(manager.shouldConnectOnInitialization()).toBeTruthy(); }); it('should not require when configured but not supported', async () => { vi.stubGlobal('navigator', medialessNavigatorMock); const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(api.getConfigManager().getConfig).mockReturnValue( createConfig({ live: { microphone: { always_connected: true, }, }, }), ); await expect(manager.connect()).rejects.toThrow(MicrophoneNotSupportedError); expect(manager.shouldConnectOnInitialization()).toBeFalsy(); }); it('should not require when neither configured nor supported', async () => { vi.stubGlobal('navigator', medialessNavigatorMock); const api = createCardAPI(); const manager = new MicrophoneManager(api); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); await expect(manager.connect()).rejects.toThrow(MicrophoneNotSupportedError); expect(manager.shouldConnectOnInitialization()).toBeFalsy(); }); }); it('should initialize', () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); manager.initialize(); expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({ microphone: { connected: false, muted: true, forbidden: false, stream: undefined }, }); }); it('should set state', async () => { const api = createCardAPI(); const manager = new MicrophoneManager(api); const stream = createMockStream(); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream); expect(api.getConditionStateManager().setState).not.toHaveBeenCalled(); await manager.connect(); let expectedState: MicrophoneState = { forbidden: false, stream: stream, connected: true, muted: true, }; expect(manager.getState()).toEqual(expectedState); expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith( expect.objectContaining({ microphone: expectedState, }), ); await manager.unmute(); expectedState = { forbidden: false, stream: stream, connected: true, muted: false, }; expect(manager.getState()).toEqual(expectedState); expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith( expect.objectContaining({ microphone: expectedState, }), ); manager.mute(); expectedState = { forbidden: false, stream: stream, connected: true, muted: true, }; expect(manager.getState()).toEqual(expectedState); expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith( expect.objectContaining({ microphone: expectedState, }), ); manager.disconnect(); expectedState = { forbidden: false, stream: undefined, connected: false, muted: true, }; expect(manager.getState()).toEqual(expectedState); expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith( expect.objectContaining({ microphone: expectedState, }), ); }); });