Files
OwnCord/Client/tauri-client/tests/unit/voice-audio-tab.test.ts
T
J3vbandClaude c3837fa32c fix(client): batch of 15 client correctness fixes (#1367)
* fix(client): 1 defect(s) (OC-0078)

* fix(client): 1 defect(s) (OC-0147)

* fix(client): 1 defect(s) (OC-0141)

* fix(voice): 1 defect(s) (OC-0125)

* fix(client): 1 defect(s) (OC-0138)

* fix(client): 1 defect(s) (OC-0136)

* fix(client): 1 defect(s) (OC-0121)

* fix(voice): 1 defect(s) (OC-0132)

* fix(client): 1 defect(s) (OC-0057)

* fix(client): 1 defect(s) (OC-0122)

* fix(client): 1 defect(s) (OC-0130)

* fix(client): 1 defect(s) (OC-0060)

* fix(client): 1 defect(s) (OC-0123)

* fix(client): 1 defect(s) (OC-0124)

* fix(ws): 1 defect(s) (OC-0056)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-14 08:11:16 +02:00

930 lines
31 KiB
TypeScript

import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const mockSwitchInputDevice = vi.fn().mockResolvedValue(undefined);
const mockSwitchOutputDevice = vi.fn().mockResolvedValue(undefined);
const mockSetVoiceSensitivity = vi.fn();
const mockSetInputVolume = vi.fn();
const mockSetOutputVolume = vi.fn();
const mockReapplyAudioProcessing = vi.fn().mockResolvedValue(undefined);
vi.mock("@lib/livekitSession", () => ({
switchInputDevice: (...args: unknown[]) => mockSwitchInputDevice(...args),
switchOutputDevice: (...args: unknown[]) => mockSwitchOutputDevice(...args),
setVoiceSensitivity: (...args: unknown[]) => mockSetVoiceSensitivity(...args),
setInputVolume: (...args: unknown[]) => mockSetInputVolume(...args),
setOutputVolume: (...args: unknown[]) => mockSetOutputVolume(...args),
reapplyAudioProcessing: (...args: unknown[]) => mockReapplyAudioProcessing(...args),
}));
import { createVoiceAudioTab } from "@components/settings/VoiceAudioTab";
describe("VoiceAudioTab camera preview", () => {
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = "";
localStorage.setItem("owncord:settings:videoInputDevice", '"camera-1"');
vi.stubGlobal(
"AudioContext",
class {
createAnalyser() {
return {
fftSize: 0,
smoothingTimeConstant: 0,
frequencyBinCount: 32,
getByteFrequencyData: vi.fn(),
};
}
createMediaStreamSource() {
return { connect: vi.fn() };
}
close() {
return Promise.resolve();
}
},
);
});
it("does not restore a stale camera stream after the tab is aborted", async () => {
let resolveVideo: ((stream: MediaStream) => void) | null = null;
const stopVideoTrack = vi.fn();
const videoStream = {
getTracks: () => [{ stop: stopVideoTrack }],
} as unknown as MediaStream;
const audioStream = {
getTracks: () => [],
} as unknown as MediaStream;
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi
.fn()
.mockResolvedValue([{ kind: "videoinput", deviceId: "camera-1", label: "Camera 1" }]),
getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
if (constraints.video && constraints.audio === false) {
return new Promise<MediaStream>((resolve) => {
resolveVideo = resolve;
});
}
return Promise.resolve(audioStream);
}),
},
});
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const element = tab.build();
document.body.appendChild(element);
const preview = element.querySelector("video") as HTMLVideoElement;
ac.abort();
(resolveVideo as ((stream: MediaStream) => void) | null)?.(videoStream);
await vi.waitFor(() => {
expect(stopVideoTrack).toHaveBeenCalledTimes(1);
expect(preview.srcObject).toBeNull();
});
});
it("does not restore a stale camera stream after the tab is cleaned up", async () => {
let resolveVideo: ((stream: MediaStream) => void) | null = null;
const stopVideoTrack = vi.fn();
const videoStream = {
getTracks: () => [{ stop: stopVideoTrack }],
} as unknown as MediaStream;
const audioStream = {
getTracks: () => [],
} as unknown as MediaStream;
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi
.fn()
.mockResolvedValue([{ kind: "videoinput", deviceId: "camera-1", label: "Camera 1" }]),
getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
if (constraints.video && constraints.audio === false) {
return new Promise<MediaStream>((resolve) => {
resolveVideo = resolve;
});
}
return Promise.resolve(audioStream);
}),
},
});
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const element = tab.build();
document.body.appendChild(element);
const preview = element.querySelector("video") as HTMLVideoElement;
tab.cleanup();
(resolveVideo as ((stream: MediaStream) => void) | null)?.(videoStream);
await vi.waitFor(() => {
expect(stopVideoTrack).toHaveBeenCalledTimes(1);
expect(preview.srcObject).toBeNull();
});
});
});
describe("VoiceAudioTab mic meter", () => {
let resolveAudio: ((stream: MediaStream) => void) | null = null;
const stopAudioTrack = vi.fn();
const audioStream = {
getTracks: () => [{ stop: stopAudioTrack }],
} as unknown as MediaStream;
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = "";
resolveAudio = null;
stopAudioTrack.mockClear();
vi.stubGlobal(
"AudioContext",
class {
createAnalyser() {
return {
fftSize: 0,
smoothingTimeConstant: 0,
frequencyBinCount: 32,
getByteFrequencyData: vi.fn(),
};
}
createMediaStreamSource() {
return { connect: vi.fn() };
}
close() {
return Promise.resolve();
}
},
);
// No videoInputDevice pref, so only the mic meter requests media.
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi.fn().mockResolvedValue([]),
getUserMedia: vi.fn().mockImplementation(
() =>
new Promise<MediaStream>((resolve) => {
resolveAudio = resolve;
}),
),
},
});
});
it("stops the mic stream when cleanup ran while getUserMedia was pending", async () => {
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
document.body.appendChild(tab.build());
// SettingsOverlay.hide() calls cleanup() without aborting; a getUserMedia
// resolving afterwards must not open the mic — nobody is left to stop it.
tab.cleanup();
(resolveAudio as ((stream: MediaStream) => void) | null)?.(audioStream);
await vi.waitFor(() => {
expect(stopAudioTrack).toHaveBeenCalledTimes(1);
});
});
it("stops the mic stream when the tab was aborted while getUserMedia was pending", async () => {
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
document.body.appendChild(tab.build());
ac.abort();
(resolveAudio as ((stream: MediaStream) => void) | null)?.(audioStream);
await vi.waitFor(() => {
expect(stopAudioTrack).toHaveBeenCalledTimes(1);
});
});
});
// ---------------------------------------------------------------------------
// UI structure and interaction tests
// ---------------------------------------------------------------------------
describe("VoiceAudioTab UI structure", () => {
/** Fires the `devicechange` listeners registered on the stubbed MediaDevices. */
let emitDeviceChange: () => void = () => {};
function stubNavigator(devices: Array<{ kind: string; deviceId: string; label: string }> = []): {
setDevices(next: Array<{ kind: string; deviceId: string; label: string }>): void;
} {
const audioStream = {
getTracks: () => [{ stop: vi.fn() }],
} as unknown as MediaStream;
let current = devices;
const listeners = new Set<() => void>();
emitDeviceChange = () => {
for (const l of listeners) l();
};
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi.fn().mockImplementation(() => Promise.resolve(current)),
getUserMedia: vi.fn().mockResolvedValue(audioStream),
addEventListener: (type: string, handler: () => void) => {
if (type === "devicechange") listeners.add(handler);
},
removeEventListener: (_type: string, handler: () => void) => {
listeners.delete(handler);
},
},
});
return {
setDevices(next) {
current = next;
},
};
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
document.body.innerHTML = "";
vi.stubGlobal(
"AudioContext",
class {
createAnalyser() {
return {
fftSize: 0,
smoothingTimeConstant: 0,
frequencyBinCount: 32,
getByteFrequencyData: vi.fn(),
};
}
createMediaStreamSource() {
return { connect: vi.fn() };
}
close() {
return Promise.resolve();
}
},
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("builds a section element with settings-pane class", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
expect(el.tagName).toBe("DIV");
expect(el.classList.contains("settings-pane")).toBe(true);
ac.abort();
});
it("contains input device, output device, and video device selects", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const selects = el.querySelectorAll("select");
// Input, output, stream quality, screen share fps, video = 5 selects
expect(selects.length).toBe(5);
ac.abort();
});
it("contains input volume and output volume sliders", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const sliders = el.querySelectorAll('input[type="range"]');
expect(sliders.length).toBe(2); // input volume + output volume
ac.abort();
});
it("input volume slider calls setInputVolume on change", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const sliders = el.querySelectorAll('input[type="range"]') as NodeListOf<HTMLInputElement>;
const inputSlider = sliders[0]!;
inputSlider.value = "75";
inputSlider.dispatchEvent(new Event("input"));
expect(mockSetInputVolume).toHaveBeenCalledWith(75);
ac.abort();
});
it("output volume slider calls setOutputVolume on change", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const sliders = el.querySelectorAll('input[type="range"]') as NodeListOf<HTMLInputElement>;
const outputSlider = sliders[1]!;
outputSlider.value = "80";
outputSlider.dispatchEvent(new Event("input"));
expect(mockSetOutputVolume).toHaveBeenCalledWith(80);
ac.abort();
});
it("restores saved input volume from preferences", () => {
localStorage.setItem("owncord:settings:inputVolume", "75");
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const sliders = el.querySelectorAll('input[type="range"]') as NodeListOf<HTMLInputElement>;
expect(sliders[0]!.value).toBe("75");
ac.abort();
});
it("restores saved output volume from preferences", () => {
localStorage.setItem("owncord:settings:outputVolume", "60");
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const sliders = el.querySelectorAll('input[type="range"]') as NodeListOf<HTMLInputElement>;
expect(sliders[1]!.value).toBe("60");
ac.abort();
});
it("refreshes the device lists when hardware is plugged or unplugged", async () => {
const nav = stubNavigator([
{ kind: "audioinput", deviceId: "mic-1", label: "Mic 1" },
{ kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" },
]);
localStorage.setItem("owncord:settings:audioInputDevice", JSON.stringify("mic-1"));
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const inputSelect = el.querySelectorAll("select")[0]!;
await vi.waitFor(() => {
expect(inputSelect.querySelectorAll("option").length).toBe(2);
expect(inputSelect.value).toBe("mic-1");
});
// Unplug mic-1, plug in mic-2. A stale list would keep offering a device
// that no longer exists.
nav.setDevices([
{ kind: "audioinput", deviceId: "mic-2", label: "Mic 2" },
{ kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" },
]);
emitDeviceChange();
await vi.waitFor(() => {
const values = Array.from(inputSelect.querySelectorAll("option")).map((o) => o.value);
expect(values).toEqual(["", "mic-2"]);
});
// The saved device is gone — fall back to Default rather than a dead entry.
expect(inputSelect.value).toBe("");
ac.abort();
});
it("stops refreshing device lists once the tab is aborted", async () => {
const nav = stubNavigator([{ kind: "audioinput", deviceId: "mic-1", label: "Mic 1" }]);
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const inputSelect = el.querySelectorAll("select")[0]!;
await vi.waitFor(() => {
expect(inputSelect.querySelectorAll("option").length).toBe(2);
});
ac.abort();
nav.setDevices([
{ kind: "audioinput", deviceId: "mic-1", label: "Mic 1" },
{ kind: "audioinput", deviceId: "mic-2", label: "Mic 2" },
]);
emitDeviceChange();
await new Promise((r) => setTimeout(r, 10));
expect(inputSelect.querySelectorAll("option").length).toBe(2);
});
it("populates device lists from enumerateDevices", async () => {
stubNavigator([
{ kind: "audioinput", deviceId: "mic-1", label: "Mic 1" },
{ kind: "audioinput", deviceId: "mic-2", label: "Mic 2" },
{ kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" },
{ kind: "videoinput", deviceId: "cam-1", label: "Cam 1" },
]);
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
// Wait for async device enumeration
await vi.waitFor(() => {
const selects = el.querySelectorAll("select");
const inputSelect = selects[0];
// Default + 2 mics = 3 options
expect(inputSelect!.querySelectorAll("option").length).toBe(3);
});
const selects = el.querySelectorAll("select");
const outputSelect = selects[1]!;
// Default + 1 speaker = 2 options
expect(outputSelect.querySelectorAll("option").length).toBe(2);
ac.abort();
});
it("input device change calls switchInputDevice and saves pref", async () => {
stubNavigator([{ kind: "audioinput", deviceId: "mic-1", label: "Mic 1" }]);
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
await vi.waitFor(() => {
const selects = el.querySelectorAll("select");
expect(selects[0]!.querySelectorAll("option").length).toBeGreaterThan(1);
});
const inputSelect = el.querySelectorAll("select")[0] as HTMLSelectElement;
inputSelect.value = "mic-1";
inputSelect.dispatchEvent(new Event("change"));
expect(mockSwitchInputDevice).toHaveBeenCalledWith("mic-1");
ac.abort();
});
it("output device change calls switchOutputDevice and saves pref", async () => {
stubNavigator([{ kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" }]);
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
await vi.waitFor(() => {
const selects = el.querySelectorAll("select");
expect(selects[1]!.querySelectorAll("option").length).toBeGreaterThan(1);
});
const outputSelect = el.querySelectorAll("select")[1] as HTMLSelectElement;
outputSelect.value = "spk-1";
outputSelect.dispatchEvent(new Event("change"));
expect(mockSwitchOutputDevice).toHaveBeenCalledWith("spk-1");
ac.abort();
});
it("stream quality select saves to preferences on change", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
// Stream quality is the 3rd select (index 2)
const qualitySelect = el.querySelectorAll("select")[2] as HTMLSelectElement;
qualitySelect.value = "low";
qualitySelect.dispatchEvent(new Event("change"));
const saved = localStorage.getItem("owncord:settings:streamQuality");
expect(saved).toBe('"low"');
ac.abort();
});
it("screen share fps select persists the preference as a number", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
// Screen share FPS is the 4th select (index 3)
const fpsSelect = el.querySelectorAll("select")[3] as HTMLSelectElement;
expect(fpsSelect.value).toBe("30"); // default
fpsSelect.value = "60";
fpsSelect.dispatchEvent(new Event("change"));
const saved = localStorage.getItem("owncord:settings:screenShareFps");
expect(saved).toBe("60");
ac.abort();
});
it("contains audio processing toggles", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const settingRows = el.querySelectorAll(".setting-row");
// 4 toggles: echo cancellation, noise suppression, auto gain control, enhanced NS
expect(settingRows.length).toBe(4);
ac.abort();
});
it("audio toggle calls reapplyAudioProcessing when changed", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
// Toggles are divs with class "toggle" (not buttons)
const toggleDiv = el.querySelector(".setting-row .toggle") as HTMLElement;
expect(toggleDiv).not.toBeNull();
toggleDiv.click();
expect(mockReapplyAudioProcessing).toHaveBeenCalled();
ac.abort();
});
it("handles enumerateDevices failure gracefully", async () => {
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi.fn().mockRejectedValue(new Error("permission denied")),
getUserMedia: vi.fn().mockRejectedValue(new Error("permission denied")),
},
});
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
await vi.waitFor(() => {
const inputSelect = el.querySelectorAll("select")[0]!;
const options = inputSelect.querySelectorAll("option");
// Should have default + error option
const texts = Array.from(options).map((o) => o.textContent);
expect(texts.some((t) => t?.includes("Could not enumerate"))).toBe(true);
});
ac.abort();
});
it("does not start camera preview when no video device is saved", () => {
stubNavigator();
// Do NOT set videoInputDevice in localStorage
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const preview = el.querySelector("video") as HTMLVideoElement;
// srcObject is undefined in JSDOM when never assigned (not null)
expect(preview.srcObject).toBeFalsy();
ac.abort();
});
it("starts camera preview when a video device is saved", async () => {
localStorage.setItem("owncord:settings:videoInputDevice", '"cam-1"');
const cameraStream = {
getTracks: () => [{ stop: vi.fn() }],
} as unknown as MediaStream;
const audioStream = {
getTracks: () => [{ stop: vi.fn() }],
} as unknown as MediaStream;
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi
.fn()
.mockResolvedValue([{ kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }]),
getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
if (constraints.video && constraints.audio === false) {
return Promise.resolve(cameraStream);
}
return Promise.resolve(audioStream);
}),
},
});
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
await vi.waitFor(() => {
const preview = el.querySelector("video") as HTMLVideoElement;
expect(preview.srcObject).toBe(cameraStream);
});
ac.abort();
});
it("video select change starts camera preview", async () => {
const cameraStream = {
getTracks: () => [{ stop: vi.fn() }],
} as unknown as MediaStream;
const audioStream = {
getTracks: () => [{ stop: vi.fn() }],
} as unknown as MediaStream;
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi
.fn()
.mockResolvedValue([{ kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }]),
getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
if (constraints.video && constraints.audio === false) {
return Promise.resolve(cameraStream);
}
return Promise.resolve(audioStream);
}),
},
});
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
// Wait for devices to load
await vi.waitFor(() => {
const videoSelect = el.querySelectorAll("select")[4] as HTMLSelectElement;
expect(videoSelect.querySelectorAll("option").length).toBeGreaterThan(1);
});
const videoSelect = el.querySelectorAll("select")[4] as HTMLSelectElement;
videoSelect.value = "cam-1";
videoSelect.dispatchEvent(new Event("change"));
await vi.waitFor(() => {
const preview = el.querySelector("video") as HTMLVideoElement;
expect(preview.srcObject).toBe(cameraStream);
});
ac.abort();
});
it("camera preview shows error when getUserMedia fails", async () => {
localStorage.setItem("owncord:settings:videoInputDevice", '"cam-1"');
const audioStream = {
getTracks: () => [{ stop: vi.fn() }],
} as unknown as MediaStream;
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi
.fn()
.mockResolvedValue([{ kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }]),
getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
if (constraints.video && constraints.audio === false) {
return Promise.reject(new Error("Camera access denied"));
}
return Promise.resolve(audioStream);
}),
},
});
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
await vi.waitFor(() => {
const errorEl = el.querySelector(".setting-desc");
expect(errorEl).not.toBeNull();
expect(errorEl!.textContent).toBe("Camera access denied");
});
ac.abort();
});
it("sensitivity threshold handle is positioned based on saved sensitivity", () => {
localStorage.setItem("owncord:settings:voiceSensitivity", "75");
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const threshold = el.querySelector(".mic-meter-threshold") as HTMLElement;
expect(threshold).not.toBeNull();
// Sensitivity 75 -> 100 - 75 = 25%
expect(threshold.style.left).toBe("25%");
ac.abort();
});
it("clicking the meter bar calls setVoiceSensitivity", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const meterBar = el.querySelector(".mic-meter-bar") as HTMLElement;
expect(meterBar).not.toBeNull();
// Simulate click at middle of bar — getBoundingClientRect returns 0,0
// so clientX=0, ratio=0, sensitivity=100 (1-0)*100
meterBar.dispatchEvent(new MouseEvent("click", { clientX: 0 }));
expect(mockSetVoiceSensitivity).toHaveBeenCalled();
ac.abort();
});
it("stops applying sensitivity after a pointercancel interrupts the drag (v097)", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const threshold = el.querySelector(".mic-meter-threshold") as HTMLElement;
expect(threshold).not.toBeNull();
// jsdom doesn't implement the Pointer Capture API — stub it as a no-op,
// same as a real browser call the handler makes unconditionally.
(threshold as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture =
vi.fn();
threshold.dispatchEvent(new PointerEvent("pointerdown", { pointerId: 1, clientX: 0 }));
mockSetVoiceSensitivity.mockClear();
threshold.dispatchEvent(new PointerEvent("pointermove", { pointerId: 1, clientX: 10 }));
expect(mockSetVoiceSensitivity).toHaveBeenCalledTimes(1);
// The OS claims the touch gesture as a pan and fires pointercancel
// instead of pointerup.
threshold.dispatchEvent(new PointerEvent("pointercancel", { pointerId: 1 }));
mockSetVoiceSensitivity.mockClear();
threshold.dispatchEvent(new PointerEvent("pointermove", { pointerId: 1, clientX: 50 }));
// Without a pointercancel listener, onMove stays attached and this
// would call setVoiceSensitivity again with no button held.
expect(mockSetVoiceSensitivity).not.toHaveBeenCalled();
ac.abort();
});
it("mic level monitoring handles getUserMedia failure gracefully", async () => {
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi.fn().mockResolvedValue([]),
getUserMedia: vi.fn().mockRejectedValue(new Error("mic denied")),
},
});
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
// Should not throw — mic meter stays empty
await new Promise((r) => setTimeout(r, 0));
ac.abort();
});
it("restores saved device selections from localStorage", async () => {
localStorage.setItem("owncord:settings:audioInputDevice", '"mic-2"');
localStorage.setItem("owncord:settings:audioOutputDevice", '"spk-2"');
stubNavigator([
{ kind: "audioinput", deviceId: "mic-2", label: "Mic 2" },
{ kind: "audiooutput", deviceId: "spk-2", label: "Speaker 2" },
]);
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
await vi.waitFor(() => {
const selects = el.querySelectorAll("select");
expect((selects[0] as HTMLSelectElement).value).toBe("mic-2");
expect((selects[1] as HTMLSelectElement).value).toBe("spk-2");
});
ac.abort();
});
it("uses device ID fallback label for devices without labels", async () => {
stubNavigator([{ kind: "audioinput", deviceId: "abcdef12", label: "" }]);
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
await vi.waitFor(() => {
const inputSelect = el.querySelectorAll("select")[0]!;
const options = inputSelect.querySelectorAll("option");
expect(options.length).toBe(2); // default + 1 device
expect(options[1]!.textContent).toContain("Microphone");
});
ac.abort();
});
it("cleanup stops mic and camera streams", async () => {
// A saved video device is required for the camera preview to start at all.
localStorage.setItem("owncord:settings:videoInputDevice", '"cam-1"');
const stopMicTrack = vi.fn();
const stopCamTrack = vi.fn();
const micStream = { getTracks: () => [{ stop: stopMicTrack }] } as unknown as MediaStream;
const camStream = { getTracks: () => [{ stop: stopCamTrack }] } as unknown as MediaStream;
vi.stubGlobal("navigator", {
mediaDevices: {
enumerateDevices: vi.fn().mockResolvedValue([]),
getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
if (constraints.video && constraints.audio === false) {
return Promise.resolve(camStream);
}
return Promise.resolve(micStream);
}),
},
});
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const preview = el.querySelector("video") as HTMLVideoElement;
// Wait for both the mic-monitoring and camera-preview getUserMedia calls to
// resolve and register their streams before triggering cleanup.
await vi.waitFor(() => {
expect(preview.srcObject).toBe(camStream);
});
tab.cleanup();
expect(stopMicTrack).toHaveBeenCalled();
expect(stopCamTrack).toHaveBeenCalled();
ac.abort();
});
it("restores saved stream quality selection", () => {
localStorage.setItem("owncord:settings:streamQuality", '"low"');
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
const qualitySelect = el.querySelectorAll("select")[2] as HTMLSelectElement;
expect(qualitySelect.value).toBe("low");
ac.abort();
});
it("rebuild cleans up previous mic/camera before building again", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
tab.build();
// Calling build again should not throw
expect(() => tab.build()).not.toThrow();
ac.abort();
});
it("does not register a new permanent abort listener on every rebuild (OC-0125)", () => {
// Simulates the settings overlay staying open for a session while the
// user re-opens the Voice & Audio tab (e.g. hide()/show() cycles):
// SettingsOverlay's single AbortController lives for the whole session,
// and build() is called again each time the tab is (re)rendered.
stubNavigator();
const ac = new AbortController();
const addEventListenerSpy = vi.spyOn(ac.signal, "addEventListener");
const abortListenerCount = (): number =>
addEventListenerSpy.mock.calls.filter(([type]) => type === "abort").length;
const tab = createVoiceAudioTab(ac.signal);
const afterCreate = abortListenerCount();
tab.build();
tab.build();
tab.build();
// Rebuilding the tab three times must not add three more permanent
// "abort" listeners to the overlay-lifetime signal — only the single
// listener the factory registers once at creation should exist.
expect(abortListenerCount()).toBe(afterCreate);
ac.abort();
});
});