Files
linearvoid/frontend/src/main.test.ts
T
zwitschi b427353672
CD / deploy (push) Successful in 53s
CI / test (push) Failing after 57s
CI / docker (push) Skipped
test: remove unused playSpy variable in player startup test
2026-08-10 00:29:41 +02:00

339 lines
12 KiB
TypeScript

/// <reference types="vitest" />
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 = `
<main class="viewer-shell">
<header class="viewer-header">
<button id="chat-toggle" type="button">Hide Chat</button>
</header>
<section class="viewer-content">
<section class="player-stage">
<video id="player" class="player" controls autoplay muted playsinline></video>
<div id="now-playing">
<span id="np-title">Loading...</span>
<span id="np-next" class="np-next"></span>
</div>
</section>
<aside id="chat-panel" class="chat-sidebar">
<div class="chat-header">
<span id="room-name">Main Channel</span>
<button type="button" onclick="togglePrivateDM()">New DM</button>
</div>
<section id="messages" class="chat-messages"></section>
<section id="presence-panel" class="presence-panel">
<div id="presence-list" class="presence-list"></div>
</section>
<div class="chat-input-area">
<input id="message-input" type="text" placeholder="Type a message..." />
<button type="button" onclick="sendMessage()">Send</button>
</div>
</aside>
</section>
</main>
`;
// 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();
});
});