import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; // Mock livekitSession before importing streamPreview const mockGetRemoteVideoStream = vi.fn<(uid: number, type: "camera" | "screenshare") => MediaStream | null>(); vi.mock("@lib/livekitSession", () => ({ getRemoteVideoStream: (uid: number, type: "camera" | "screenshare") => mockGetRemoteVideoStream(uid, type), setUserVolume: vi.fn(), getUserVolume: vi.fn(() => 1), })); const mockVoiceStoreState = { currentChannelId: null as number | null, localDeafened: false, }; vi.mock("@stores/voice.store", () => ({ voiceStore: { getState: () => mockVoiceStoreState, }, })); vi.mock("@lib/icons", () => ({ createIcon: (name: string, size: number) => { const el = document.createElement("span"); el.dataset.icon = name; el.dataset.size = String(size); return el; }, })); import { attachStreamPreview, attachScrollCollapse } from "../../src/lib/streamPreview"; // jsdom doesn't implement HTMLVideoElement.play() — provide a mock beforeAll(() => { HTMLVideoElement.prototype.play = vi.fn(() => Promise.resolve()); }); /** Get the preview sibling div after a row (preview is inserted as next sibling). */ function getPreview(row: HTMLElement): HTMLElement | null { const next = row.nextElementSibling; return next !== null && next.classList.contains("vu-preview") ? (next as HTMLElement) : null; } function createRow(userId: number): HTMLElement { const row = document.createElement("div"); row.className = "voice-user-item"; row.dataset.voiceUid = String(userId); document.body.appendChild(row); return row; } function createMockMediaStream(trackState: "live" | "ended" = "live"): MediaStream { const track = new EventTarget() as MediaStreamTrack; Object.defineProperty(track, "readyState", { value: trackState }); Object.defineProperty(track, "kind", { value: "video" }); const stream = { getVideoTracks: () => [track], getTracks: () => [track], } as unknown as MediaStream; return stream; } describe("streamPreview", () => { let ac: AbortController; beforeEach(() => { ac = new AbortController(); mockGetRemoteVideoStream.mockReset(); mockVoiceStoreState.currentChannelId = null; mockVoiceStoreState.localDeafened = false; vi.useFakeTimers(); }); afterEach(() => { ac.abort(); vi.useRealTimers(); document.body.innerHTML = ""; }); // T3: getRemoteVideoStream room null → null (via mock returning null) it("shows placeholder when getRemoteVideoStream returns null", () => { mockGetRemoteVideoStream.mockReturnValue(null); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const placeholder = getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null; expect(placeholder).not.toBeNull(); expect(placeholder?.textContent).toContain("Join to preview"); }); // T7: getRemoteVideoStream success → shows video it("shows video when getRemoteVideoStream returns a stream", () => { const stream = createMockMediaStream(); mockGetRemoteVideoStream.mockReturnValue(stream); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const video = getPreview(row)?.querySelector("video") ?? null; expect(video).not.toBeNull(); expect(video?.srcObject).toBe(stream); expect(video?.muted).toBe(true); }); // T8: Hover creates preview video element it("creates .vu-preview container on hover", () => { mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); expect(getPreview(row)).not.toBeNull(); }); // T9: Hover with no stream → shows placeholder it("shows placeholder with icon and actionable text", () => { mockGetRemoteVideoStream.mockReturnValue(null); const row = createRow(42); attachStreamPreview(row, 42, "Alice", true, false, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const placeholder = getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null; expect(placeholder).not.toBeNull(); expect(placeholder?.getAttribute("role")).toBe("button"); expect(placeholder?.getAttribute("aria-label")).toContain("Join channel to preview"); }); // T10: Mouseleave removes preview it("removes preview on mouseleave after animation", () => { mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); expect(getPreview(row)).not.toBeNull(); row.dispatchEvent(new MouseEvent("mouseleave")); vi.advanceTimersByTime(150 + 200); // 150ms delayed check + 200ms animation expect(getPreview(row)).toBeNull(); }); // T11: Debounce: rapid hover/unhover → no preview it("does not show preview if mouse leaves within debounce period", () => { mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(100); // Only 100ms, below 300ms debounce row.dispatchEvent(new MouseEvent("mouseleave")); vi.advanceTimersByTime(300); expect(getPreview(row)).toBeNull(); }); // T13: Track ended → swaps to placeholder it("swaps to placeholder when track ends", () => { const stream = createMockMediaStream(); const track = stream.getVideoTracks()[0]!; mockGetRemoteVideoStream.mockReturnValue(stream); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); expect(getPreview(row)?.querySelector("video") ?? null).not.toBeNull(); // Simulate track ending track.dispatchEvent(new Event("ended")); expect(getPreview(row)?.querySelector("video") ?? null).toBeNull(); expect(getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null).not.toBeNull(); }); // T15: Focus/blur mirrors hover/leave it("shows preview on focusin and hides on focusout", () => { mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new FocusEvent("focusin")); vi.advanceTimersByTime(300); expect(getPreview(row)).not.toBeNull(); row.dispatchEvent(new FocusEvent("focusout")); vi.advanceTimersByTime(200); expect(getPreview(row)).toBeNull(); }); // T16: ARIA labels present on video it("sets aria-label on video element", () => { mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const video = getPreview(row)?.querySelector("video") ?? null; expect(video?.getAttribute("aria-label")).toBe("Stream preview for Alice"); }); // T16b: Screen reader announcement it("includes screen reader announcement", () => { mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const srAnnouncement = getPreview(row)?.querySelector(".sr-only") ?? null; expect(srAnnouncement?.textContent).toContain("Showing stream preview for Alice"); }); // T17: Camera uses preview-camera class it("uses preview-camera class for camera streams", () => { mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const video = getPreview(row)?.querySelector("video") ?? null; expect(video?.className).toBe("preview-camera"); }); // T18: Screenshare uses preview-screen class it("uses preview-screen class for screenshare streams", () => { const stream = createMockMediaStream(); mockGetRemoteVideoStream.mockImplementation((uid, type) => type === "screenshare" ? stream : null, ); const row = createRow(42); attachStreamPreview(row, 42, "Alice", true, false, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const video = getPreview(row)?.querySelector("video") ?? null; expect(video?.className).toBe("preview-screen"); }); // T4+T5+T6: getRemoteVideoStream various null returns → placeholder it("tries screenshare first, falls back to camera", () => { const cameraStream = createMockMediaStream(); mockGetRemoteVideoStream.mockImplementation((uid, type) => type === "camera" ? cameraStream : null, ); const row = createRow(42); attachStreamPreview(row, 42, "Alice", true, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); // Should have tried screenshare first, then camera expect(mockGetRemoteVideoStream).toHaveBeenCalledWith(42, "screenshare"); expect(mockGetRemoteVideoStream).toHaveBeenCalledWith(42, "camera"); // Should show camera stream since screenshare returned null const video = getPreview(row)?.querySelector("video") ?? null; expect(video?.srcObject).toBe(cameraStream); expect(video?.className).toBe("preview-camera"); }); // Abort signal cleanup it("cleans up on abort signal", () => { mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); expect(getPreview(row)).not.toBeNull(); ac.abort(); expect(getPreview(row)).toBeNull(); }); // OC-0124: hidePreview overwrites state.animation without clearing the // timer already sitting there. When stopPreviewDelayed's 150ms grace timer // (T1) is still pending and something else (scroll/focusout) calls // hidePreview directly, T1 survives uncancelled, fires later, and calls // hidePreview a second time — orphaning that call's own 200ms removal // timer in turn. One of those orphaned removal timers eventually runs // `previewTimers.delete(row)` against whatever state a *later* hover // installed, deleting it before its debounce ever fires. showPreview then // finds no state to store trackCleanup on, so the ended/mute listeners it // just registered on the live MediaStreamTrack become permanently // unreachable by hidePreview, clearPreviewState, and the abort handler. it("does not leak track listeners when hidePreview interrupts a pending stopPreviewDelayed timer (OC-0124)", () => { const stream = createMockMediaStream(); const track = stream.getVideoTracks()[0]!; const removeSpy = vi.spyOn(track, "removeEventListener"); mockGetRemoteVideoStream.mockReturnValue(stream); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); // t=0: hover -> debounce armed. row.dispatchEvent(new MouseEvent("mouseenter")); // t=300: debounce fires -> preview shown, ended/mute listeners #1 registered. vi.advanceTimersByTime(300); expect(getPreview(row)).not.toBeNull(); // t=400: mouse leaves -> stopPreviewDelayed arms its 150ms grace timer. vi.advanceTimersByTime(100); row.dispatchEvent(new MouseEvent("mouseleave")); // t=450: a second, independent trigger (e.g. focusout from a Tab, or a // scroll-collapse) calls hidePreview directly while the grace timer from // t=400 is still pending. This must cancel that timer, not just // overwrite the handle to it. vi.advanceTimersByTime(50); row.dispatchEvent(new FocusEvent("focusout")); // trackCleanup #1 runs synchronously inside this hidePreview call. expect(removeSpy).toHaveBeenCalledTimes(2); // "ended" + "mute" for listener #1 // t=650: the removal timer armed by the t=450 hidePreview call fires and // tears down the (now empty) preview + state. vi.advanceTimersByTime(200); expect(getPreview(row)).toBeNull(); // t=660: user hovers again -> a fresh debounce/state is installed. vi.advanceTimersByTime(10); row.dispatchEvent(new MouseEvent("mouseenter")); // t=960: the new debounce fires and showPreview runs again, registering // ended/mute listeners #2 on the same track and trying to store // trackCleanup on the freshly-installed state. vi.advanceTimersByTime(300); expect(getPreview(row)).not.toBeNull(); // Close the second preview. If the fresh state survived intact, // trackCleanup #2 fires here, removing listener set #2 as well. row.dispatchEvent(new FocusEvent("focusout")); expect(removeSpy).toHaveBeenCalledTimes(4); // "ended" + "mute" for BOTH listener sets }); // Abort-listener accumulation (leak fix) it("registers only one abort listener per signal, not one per attach call", () => { mockGetRemoteVideoStream.mockReturnValue(null); const addSpy = vi.spyOn(ac.signal, "addEventListener"); const row1 = createRow(1); const row2 = createRow(2); attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); attachStreamPreview(row2, 2, "Bob", false, true, ac.signal); // A re-render re-attaches the same row to the same sidebar-lifetime signal. attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); // Exactly two registrations regardless of attach count: the module's own // guarded listener plus the single internal bookkeeping listener jsdom // registers per signal for `{ signal }`-scoped DOM listeners. A // per-attach leak would register one per attach call. const abortCalls = addSpy.mock.calls.filter(([type]) => type === "abort"); expect(abortCalls).toHaveLength(2); }); // v091: a structural re-render (clearChildren + rebuild) detaches the old // row from the DOM without running any preview cleanup on it. Without // retiring it, that row (and any live track-event listeners it registered) // is retained by the shared rowsBySignal map for the sidebar's entire // lifetime instead of being cleaned up as soon as the next render proves // it's dead. it("cleans up a superseded row's track listeners as soon as the next attach call sees it (v091)", () => { const stream = createMockMediaStream(); const track = stream.getVideoTracks()[0]!; const removeSpy = vi.spyOn(track, "removeEventListener"); mockGetRemoteVideoStream.mockReturnValue(stream); const row1 = createRow(1); attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); row1.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); expect(getPreview(row1)).not.toBeNull(); // Simulate renderChannels()'s clearChildren + rebuild: the old row (and // its preview sibling) is removed from the DOM directly, without going // through hidePreview/mouseleave. row1.remove(); expect(removeSpy).not.toHaveBeenCalled(); // The next render re-attaches a fresh row for (possibly) the same user // to the same sidebar-lifetime signal. const row2 = createRow(1); attachStreamPreview(row2, 1, "Alice", false, true, ac.signal); // Retiring the dead row1 entry must have run its cleanup immediately — // not deferred until the signal eventually aborts. expect(removeSpy).toHaveBeenCalledWith("ended", expect.any(Function)); expect(removeSpy).toHaveBeenCalledWith("mute", expect.any(Function)); }); it("leaves another user's live row alone when a new row attaches", () => { mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); const row1 = createRow(1); attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); row1.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); expect(getPreview(row1)).not.toBeNull(); // A second row attaches to the same signal while row1 is still live. const row2 = createRow(2); attachStreamPreview(row2, 2, "Bob", false, true, ac.signal); // row1's preview must be untouched — only the row a re-render replaced // for the *same* user is retired. expect(getPreview(row1)).not.toBeNull(); }); // ChannelSidebar builds a whole category subtree (rows included) and only // appends it to the live channel list afterwards, so every row is still // disconnected when attachStreamPreview runs. Deciding which tracked rows // are dead by liveness at that moment therefore drops rows that are about // to be inserted, losing their abort-time cleanup entirely (v091). it("still tracks rows attached before their subtree is inserted (v091)", () => { const stream = createMockMediaStream(); const track = stream.getVideoTracks()[0]!; const removeSpy = vi.spyOn(track, "removeEventListener"); mockGetRemoteVideoStream.mockReturnValue(stream); const group = document.createElement("div"); const row1 = document.createElement("div"); row1.className = "voice-user-item"; const row2 = document.createElement("div"); row2.className = "voice-user-item"; group.appendChild(row1); group.appendChild(row2); attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); attachStreamPreview(row2, 2, "Bob", false, true, ac.signal); document.body.appendChild(group); row1.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); expect(getPreview(row1)).not.toBeNull(); ac.abort(); expect(getPreview(row1)).toBeNull(); expect(removeSpy).toHaveBeenCalledWith("ended", expect.any(Function)); expect(removeSpy).toHaveBeenCalledWith("mute", expect.any(Function)); }); it("still cleans up every row attached to a signal when it aborts", () => { mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); const row1 = createRow(1); const row2 = createRow(2); attachStreamPreview(row1, 1, "Alice", false, true, ac.signal); attachStreamPreview(row2, 2, "Bob", false, true, ac.signal); row1.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); row2.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); expect(getPreview(row1)).not.toBeNull(); expect(getPreview(row2)).not.toBeNull(); ac.abort(); expect(getPreview(row1)).toBeNull(); expect(getPreview(row2)).toBeNull(); }); // Track mute event → placeholder it("swaps to placeholder on track mute event", () => { const stream = createMockMediaStream(); const track = stream.getVideoTracks()[0]!; mockGetRemoteVideoStream.mockReturnValue(stream); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); track.dispatchEvent(new Event("mute")); expect(getPreview(row)?.querySelector("video") ?? null).toBeNull(); expect(getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null).not.toBeNull(); }); }); describe("streamPreview — channel-aware placeholder", () => { let ac: AbortController; beforeEach(() => { ac = new AbortController(); mockGetRemoteVideoStream.mockReset(); mockVoiceStoreState.currentChannelId = null; mockVoiceStoreState.localDeafened = false; vi.useFakeTimers(); }); afterEach(() => { ac.abort(); vi.useRealTimers(); document.body.innerHTML = ""; }); it("shows 'Join to preview' when NOT in a voice channel and stream is null", () => { mockGetRemoteVideoStream.mockReturnValue(null); mockVoiceStoreState.currentChannelId = null; const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const placeholder = row.nextElementSibling?.querySelector(".vu-preview-placeholder"); expect(placeholder?.textContent).toContain("Join to preview"); }); it("does NOT show 'Join to preview' when already in a voice channel and stream is null", () => { mockGetRemoteVideoStream.mockReturnValue(null); mockVoiceStoreState.currentChannelId = 1; const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const placeholder = row.nextElementSibling?.querySelector(".vu-preview-placeholder"); expect(placeholder).not.toBeNull(); // Should NOT say "Join to preview" when already in channel expect(placeholder?.textContent).not.toContain("Join to preview"); }); it("shows 'Stream unavailable' when in channel but no stream available", () => { mockGetRemoteVideoStream.mockReturnValue(null); mockVoiceStoreState.currentChannelId = 1; const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const placeholder = row.nextElementSibling?.querySelector(".vu-preview-placeholder"); expect(placeholder?.textContent).toContain("Stream unavailable"); }); it("uses onClickWatch when in channel and stream is null but onClickWatch provided", () => { mockGetRemoteVideoStream.mockReturnValue(null); mockVoiceStoreState.currentChannelId = 1; const onClickJoin = vi.fn(); const onClickWatch = vi.fn(); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal, onClickJoin, onClickWatch); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const placeholder = row.nextElementSibling?.querySelector( ".vu-preview-placeholder", ) as HTMLElement; placeholder?.click(); expect(onClickWatch).toHaveBeenCalledOnce(); expect(onClickJoin).not.toHaveBeenCalled(); }); it("uses onClickJoin when NOT in channel and stream is null", () => { mockGetRemoteVideoStream.mockReturnValue(null); mockVoiceStoreState.currentChannelId = null; const onClickJoin = vi.fn(); const onClickWatch = vi.fn(); const row = createRow(42); attachStreamPreview(row, 42, "Alice", false, true, ac.signal, onClickJoin, onClickWatch); row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); const placeholder = row.nextElementSibling?.querySelector( ".vu-preview-placeholder", ) as HTMLElement; placeholder?.click(); expect(onClickJoin).toHaveBeenCalledOnce(); expect(onClickWatch).not.toHaveBeenCalled(); }); }); describe("attachScrollCollapse", () => { let ac: AbortController; beforeEach(() => { ac = new AbortController(); vi.useFakeTimers(); mockGetRemoteVideoStream.mockReset(); }); afterEach(() => { ac.abort(); vi.useRealTimers(); document.body.innerHTML = ""; }); // T14: Scroll collapses open preview it("collapses open previews on scroll", () => { mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream()); const container = document.createElement("div"); container.className = "voice-users-list"; document.body.appendChild(container); const row = createRow(42); container.appendChild(row); attachStreamPreview(row, 42, "Alice", false, true, ac.signal); attachScrollCollapse(container, ac.signal); // Show preview row.dispatchEvent(new MouseEvent("mouseenter")); vi.advanceTimersByTime(300); expect(getPreview(row)).not.toBeNull(); // Scroll container.dispatchEvent(new Event("scroll")); vi.advanceTimersByTime(200); expect(getPreview(row)).toBeNull(); }); });