mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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.
428 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|