Files
linearvoid/frontend/src/main.test.ts
T
zwitschi 33b6296c5e
CI / test (push) Successful in 2m50s
CI / docker (push) Successful in 5m12s
Refactor load testing scripts to support Bash and PowerShell
- Updated README.md to reflect changes in load testing commands and added usage examples for Bash scripts.
- Converted PowerShell load testing scripts to Bash equivalents for better cross-platform compatibility.
- Added new Bash scripts: run-loadtests.sh, summarize-artillery.sh, and summarize-k6.sh to mirror existing PowerShell functionality.
- Updated admin-api.js and ws-chat.yml for consistency and improved performance metrics reporting.
- Enhanced error handling and output formatting in summary scripts for better readability.
2026-09-02 19:47:34 +02:00

599 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// <reference types="vitest" />
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
resetViewerStateForTests,
resolveManifestUrl,
resolveWebSocketBaseUrl,
showReconnecting,
hideReconnecting,
} 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
// Inject minimal CSS for jsdom layout tests (external style.css not loaded)
document.head.innerHTML = `
<style>
.viewer-content { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 420px); }
.viewer-shell.chat-collapsed .chat-sidebar { display: none; }
</style>
`;
document.body.innerHTML = `
<main class="viewer-shell">
<header class="viewer-header">
<button id="chat-toggle" type="button" aria-expanded="true">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" aria-live="polite">
<span id="np-title">Loading...</span>
<span id="np-next" class="np-next"></span>
</div>
</section>
<aside id="chat-panel" class="chat-sidebar" role="complementary" aria-label="Chat panel">
<div class="chat-header">
<span id="room-name">Main Channel</span>
<button type="button" onclick="togglePrivateDM()">New DM</button>
</div>
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
<section id="messages" class="chat-messages" aria-live="polite"></section>
<section id="presence-panel" class="presence-panel">
<div id="presence-list" class="presence-list"></div>
</section>
<div class="chat-input-area">
<label for="message-input">Message input</label>
<input id="message-input" type="text" placeholder="Type a message..." />
<button type="button" onclick="sendMessage()">Send</button>
</div>
</aside>
</section>
</main>
<div id="reconnecting-overlay" role="alert" aria-live="assertive" hidden>
<span id="reconnecting-text">Reconnecting...</span>
</div>
<div id="chat-user-label" hidden></div>
<button id="auth-toggle-btn" type="button">Sign in</button>
<button id="auth-logout-btn" type="button" hidden>Logout</button>
<div id="auth-panel" class="auth-modal" hidden>
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
<div class="auth-modal-content">
<button id="auth-close-btn" type="button">×</button>
<input id="auth-username" type="text" />
<input id="auth-password" type="password" />
<span id="auth-status"></span>
<button id="auth-login-btn" type="button">Login</button>
<button id="auth-register-btn" type="button">Register</button>
</div>
</div>
<div id="typing-indicator" hidden></div>
`;
// 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");
});
it("does not reconnect from a socket that was intentionally replaced", async () => {
vi.useFakeTimers();
window.switchRoom("lobby");
expect(websocketInstances).toHaveLength(1);
const replaced = websocketInstances[0];
// A room switch intentionally replaces the socket; the old socket must not
// schedule its own reconnect, otherwise history is re-pulled on a loop.
window.switchRoom("main");
expect(websocketInstances).toHaveLength(2);
expect(replaced.onclose).toBeNull();
replaced.onclose?.();
await vi.advanceTimersByTimeAsync(3000);
expect(websocketInstances).toHaveLength(2);
});
});
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("reconnecting overlay", () => {
it("shows and hides the overlay with a custom message", () => {
showReconnecting("Custom message");
const overlay = document.getElementById("reconnecting-overlay")!;
expect(overlay.classList.contains("hidden")).toBe(false);
expect(document.getElementById("reconnecting-text")!.textContent).toBe("Custom message");
hideReconnecting();
expect(overlay.classList.contains("hidden")).toBe(true);
});
});
describe("typing indicator", () => {
it("shows typing on a typing event and clears on the next 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: "typing", username: "alice" }) });
const indicator = document.getElementById("typing-indicator")!;
expect(indicator.textContent).toBe("alice is typing...");
expect(indicator.hidden).toBe(false);
ws.onmessage?.({ data: JSON.stringify({ type: "public", text: "hi", username: "alice", timestamp: Date.now() }) });
expect(indicator.hidden).toBe(true);
expect(indicator.textContent).toBe("");
});
it("ignores typing events from the current user", async () => {
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
const ws = websocketInstances[0];
ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "viewer" }) });
const indicator = document.getElementById("typing-indicator")!;
expect(indicator.textContent).toBe("");
expect(indicator.hidden).toBe(true);
});
});
describe("sendMessage over websocket", () => {
it("sends a JSON message and clears the input", async () => {
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
const input = document.getElementById("message-input") as HTMLInputElement;
input.value = "hello world";
window.sendMessage();
const ws = websocketInstances[0];
expect(ws.send).toHaveBeenCalledTimes(1);
expect(ws.send).toHaveBeenCalledWith(expect.stringContaining("hello world"));
expect(input.value).toBe("");
});
});
describe("auth wiring", () => {
it("logs in and updates the auth UI", async () => {
await import("../src/main");
globalThis.fetch = vi.fn(async (url: any) => {
const u = String(url);
if (u.includes("/api/auth/login")) {
return {
ok: true,
status: 200,
json: async () => ({ username: "alice", authenticated: true }),
text: async () => "",
};
}
return { ok: true, status: 200, json: async () => ({ authenticated: false }), text: async () => "" };
}) as any;
document.dispatchEvent(new Event("DOMContentLoaded"));
const usernameInput = document.getElementById("auth-username") as HTMLInputElement;
const passwordInput = document.getElementById("auth-password") as HTMLInputElement;
usernameInput.value = "alice";
passwordInput.value = "secret";
document.getElementById("auth-login-btn")!.click();
await new Promise((r) => setTimeout(r, 0));
expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice");
expect(document.getElementById("chat-user-label")!.textContent).toBe("alice");
});
});
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");
});
});
describe("presence flash", () => {
it("flashes join/leave messages instead of appending chat rows", 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 flash = document.getElementById("chat-flash")!;
expect(flash.textContent).toBe("alice joined");
expect(flash.hidden).toBe(false);
// Join/leave must not be rendered as chat rows.
const messages = document.getElementById("messages")!;
expect(messages.textContent).not.toContain("joined");
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
expect(flash.textContent).toBe("alice left");
});
});