feat: add metrics, observability, test fixtures, and scaling playbook artifacts
This commit is contained in:
1 parent
926a663c23
commit
f9aba11b90
32 files changed
+1788
-124
No files matched your search
+134
-3
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user