// @vitest-environment happy-dom import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { audienceKeyFor } from "../../src/ui/panels/DisplaysPanel.js"; import { SignageDashboard } from "../../src/ui/SignageDashboard.js"; import { createFakeHost, FAKE_SETTINGS, type FakeHostRoute, jsonResponse, } from "./support/fake-host.js"; /** * Stage 0 production-readiness regressions for the management screen. * * Each case is a defect an operator met: an activation that looked lost after changing tabs, a * screen that collapsed to a spinner on every tab switch, deletions with no confirmation, an * internal key presented as something to fill in, and test displays nobody could remove. */ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; let container: HTMLElement; let root: Root; beforeEach(() => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); async function settle(): Promise { for (let index = 0; index < 8; index += 1) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } } async function click(element: Element | null | undefined): Promise { if (!element) { throw new Error("Expected an element to click."); } await act(async () => (element as HTMLElement).click()); await settle(); } function button(label: string): HTMLButtonElement | undefined { return [...container.querySelectorAll("button")].find( (candidate) => candidate.textContent?.trim() === label, ) as HTMLButtonElement | undefined; } function tab(label: string): Element | undefined { return [...container.querySelectorAll("[role='tab']")].find( (candidate) => candidate.textContent === label, ); } async function choose(select: HTMLSelectElement, value: string): Promise { const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set; await act(async () => { setter?.call(select, value); select.dispatchEvent(new Event("change", { bubbles: true })); }); } async function type(input: HTMLInputElement, value: string): Promise { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; await act(async () => { setter?.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); }); } function timeline(id: string, name: string) { return { description: null, eligibleFrom: null, eligibleUntil: null, id, name, revision: 1, updatedAt: "2026-10-01T15:00:00.000Z", }; } function group(activeTimelineId: string | null) { return { channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }], id: "group-1", name: "Main", playback: { activeTimelineId, blackout: false, currentSelectionId: null, currentSlotId: null, deadlineAtMs: null, groupId: "group-1", pendingTimelineId: null, remainingMs: null, revision: activeTimelineId === null ? 1 : 2, scheduledActivationAtMs: null, scheduledTimelineId: null, selectionRevision: null, serverNowMs: 1_700_000_000_000, startedAtMs: null, staticAssetId: null, transport: activeTimelineId === null ? ("idle" as const) : ("playing" as const), }, revision: 1, }; } /** A small, stateful Signage backend: activation really changes what later reads return. */ function backend(extra: readonly FakeHostRoute[] = []) { let active: string | null = null; const sent: string[] = []; const routes: FakeHostRoute[] = [ ...extra, { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [group(active)] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [timeline("timeline-1", "Sunday"), timeline("timeline-2", "Easter")], }), }, { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, { method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) }, { method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) }, { method: "GET", path: "playback/group-1", respond: () => jsonResponse({ current: null, history: [], snapshot: group(active).playback }), }, { method: "GET", path: /^channels\/.*\/display-state$/u, respond: () => jsonResponse({}) }, { method: "POST", path: "playback/group-1/activate", respond: ({ init }) => { active = (JSON.parse(String(init?.body)) as { timelineId: string }).timelineId; sent.push(`activate ${active}`); return jsonResponse(group(active).playback); }, }, ]; return { routes, sent }; } function mount(host: ReturnType) { return act(async () => { root.render( Promise.resolve()} />); }); } describe("Signage dashboard — Stage 0 regressions", () => { it("shows a timeline activated on Playback as active on Timelines too", async () => { const server = backend(); const host = createFakeHost({ permissions: ["signage.view", "signage.playback.control", "signage.timelines.manage"], routes: server.routes, }); await mount(host); await settle(); const activate = [...container.querySelectorAll("select")].find((select) => [...select.options].some((option) => option.textContent === "Choose a timeline…"), ) as HTMLSelectElement; await choose(activate, "timeline-2"); await click(button("Now")); expect(server.sent).toEqual(["activate timeline-2"]); await click(tab("Timelines")); const row = [...container.querySelectorAll("tr")].find((candidate) => candidate.textContent?.includes("Easter"), ); expect(row?.textContent).toContain("Active in Main"); expect(container.querySelector("tr")?.parentElement).not.toBeNull(); }); it("keeps what it loaded when switching tabs, instead of flashing back to a loading state", async () => { let slow = false; const server = backend([ { method: "GET", path: "timelines", respond: () => slow ? new Promise(() => undefined) : jsonResponse({ timelines: [timeline("timeline-1", "Sunday")] }), }, ]); const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes }); await mount(host); await settle(); await click(tab("Timelines")); expect(container.textContent).toContain("Sunday"); // The next load never finishes. Coming back must still show the last known list. slow = true; await click(tab("Images")); await click(tab("Timelines")); expect(container.textContent).toContain("Sunday"); expect(container.textContent).not.toContain("Loading timelines…"); }); it("deletes an image only after a second, deliberate press, and Cancel stands it down", async () => { const deleted: string[] = []; const image = { byteSize: 1000, contentType: "image/png", createdAt: "2026-10-01T15:00:00.000Z", displayName: "Welcome", expiresAt: null, height: 1080, id: "image-1", originalFilename: "welcome.png", renditions: [], status: "ready", width: 1920, }; const server = backend([ { method: "GET", path: "images", respond: () => jsonResponse({ images: [image] }) }, { method: "DELETE", path: "images/image-1", respond: ({ path }) => { deleted.push(path); return new Response(null, { status: 204 }); }, }, ]); const host = createFakeHost({ permissions: ["signage.view", "signage.assets.manage"], routes: server.routes, }); await mount(host); await settle(); await click(tab("Images")); await click(container.querySelector("[data-testid='signage-image-delete-image-1']")); expect(deleted).toEqual([]); expect(button("Delete this image")).toBeDefined(); await click(container.querySelector("[data-testid='signage-image-delete-image-1-cancel']")); expect(button("Delete this image")).toBeUndefined(); expect(deleted).toEqual([]); await click(container.querySelector("[data-testid='signage-image-delete-image-1']")); await click(container.querySelector("[data-testid='signage-image-delete-image-1-confirm']")); expect(deleted).toEqual(["images/image-1"]); }); it("adds an audience from its name alone, deriving the content key", async () => { const posted: unknown[] = []; const server = backend([ { method: "POST", path: "channel-groups/group-1/channels", respond: ({ init }) => { posted.push(JSON.parse(String(init?.body))); return jsonResponse({}, 201); }, }, ]); const host = createFakeHost({ permissions: ["signage.view", "signage.displays.manage"], routes: server.routes, }); await mount(host); await settle(); await click(tab("Displays")); const name = container.querySelector("input[placeholder='Online']") as HTMLInputElement; await type(name, "Watching Online"); await click(button("Add audience")); expect(posted).toEqual([{ key: "watching-online", name: "Watching Online" }]); }); it("labels a signed-in browser and removes an offline entry only after confirmation", async () => { const removed: string[] = []; const viewer = { cachedAssetCount: 0, channelId: "chan-1", deviceId: null, id: "viewer-1", lastError: null, lastSeenAtMs: Date.now() - 600_000, name: "New display", offlineReady: false, online: false, reportedContributionId: null, reportedProviderId: null, reportedSelectionId: null, reportedTimelineId: null, }; const server = backend([ { method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [viewer] }) }, { method: "DELETE", path: "viewers/viewer-1", respond: ({ path }) => { removed.push(path); return new Response(null, { status: 204 }); }, }, ]); const host = createFakeHost({ permissions: ["signage.view", "signage.displays.manage"], routes: server.routes, }); await mount(host); await settle(); await click(tab("Displays")); expect(container.textContent).toContain("Signed-in browser"); await click(container.querySelector("[data-testid='signage-display-remove-viewer-1']")); expect(removed).toEqual([]); await click(container.querySelector("[data-testid='signage-display-remove-viewer-1-confirm']")); expect(removed).toEqual(["viewers/viewer-1"]); }); it("follows playback changes on the management channel, and stops when Playback is left", async () => { const subscriptions: { closed: boolean; onUpdate: ((payload: unknown) => void) | undefined }[] = []; const server = backend(); const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes }); Object.assign(host.context.realtime, { subscribe(channel: string, handlers: { onUpdate?: (payload: unknown) => void }) { const entry = { closed: false, onUpdate: handlers.onUpdate }; if (channel === "management-state") { subscriptions.push(entry); } return { close: () => (entry.closed = true) }; }, }); await mount(host); await settle(); const detailReads = () => host.requests.filter((request) => request.path === "playback/group-1").length; const before = detailReads(); const live = subscriptions.find((entry) => !entry.closed); await act(async () => { live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 1, selectionRevision: null }] }); live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 2, selectionRevision: 4 }] }); }); await settle(); expect(detailReads()).toBeGreaterThan(before); await click(tab("Images")); expect(subscriptions.every((entry) => entry.closed)).toBe(true); }); }); describe("audience content keys", () => { it("derives a valid, unique key that is never the reserved default", () => { expect(audienceKeyFor("Online", [])).toBe("online"); expect(audienceKeyFor("Online", ["online"])).toBe("online-2"); expect(audienceKeyFor("Default", [])).toBe("default-2"); expect(audienceKeyFor("2nd service", [])).toBe("audience-2nd-service"); expect(audienceKeyFor(" ", [])).toBe("audience"); expect(audienceKeyFor("Café — Room!", [])).toBe("cafe-room"); }); }); /** Source-level guards for two product-wide rules (DESIGN §2.1 and §21.1). */ describe("product rules in the browser source", () => { function sources(directory: string): string[] { return readdirSync(directory).flatMap((entry) => { const path = join(directory, entry); if (statSync(path).isDirectory()) { return sources(path); } return /\.(ts|tsx)$/u.test(entry) ? [path] : []; }); } const files = ["src/ui", "src/browser", "src/display"].flatMap(sources); it("never uses a browser-native alert, confirm, or prompt", () => { for (const file of files) { expect(readFileSync(file, "utf8"), file).not.toMatch( /\b(?:window\.|globalThis\.)?(?:alert|confirm|prompt)\s*\(/u, ); } }); it("never puts the platform's name in user-facing text", () => { for (const file of files) { const code = readFileSync(file, "utf8") .replace(/\/\*[\s\S]*?\*\//gu, "") .replace(/^\s*\/\/.*$/gmu, "") .replace(/\{\/\*[\s\S]*?\*\/\}/gu, ""); // Quoted strings and JSX text; identifiers such as `EkklesiaUiStyles` are not text. expect(code, file).not.toMatch( /["'`][^"'`\n]*\bEkklesia\b[^"'`\n]*["'`]|>[^<{]*\bEkklesia\b/u, ); } }); });