feat: add metrics, observability, test fixtures, and scaling playbook artifacts
CD / deploy (push) Successful in 57s
CI / test (push) Failing after 1m29s
CI / docker (push) Skipped

This commit is contained in:
zwitschi committed 2026-08-10 00:23:50 +02:00
1 parent 926a663c23
commit f9aba11b90
32 files changed
+1788 -124

No files matched your search

+134 -3
View File
@@ -1,16 +1,18 @@
/// <reference types="vitest" />
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin";
// Mock fetch globally
let fetchCalls: { url: string; options?: RequestInit }[] = [];
let mockFetchResponse: any = { ok: true, json: () => Promise.resolve({}) };
let mockFetchResponse: any = { ok: true, payload: {} };
beforeEach(() => {
fetchCalls = [];
globalThis.fetch = vi.fn((url: string, options?: RequestInit) => {
globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => {
fetchCalls.push({ url, options });
const payload = mockFetchResponse.payload ?? {};
return Promise.resolve(
new Response(JSON.stringify(mockFetchResponse.json ? mockFetchResponse.json() : mockFetchResponse), {
new Response(JSON.stringify(payload), {
status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
headers: { "Content-Type": "application/json" },
}),
@@ -140,3 +142,132 @@ describe("API calls on tab load", () => {
expect(true).toBe(true);
});
});
describe("admin data refresh", () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="overview-status"></div>
<span id="stat-viewers"></span>
<span id="stat-stored-messages"></span>
<span id="stat-moderation-actions"></span>
<span id="stat-media-runtime"></span>
<span id="stat-media-count"></span>
<span id="system-stored-messages"></span>
<span id="system-redis-clients"></span>
<span id="system-redis-enabled"></span>
<span id="system-redis-memory"></span>
<span id="system-db-driver"></span>
<span id="system-generated-at"></span>
<div id="analytics-status"></div>
<span id="analytics-total-messages"></span>
<span id="analytics-messages-24h"></span>
<span id="analytics-distinct-rooms"></span>
<span id="analytics-moderation-actions"></span>
<span id="analytics-generated-at"></span>
<table><tbody id="analytics-table-body"></tbody></table>
`;
});
it("renders overview stats from the admin overview API", async () => {
mockFetchResponse = {
ok: true,
payload: {
viewers: 12,
storedMessages: 345,
moderationActions: 9,
mediaTotalRuntime: "01:23:45",
mediaCount: 7,
redisClients: 4,
redisEnabled: true,
redisMemoryUsed: "18 MB",
databaseDriver: "sqlite",
generatedAt: "2026-08-08T12:00:00Z",
},
};
await refreshOverview();
expect(document.getElementById("stat-viewers")?.textContent).toBe("12");
expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345");
expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes");
expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z");
});
it("renders analytics rows from the analytics API", async () => {
mockFetchResponse = {
ok: true,
payload: {
totalMessages: 1000,
messagesLast24Hours: 75,
distinctRooms: 3,
moderationActions: 11,
generatedAt: "2026-08-08T12:30:00Z",
topRooms: [
{ room: "main", messageCount: 55 },
{ room: "lobby", messageCount: 20 },
],
},
};
await refreshAnalytics();
expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000");
expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75");
expect(document.getElementById("analytics-table-body")?.textContent).toContain("main");
expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby");
});
});
describe("playlist management", () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="playlist-status"></div>
<textarea id="playlist-content">file '/app/media/demo1.mp4'
file '/app/media/demo2.mp4'
</textarea>
<ul id="playlist-sortable"></ul>
<div id="playlist-warnings" hidden></div>
`;
});
it("saves playlist via PUT request", async () => {
mockFetchResponse = { ok: true, payload: {} };
await savePlaylist();
const putCall = fetchCalls.find((c) => c.options?.method === "PUT");
expect(putCall).toBeDefined();
expect(putCall?.url).toBe("/api/admin/playlist");
expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" });
const body = JSON.parse(putCall?.options?.body as string);
expect(body.content).toContain("demo1.mp4");
});
it("sets error status on save failure", async () => {
mockFetchResponse = { ok: false, status: 500, payload: {} };
await savePlaylist();
const status = document.getElementById("playlist-status");
expect(status?.textContent).toContain("Failed");
});
it("renders sortable playlist with indexes and paths", () => {
renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n");
const list = document.getElementById("playlist-sortable")!;
expect(list.children).toHaveLength(2);
expect(list.innerHTML).toContain("demo1.mp4");
expect(list.innerHTML).toContain("demo2.mp4");
expect(list.querySelector(".sortable-index")?.textContent).toBe("1");
expect(list.querySelector("[data-remove]")).toBeTruthy();
});
it("skips comment and blank lines in sortable", () => {
renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n");
const list = document.getElementById("playlist-sortable")!;
expect(list.children).toHaveLength(1);
expect(list.innerHTML).toContain("demo1.mp4");
});
});
+4 -4
View File
@@ -172,7 +172,7 @@ async function switchTab(tabName: string) {
if (tabName === "system") refreshSystemSummary();
}
async function refreshOverview() {
export async function refreshOverview() {
try {
const response = await fetch("/api/admin/overview");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -198,7 +198,7 @@ async function refreshOverview() {
}
}
async function refreshAnalytics() {
export async function refreshAnalytics() {
setStatus("analytics-status", "Loading analytics...");
try {
const response = await fetch("/api/admin/analytics");
@@ -255,7 +255,7 @@ async function refreshPlaylist() {
}
}
function renderPlaylistSortable(content: string) {
export function renderPlaylistSortable(content: string) {
const list = document.getElementById("playlist-sortable");
if (!list) return;
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
@@ -352,7 +352,7 @@ async function showPlaylistWarnings(content: string) {
}
}
async function savePlaylist() {
export async function savePlaylist() {
if (state.playlistSaving) return;
state.playlistSaving = true;
setStatus("playlist-status", "Saving playlist...");
+194 -16
View File
@@ -1,18 +1,29 @@
/// <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: vi.fn().mockImplementation(() => ({
loadSource: vi.fn(),
attachMedia: vi.fn(),
on: vi.fn(),
destroy: vi.fn(),
})),
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
default: HlsMock,
}));
// Mock WebSocket
const websocketInstances: MockWebSocket[] = [];
class MockWebSocket {
url: string;
readyState = 0;
@@ -23,7 +34,8 @@ class MockWebSocket {
close = vi.fn();
constructor(url: string) {
this.url = url;
this.url = String(url);
websocketInstances.push(this);
}
}
@@ -41,6 +53,8 @@ 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({
@@ -82,16 +96,44 @@ beforeEach(() => {
// 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();
});
// Must import after DOM setup
import "../src/main";
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,
});
const playSpy = 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", () => {
@@ -112,13 +154,7 @@ describe("sendMessage", () => {
const input = document.getElementById("message-input") as HTMLInputElement;
input.value = "hello!";
// sendMessage requires ws to be non-null — ws set in initPlayerAndChat
// The DOMContentLoaded triggers initPlayerAndChat which creates ws
// Wait for async init, then set ws manually
// For now just verify input state logic
window.sendMessage();
// ws is null at this point, so message won't send but input won't clear either
// Verify input not cleared (ws check fails before clear)
expect(input.value).toBe("hello!");
});
@@ -158,3 +194,145 @@ describe("togglePrivateDM", () => {
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();
});
});
+13 -4
View File
@@ -18,7 +18,16 @@ let username = "viewer" + Math.floor(Math.random() * 1000);
let chatVisible = true;
const onlineUsers = new Set<string>();
function resolveWebSocketBaseUrl() {
export function resetViewerStateForTests() {
hls = null;
ws = null;
currentRoom = "main";
username = "viewer";
chatVisible = true;
onlineUsers.clear();
}
export function resolveWebSocketBaseUrl() {
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
if (configuredUrl) {
@@ -29,7 +38,7 @@ function resolveWebSocketBaseUrl() {
return `${protocol}//${window.location.host}/ws`;
}
function resolveManifestUrl() {
export function resolveManifestUrl() {
const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
const cacheBust = `stream=${Date.now()}`;
const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin);
@@ -139,7 +148,7 @@ async function refreshSchedule() {
}
}
async function initPlayerAndChat() {
export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: HlsClass }> = () => import("hls.js")) {
const video = document.getElementById("player") as HTMLVideoElement;
const manifestUrl = resolveManifestUrl();
@@ -148,7 +157,7 @@ async function initPlayerAndChat() {
video.addEventListener("loadedmetadata", () => video.play());
} else {
try {
const { default: Hls } = await import("hls.js");
const { default: Hls } = await loadHlsModule();
if (Hls.isSupported()) {
hls = new Hls();
hls.loadSource(manifestUrl);