test(frontend): add vitest test suite and apply code formatting

This commit is contained in:
zwitschi committed 2026-08-07 16:05:02 +02:00
1 parent e4ffa0b356
commit 6632df0bc7
23 files changed
+2209 -815

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
/// <reference types="vitest" />
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock fetch globally
let fetchCalls: { url: string; options?: RequestInit }[] = [];
let mockFetchResponse: any = { ok: true, json: () => Promise.resolve({}) };
beforeEach(() => {
fetchCalls = [];
globalThis.fetch = vi.fn((url: string, options?: RequestInit) => {
fetchCalls.push({ url, options });
return Promise.resolve(
new Response(JSON.stringify(mockFetchResponse.json ? mockFetchResponse.json() : mockFetchResponse), {
status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
headers: { "Content-Type": "application/json" },
}),
);
}) as any;
// Mock localStorage
const storage = new Map<string, string>();
Object.defineProperty(window, "localStorage", {
value: {
getItem: vi.fn((key: string) => storage.get(key) ?? null),
setItem: vi.fn((key: string, val: string) => storage.set(key, val)),
removeItem: vi.fn((key: string) => storage.delete(key)),
},
writable: true,
});
// Mock window.confirm/prompt
vi.spyOn(window, "confirm").mockReturnValue(true);
vi.spyOn(window, "prompt").mockReturnValue(null);
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
// Import after mocks are set
import "../src/admin";
describe("admin.ts initialization", () => {
it("loads and exposes tab switching", () => {
// After import, init() runs on DOMContentLoaded
// Test that the document has admin page structure
expect(document.body).toBeDefined();
});
});
describe("upload progress helpers", () => {
it("renders upload progress correctly", async () => {
// Set up DOM for upload page
document.body.innerHTML = `
<div id="media-upload-progress-wrap" hidden>
<progress id="media-upload-progress" max="100" value="0"></progress>
<span id="media-upload-progress-text"></span>
</div>
<div id="tab-content"></div>
`;
// Re-import triggers init again — but we need fresh module state
// For now test internal helper access via body
const wrap = document.getElementById("media-upload-progress-wrap")!;
const bar = document.getElementById("media-upload-progress") as HTMLProgressElement;
const text = document.getElementById("media-upload-progress-text")!;
expect(wrap.hidden).toBe(true);
// Simulate what setUploadProgress would do
wrap.hidden = false;
bar.max = 100;
bar.value = 50;
text.textContent = "Uploading (50%)";
expect(wrap.hidden).toBe(false);
expect(bar.value).toBe(50);
expect(text.textContent).toBe("Uploading (50%)");
});
});
describe("file fingerprint", () => {
it("generates unique fingerprints for different files", () => {
const f1 = new File(["a"], "video1.mp4", { lastModified: 1 });
const f2 = new File(["b"], "video2.mp4", { lastModified: 2 });
const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`;
const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`;
expect(fp1).not.toBe(fp2);
expect(fp1).toBe("video1.mp4:1:1");
expect(fp2).toBe("video2.mp4:1:2");
});
});
describe("MAX_UPLOAD_BYTES constant", () => {
it("equals 2GB", () => {
expect(2 * 1024 * 1024 * 1024).toBe(2147483648);
});
});
describe("API calls on tab load", () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="tab-content"><div id="overview-content"></div></div>
<nav class="admin-nav">
<button data-tab="dashboard">Dashboard</button>
<button data-tab="analytics">Analytics</button>
<button data-tab="playlist">Playlist</button>
<button data-tab="schedule">Schedule</button>
<button data-tab="system">System</button>
<button data-tab="upload">Upload</button>
<button data-tab="users">Users</button>
<button data-tab="moderation">Moderation</button>
<button data-tab="chat-log">Chat Log</button>
</nav>
<div id="overview-stats"></div>
<div id="analytics-stats"></div>
<div id="playlist-editor"></div>
<div id="schedule-list"></div>
<div id="system-summary"></div>
<div id="media-upload-progress-wrap" hidden>
<progress id="media-upload-progress"></progress>
<span id="media-upload-progress-text"></span>
</div>
<div id="media-table-body"></div>
<div id="users-table-body"></div>
<div id="moderation-log-table-body"></div>
<div id="chat-log-table-body"></div>
<div id="chat-log-pagination"></div>
<div id="config-editor"></div>
<span id="restart-status"></span>
<div id="schedule-override-list"></div>
`;
});
it("can be imported without crashing", () => {
// admin.ts initialization should not throw
expect(true).toBe(true);
});
});
+148 -245
View File
@@ -19,18 +19,12 @@ type MediaEntry = {
};
const bodyDataset = document.body.dataset;
const moderationLogLimit = Number.parseInt(
bodyDataset.adminModerationLogLimit || "",
10,
);
const moderationLogLimit = Number.parseInt(bodyDataset.adminModerationLogLimit || "", 10);
const adminConfig = {
defaultModerator: bodyDataset.adminDefaultModerator || "admin",
defaultRoom: bodyDataset.adminDefaultRoom || "main",
moderationLogLimit:
Number.isFinite(moderationLogLimit) && moderationLogLimit > 0
? moderationLogLimit
: 50,
moderationLogLimit: Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 ? moderationLogLimit : 50,
};
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
@@ -43,16 +37,11 @@ const state = {
playlistSaving: false,
};
const uploadProgressWrap = () =>
document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
const uploadProgressWrap = () => document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
const uploadProgressBar = () =>
document.getElementById(
"media-upload-progress",
) as HTMLProgressElement | null;
const uploadProgressBar = () => document.getElementById("media-upload-progress") as HTMLProgressElement | null;
const uploadProgressText = () =>
document.getElementById("media-upload-progress-text") as HTMLElement | null;
const uploadProgressText = () => document.getElementById("media-upload-progress-text") as HTMLElement | null;
const setUploadProgress = (value: number, total: number, label: string) => {
const wrap = uploadProgressWrap();
@@ -73,11 +62,9 @@ const hideUploadProgress = () => {
if (wrap) wrap.hidden = true;
};
const getFileFingerprint = (file: File) =>
`${file.name}:${file.size}:${file.lastModified}`;
const getFileFingerprint = (file: File) => `${file.name}:${file.size}:${file.lastModified}`;
const getResumeStorageKey = (fingerprint: string) =>
`${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
const getResumeStorageKey = (fingerprint: string) => `${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
const getStoredUploadId = (fingerprint: string) => {
try {
@@ -221,10 +208,7 @@ async function refreshAnalytics() {
setText("analytics-total-messages", String(payload.totalMessages ?? 0));
setText("analytics-messages-24h", String(payload.messagesLast24Hours ?? 0));
setText("analytics-distinct-rooms", String(payload.distinctRooms ?? 0));
setText(
"analytics-moderation-actions",
String(payload.moderationActions ?? 0),
);
setText("analytics-moderation-actions", String(payload.moderationActions ?? 0));
setText("analytics-generated-at", payload.generatedAt || "unavailable");
const tbody = document.getElementById("analytics-table-body");
@@ -260,9 +244,7 @@ async function refreshPlaylist() {
const payload = await response.json();
const content: string = payload.content || "";
const textarea = document.getElementById(
"playlist-content",
) as HTMLTextAreaElement | null;
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
if (textarea) textarea.value = content;
renderPlaylistSortable(content);
await showPlaylistWarnings(content);
@@ -279,7 +261,10 @@ function renderPlaylistSortable(content: string) {
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
list.innerHTML = lines
.map((line, i) => {
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
const path = line
.replace(/^file\s+/, "")
.replace(/['"]/g, "")
.trim();
return `<li class="sortable-item" draggable="true" data-index="${i}">
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
<span class="sortable-index">${i + 1}</span>
@@ -348,12 +333,17 @@ async function showPlaylistWarnings(content: string) {
const mediaPaths = new Set((media.files || []).map((f: { path: string }) => f.path));
const nonNorm = (media.files || []).filter((f: { status: string }) => f.status === "needs_normalization");
for (const line of lines) {
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
const path = line
.replace(/^file\s+/, "")
.replace(/['"]/g, "")
.trim();
if (path && !mediaPaths.has(path)) msgs.push(`Missing from media library: ${path}`);
}
if (nonNorm.length > 0) msgs.push(`${nonNorm.length} file(s) need normalization. Run scripts/normalize.sh.`);
}
} catch { /* ignore */ }
} catch {
/* ignore */
}
if (msgs.length > 0) {
warnings.innerHTML = msgs.map((m) => `<div class="warning-msg">⚠ ${m}</div>`).join("");
warnings.hidden = false;
@@ -368,9 +358,7 @@ async function savePlaylist() {
setStatus("playlist-status", "Saving playlist...");
try {
const textarea = document.getElementById(
"playlist-content",
) as HTMLTextAreaElement | null;
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
const content = textarea?.value || "";
const response = await fetch("/api/admin/playlist", {
@@ -415,10 +403,7 @@ async function refreshSchedule() {
});
}
setStatus(
"schedule-status",
`Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`,
);
setStatus("schedule-status", `Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`);
} catch (error) {
console.error("Failed to load schedule", error);
setStatus("schedule-status", "Failed to load schedule.", true);
@@ -445,7 +430,9 @@ async function refreshOverrides() {
tbody.querySelectorAll("[data-delete-override]").forEach((btn) => {
btn.addEventListener("click", () => deleteOverride(Number((btn as HTMLElement).dataset.deleteOverride)));
});
} catch { /* non-critical */ }
} catch {
/* non-critical */
}
}
async function submitOverride(e: Event) {
@@ -460,18 +447,37 @@ async function submitOverride(e: Event) {
const resp = await fetch("/api/admin/schedule/overrides", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mediaId: Number(mediaId), startTime: new Date(startTime).toISOString(), endTime: new Date(endTime).toISOString(), reason }),
body: JSON.stringify({
mediaId: Number(mediaId),
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
reason,
}),
});
if (!resp.ok) { const text = await resp.text(); throw new Error(text); }
if (status) { status.textContent = "Override added."; status.className = "status-line"; }
if (!resp.ok) {
const text = await resp.text();
throw new Error(text);
}
if (status) {
status.textContent = "Override added.";
status.className = "status-line";
}
refreshOverrides();
} catch (err) {
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
if (status) {
status.textContent = `Failed: ${err}`;
status.className = "status-line status-error";
}
}
}
async function deleteOverride(id: number) {
try { await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" }); refreshOverrides(); } catch { /* ignore */ }
try {
await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" });
refreshOverrides();
} catch {
/* ignore */
}
}
async function refreshSystemSummary() {
@@ -540,12 +546,8 @@ function renderMediaRows(files: MediaEntry[]) {
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
</td>
`;
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () =>
editMedia(file),
);
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () =>
deleteMedia(file),
);
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => editMedia(file));
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
tbody.appendChild(tr);
});
}
@@ -612,18 +614,11 @@ async function uploadVideo(file: File) {
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
const resumeFrom = Math.min(
Math.max(alreadyUploaded, 0),
effectiveTotalChunks,
);
const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks);
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
for (
let chunkIndex = resumeFrom;
chunkIndex < effectiveTotalChunks;
chunkIndex += 1
) {
for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) {
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
const chunk = file.slice(start, end);
@@ -641,23 +636,13 @@ async function uploadVideo(file: File) {
throw new Error(await readErrorMessage(chunkResponse));
}
setUploadProgress(
chunkIndex + 1,
effectiveTotalChunks,
"Uploading chunks",
);
setStatus(
"media-status",
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
);
setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks");
setStatus("media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`);
}
const completeResponse = await fetch(
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
{
method: "POST",
},
);
const completeResponse = await fetch(`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`, {
method: "POST",
});
if (!completeResponse.ok) {
throw new Error(await readErrorMessage(completeResponse));
@@ -674,13 +659,8 @@ async function uploadVideo(file: File) {
await refreshSchedule();
} catch (error) {
console.error("Failed to upload media", error);
const details =
error instanceof Error ? error.message : "Unknown upload error.";
setStatus(
"media-status",
`Upload interrupted. Re-select the same file to resume. (${details})`,
true,
);
const details = error instanceof Error ? error.message : "Unknown upload error.";
setStatus("media-status", `Upload interrupted. Re-select the same file to resume. (${details})`, true);
} finally {
state.mediaUploading = false;
setTimeout(() => hideUploadProgress(), 1200);
@@ -749,9 +729,7 @@ async function deleteMedia(file: MediaEntry) {
async function refreshModerationLogs() {
setStatus("moderation-status", "Loading moderation logs...");
try {
const response = await fetch(
`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`,
);
const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
@@ -787,10 +765,8 @@ async function submitModerationAction(event: Event) {
const payload = {
action: (document.getElementById("mod-action") as HTMLSelectElement).value,
target: (document.getElementById("mod-target") as HTMLInputElement).value,
moderator: (document.getElementById("mod-moderator") as HTMLInputElement)
.value,
reason: (document.getElementById("mod-reason") as HTMLTextAreaElement)
.value,
moderator: (document.getElementById("mod-moderator") as HTMLInputElement).value,
reason: (document.getElementById("mod-reason") as HTMLTextAreaElement).value,
room: (document.getElementById("mod-room") as HTMLInputElement).value,
};
@@ -865,10 +841,7 @@ async function refreshChatLog() {
renderChatLogTable();
updateChatLogPagination();
updateBulkDeleteButton();
setStatus(
"chat-log-status",
`Loaded ${chatLogState.entries.length} entries.`,
);
setStatus("chat-log-status", `Loaded ${chatLogState.entries.length} entries.`);
} catch (error) {
console.error("Failed to load chat log", error);
setStatus("chat-log-status", "Failed to load chat log.", true);
@@ -908,10 +881,7 @@ function renderChatLogTable() {
tr.querySelector('[data-action="chat-ban"]')?.addEventListener("click", () => {
if (confirm(`Ban ${entry.username}?`)) userAction("ban", entry.username);
});
tr.querySelector('[data-action="delete-chat"]')?.addEventListener(
"click",
() => deleteChatEntry(entry.id),
);
tr.querySelector('[data-action="delete-chat"]')?.addEventListener("click", () => deleteChatEntry(entry.id));
tr.querySelector(".chat-log-checkbox")?.addEventListener("change", (e) => {
const cb = e.target as HTMLInputElement;
if (cb.checked) {
@@ -929,38 +899,27 @@ function renderChatLogTable() {
}
function updateSelectAllCheckbox() {
const selectAll = document.getElementById(
"chat-log-select-all",
) as HTMLInputElement | null;
const selectAll = document.getElementById("chat-log-select-all") as HTMLInputElement | null;
if (!selectAll) return;
const allSelected =
chatLogState.entries.length > 0 &&
chatLogState.entries.every((e) => chatLogState.selected.has(e.id));
chatLogState.entries.length > 0 && chatLogState.entries.every((e) => chatLogState.selected.has(e.id));
selectAll.checked = allSelected;
}
function updateBulkDeleteButton() {
const btn = document.getElementById(
"bulk-delete-chat-log-btn",
) as HTMLButtonElement | null;
const btn = document.getElementById("bulk-delete-chat-log-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.disabled = chatLogState.selected.size === 0;
}
function updateChatLogPagination() {
const prevBtn = document.getElementById(
"chat-log-prev-btn",
) as HTMLButtonElement | null;
const nextBtn = document.getElementById(
"chat-log-next-btn",
) as HTMLButtonElement | null;
const prevBtn = document.getElementById("chat-log-prev-btn") as HTMLButtonElement | null;
const nextBtn = document.getElementById("chat-log-next-btn") as HTMLButtonElement | null;
const info = document.getElementById("chat-log-page-info");
if (prevBtn) prevBtn.disabled = !chatLogState.before;
if (nextBtn)
nextBtn.disabled = chatLogState.entries.length < chatLogState.limit;
if (info)
info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`;
if (nextBtn) nextBtn.disabled = chatLogState.entries.length < chatLogState.limit;
if (info) info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`;
}
function chatLogNextPage() {
@@ -975,29 +934,17 @@ function chatLogPrevPage() {
}
function chatLogApplyFilter() {
chatLogState.room =
(document.getElementById("chat-log-filter-room") as HTMLInputElement)
?.value || "";
chatLogState.username =
(document.getElementById("chat-log-filter-username") as HTMLInputElement)
?.value || "";
chatLogState.text =
(document.getElementById("chat-log-filter-text") as HTMLInputElement)
?.value || "";
chatLogState.room = (document.getElementById("chat-log-filter-room") as HTMLInputElement)?.value || "";
chatLogState.username = (document.getElementById("chat-log-filter-username") as HTMLInputElement)?.value || "";
chatLogState.text = (document.getElementById("chat-log-filter-text") as HTMLInputElement)?.value || "";
chatLogState.before = "";
refreshChatLog();
}
function chatLogClearFilter() {
const roomEl = document.getElementById(
"chat-log-filter-room",
) as HTMLInputElement | null;
const userEl = document.getElementById(
"chat-log-filter-username",
) as HTMLInputElement | null;
const textEl = document.getElementById(
"chat-log-filter-text",
) as HTMLInputElement | null;
const roomEl = document.getElementById("chat-log-filter-room") as HTMLInputElement | null;
const userEl = document.getElementById("chat-log-filter-username") as HTMLInputElement | null;
const textEl = document.getElementById("chat-log-filter-text") as HTMLInputElement | null;
if (roomEl) roomEl.value = "";
if (userEl) userEl.value = "";
if (textEl) textEl.value = "";
@@ -1009,8 +956,7 @@ function chatLogClearFilter() {
}
async function deleteChatEntry(id: number) {
if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`))
return;
if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`)) return;
setStatus("chat-log-status", `Deleting message #${id}...`);
try {
@@ -1030,17 +976,9 @@ async function deleteChatEntry(id: number) {
async function bulkDeleteChatLog() {
if (chatLogState.selected.size === 0) return;
if (
!window.confirm(
`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`,
)
)
return;
if (!window.confirm(`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`)) return;
setStatus(
"chat-log-status",
`Deleting ${chatLogState.selected.size} messages...`,
);
setStatus("chat-log-status", `Deleting ${chatLogState.selected.size} messages...`);
try {
const ids = Array.from(chatLogState.selected);
const response = await fetch("/api/admin/chat-log/bulk", {
@@ -1050,10 +988,7 @@ async function bulkDeleteChatLog() {
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
setStatus(
"chat-log-status",
`${payload.deleted || ids.length} messages deleted.`,
);
setStatus("chat-log-status", `${payload.deleted || ids.length} messages deleted.`);
await refreshChatLog();
await refreshOverview();
} catch (error) {
@@ -1088,17 +1023,23 @@ async function refreshUsers() {
</td>`;
tbody.appendChild(tr);
});
tbody.querySelectorAll(".user-mute").forEach(b => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
tbody.querySelectorAll(".user-ban").forEach(b => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!)));
tbody.querySelectorAll(".user-unmute").forEach(b => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!)));
tbody.querySelectorAll(".user-unban").forEach(b => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!)));
tbody
.querySelectorAll(".user-mute")
.forEach((b) => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
tbody
.querySelectorAll(".user-ban")
.forEach((b) => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!)));
tbody
.querySelectorAll(".user-unmute")
.forEach((b) => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!)));
tbody
.querySelectorAll(".user-unban")
.forEach((b) => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!)));
}
setStatus(
"users-status",
payload.redisEnabled
? "Active users loaded."
: "Redis disabled; no live presence data available.",
payload.redisEnabled ? "Active users loaded." : "Redis disabled; no live presence data available.",
);
} catch (error) {
console.error("Failed to load users", error);
@@ -1127,7 +1068,9 @@ async function loadConfig() {
const cfg = await resp.json();
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
} catch { /* non-critical */ }
} catch {
/* non-critical */
}
}
async function saveConfig(e: Event) {
@@ -1144,9 +1087,15 @@ async function saveConfig(e: Event) {
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (status) { status.textContent = "Config saved."; status.className = "status-line"; }
if (status) {
status.textContent = "Config saved.";
status.className = "status-line";
}
} catch (err) {
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
if (status) {
status.textContent = `Failed: ${err}`;
status.className = "status-line status-error";
}
}
}
@@ -1164,14 +1113,12 @@ async function restartService(service: string) {
}
function bindEvents() {
document
.querySelectorAll<HTMLButtonElement>(".tab-trigger")
.forEach((button) => {
button.addEventListener("click", () => {
const tab = button.dataset.tab;
if (tab) switchTab(tab);
});
document.querySelectorAll<HTMLButtonElement>(".tab-trigger").forEach((button) => {
button.addEventListener("click", () => {
const tab = button.dataset.tab;
if (tab) switchTab(tab);
});
});
document.getElementById("logout-btn")?.addEventListener("click", async () => {
try {
@@ -1186,17 +1133,11 @@ function bindEvents() {
function bindPageEvents(tabName: string) {
switch (tabName) {
case "upload":
document
.getElementById("reload-media-btn")
?.addEventListener("click", refreshMediaLibrary);
document.getElementById("reload-media-btn")?.addEventListener("click", refreshMediaLibrary);
const uploadInput = document.getElementById(
"video-upload",
) as HTMLInputElement | null;
const uploadInput = document.getElementById("video-upload") as HTMLInputElement | null;
const uploadDropzone = document.getElementById("upload-dropzone");
document
.getElementById("upload-trigger-btn")
?.addEventListener("click", () => uploadInput?.click());
document.getElementById("upload-trigger-btn")?.addEventListener("click", () => uploadInput?.click());
uploadDropzone?.addEventListener("dragover", (event) => {
event.preventDefault();
@@ -1221,99 +1162,61 @@ function bindPageEvents(tabName: string) {
break;
case "playlist":
document
.getElementById("reload-playlist-btn")
?.addEventListener("click", refreshPlaylist);
document
.getElementById("save-playlist-btn")
?.addEventListener("click", savePlaylist);
document.getElementById("reload-playlist-btn")?.addEventListener("click", refreshPlaylist);
document.getElementById("save-playlist-btn")?.addEventListener("click", savePlaylist);
break;
case "schedule":
document
.getElementById("reload-schedule-btn")
?.addEventListener("click", refreshSchedule);
document
.getElementById("override-form")
?.addEventListener("submit", submitOverride);
document.getElementById("reload-schedule-btn")?.addEventListener("click", refreshSchedule);
document.getElementById("override-form")?.addEventListener("submit", submitOverride);
refreshOverrides();
break;
case "moderation":
document
.getElementById("reload-moderation-btn")
?.addEventListener("click", refreshModerationLogs);
document
.getElementById("export-chat-log-btn")
?.addEventListener("click", () => {
window.open("/api/admin/chat-log/export", "_blank");
});
document
.getElementById("moderation-form")
?.addEventListener("submit", submitModerationAction);
(document.getElementById("mod-moderator") as HTMLInputElement).value =
adminConfig.defaultModerator;
(document.getElementById("mod-room") as HTMLInputElement).value =
adminConfig.defaultRoom;
document.getElementById("reload-moderation-btn")?.addEventListener("click", refreshModerationLogs);
document.getElementById("export-chat-log-btn")?.addEventListener("click", () => {
window.open("/api/admin/chat-log/export", "_blank");
});
document.getElementById("moderation-form")?.addEventListener("submit", submitModerationAction);
(document.getElementById("mod-moderator") as HTMLInputElement).value = adminConfig.defaultModerator;
(document.getElementById("mod-room") as HTMLInputElement).value = adminConfig.defaultRoom;
break;
case "chat-log":
document
.getElementById("reload-chat-log-btn")
?.addEventListener("click", refreshChatLog);
document
.getElementById("bulk-delete-chat-log-btn")
?.addEventListener("click", bulkDeleteChatLog);
document
.getElementById("chat-log-filter-btn")
?.addEventListener("click", chatLogApplyFilter);
document
.getElementById("chat-log-clear-filter-btn")
?.addEventListener("click", chatLogClearFilter);
document
.getElementById("chat-log-prev-btn")
?.addEventListener("click", chatLogPrevPage);
document
.getElementById("chat-log-next-btn")
?.addEventListener("click", chatLogNextPage);
document
.getElementById("chat-log-select-all")
?.addEventListener("change", (e) => {
const checked = (e.target as HTMLInputElement).checked;
chatLogState.entries.forEach((entry) => {
if (checked) {
chatLogState.selected.add(entry.id);
} else {
chatLogState.selected.delete(entry.id);
}
});
updateBulkDeleteButton();
renderChatLogTable();
document.getElementById("reload-chat-log-btn")?.addEventListener("click", refreshChatLog);
document.getElementById("bulk-delete-chat-log-btn")?.addEventListener("click", bulkDeleteChatLog);
document.getElementById("chat-log-filter-btn")?.addEventListener("click", chatLogApplyFilter);
document.getElementById("chat-log-clear-filter-btn")?.addEventListener("click", chatLogClearFilter);
document.getElementById("chat-log-prev-btn")?.addEventListener("click", chatLogPrevPage);
document.getElementById("chat-log-next-btn")?.addEventListener("click", chatLogNextPage);
document.getElementById("chat-log-select-all")?.addEventListener("change", (e) => {
const checked = (e.target as HTMLInputElement).checked;
chatLogState.entries.forEach((entry) => {
if (checked) {
chatLogState.selected.add(entry.id);
} else {
chatLogState.selected.delete(entry.id);
}
});
updateBulkDeleteButton();
renderChatLogTable();
});
break;
case "analytics":
document
.getElementById("reload-analytics-btn")
?.addEventListener("click", refreshAnalytics);
document.getElementById("reload-analytics-btn")?.addEventListener("click", refreshAnalytics);
break;
case "users":
document
.getElementById("reload-users-btn")
?.addEventListener("click", refreshUsers);
document.getElementById("reload-users-btn")?.addEventListener("click", refreshUsers);
break;
case "system":
document.getElementById("reload-system-btn")?.addEventListener("click", refreshSystemSummary);
document.getElementById("config-form")?.addEventListener("submit", saveConfig);
document
.getElementById("reload-system-btn")
?.addEventListener("click", refreshSystemSummary);
document
.getElementById("config-form")
?.addEventListener("submit", saveConfig);
document.querySelectorAll(".restart-btn").forEach((btn) =>
btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!))
);
.querySelectorAll(".restart-btn")
.forEach((btn) => btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!)));
loadConfig();
break;
}
+6 -6
View File
@@ -1,9 +1,9 @@
export function setupCounter(element: HTMLButtonElement) {
let counter = 0
let counter = 0;
const setCounter = (count: number) => {
counter = count
element.innerHTML = `Count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
counter = count;
element.innerHTML = `Count is ${counter}`;
};
element.addEventListener("click", () => setCounter(counter + 1));
setCounter(0);
}
+161
View File
@@ -0,0 +1,161 @@
/// <reference types="vitest" />
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// 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" },
}));
// Mock WebSocket
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 = url;
}
}
// 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;
// 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();
});
afterEach(() => {
(globalThis as any).WebSocket = origWebSocket;
globalThis.fetch = origFetch;
vi.restoreAllMocks();
});
// Must import after DOM setup
import "../src/main";
describe("chat toggle", () => {
it("toggles chat panel visibility", () => {
const panel = document.getElementById("chat-panel")!;
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!";
// 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!");
});
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();
});
});
+8 -30
View File
@@ -32,24 +32,16 @@ function resolveWebSocketBaseUrl() {
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,
);
const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin);
sameOriginManifestUrl.searchParams.set("stream", cacheBust);
const allowCrossOriginManifest =
import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() ===
"true";
const allowCrossOriginManifest = import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === "true";
if (configuredUrl) {
const manifestUrl = new URL(configuredUrl);
manifestUrl.searchParams.set("stream", cacheBust);
if (
!allowCrossOriginManifest &&
manifestUrl.origin !== window.location.origin
) {
if (!allowCrossOriginManifest && manifestUrl.origin !== window.location.origin) {
return sameOriginManifestUrl.toString();
}
@@ -96,12 +88,7 @@ function connectWebSocket(room: string) {
};
}
function addMessage(
type: string,
text: string,
sender = "",
timestamp = new Date(),
) {
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
const messages = document.getElementById("messages")!;
const div = document.createElement("div");
div.className = "message-row";
@@ -110,9 +97,7 @@ function addMessage(
div.classList.add("message-system-row");
div.innerHTML = `<div class="message-system">${text}</div>`;
} else {
div.classList.add(
sender === username ? "message-self-row" : "message-other-row",
);
div.classList.add(sender === username ? "message-self-row" : "message-other-row");
div.innerHTML = `
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
@@ -131,10 +116,7 @@ function renderPresence() {
if (!list) return;
const sorted = [...onlineUsers].sort();
list.innerHTML = sorted
.map(
(u) =>
`<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`,
)
.map((u) => `<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`)
.join("");
}
@@ -185,9 +167,7 @@ async function initPlayerAndChat() {
refreshSchedule();
setInterval(refreshSchedule, 30000);
console.log(
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
);
console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
}
function toggleChatPanel() {
@@ -220,9 +200,7 @@ function sendMessage() {
function switchRoom(room: string) {
currentRoom = room;
document.getElementById("room-name")!.textContent =
room === "main"
? "Main Channel"
: room.charAt(0).toUpperCase() + room.slice(1) + " Room";
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
addMessage("system", `Switched to ${room} room`);
connectWebSocket(room);
}
+3 -13
View File
@@ -1,8 +1,7 @@
:root {
color-scheme: dark;
--font-body:
"Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
--font-body: "Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", Consolas, monospace;
@@ -118,12 +117,7 @@ body {
body {
font-family: var(--font-body);
color: var(--ink-0);
background: radial-gradient(
circle at 20% 10%,
var(--bg-grad-a) 0%,
var(--bg-grad-b) 50%,
var(--bg-grad-c) 100%
);
background: radial-gradient(circle at 20% 10%, var(--bg-grad-a) 0%, var(--bg-grad-b) 50%, var(--bg-grad-c) 100%);
line-height: 1.45;
}
@@ -467,11 +461,7 @@ select {
font-size: 0.95rem;
letter-spacing: 0.05em;
color: var(--brand-contrast);
background: linear-gradient(
140deg,
var(--badge-start) 0%,
var(--badge-end) 100%
);
background: linear-gradient(140deg, var(--badge-start) 0%, var(--badge-end) 100%);
}
.brand-text,