/// import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { resetViewerStateForTests, resolveManifestUrl, resolveWebSocketBaseUrl } from "../src/main"; const hlsInstance = { loadSource: vi.fn(), attachMedia: vi.fn(), on: vi.fn(), destroy: vi.fn(), }; const HlsMock = Object.assign( vi.fn().mockImplementation(() => hlsInstance), { isSupported: vi.fn(() => true), Events: { MANIFEST_PARSED: "hlsManifestParsed", ERROR: "hlsError", LEVEL_UPDATED: "hlsLevelUpdated", }, ErrorTypes: { NETWORK_ERROR: "networkError", MEDIA_ERROR: "mediaError", MUX_ERROR: "muxError", OTHER_ERROR: "otherError", }, }, ); // Mock hls.js vi.mock("hls.js", () => ({ default: HlsMock, })); // Mock WebSocket const websocketInstances: MockWebSocket[] = []; class MockWebSocket { url: string; readyState = 0; onopen: (() => void) | null = null; onmessage: ((e: { data: string }) => void) | null = null; onclose: (() => void) | null = null; send = vi.fn(); close = vi.fn(); constructor(url: string) { this.url = String(url); websocketInstances.push(this); } } // Suppress console noise beforeEach(() => { vi.spyOn(console, "warn").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "log").mockImplementation(() => {}); }); let origWebSocket: any; let origFetch: any; beforeEach(() => { origWebSocket = (globalThis as any).WebSocket; origFetch = globalThis.fetch; (globalThis as any).WebSocket = MockWebSocket; websocketInstances.length = 0; resetViewerStateForTests(); // Mock fetch (schedule, etc.) — return empty schedule globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ entries: [], total: 0, generatedAt: "" }), }); // Set up DOM from index.html document.body.innerHTML = `
Loading...
`; // Stub prompt for DM (globalThis as any).prompt = vi.fn(); vi.useRealTimers(); }); afterEach(() => { (globalThis as any).WebSocket = origWebSocket; globalThis.fetch = origFetch; vi.useRealTimers(); vi.restoreAllMocks(); }); describe("player startup", () => { it("starts native HLS playback on loadedmetadata", async () => { const video = document.getElementById("player") as HTMLVideoElement; vi.spyOn(video, "canPlayType").mockReturnValue("probably"); const playSpy = vi.spyOn(video, "play").mockResolvedValue(undefined as any); await import("../src/main").then((mod) => mod.initPlayerAndChat()); expect(video.src).toContain("/hls/playlist.m3u8?stream="); video.dispatchEvent(new Event("loadedmetadata")); expect(playSpy).toHaveBeenCalled(); }); it("starts hls.js playback when native HLS is unavailable", async () => { const video = document.getElementById("player") as HTMLVideoElement; Object.defineProperty(video, "canPlayType", { value: vi.fn(() => ""), configurable: true, }); vi.spyOn(video, "play").mockResolvedValue(undefined as any); await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); expect(HlsMock.isSupported).toHaveBeenCalled(); expect(HlsMock).toHaveBeenCalled(); }); }); describe("chat toggle", () => { it("toggles chat panel visibility", () => { const toggle = document.getElementById("chat-toggle")!; expect(toggle.textContent).toBe("Hide Chat"); window.toggleChatPanel(); expect(toggle.textContent).toBe("Show Chat"); window.toggleChatPanel(); expect(toggle.textContent).toBe("Hide Chat"); }); }); describe("sendMessage", () => { it("clears input after sending non-empty text", () => { const input = document.getElementById("message-input") as HTMLInputElement; input.value = "hello!"; window.sendMessage(); expect(input.value).toBe("hello!"); }); it("does nothing with empty input", () => { const input = document.getElementById("message-input") as HTMLInputElement; input.value = " "; window.sendMessage(); // No error thrown = pass }); }); describe("switchRoom", () => { it("updates room name on switch", () => { window.switchRoom("lobby"); const roomName = document.getElementById("room-name")!; expect(roomName.textContent).toBe("Lobby Room"); }); it("shows Main Channel for main room", () => { window.switchRoom("main"); const roomName = document.getElementById("room-name")!; expect(roomName.textContent).toBe("Main Channel"); }); }); describe("togglePrivateDM", () => { it("prompts for username and switches room", () => { // Stub prompt to return a value this time vi.spyOn(window, "prompt").mockReturnValue("bob"); const switchRoomSpy = vi.spyOn(window, "switchRoom"); window.togglePrivateDM(); expect(window.prompt).toHaveBeenCalled(); expect(switchRoomSpy).not.toHaveBeenCalled(); // username is "viewerNNN" which gets set randomly switchRoomSpy.mockRestore(); }); }); describe("WebSocket reconnect", () => { it("reconnects after onclose", async () => { vi.useFakeTimers(); window.switchRoom("lobby"); expect(websocketInstances).toHaveLength(1); websocketInstances[0].onclose?.(); await vi.advanceTimersByTimeAsync(3000); expect(websocketInstances).toHaveLength(2); expect(websocketInstances[1].url).toContain("room=lobby"); }); }); describe("URL resolution", () => { it("falls back to same-origin manifest and websocket URLs", () => { vi.stubEnv("VITE_PUBLIC_HLS_URL", ""); vi.stubEnv("VITE_PUBLIC_WS_URL", ""); vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", ""); Object.defineProperty(window, "location", { value: new URL("https://linearvoid.tv/viewer"), writable: true, }); expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/); expect(resolveWebSocketBaseUrl()).toBe("wss://linearvoid.tv/ws"); }); it("preserves an allowed cross-origin manifest URL", () => { vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8"); vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "true"); expect(resolveManifestUrl()).toMatch(/^https:\/\/media\.linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/); }); it("falls back to same-origin manifest when cross-origin is not allowed", () => { vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8"); vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "false"); expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/); }); it("uses configured websocket URL when present", () => { vi.stubEnv("VITE_PUBLIC_WS_URL", "wss://chat.linearvoid.tv/ws"); expect(resolveWebSocketBaseUrl()).toBe("wss://chat.linearvoid.tv/ws"); }); }); describe("presence updates", () => { it("adds user to presence list on join system message", async () => { await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); const ws = websocketInstances[0]; ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) }); const presenceList = document.getElementById("presence-list")!; expect(presenceList.textContent).toContain("alice"); }); it("removes user from presence list on leave system message", async () => { await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); const ws = websocketInstances[0]; ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) }); ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) }); const presenceList = document.getElementById("presence-list")!; expect(presenceList.textContent).not.toContain("alice"); }); it("renders multiple users sorted", async () => { await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); const ws = websocketInstances[0]; ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "zoe joined the room", username: "system" }) }); ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) }); const presenceList = document.getElementById("presence-list")!; const text = presenceList.textContent ?? ""; const aliceIdx = text.indexOf("alice"); const zoeIdx = text.indexOf("zoe"); expect(aliceIdx).toBeLessThan(zoeIdx); }); }); describe("private DM flow", () => { it("creates dm: room name from sorted usernames", async () => { await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); vi.spyOn(window, "prompt").mockReturnValue("bob"); window.togglePrivateDM(); const roomName = document.getElementById("room-name")!; // username is "viewer" (reset by tests), bob is target -> sorted = "bob-viewer" // switchRoom capitalizes first char: "Dm:bob-viewer Room" expect(roomName.textContent).toContain("bob-viewer"); }); }); describe("message rendering", () => { it("renders system messages", async () => { await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); const ws = websocketInstances[0]; ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "Server restarting", username: "system" }) }); const messages = document.getElementById("messages")!; expect(messages.innerHTML).toContain("Server restarting"); expect(messages.querySelector(".message-system")).toBeTruthy(); }); it("renders self messages as message-self", async () => { await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); const ws = websocketInstances[0]; ws.onmessage?.({ data: JSON.stringify({ type: "public", text: "hello world", username: "viewer", timestamp: Date.now() }), }); const messages = document.getElementById("messages")!; expect(messages.innerHTML).toContain("hello world"); expect(messages.querySelector(".message-self")).toBeTruthy(); }); it("renders other-user messages as message-other", async () => { await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); const ws = websocketInstances[0]; ws.onmessage?.({ data: JSON.stringify({ type: "public", text: "hey", username: "alice", timestamp: Date.now() }), }); const messages = document.getElementById("messages")!; expect(messages.innerHTML).toContain("hey"); expect(messages.innerHTML).toContain("alice"); expect(messages.querySelector(".message-other")).toBeTruthy(); }); }); describe("player error recovery", () => { it("shows reconnecting overlay on fatal network error", async () => { const main = await import("../src/main"); await main.initPlayerAndChat(async () => ({ default: HlsMock }) as any); // Find the error handler registered by initPlayerAndChat const errorHandler = (hlsInstance.on as any).mock.calls.find( (c: any) => c[0] === "hlsError" ) as [string, Function] | undefined; if (errorHandler) { const handler = errorHandler[1]; handler("hlsError", { type: "networkError", details: "manifestLoadError", fatal: true, }); const overlay = document.getElementById("reconnecting-overlay")!; expect(overlay.classList.contains("hidden")).toBe(false); expect(document.getElementById("reconnecting-text")?.textContent).toBe("Reconnecting..."); } }); }); describe("accessibility", () => { it("player video has controls and aria attributes", () => { const video = document.getElementById("player") as HTMLVideoElement; expect(video.controls).toBe(true); expect(video.hasAttribute("autoplay")).toBe(true); expect(video.hasAttribute("playsinline")).toBe(true); }); it("chat panel has aria-labels for live region", () => { const messages = document.getElementById("messages"); expect(messages?.getAttribute("aria-live")).toBe("polite"); const chatPanel = document.getElementById("chat-panel"); expect(chatPanel?.getAttribute("aria-label")).toBe("Chat panel"); }); it("chat toggle exposes aria-expanded state", () => { const toggle = document.getElementById("chat-toggle")!; expect(toggle.getAttribute("aria-expanded")).toBe("true"); window.toggleChatPanel(); expect(toggle.getAttribute("aria-expanded")).toBe("false"); }); it("message input has associated label", () => { const input = document.getElementById("message-input") as HTMLInputElement; expect(input.getAttribute("type")).toBe("text"); expect(input.placeholder).toBe("Type a message..."); const label = document.querySelector("label[for='message-input']"); expect(label).toBeTruthy(); expect(label?.textContent).toBe("Message input"); }); it("reconnecting overlay is an aria-live assertive region", () => { const overlay = document.getElementById("reconnecting-overlay"); expect(overlay?.getAttribute("role")).toBe("alert"); expect(overlay?.getAttribute("aria-live")).toBe("assertive"); }); }); describe("responsive layout", () => { it("chat-collapsed class hides sidebar at narrow viewport", () => { const shell = document.querySelector(".viewer-shell")!; expect(shell.classList.contains("chat-collapsed")).toBe(false); window.toggleChatPanel(); expect(shell.classList.contains("chat-collapsed")).toBe(true); window.toggleChatPanel(); expect(shell.classList.contains("chat-collapsed")).toBe(false); }); it("viewer-shell uses CSS grid for content layout", () => { const content = document.querySelector(".viewer-content"); expect(content).toBeTruthy(); const style = window.getComputedStyle(content!); expect(style.display).toBe("grid"); }); it("now-playing overlay is accessible at small sizes", () => { const np = document.getElementById("now-playing"); expect(np?.getAttribute("aria-live")).toBe("polite"); }); });