- Viewer accessibility: 5 tests (aria-labels, aria-expanded, label association, alert role) - Viewer responsive: 3 tests (chat-collapsed, CSS grid, now-playing aria-live) - Admin accessibility: 3 tests (nav aria-label, aria-current, focusability) - Admin responsive: 2 tests (tab visibility, essential tabs) - Coverage: vitest v8 provider, 40% stmt/lines, 30% branches/funcs - CI: test:coverage step between lint and build - Total: 44 frontend tests (31 + 13 new) - Updated TODO.md (Phase 2+3 done), DONE.md, MEMORY.md
439 lines
15 KiB
TypeScript
439 lines
15 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",
|
|
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 = `
|
|
<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("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");
|
|
});
|
|
});
|