import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // --- Hoisted mocks --- const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({ mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal), mockSavePref: vi.fn(), })); vi.mock("@components/settings/helpers", () => ({ loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal), savePref: (key: string, val: unknown) => mockSavePref(key, val), })); vi.mock("@lib/logger", () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }), })); const mockGetLocalDevices = vi.fn(); vi.mock("livekit-client", () => ({ Room: Object.assign(vi.fn(), { getLocalDevices: (...args: unknown[]) => mockGetLocalDevices(...args), }), })); const mockVoiceState = vi.hoisted(() => ({ localMuted: false, localDeafened: false, localServerMuted: false, pttGated: false, })); vi.mock("@stores/voice.store", () => ({ voiceStore: { getState: () => mockVoiceState, }, })); import { DeviceManager, isMicPolicyGated } from "../../src/lib/deviceManager"; describe("isMicPolicyGated", () => { beforeEach(() => { mockVoiceState.localMuted = false; mockVoiceState.localDeafened = false; mockVoiceState.localServerMuted = false; mockVoiceState.pttGated = false; }); it("is false when nothing gates the mic", () => { expect(isMicPolicyGated()).toBe(false); }); it("is true when localMuted", () => { mockVoiceState.localMuted = true; expect(isMicPolicyGated()).toBe(true); }); it("is true when localDeafened", () => { mockVoiceState.localDeafened = true; expect(isMicPolicyGated()).toBe(true); }); it("is true when localServerMuted", () => { mockVoiceState.localServerMuted = true; expect(isMicPolicyGated()).toBe(true); }); it("is true when pttGated", () => { mockVoiceState.pttGated = true; expect(isMicPolicyGated()).toBe(true); }); }); describe("DeviceManager", () => { let dm: DeviceManager; let mockRoom: any; beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); mockVoiceState.localMuted = false; mockVoiceState.localDeafened = false; mockVoiceState.localServerMuted = false; mockVoiceState.pttGated = false; dm = new DeviceManager(); mockRoom = { localParticipant: { setMicrophoneEnabled: vi.fn().mockResolvedValue(undefined), }, switchActiveDevice: vi.fn().mockResolvedValue(undefined), }; // Stub navigator.mediaDevices vi.stubGlobal("navigator", { mediaDevices: { addEventListener: vi.fn(), removeEventListener: vi.fn(), enumerateDevices: vi.fn().mockResolvedValue([]), }, }); }); afterEach(() => { dm.setRoom(null); vi.useRealTimers(); vi.unstubAllGlobals(); }); // ----------------------------------------------------------------------- // setRoom // ----------------------------------------------------------------------- describe("setRoom", () => { it("accepts null and does not register a device change listener", () => { dm.setRoom(null); expect(navigator.mediaDevices.addEventListener).not.toHaveBeenCalled(); }); it("starts device change listener when room is set", () => { dm.setRoom(mockRoom); expect(navigator.mediaDevices.addEventListener).toHaveBeenCalledWith( "devicechange", expect.any(Function), ); }); it("stops device change listener when room is set to null", () => { dm.setRoom(mockRoom); dm.setRoom(null); expect(navigator.mediaDevices.removeEventListener).toHaveBeenCalledWith( "devicechange", expect.any(Function), ); }); it("stops old listener before starting new one when room changes", () => { dm.setRoom(mockRoom); const firstHandler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; dm.setRoom(mockRoom); expect(navigator.mediaDevices.removeEventListener).toHaveBeenCalledWith( "devicechange", firstHandler, ); }); }); // ----------------------------------------------------------------------- // setAudioPipeline, setOnError, setOnToast // ----------------------------------------------------------------------- describe("setAudioPipeline", () => { it("clears the pipeline so device switches skip pipeline setup", async () => { const pipeline = { setupAudioPipeline: vi.fn(), applyNoiseSuppressor: vi.fn(), removeNoiseSuppressor: vi.fn(), } as any; dm.setAudioPipeline(pipeline); dm.setAudioPipeline(null); // Verify pipeline methods are NOT called on a subsequent device switch dm.setRoom(mockRoom); await dm.switchInputDevice("device-1"); expect(pipeline.setupAudioPipeline).not.toHaveBeenCalled(); }); it("stores a pipeline that is invoked during device switches", async () => { const pipeline = { setupAudioPipeline: vi.fn(), applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined), removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined), } as any; dm.setRoom(mockRoom); dm.setAudioPipeline(pipeline); await dm.switchInputDevice("device-1"); expect(pipeline.setupAudioPipeline).toHaveBeenCalled(); }); }); describe("setOnError", () => { it("clears the error callback so device switch errors are suppressed", async () => { const onError = vi.fn(); dm.setOnError(onError); dm.setOnError(null); dm.setRoom(mockRoom); mockRoom.switchActiveDevice.mockRejectedValue(new Error("device error")); await dm.switchInputDevice("device-1"); expect(onError).not.toHaveBeenCalled(); }); }); describe("setOnToast", () => { it("clears the toast callback so device switch toasts are suppressed", async () => { const onToast = vi.fn(); const pipeline = { setupAudioPipeline: vi.fn(() => { throw new Error("pipeline error"); }), applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined), removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined), } as any; dm.setOnToast(onToast); dm.setOnToast(null); dm.setRoom(mockRoom); dm.setAudioPipeline(pipeline); await dm.switchInputDevice("device-1"); expect(onToast).not.toHaveBeenCalled(); }); }); // ----------------------------------------------------------------------- // switchInputDevice // ----------------------------------------------------------------------- describe("switchInputDevice", () => { it("does nothing when no room is set", async () => { await dm.switchInputDevice("device-1"); expect(mockRoom.switchActiveDevice).not.toHaveBeenCalled(); }); it("calls room.switchActiveDevice for non-empty deviceId", async () => { dm.setRoom(mockRoom); await dm.switchInputDevice("device-1"); expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audioinput", "device-1"); }); it("re-enables microphone for empty deviceId (default fallback)", async () => { dm.setRoom(mockRoom); await dm.switchInputDevice(""); // The pinned capture-device constraint must be reset to the system // default before the cycle — otherwise the off/on toggle re-acquires // whatever device a previous switchActiveDevice pinned. expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audioinput", "default", false); expect(mockRoom.switchActiveDevice.mock.invocationCallOrder[0]).toBeLessThan( mockRoom.localParticipant.setMicrophoneEnabled.mock.invocationCallOrder[0], ); expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true); }); it("waits for the default-device reset before cycling the mic", async () => { let resolveSwitch: ((switched: boolean) => void) | null = null; mockRoom.switchActiveDevice.mockImplementation( () => new Promise((resolve) => { resolveSwitch = resolve; }), ); dm.setRoom(mockRoom); const done = dm.switchInputDevice(""); expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audioinput", "default", false); expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); resolveSwitch!(true); await done; expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true); }); // B1_voice_mic-2: switchInputDevice('') must not silently undo a mute, // deafen, server-mute, or PTT gate — livekit's setMicrophoneEnabled(true) // is a bare unmute when the muted-but-published track survives the // toggle, so nothing downstream ever observes the re-publish. it("does not re-enable the mic on default-device switch while the user is muted", async () => { mockVoiceState.localMuted = true; dm.setRoom(mockRoom); await dm.switchInputDevice(""); expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true); }); it("does not re-enable the mic on default-device switch while push-to-talk is gating it", async () => { mockVoiceState.pttGated = true; dm.setRoom(mockRoom); await dm.switchInputDevice(""); expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true); }); it("calls setupAudioPipeline on the pipeline after switch", async () => { const pipeline = { setupAudioPipeline: vi.fn(), applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined), removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined), } as any; dm.setRoom(mockRoom); dm.setAudioPipeline(pipeline); await dm.switchInputDevice("device-1"); expect(pipeline.setupAudioPipeline).toHaveBeenCalled(); }); it("applies enhanced noise suppression when enabled", async () => { mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { if (key === "enhancedNoiseSuppression") return true; return defaultVal; }); const pipeline = { setupAudioPipeline: vi.fn(), applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined), removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined), } as any; dm.setRoom(mockRoom); dm.setAudioPipeline(pipeline); await dm.switchInputDevice("device-1"); expect(pipeline.applyNoiseSuppressor).toHaveBeenCalled(); }); it("removes noise suppression when not enabled", async () => { mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { if (key === "enhancedNoiseSuppression") return false; return defaultVal; }); const pipeline = { setupAudioPipeline: vi.fn(), applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined), removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined), } as any; dm.setRoom(mockRoom); dm.setAudioPipeline(pipeline); await dm.switchInputDevice("device-1"); expect(pipeline.removeNoiseSuppressor).toHaveBeenCalled(); }); it("calls onError callback on device switch failure", async () => { const onError = vi.fn(); dm.setRoom(mockRoom); dm.setOnError(onError); mockRoom.switchActiveDevice.mockRejectedValue(new Error("device error")); await dm.switchInputDevice("device-1"); expect(onError).toHaveBeenCalledWith("Failed to switch microphone"); }); it("shows toast when pipeline setup fails", async () => { const onToast = vi.fn(); const pipeline = { setupAudioPipeline: vi.fn(() => { throw new Error("pipeline error"); }), applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined), removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined), } as any; dm.setRoom(mockRoom); dm.setAudioPipeline(pipeline); dm.setOnToast(onToast); await dm.switchInputDevice("device-1"); expect(onToast).toHaveBeenCalledWith("Audio pipeline error after device switch"); }); }); // ----------------------------------------------------------------------- // switchOutputDevice // ----------------------------------------------------------------------- describe("switchOutputDevice", () => { it("skips device switch when no room is set", async () => { await dm.switchOutputDevice("device-1"); expect(mockRoom.switchActiveDevice).not.toHaveBeenCalled(); }); it("calls room.switchActiveDevice for audiooutput", async () => { dm.setRoom(mockRoom); await dm.switchOutputDevice("device-1"); expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audiooutput", "device-1"); }); it("reports a failed switch instead of rejecting into the void", async () => { // The settings tab calls this as a bare `void` — an unhandled rejection // would leave the user with a selection that silently never applied. const onError = vi.fn(); dm.setOnError(onError); dm.setRoom(mockRoom); mockRoom.switchActiveDevice.mockRejectedValueOnce(new Error("setSinkId unsupported")); await expect(dm.switchOutputDevice("device-1")).resolves.toBeUndefined(); expect(onError).toHaveBeenCalledWith("Failed to switch speaker"); }); }); // ----------------------------------------------------------------------- // Device change detection (hot-swap) // ----------------------------------------------------------------------- describe("handleDeviceChange", () => { it("skips device enumeration when room is null at change time", async () => { dm.setRoom(mockRoom); // Capture the handler const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; // Set room to null before triggering dm.setRoom(null); // Trigger the handler (simulates device change event) handler(); await vi.advanceTimersByTimeAsync(600); // With room null, getLocalDevices should never be called expect(mockGetLocalDevices).not.toHaveBeenCalled(); expect(mockRoom.switchActiveDevice).not.toHaveBeenCalled(); }); it("falls back to default input when saved device is removed", async () => { mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { if (key === "audioInputDevice") return "saved-device-id"; if (key === "audioOutputDevice") return ""; return defaultVal; }); // The saved device is not in the returned list mockGetLocalDevices.mockImplementation((kind: string) => { if (kind === "audioinput") return Promise.resolve([{ deviceId: "other-device" }]); return Promise.resolve([]); }); const onToast = vi.fn(); dm.setRoom(mockRoom); dm.setOnToast(onToast); // Trigger device change const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; handler(); await vi.advanceTimersByTimeAsync(600); expect(mockSavePref).toHaveBeenCalledWith("audioInputDevice", ""); // The removed device's pinned constraint must be reset so the cycle // actually reaches the system default. expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audioinput", "default", false); expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true); expect(onToast).toHaveBeenCalledWith("Audio device disconnected — switched to default"); }); // B1_voice_mic-1: the device-removed fallback must not silently undo a // mute the user (or a moderator, or push-to-talk) applied. it("does not re-enable the mic on device-removed fallback while the user is muted", async () => { mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { if (key === "audioInputDevice") return "saved-device-id"; if (key === "audioOutputDevice") return ""; return defaultVal; }); mockGetLocalDevices.mockImplementation((kind: string) => { if (kind === "audioinput") return Promise.resolve([{ deviceId: "other-device" }]); return Promise.resolve([]); }); mockVoiceState.localMuted = true; dm.setRoom(mockRoom); const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; handler(); await vi.advanceTimersByTimeAsync(600); expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true); }); it("does nothing if saved input device still exists", async () => { mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { if (key === "audioInputDevice") return "device-A"; if (key === "audioOutputDevice") return ""; return defaultVal; }); mockGetLocalDevices.mockImplementation((kind: string) => { if (kind === "audioinput") return Promise.resolve([{ deviceId: "device-A" }]); return Promise.resolve([]); }); dm.setRoom(mockRoom); const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; handler(); await vi.advanceTimersByTimeAsync(600); // Should not reset the saved device expect(mockSavePref).not.toHaveBeenCalledWith("audioInputDevice", ""); }); it("does nothing if no saved device (empty string)", async () => { mockLoadPref.mockImplementation((_key: string, defaultVal: unknown) => defaultVal); mockGetLocalDevices.mockResolvedValue([]); dm.setRoom(mockRoom); const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; handler(); await vi.advanceTimersByTimeAsync(600); expect(mockSavePref).not.toHaveBeenCalledWith("audioInputDevice", ""); }); it("falls back to default output when saved output device is removed", async () => { mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { if (key === "audioInputDevice") return ""; if (key === "audioOutputDevice") return "saved-output-id"; return defaultVal; }); mockGetLocalDevices.mockImplementation((kind: string) => { if (kind === "audioinput") return Promise.resolve([]); if (kind === "audiooutput") return Promise.resolve([{ deviceId: "other-output" }]); return Promise.resolve([]); }); const onToast = vi.fn(); dm.setRoom(mockRoom); dm.setOnToast(onToast); const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; handler(); await vi.advanceTimersByTimeAsync(600); expect(mockSavePref).toHaveBeenCalledWith("audioOutputDevice", ""); expect(onToast).toHaveBeenCalledWith( "Audio output device disconnected — switched to default", ); }); it("calls onError when mic fallback fails", async () => { mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { if (key === "audioInputDevice") return "saved-device-id"; if (key === "audioOutputDevice") return ""; return defaultVal; }); mockGetLocalDevices.mockImplementation((kind: string) => { if (kind === "audioinput") return Promise.resolve([]); return Promise.resolve([]); }); mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValue(new Error("no device")); const onError = vi.fn(); dm.setRoom(mockRoom); dm.setOnError(onError); const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; handler(); await vi.advanceTimersByTimeAsync(600); expect(onError).toHaveBeenCalledWith("No audio input device available"); }); it("debounces rapid device change events", async () => { mockLoadPref.mockImplementation((_key: string, defaultVal: unknown) => defaultVal); mockGetLocalDevices.mockResolvedValue([]); dm.setRoom(mockRoom); const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; // Fire multiple times in rapid succession handler(); handler(); handler(); await vi.advanceTimersByTimeAsync(600); // handleDeviceChange calls getLocalDevices twice (audioinput + audiooutput) // but only ONE handleDeviceChange should run (debounced from 3 events) expect(mockGetLocalDevices).toHaveBeenCalledTimes(2); expect(mockGetLocalDevices).toHaveBeenCalledWith("audioinput"); expect(mockGetLocalDevices).toHaveBeenCalledWith("audiooutput"); }); it("shows toast when pipeline setup fails during fallback", async () => { mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { if (key === "audioInputDevice") return "saved-device-id"; if (key === "audioOutputDevice") return ""; return defaultVal; }); mockGetLocalDevices.mockImplementation((kind: string) => { if (kind === "audioinput") return Promise.resolve([]); // Device removed return Promise.resolve([]); }); const pipeline = { setupAudioPipeline: vi.fn(() => { throw new Error("pipeline error"); }), } as any; const onToast = vi.fn(); dm.setRoom(mockRoom); dm.setAudioPipeline(pipeline); dm.setOnToast(onToast); const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; handler(); await vi.advanceTimersByTimeAsync(600); expect(onToast).toHaveBeenCalledWith("Audio pipeline error after device switch"); }); it("handles enumerate devices failure without crashing or switching devices", async () => { mockGetLocalDevices.mockRejectedValue(new Error("enumerate error")); dm.setRoom(mockRoom); const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; handler(); await vi.advanceTimersByTimeAsync(600); // Enumerate failed, so no device switch should have been attempted expect(mockRoom.switchActiveDevice).not.toHaveBeenCalled(); }); it("ignores a stale enumeration result when the room is swapped mid-await (v096)", async () => { mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => { if (key === "audioInputDevice") return "saved-device-id"; if (key === "audioOutputDevice") return ""; return defaultVal; }); let resolveDevices: ((devices: Array<{ deviceId: string }>) => void) | null = null; mockGetLocalDevices.mockImplementation((kind: string) => { if (kind === "audioinput") { return new Promise((resolve) => { resolveDevices = resolve; }); } return Promise.resolve([]); }); dm.setRoom(mockRoom); const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1]; handler(); // Fire the debounce — handleDeviceChange starts and suspends on the // still-pending getLocalDevices("audioinput") call. await vi.advanceTimersByTimeAsync(500); expect(resolveDevices).not.toBeNull(); // A system-driven reconnect (LiveKit Disconnected -> syncModuleRooms) // swaps in a fresh Room while enumeration is still in flight. const newRoom = { localParticipant: { setMicrophoneEnabled: vi.fn().mockResolvedValue(undefined) }, switchActiveDevice: vi.fn().mockResolvedValue(undefined), } as any; dm.setRoom(newRoom); // The saved device is missing from this (stale) result, which would // normally trigger a fallback. resolveDevices!([{ deviceId: "other-device" }]); await vi.advanceTimersByTimeAsync(0); // The stale attempt must not act on either the old room (it isn't // "current" anymore) or the new one (this attempt was never for it). expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); expect(newRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); }); }); });