mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116) Route the tray Status submenu through saveUserStatus() (mapping the legacy "offline" to "invisible") so notifications, autoIdle, and reconnect presence restore all agree with the tray's choice; build the connected overlay from the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the TOTP overlay open across a rejected verify (totpPending latch) and retain the partial token for the retry instead of clearing it in finally. Hand-applied combined cluster preserved from the previous fix run's overlap-guard block (both clusters edit main.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 2 defect(s) (OC-0010, OC-0011) * fix(ws): 1 defect(s) (OC-0050) * fix(db): 1 defect(s) (OC-0052) * fix(client): 1 defect(s) (OC-0054) * fix(client): 1 defect(s) (OC-0059) * fix(auth): 1 defect(s) (OC-0061) * fix(ws): 1 defect(s) (OC-0062) * fix(client): 1 defect(s) (OC-0064) * fix(service): 1 defect(s) (OC-0070) * fix(ws): 1 defect(s) (OC-0073) * fix(service): 2 defect(s) (OC-0075, OC-0120) * fix(admin): 1 defect(s) (OC-0076) * fix(voice): 1 defect(s) (OC-0084) * fix(client): 2 defect(s) (OC-0085, OC-0094) Scope collapsed-category persistence to the connected host instead of the server display name, and stop the DM back button from jumping to the first text channel when DM mode was entered without recording channelBeforeDm. * fix(service): 1 defect(s) (OC-0087) * fix(client): 1 defect(s) (OC-0089) * fix(ws): 1 defect(s) (OC-0091) * fix(api): 1 defect(s) (OC-0093) * fix(identity): 1 defect(s) (OC-0118) * fix(dm): 1 defect(s) (OC-0119) * fix(voice): 1 defect(s) (OC-0135) * fix(api): 1 defect(s) (OC-0137) * fix(client): 1 defect(s) (OC-0142) * fix(client): 1 defect(s) (OC-0144) * fix(admin): 1 defect(s) (OC-0145) * fix(updater): 1 defect(s) (OC-0146) * fix(client): 1 defect(s) (OC-0150) * fix(mentions): 1 defect(s) (OC-0131) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
734 lines
26 KiB
TypeScript
734 lines
26 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
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(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("livekit-client", () => ({
|
|
Track: {
|
|
Source: {
|
|
Microphone: "microphone",
|
|
Camera: "camera",
|
|
ScreenShare: "screenShare",
|
|
ScreenShareAudio: "screenShareAudio",
|
|
},
|
|
},
|
|
}));
|
|
|
|
const mockVoiceStoreState = {
|
|
localDeafened: false,
|
|
currentChannelId: null as number | null,
|
|
};
|
|
|
|
vi.mock("@stores/voice.store", () => ({
|
|
voiceStore: {
|
|
getState: () => mockVoiceStoreState,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@lib/livekitSession", () => ({
|
|
parseUserId: (identity: string) => {
|
|
const match = identity.match(/^user-(\d+)$/);
|
|
if (match !== null && match[1] !== undefined) return parseInt(match[1], 10);
|
|
return 0;
|
|
},
|
|
}));
|
|
|
|
import { AudioElements, setAudioVolumeHost } from "../../src/lib/audioElements";
|
|
|
|
function createMockTrack(kind: string, sid: string) {
|
|
const audioEl = document.createElement("audio");
|
|
// The track passed to handleTrackSubscribedAudio is a RemoteTrack directly
|
|
// (not wrapped in a publication), so detach/attach are on the top-level object
|
|
const track = {
|
|
kind,
|
|
sid,
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
return { track, audioEl };
|
|
}
|
|
|
|
describe("AudioElements", () => {
|
|
let elements: AudioElements;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockVoiceStoreState.localDeafened = false;
|
|
mockVoiceStoreState.currentChannelId = null;
|
|
elements = new AudioElements();
|
|
});
|
|
|
|
describe("initial state", () => {
|
|
it("has default output volume multiplier of 1.0", () => {
|
|
expect(elements.getOutputVolumeMultiplier()).toBe(1.0);
|
|
});
|
|
|
|
it("returns effective volume with default multiplier", () => {
|
|
expect(elements.getEffectiveVolume(1)).toBe(1.0);
|
|
});
|
|
});
|
|
|
|
describe("setRoom", () => {
|
|
it("accepts null without throwing", () => {
|
|
expect(() => elements.setRoom(null)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("handleTrackSubscribedAudio — mic", () => {
|
|
it("attaches mic audio element to DOM", () => {
|
|
const { track, audioEl } = createMockTrack("audio", "track-1");
|
|
const publication = { source: "microphone" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.attach).toHaveBeenCalled();
|
|
expect(audioEl.style.display).toBe("none");
|
|
expect(participant.setVolume).toHaveBeenCalledWith(1.0);
|
|
});
|
|
});
|
|
|
|
describe("handleTrackSubscribedAudio — screenshare", () => {
|
|
it("attaches screenshare audio element to DOM", () => {
|
|
const { track, audioEl } = createMockTrack("audio", "track-ss-1");
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.attach).toHaveBeenCalled();
|
|
expect(audioEl.volume).toBe(1);
|
|
});
|
|
|
|
it("inherits muted state from previous tracks", () => {
|
|
elements.muteScreenshareAudio(42, true);
|
|
|
|
const { track, audioEl } = createMockTrack("audio", "track-ss-2");
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(audioEl.muted).toBe(true);
|
|
});
|
|
|
|
it("does not leak the previously-attached element into the tracking set on a fast re-subscribe (OC-0135)", () => {
|
|
// LiveKit can fire TrackSubscribed for an already-attached screenshare-
|
|
// audio track before the old TrackUnsubscribed lands (fast reconnect).
|
|
// The same underlying track's detach() then returns the element from
|
|
// the prior attach(), which the handler removes from the DOM but must
|
|
// also drop from screenshareAudioElements — otherwise it lives on in
|
|
// the Set forever.
|
|
let lastEl: HTMLAudioElement | null = null;
|
|
const track = {
|
|
kind: "audio",
|
|
sid: "track-ss-resub",
|
|
attach: vi.fn(() => {
|
|
const el = document.createElement("audio");
|
|
lastEl = el;
|
|
return el;
|
|
}),
|
|
detach: vi.fn(() => (lastEl === null ? [] : [lastEl])),
|
|
};
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
const firstEl = lastEl as unknown as HTMLAudioElement;
|
|
|
|
// Re-fire subscribe for the same track before any unsubscribe arrives.
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
const secondEl = lastEl as unknown as HTMLAudioElement;
|
|
|
|
const trackedEls = (elements as any).screenshareAudioElements.get(
|
|
42,
|
|
) as Set<HTMLAudioElement>;
|
|
expect(trackedEls.has(firstEl)).toBe(false);
|
|
expect(trackedEls.has(secondEl)).toBe(true);
|
|
expect(trackedEls.size).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("handleTrackUnsubscribedAudio", () => {
|
|
it("removes mic audio element from tracking", () => {
|
|
const { track } = createMockTrack("audio", "track-1");
|
|
const publication = { source: "microphone" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
// Subscribe first
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
// Then unsubscribe
|
|
elements.handleTrackUnsubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.detach).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("setUserVolume", () => {
|
|
it("saves clamped volume to preferences", () => {
|
|
elements.setUserVolume(42, 150);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 150);
|
|
});
|
|
|
|
it("clamps to 0-200 range", () => {
|
|
elements.setUserVolume(42, -10);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 0);
|
|
|
|
elements.setUserVolume(42, 250);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 200);
|
|
});
|
|
|
|
it("applies volume to matching remote participant", () => {
|
|
const mockParticipant = {
|
|
identity: "user-42",
|
|
setVolume: vi.fn(),
|
|
};
|
|
const mockRoom = {
|
|
remoteParticipants: new Map([["user-42", mockParticipant]]),
|
|
} as any;
|
|
elements.setRoom(mockRoom);
|
|
|
|
elements.setUserVolume(42, 80);
|
|
|
|
expect(mockParticipant.setVolume).toHaveBeenCalledWith(0.8);
|
|
});
|
|
});
|
|
|
|
describe("getUserVolume", () => {
|
|
it("returns default volume of 100", () => {
|
|
expect(elements.getUserVolume(42)).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe("per-user volume — host scoping (B3-6)", () => {
|
|
afterEach(() => {
|
|
// currentHost is module-level state that outlives a single test.
|
|
setAudioVolumeHost(null);
|
|
});
|
|
|
|
it("falls back to the legacy unscoped key when no host is set", () => {
|
|
elements.setUserVolume(42, 150);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 150);
|
|
});
|
|
|
|
it("scopes the saved-volume key to the current host", () => {
|
|
setAudioVolumeHost("a.example.com");
|
|
elements.setUserVolume(42, 150);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42:a.example.com", 150);
|
|
});
|
|
|
|
it("reads the volume back under the scoped key, not the unscoped one", () => {
|
|
setAudioVolumeHost("a.example.com");
|
|
elements.getUserVolume(42);
|
|
expect(mockLoadPref).toHaveBeenCalledWith("userVolume_42:a.example.com", 100);
|
|
});
|
|
|
|
it("does not leak a volume set on one host onto another", () => {
|
|
// Regression for B3-6: a mute (volume 0) set for user 7 on server A
|
|
// must not silence user 7 on server B.
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "userVolume_7:a.example.com") return 0;
|
|
return defaultVal;
|
|
});
|
|
|
|
setAudioVolumeHost("a.example.com");
|
|
expect(elements.getUserVolume(7)).toBe(0);
|
|
|
|
setAudioVolumeHost("b.example.com");
|
|
expect(elements.getUserVolume(7)).toBe(100);
|
|
});
|
|
|
|
it("migrates a pre-scoping legacy volume through to the scoped key without leaking to a different host", () => {
|
|
// A save made before host-scoping existed lives at the bare
|
|
// `userVolume_7` key. It must still apply once a host is set, and the
|
|
// migration must write under THAT host's scoped key specifically —
|
|
// not clobber a different host's own explicit choice.
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "userVolume_7") return 30;
|
|
if (key === "userVolume_7:b.example.com") return 80;
|
|
return defaultVal;
|
|
});
|
|
|
|
setAudioVolumeHost("a.example.com");
|
|
expect(elements.getUserVolume(7)).toBe(30);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_7:a.example.com", 30);
|
|
|
|
setAudioVolumeHost("b.example.com");
|
|
expect(elements.getUserVolume(7)).toBe(80);
|
|
});
|
|
});
|
|
|
|
describe("setOutputVolume", () => {
|
|
it("saves clamped volume to preferences", () => {
|
|
elements.setOutputVolume(80);
|
|
expect(mockSavePref).toHaveBeenCalledWith("outputVolume", 80);
|
|
});
|
|
|
|
it("clamps to 0-200 range", () => {
|
|
elements.setOutputVolume(-10);
|
|
expect(mockSavePref).toHaveBeenCalledWith("outputVolume", 0);
|
|
|
|
elements.setOutputVolume(250);
|
|
expect(mockSavePref).toHaveBeenCalledWith("outputVolume", 200);
|
|
});
|
|
|
|
it("updates screenshare audio element volumes", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setOutputVolume(80);
|
|
|
|
expect(audioEl.volume).toBe(0.8);
|
|
});
|
|
|
|
it("clamps screenshare volume to browser max of 1.0", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setOutputVolume(150);
|
|
|
|
expect(audioEl.volume).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("screenshare audio", () => {
|
|
it("setScreenshareAudioVolume does not throw for unknown userId", () => {
|
|
expect(() => elements.setScreenshareAudioVolume(999, 0.5)).not.toThrow();
|
|
});
|
|
|
|
it("setScreenshareAudioVolume sets volume on tracked elements", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setScreenshareAudioVolume(42, 0.7);
|
|
expect(audioEl.volume).toBe(0.7);
|
|
});
|
|
|
|
it("setScreenshareAudioVolume clamps to 0-1 range", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setScreenshareAudioVolume(42, -0.5);
|
|
expect(audioEl.volume).toBe(0);
|
|
|
|
elements.setScreenshareAudioVolume(42, 2.0);
|
|
expect(audioEl.volume).toBe(1);
|
|
});
|
|
|
|
it("getScreenshareAudioVolume defaults to 1 and reflects stored value", () => {
|
|
expect(elements.getScreenshareAudioVolume(42)).toBe(1);
|
|
elements.setScreenshareAudioVolume(42, 0.4);
|
|
expect(elements.getScreenshareAudioVolume(42)).toBe(0.4);
|
|
});
|
|
|
|
it("volume chosen before the track attaches applies on attach", () => {
|
|
elements.setScreenshareAudioVolume(42, 0.25);
|
|
|
|
const { track, audioEl } = createMockTrack("audio", "track-ss-early");
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(audioEl.volume).toBe(0.25);
|
|
});
|
|
|
|
it("setOutputVolume preserves per-user screenshare volume", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setScreenshareAudioVolume(42, 0.5);
|
|
elements.setOutputVolume(50);
|
|
|
|
expect(audioEl.volume).toBe(0.25); // 0.5 user * 0.5 master
|
|
});
|
|
|
|
it("setScreenshareAudioVolume scales by the master output multiplier", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.setOutputVolume(50);
|
|
elements.setScreenshareAudioVolume(42, 0.8);
|
|
|
|
expect(audioEl.volume).toBe(0.4); // 0.8 user * 0.5 master
|
|
});
|
|
|
|
it("muteScreenshareAudio persists muted state", () => {
|
|
elements.muteScreenshareAudio(42, true);
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(true);
|
|
});
|
|
|
|
it("muteScreenshareAudio toggles back to unmuted", () => {
|
|
elements.muteScreenshareAudio(42, true);
|
|
elements.muteScreenshareAudio(42, false);
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(false);
|
|
});
|
|
|
|
it("muteScreenshareAudio applies muted to tracked audio elements", () => {
|
|
const audioEl = document.createElement("audio");
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.muteScreenshareAudio(42, true);
|
|
expect(audioEl.muted).toBe(true);
|
|
|
|
elements.muteScreenshareAudio(42, false);
|
|
expect(audioEl.muted).toBe(false);
|
|
});
|
|
|
|
it("getScreenshareAudioMuted returns false for unknown userId", () => {
|
|
expect(elements.getScreenshareAudioMuted(999)).toBe(false);
|
|
});
|
|
|
|
it("getScreenshareAudioMuted reads from audio element when no persisted state", () => {
|
|
const audioEl = document.createElement("audio");
|
|
audioEl.muted = true;
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
// No call to muteScreenshareAudio, so no persisted state for user 42
|
|
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(true);
|
|
});
|
|
|
|
it("getScreenshareAudioMuted returns false for empty audio element set", () => {
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set()]]);
|
|
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("applyRemoteAudioSubscriptionState", () => {
|
|
it("does not throw when no room is set", () => {
|
|
expect(() => elements.applyRemoteAudioSubscriptionState(true)).not.toThrow();
|
|
});
|
|
|
|
it("unsubscribes voice audio when deafened", () => {
|
|
const mockPub = { source: "microphone", setSubscribed: vi.fn() };
|
|
const mockParticipant = {
|
|
audioTrackPublications: new Map([["pub-1", mockPub]]),
|
|
};
|
|
const mockRoom = {
|
|
remoteParticipants: new Map([["user-1", mockParticipant]]),
|
|
} as any;
|
|
elements.setRoom(mockRoom);
|
|
|
|
elements.applyRemoteAudioSubscriptionState(true);
|
|
|
|
expect(mockPub.setSubscribed).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("re-subscribes voice audio when undeafened", () => {
|
|
const mockPub = { source: "microphone", setSubscribed: vi.fn() };
|
|
const mockParticipant = {
|
|
audioTrackPublications: new Map([["pub-1", mockPub]]),
|
|
};
|
|
const mockRoom = {
|
|
remoteParticipants: new Map([["user-1", mockParticipant]]),
|
|
} as any;
|
|
elements.setRoom(mockRoom);
|
|
|
|
elements.applyRemoteAudioSubscriptionState(false);
|
|
|
|
expect(mockPub.setSubscribed).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("leaves screenshare audio subscribed when deafened", () => {
|
|
// Muting/deafening yourself gates voices, not the stream someone is
|
|
// sharing — its audio keeps playing under its own per-tile controls.
|
|
const micPub = { source: "microphone", setSubscribed: vi.fn() };
|
|
const streamPub = { source: "screenShareAudio", setSubscribed: vi.fn() };
|
|
const mockParticipant = {
|
|
audioTrackPublications: new Map([
|
|
["pub-mic", micPub],
|
|
["pub-stream", streamPub],
|
|
]),
|
|
};
|
|
const mockRoom = {
|
|
remoteParticipants: new Map([["user-1", mockParticipant]]),
|
|
} as any;
|
|
elements.setRoom(mockRoom);
|
|
|
|
elements.applyRemoteAudioSubscriptionState(true);
|
|
|
|
expect(micPub.setSubscribed).toHaveBeenCalledWith(false);
|
|
expect(streamPub.setSubscribed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("getEffectiveVolume", () => {
|
|
it("returns 1.0 for default user with default multiplier", () => {
|
|
expect(elements.getEffectiveVolume(42)).toBe(1.0);
|
|
});
|
|
|
|
it("returns outputVolumeMultiplier for userId <= 0", () => {
|
|
// userId <= 0 uses default 100, so effective = (100/100) * multiplier
|
|
expect(elements.getEffectiveVolume(0)).toBe(1.0);
|
|
expect(elements.getEffectiveVolume(-1)).toBe(1.0);
|
|
});
|
|
|
|
it("scales with output volume multiplier", () => {
|
|
elements.setOutputVolume(50);
|
|
expect(elements.getEffectiveVolume(42)).toBe(0.5);
|
|
});
|
|
});
|
|
|
|
describe("applyAllVolumes", () => {
|
|
it("does not throw when no room is set", () => {
|
|
expect(() => elements.applyAllVolumes()).not.toThrow();
|
|
});
|
|
|
|
it("applies effective volume to all remote participants", () => {
|
|
const mockParticipant1 = { identity: "user-10", setVolume: vi.fn() };
|
|
const mockParticipant2 = { identity: "user-20", setVolume: vi.fn() };
|
|
const mockRoom = {
|
|
remoteParticipants: new Map([
|
|
["user-10", mockParticipant1],
|
|
["user-20", mockParticipant2],
|
|
]),
|
|
} as any;
|
|
elements.setRoom(mockRoom);
|
|
|
|
elements.applyAllVolumes();
|
|
|
|
expect(mockParticipant1.setVolume).toHaveBeenCalledWith(1.0);
|
|
expect(mockParticipant2.setVolume).toHaveBeenCalledWith(1.0);
|
|
});
|
|
});
|
|
|
|
describe("handleTrackUnsubscribedAudio — screenshare", () => {
|
|
it("removes screenshare audio elements and cleans up tracking", () => {
|
|
const audioEl = document.createElement("audio");
|
|
const { track } = createMockTrack("audio", "ss-track-1");
|
|
track.detach = vi.fn(() => [audioEl]);
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
// Set up screenshare elements manually
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]);
|
|
|
|
elements.handleTrackUnsubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.detach).toHaveBeenCalled();
|
|
// Should clean up from tracking map
|
|
expect((elements as any).screenshareAudioElements.has(42)).toBe(false);
|
|
});
|
|
|
|
it("handles screenshare unsubscribe when no audio elements are tracked", () => {
|
|
const { track } = createMockTrack("audio", "ss-track-2");
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
// No screenshare elements tracked
|
|
expect(() => {
|
|
elements.handleTrackUnsubscribedAudio(track as any, publication as any, participant as any);
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("handleTrackSubscribedAudio — mic with undefined sid", () => {
|
|
it("handles track with undefined sid gracefully", () => {
|
|
const audioEl = document.createElement("audio");
|
|
const track = {
|
|
kind: "audio",
|
|
sid: undefined,
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
const publication = { source: "microphone" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
expect(() => {
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("handleTrackSubscribedAudio — with saved output device", () => {
|
|
it("calls setSinkId for mic audio when audioOutputDevice preference is set", () => {
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "audioOutputDevice") return "device-123";
|
|
return defaultVal;
|
|
});
|
|
|
|
const audioEl = document.createElement("audio");
|
|
audioEl.setSinkId = vi.fn().mockResolvedValue(undefined);
|
|
const track = {
|
|
kind: "audio",
|
|
sid: "track-dev-1",
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
const publication = { source: "microphone" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(audioEl.setSinkId).toHaveBeenCalledWith("device-123");
|
|
});
|
|
|
|
it("calls setSinkId for screenshare audio when audioOutputDevice preference is set", () => {
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "audioOutputDevice") return "device-456";
|
|
return defaultVal;
|
|
});
|
|
|
|
const audioEl = document.createElement("audio");
|
|
audioEl.setSinkId = vi.fn().mockResolvedValue(undefined);
|
|
const track = {
|
|
kind: "audio",
|
|
sid: "track-ss-dev-1",
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(audioEl.setSinkId).toHaveBeenCalledWith("device-456");
|
|
});
|
|
|
|
it("handles setSinkId failure gracefully for screenshare audio", () => {
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "audioOutputDevice") return "bad-device";
|
|
return defaultVal;
|
|
});
|
|
|
|
const audioEl = document.createElement("audio");
|
|
audioEl.setSinkId = vi.fn().mockRejectedValue(new Error("Device not found"));
|
|
const track = {
|
|
kind: "audio",
|
|
sid: "track-ss-err",
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
const publication = { source: "screenShareAudio" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
expect(() => {
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it("handles setSinkId failure gracefully for mic audio", () => {
|
|
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
|
|
if (key === "audioOutputDevice") return "bad-device";
|
|
return defaultVal;
|
|
});
|
|
|
|
const audioEl = document.createElement("audio");
|
|
audioEl.setSinkId = vi.fn().mockRejectedValue(new Error("Device not found"));
|
|
const track = {
|
|
kind: "audio",
|
|
sid: "track-mic-err",
|
|
detach: vi.fn(() => [audioEl]),
|
|
attach: vi.fn(() => audioEl),
|
|
};
|
|
const publication = { source: "microphone" };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
expect(() => {
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("setUserVolume — without room", () => {
|
|
it("saves volume even when no room is set", () => {
|
|
elements.setUserVolume(42, 120);
|
|
expect(mockSavePref).toHaveBeenCalledWith("userVolume_42", 120);
|
|
});
|
|
});
|
|
|
|
describe("deafen guard — handleTrackSubscribedAudio", () => {
|
|
it("does not attach mic audio when locally deafened", () => {
|
|
mockVoiceStoreState.localDeafened = true;
|
|
|
|
const { track, audioEl } = createMockTrack("audio", "track-deaf-1");
|
|
const publication = { source: "microphone", setSubscribed: vi.fn() };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
// Should NOT attach any audio element
|
|
expect(track.attach).not.toHaveBeenCalled();
|
|
// Should unsubscribe the publication
|
|
expect(publication.setSubscribed).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("attaches screenshare audio even when locally deafened", () => {
|
|
// Stream audio is exempt from the deafen gate: muting/deafening
|
|
// yourself silences voices, not the content someone is streaming.
|
|
mockVoiceStoreState.localDeafened = true;
|
|
|
|
const { track } = createMockTrack("audio", "track-deaf-ss");
|
|
const publication = { source: "screenShareAudio", setSubscribed: vi.fn() };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.attach).toHaveBeenCalled();
|
|
expect(publication.setSubscribed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("attaches mic audio normally when not deafened", () => {
|
|
mockVoiceStoreState.localDeafened = false;
|
|
|
|
const { track } = createMockTrack("audio", "track-ok-1");
|
|
const publication = { source: "microphone", setSubscribed: vi.fn() };
|
|
const participant = { identity: "user-42", setVolume: vi.fn() };
|
|
|
|
elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any);
|
|
|
|
expect(track.attach).toHaveBeenCalled();
|
|
expect(publication.setSubscribed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("cleanupAllAudioElements", () => {
|
|
it("does not throw when empty", () => {
|
|
expect(() => elements.cleanupAllAudioElements()).not.toThrow();
|
|
});
|
|
|
|
it("removes all tracked audio elements", () => {
|
|
const micEl = document.createElement("audio");
|
|
const ssEl = document.createElement("audio");
|
|
const removeMic = vi.spyOn(micEl, "remove");
|
|
const removeSs = vi.spyOn(ssEl, "remove");
|
|
|
|
(elements as any).remoteMicAudioElements = new Map([["track-1", micEl]]);
|
|
(elements as any).screenshareAudioElements = new Map([[42, new Set([ssEl])]]);
|
|
|
|
elements.cleanupAllAudioElements();
|
|
|
|
expect(removeMic).toHaveBeenCalled();
|
|
expect(removeSs).toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves muted-by-user state for reconnecting tracks", () => {
|
|
elements.muteScreenshareAudio(42, true);
|
|
elements.cleanupAllAudioElements();
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(true);
|
|
});
|
|
|
|
it("clears muted-by-user state with full cleanup", () => {
|
|
elements.muteScreenshareAudio(42, true);
|
|
elements.cleanupAllAudioElementsFull();
|
|
expect(elements.getScreenshareAudioMuted(42)).toBe(false);
|
|
});
|
|
});
|
|
});
|