Files
OwnCord/Client/tauri-client/tests/unit/device-manager.test.ts
T
jevb 5c616d53fe test: fix 10 test quality bugs (BUG-058–067) and resolve 115 TS type errors
BUG-058: Unblock prod-build E2E — created tsconfig.build.json excluding
tests from the production build. Added typecheck/typecheck:build scripts.

BUG-059: Harden native E2E — CDP timeout 30→60s with exponential backoff,
config timeouts doubled (test 120s, action 30s, nav 45s, expect 15s).

BUG-060: Add 25 Rust unit tests across commands.rs, ws_proxy.rs,
livekit_proxy.rs, credentials.rs (was zero behavioral tests).

BUG-061/067: Add behavioral assertions to server coverage_boost_test.go —
GracefulStop verifies client count, channel_focus verifies no error sent.

BUG-062: Upgrade low-signal test assertions in livekit-session,
device-manager, channel-controller (no-op checks → state checks).

BUG-063: Consolidate native E2E skip gates into beforeEach blocks
(voice-controls 7→1 skip, channel-navigation 4→1 skip).

BUG-064: Add 9 integration tests for channel CRUD, member lifecycle,
DM open/close, and presence events.

BUG-065: Replace 3 fixed sleeps with condition-based waits in E2E specs.

BUG-066: Verified toast/audio tests already cleaned in prior session.

TypeScript: Fix 115 type errors across 21 test files — add non-null
assertions for strict indexing, fix mock typing (vi.fn<any>()), add
missing fields (color, version, deleted) to test fixtures.
2026-03-30 16:35:02 +02:00

428 lines
16 KiB
TypeScript

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),
}),
}));
import { DeviceManager } from "../../src/lib/deviceManager";
describe("DeviceManager", () => {
let dm: DeviceManager;
let mockRoom: any;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
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("accepts null to clear the pipeline", () => {
const pipeline = { setupAudioPipeline: vi.fn(), applyNoiseSuppressor: vi.fn(), removeNoiseSuppressor: vi.fn() } as any;
dm.setAudioPipeline(pipeline);
dm.setAudioPipeline(null);
// After clearing, pipeline methods should not be called on device switch
});
it("stores a pipeline object for use during device switches", () => {
const pipeline = { setupAudioPipeline: vi.fn(), applyNoiseSuppressor: vi.fn(), removeNoiseSuppressor: vi.fn() } as any;
dm.setAudioPipeline(pipeline);
// Pipeline is stored internally — integration with switchInputDevice tested below
});
});
describe("setOnError", () => {
it("accepts null to clear the error callback", () => {
dm.setOnError(null);
// No error callback registered — errors during device switch are silently handled
});
});
describe("setOnToast", () => {
it("accepts null to clear the toast callback", () => {
dm.setOnToast(null);
// No toast callback — device switch messages are suppressed
});
});
// -----------------------------------------------------------------------
// 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("");
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
expect(mockRoom.localParticipant.setMicrophoneEnabled).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");
});
});
// -----------------------------------------------------------------------
// Device change detection (hot-swap)
// -----------------------------------------------------------------------
describe("handleDeviceChange", () => {
it("does nothing if room is null when change fires", 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);
// No crash expected
});
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", "");
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
expect(onToast).toHaveBeenCalledWith("Audio device disconnected — switched to default");
});
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();
});
});
});