// @vitest-environment happy-dom import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { SignageDashboard } from "../../src/ui/SignageDashboard.js"; import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse, } from "./support/fake-host.js"; /** * The management UI's behaviour, mounted as React rather than inspected as source. * * The artifact-level proof lives in `module-ui.test.ts`; this exercises what an operator does — read * a screen, press a control, be told why a save was refused — against a host that answers only the * relative paths Signage actually asks for. */ // React needs this to run effects the way a browser does. ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; let container: HTMLElement; let root: Root; const DISPLAY_SETUP_URL = "https://api.example.test/display/signage"; const copiedText: string[] = []; function dashboard( context: Parameters[0]["context"], ): React.ReactElement { return ( { copiedText.push(value); return Promise.resolve(); }} /> ); } async function render(element: React.ReactElement): Promise { await act(async () => { root.render(element); }); // Let the resource loads settle. for (let index = 0; index < 6; 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(); }); for (let index = 0; index < 6; index += 1) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } } /** * Types into a controlled React input the way a person does. * * Assigning `input.value` directly and dispatching an event is not enough: React tracks the value on * the element and would treat the change as already handled. Going through the prototype's setter is * what makes React see it. */ async function type(input: HTMLInputElement, value: string): Promise { const setter = Object.getOwnPropertyDescriptor( globalThis.HTMLInputElement.prototype, "value", )?.set; await act(async () => { setter?.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); }); } function buttonNamed(label: string): HTMLButtonElement | undefined { return [...container.querySelectorAll("button")].find( (button) => button.textContent?.trim() === label, ) as HTMLButtonElement | undefined; } const GROUP = { channels: [ { id: "chan-1", isDefault: true, key: "default", name: "Default" }, ], id: "group-1", name: "Main", playback: { activeTimelineId: "timeline-1", blackout: false, currentSelectionId: "sel-1", currentSlotId: "slot-1", deadlineAtMs: 1_700_000_005_000, groupId: "group-1", pendingTimelineId: null, remainingMs: 4000, revision: 7, scheduledActivationAtMs: null, scheduledTimelineId: null, selectionRevision: 1, serverNowMs: 1_700_000_001_000, startedAtMs: 1_700_000_000_000, staticAssetId: null, transport: "playing" as const, }, revision: 1, }; const PLAYBACK_DETAIL = { current: { deadlineAtMs: 1_700_000_005_000, durationMs: 5000, id: "sel-1", itemId: "welcome", itemRevision: "1", providerId: "fixture.announcements", rendererContributionId: "fixture.announcements.slide", revision: 1, stale: false, startedAtMs: 1_700_000_000_000, withdrawn: false, }, history: [ { id: "sel-0", itemId: "giving", providerId: "fixture.announcements", sequence: 1, startedAtMs: 1_699_999_990_000, }, ], snapshot: GROUP.playback, }; const TIMELINE = { description: null, eligibleFrom: null, eligibleUntil: null, id: "timeline-1", name: "Sunday morning", revision: 3, updatedAt: "2026-08-30T15:00:00.000Z", }; beforeEach(() => { copiedText.length = 0; container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => { root.unmount(); }); container.remove(); }); describe("Signage dashboard", () => { it("shows live transport for the default channel group and drives it with one command each", async () => { const commands: string[] = []; const host = createFakeHost({ permissions: ["signage.view", "signage.playback.control"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }), }, { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL), }, { method: "POST", path: /^playback\/group-1\//u, respond: ({ path }) => { commands.push(path); return jsonResponse(GROUP.playback); }, }, ], }); await render(dashboard(host.context)); expect( container.querySelector("[data-testid='signage-current']")?.textContent, ).toContain("welcome"); expect( container.querySelector("[data-testid='signage-remaining']")?.textContent, ).toBe("4s"); await click(buttonNamed("Pause")); await click(buttonNamed("Next")); await click(buttonNamed("Blackout")); expect(commands).toEqual([ "playback/group-1/pause", "playback/group-1/next", "playback/group-1/blackout", ]); }); it("offers no transport controls to an operator who may only view", async () => { const host = createFakeHost({ permissions: ["signage.view"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }), }, { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL), }, ], }); await render(dashboard(host.context)); // The current slide is still legible; the buttons that would be refused are simply not offered. expect( container.querySelector("[data-testid='signage-current']"), ).not.toBeNull(); expect( container.querySelector("[data-testid='signage-controls']"), ).toBeNull(); }); it("shows which timeline remains active after the dashboard data is reloaded", async () => { const host = createFakeHost({ permissions: ["signage.view", "signage.playback.control"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [TIMELINE] }), }, { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL), }, { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }), }, ], }); await render(dashboard(host.context)); expect(container.textContent).toContain("Active: Sunday morning"); await click( [...container.querySelectorAll("[role='tab']")].find( (candidate) => candidate.textContent === "Timelines", ), ); expect(container.textContent).toContain("Active in Main"); expect(buttonNamed("Active")?.disabled).toBe(true); }); it("keeps a failed activation visible instead of pretending it succeeded", async () => { const inactiveGroup = { ...GROUP, playback: { ...GROUP.playback, activeTimelineId: null, currentSelectionId: null, }, }; const host = createFakeHost({ permissions: ["signage.view", "signage.playback.control"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [inactiveGroup] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [TIMELINE] }), }, { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL), }, { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }), }, { method: "POST", path: "playback/group-1/activate", respond: () => errorResponse( "SIGNAGE_CONFLICT", "The request conflicts with the current state.", 409, ), }, ], }); await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (candidate) => candidate.textContent === "Timelines", ), ); await click(buttonNamed("Activate")); expect(container.textContent).toContain("Sunday morning"); expect(container.querySelector("[role='alert']")?.textContent).toContain( "The request conflicts with the current state.", ); }); it("shows Signage's own sanitized reason when a save is refused", async () => { const host = createFakeHost({ permissions: ["signage.view", "signage.timelines.manage"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }), }, { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL), }, { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }), }, { method: "POST", path: "timelines", respond: () => errorResponse( "SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400, ), }, ], }); await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Timelines", ), ); const input = container.querySelector( "input[type='text']", ) as HTMLInputElement; await type(input, "Sunday morning"); await click(buttonNamed("Create")); const alert = container.querySelector("[role='alert']"); expect(alert?.textContent).toBe("The request is not valid."); // The server's own fixed message, and nothing else: no status code, no URL, no request detail. expect(alert?.textContent).not.toMatch(/400|http|timelines/u); }); it("says a timeline has no slots rather than showing an empty table", async () => { const host = createFakeHost({ permissions: ["signage.view", "signage.timelines.manage"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }), }, { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }), }, { method: "GET", path: "images", respond: () => jsonResponse({ images: [] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [ { description: null, eligibleFrom: null, eligibleUntil: null, id: "timeline-1", name: "Sunday morning", revision: 3, updatedAt: "2026-08-30T15:00:00.000Z", }, ], }), }, { method: "GET", path: "timelines/timeline-1", respond: () => jsonResponse({ slots: [], sources: [], timeline: { description: null, eligibleFrom: null, eligibleUntil: null, id: "timeline-1", name: "Sunday morning", revision: 3, updatedAt: "2026-08-30T15:00:00.000Z", }, }), }, ], }); await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Timelines", ), ); await click(buttonNamed("Sunday morning")); expect( container.querySelector("[data-testid='signage-timeline-editor']"), ).not.toBeNull(); expect(container.textContent).toContain("This timeline has no slots"); }); it("reports a provider whose renderer the platform did not publish", async () => { const host = createFakeHost({ // The platform published nothing against Signage's renderer capability — the contributing // module is absent, disabled, or outside the compatible range. contributions: [], permissions: ["signage.view"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }), }, { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL), }, { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [ { consecutiveFailures: 0, contractVersion: "1.0.0", defaultDurationMs: 8000, lastFailureAt: null, lastFailureCode: null, lastSuccessAt: "2026-08-30T15:00:00.000Z", providerId: "fixture.announcements", providerVersion: "1.0.0", registeredAt: "2026-08-30T14:00:00.000Z", renderer: { contributionId: "fixture.announcements.slide", contributionVersion: "1.0.0", }, }, ], }), }, ], }); await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Providers", ), ); expect(container.textContent).toContain("Renderer not published"); // Which is a fact only the browser holds: the server cannot know what the host published. }); it("says Signage works without providers rather than looking broken", async () => { const host = createFakeHost({ permissions: ["signage.view"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }), }, { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL), }, { method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }), }, ], }); await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Providers", ), ); expect(container.textContent).toContain("No content providers registered"); expect(container.textContent).toContain( "Signage works on its own with native images", ); }); it("shows the display roster with online state and offline readiness", async () => { const host = createFakeHost({ permissions: ["signage.view", "signage.displays.manage"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }), }, { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL), }, { method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [ { cachedAssetCount: 12, channelId: "chan-1", deviceId: null, id: "viewer-1", lastError: { atMs: Date.now(), code: "SLIDE_DECODE_FAILED", detail: null, }, lastSeenAtMs: Date.now() - 4000, name: "Lobby north", offlineReady: true, online: true, reportedContributionId: null, reportedProviderId: "fixture.announcements", reportedSelectionId: "sel-1", reportedTimelineId: "timeline-1", }, ], }), }, ], }); await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Displays", ), ); expect(container.textContent).toContain("Online"); expect(container.textContent).toContain("12 cached"); const display = container.querySelector( "[data-testid='signage-display-viewer-1']", ); expect(display?.closest(".signage-channel")?.textContent).toContain( "Default", ); // A sanitized code, never a stack or a message a browser happened to have. expect(container.textContent).toContain("SLIDE_DECODE_FAILED"); }); it("explains display registration and deletes a channel group only after confirmation", async () => { const secondGroup = { ...GROUP, channels: [ { id: "chan-2", isDefault: true, key: "default", name: "Default" }, ], id: "group-2", name: "Foyer", playback: { ...GROUP.playback, groupId: "group-2" }, }; let currentGroups = [GROUP, secondGroup]; const deleted: string[] = []; const host = createFakeHost({ permissions: ["signage.view", "signage.displays.manage"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: currentGroups }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }), }, { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL), }, { method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }), }, { method: "DELETE", path: "channel-groups/group-1", respond: ({ path }) => { deleted.push(path); currentGroups = [secondGroup]; return new Response(null, { status: 204 }); }, }, ], }); await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (candidate) => candidate.textContent === "Displays", ), ); const setupLink = [...container.querySelectorAll("a")].find( (candidate) => candidate.textContent?.trim() === "Open display setup", ); expect(setupLink?.href).toBe(DISPLAY_SETUP_URL); expect(setupLink?.target).toBe("_blank"); expect(setupLink?.rel).toContain("noopener"); await click(buttonNamed("Copy URL")); expect(copiedText).toEqual([DISPLAY_SETUP_URL]); expect(container.textContent).toContain("Display setup URL copied."); expect(container.textContent).toContain("Channel groups and displays"); expect(container.textContent).toContain("Audience channels"); expect(container.textContent).toContain("Channel name"); expect(container.textContent).toContain("Audience key"); expect(container.textContent).toContain( "Advanced: lowercase ID sent to content providers", ); await click(buttonNamed("Delete group")); expect(deleted).toEqual([]); expect(container.textContent).toContain("Displays assigned here will move"); await click(buttonNamed("Confirm delete")); expect(deleted).toEqual(["channel-groups/group-1"]); }); it("uploads one request per file and reports each one's own outcome", async () => { const uploaded: string[] = []; const host = createFakeHost({ permissions: ["signage.view", "signage.assets.manage"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }), }, { method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL), }, { method: "GET", path: "images", respond: () => jsonResponse({ images: [] }), }, { method: "POST", path: /^images\?/u, respond: ({ path }) => { uploaded.push(path); return path.includes("broken") ? errorResponse( "SIGNAGE_IMAGE_MALFORMED", "The uploaded file is not a decodable image.", 422, ) : jsonResponse({ deduplicated: false, image: {} }, 201); }, }, ], }); await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Images", ), ); const input = container.querySelector( "[data-testid='signage-upload-input']", ) as HTMLInputElement; // happy-dom's `FileList` is read-only, so the queue is driven through the same change handler a // real picker fires, with file-shaped values. Object.defineProperty(input, "files", { configurable: true, value: [ { name: "welcome.png", size: 1024, type: "image/png" }, { name: "broken.png", size: 1024, type: "image/png" }, ], }); await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); for (let index = 0; index < 10; index += 1) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } // Two files, two requests — each with its own name in the query string and the bytes as the body. expect(uploaded).toHaveLength(2); expect(uploaded.every((path) => path.startsWith("images?"))).toBe(true); const uploads = container.querySelector("[data-testid='signage-uploads']"); expect(uploads?.textContent).toContain("done"); expect(uploads?.textContent).toContain("failed"); expect(uploads?.textContent).toContain( "The uploaded file is not a decodable image.", ); }); it("stays on screen when one panel's data cannot be loaded", async () => { const host = createFakeHost({ permissions: ["signage.view"], routes: [ { method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS), }, { method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }), }, { method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }), }, { method: "GET", path: "playback/group-1", respond: () => errorResponse( "SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500, ), }, ], }); await render(dashboard(host.context)); expect(container.textContent).toContain("Signage could not load this"); expect(container.textContent).toContain( "The operation could not be completed.", ); // The rest of the dashboard is still usable: one failing panel is not a failed screen. expect(container.querySelectorAll("[role='tab']").length).toBe(5); }); });