From 02ff53229cbef8f5e430c8acdec93a87b54059f5 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Thu, 13 Aug 2026 13:22:08 +0200 Subject: [PATCH] test(frontend): expand admin and viewer test coverage for auth, typing, reconnect, and playlist badges --- frontend/src/admin.test.ts | 50 +++++++++++++ frontend/src/counter.test.ts | 23 ++++++ frontend/src/main.test.ts | 137 ++++++++++++++++++++++++++++++++++- 3 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 frontend/src/counter.test.ts diff --git a/frontend/src/admin.test.ts b/frontend/src/admin.test.ts index 63b52ed..c8e57de 100644 --- a/frontend/src/admin.test.ts +++ b/frontend/src/admin.test.ts @@ -326,3 +326,53 @@ describe("admin responsive layout", () => { expect(tabs).toContain("Dashboard"); }); }); + +describe("admin data refresh failures", () => { + beforeEach(() => { + document.body.innerHTML = ` +
+
+ `; + }); + + it("sets error status when overview fails", async () => { + mockFetchResponse = { ok: false, status: 500, payload: {} }; + await refreshOverview(); + expect(document.getElementById("overview-status")?.textContent).toContain("Failed"); + }); + + it("sets error status when analytics fails", async () => { + mockFetchResponse = { ok: false, status: 500, payload: {} }; + await refreshAnalytics(); + expect(document.getElementById("analytics-status")?.textContent).toContain("Failed"); + }); +}); + +describe("playlist status badges", () => { + beforeEach(() => { + document.body.innerHTML = ``; + }); + + it("renders badges for every media status", () => { + const statusMap = new Map([ + ["/app/media/ready.mp4", "ready"], + ["/app/media/needs.mp4", "needs_normalization"], + ["/app/media/normalizing.mp4", "normalizing"], + ["/app/media/failed.mp4", "normalization_failed"], + ["/app/media/other.mp4", "weird"], + ]); + + renderPlaylistSortable( + "file '/app/media/ready.mp4'\nfile '/app/media/needs.mp4'\nfile '/app/media/normalizing.mp4'\nfile '/app/media/failed.mp4'\nfile '/app/media/other.mp4'\nfile '/app/media/nopath.mp4'\n", + statusMap, + ); + + const list = document.getElementById("playlist-sortable")!; + expect(list.innerHTML).toContain("badge-ok"); + expect(list.innerHTML).toContain("badge-warn"); + expect(list.innerHTML).toContain("badge-info"); + expect(list.innerHTML).toContain("badge-danger"); + expect(list.innerHTML).toContain("badge-muted"); // default + unknown fall back to muted + expect(list.children).toHaveLength(6); + }); +}); diff --git a/frontend/src/counter.test.ts b/frontend/src/counter.test.ts new file mode 100644 index 0000000..074ec19 --- /dev/null +++ b/frontend/src/counter.test.ts @@ -0,0 +1,23 @@ +/// +import { describe, it, expect } from "vitest"; +import { setupCounter } from "../src/counter"; + +describe("setupCounter", () => { + it("initializes the button with count 0", () => { + const btn = document.createElement("button"); + setupCounter(btn); + expect(btn.innerHTML).toBe("Count is 0"); + }); + + it("increments the counter on each click", () => { + const btn = document.createElement("button"); + setupCounter(btn); + + btn.click(); + expect(btn.innerHTML).toBe("Count is 1"); + + btn.click(); + btn.click(); + expect(btn.innerHTML).toBe("Count is 3"); + }); +}); diff --git a/frontend/src/main.test.ts b/frontend/src/main.test.ts index 3356c14..5c38ed8 100644 --- a/frontend/src/main.test.ts +++ b/frontend/src/main.test.ts @@ -1,6 +1,12 @@ /// import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { resetViewerStateForTests, resolveManifestUrl, resolveWebSocketBaseUrl } from "../src/main"; +import { + resetViewerStateForTests, + resolveManifestUrl, + resolveWebSocketBaseUrl, + showReconnecting, + hideReconnecting, +} from "../src/main"; const hlsInstance = { loadSource: vi.fn(), @@ -114,6 +120,22 @@ beforeEach(() => { + + + + + + + `; // Stub prompt for DM @@ -359,6 +381,119 @@ describe("message rendering", () => { }); }); +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 and user list wiring", () => { + it("logs in and updates the auth UI", async () => { + await import("../src/main"); + + globalThis.fetch = vi.fn(async (url: any, opts?: 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"); + }); + + it("toggles the user list visibility", async () => { + await import("../src/main"); + + globalThis.fetch = vi + .fn() + .mockResolvedValue({ + ok: true, + json: async () => ({ authenticated: false, entries: [] }), + text: async () => "", + }) as any; + + document.dispatchEvent(new Event("DOMContentLoaded")); + + const panel = document.getElementById("presence-panel")!; + const btn = document.getElementById("users-toggle-btn")!; + expect(panel.hidden).toBe(false); + + btn.click(); + expect(panel.hidden).toBe(true); + expect(btn.getAttribute("aria-expanded")).toBe("false"); + + btn.click(); + expect(panel.hidden).toBe(false); + expect(btn.getAttribute("aria-expanded")).toBe("true"); + }); +}); + describe("player error recovery", () => { it("shows reconnecting overlay on fatal network error", async () => { const main = await import("../src/main");