/// 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" }, }, ); // 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("alice"); expect(messages.innerHTML).toContain("hey"); expect(messages.querySelector(".message-other")).toBeTruthy(); }); });