Files
Signage/tests/browser/management-ui.test.tsx

907 lines
23 KiB
TypeScript

// @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<typeof SignageDashboard>[0]["context"],
): React.ReactElement {
return (
<SignageDashboard
context={context}
copyText={(value) => {
copiedText.push(value);
return Promise.resolve();
}}
/>
);
}
async function render(element: React.ReactElement): Promise<void> {
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<void> {
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<void> {
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);
});
});