CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s
Real-device review of the platform display flow found four defects in display management: - Shared SelectMenu in @ekklesia/ui (additive): an Ekklesia-drawn single choice following the WAI-ARIA select-only combobox pattern, replacing the native select for "Show on this screen". It has a themed list, a chevron inside the control, keyboard support (arrows, Home/End, type-ahead, Enter/Space/Tab, and Escape that closes only the list), pointer and touch support, and focus that stays on the control. It is an ek-control, so it keeps the 16px phone size. - Newly paired displays appear without a reload. Approval doesn't create the display; the screen does when it claims its credential a few seconds later. The page now shows a "Waiting for the screen to connect" placeholder and refetches every second until the new display appears (for up to a minute), and refreshes the list every 30 seconds. - What a display shows can change after pairing. In the single-target Stage 1 model, a display's current target is its one active display permission, so no migration is needed. PUT /api/v1/devices/:id/display, with core.devices.manage, a trusted origin and a bounded body, revokes its other active permission rows (kept as history) and grants the chosen module's declared display permission, in one transaction on the same device. The screen notices within about 30 seconds: a paired device on a module page that is no longer set to show it goes to /display/setup instead of re-pairing, and /display/setup opens the new target. - Revoked displays leave the normal list at once. Their rows stay in the database as history. Each display card now shows its name, Online/Offline/Not connected yet, "Showing: ...", last seen and paired dates, and Change and Revoke. Co-Authored-By: Claude Opus 5.5 <[email protected]>
544 lines
22 KiB
TypeScript
544 lines
22 KiB
TypeScript
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { DisplaysSettingsPage } from "../src/browser/DisplaysSettingsPage.js";
|
|
|
|
declare global {
|
|
// eslint-disable-next-line no-var
|
|
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
}
|
|
|
|
/**
|
|
* Settings → Displays (ADR 0064, ADR 0065): the active platform displays, setting up a new screen,
|
|
* pairing it, changing what it shows, and revoking it.
|
|
*/
|
|
|
|
const apiBaseUrl = "https://ekklesia.example.test";
|
|
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
|
|
|
/** A screen that asked from Signage's own display page. */
|
|
const MODULE_CODE = "123456";
|
|
/** A screen that asked from the platform's `/display/setup`, with no module. */
|
|
const PLATFORM_CODE = "654321";
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
function json(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
headers: { "content-type": "application/json" },
|
|
status,
|
|
});
|
|
}
|
|
|
|
interface Device {
|
|
id: string;
|
|
name: string;
|
|
createdAt: string;
|
|
lastSeenAt?: string;
|
|
permissions: string[];
|
|
revoked: boolean;
|
|
}
|
|
|
|
interface ServerOptions {
|
|
readonly devices?: Device[];
|
|
readonly denied?: boolean;
|
|
/** What `GET /api/v1/devices/display-modules` answers. */
|
|
readonly modules?: unknown[] | "fails";
|
|
}
|
|
|
|
const SIGNAGE = { moduleId: "signage", name: "Signage", permissionId: "signage.display" };
|
|
const BULLETIN = { moduleId: "bulletin", name: "Bulletin", permissionId: "bulletin.display" };
|
|
|
|
/**
|
|
* A fake API that behaves like the real one where it matters: an approved display does not exist
|
|
* until its screen collects its credential, which here happens a couple of list requests later.
|
|
*/
|
|
function server(options: ServerOptions = {}) {
|
|
const devices: Device[] = options.devices ?? [
|
|
{
|
|
createdAt: "2026-10-01T09:00:00.000Z",
|
|
id: "device-1",
|
|
lastSeenAt: "2026-10-15T11:50:00.000Z",
|
|
name: "Foyer TV",
|
|
permissions: [],
|
|
revoked: false,
|
|
},
|
|
];
|
|
const modules =
|
|
options.modules === "fails" ? [] : ((options.modules ?? [SIGNAGE]) as (typeof SIGNAGE)[]);
|
|
let pending: { device: Device; listsUntilClaimed: number } | undefined;
|
|
const requests: { path: string; method: string; body: unknown }[] = [];
|
|
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const path = new URL(String(input)).pathname;
|
|
const method = init?.method ?? "GET";
|
|
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
|
|
requests.push({ body, method, path });
|
|
if (path === "/api/v1/devices/display-modules") {
|
|
return options.modules === "fails"
|
|
? json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503)
|
|
: json({ modules });
|
|
}
|
|
if (path === "/api/v1/devices") {
|
|
if (options.denied) {
|
|
return json({ code: "AUTHORIZATION_DENIED" }, 403);
|
|
}
|
|
if (pending !== undefined) {
|
|
pending.listsUntilClaimed -= 1;
|
|
if (pending.listsUntilClaimed <= 0) {
|
|
devices.push(pending.device);
|
|
pending = undefined;
|
|
}
|
|
}
|
|
return json({ devices });
|
|
}
|
|
const display = /^\/api\/v1\/devices\/([^/]+)\/display$/u.exec(path);
|
|
if (display && method === "PUT") {
|
|
const device = devices.find((candidate) => candidate.id === display[1]);
|
|
const moduleId = (body as { moduleId: string | null }).moduleId;
|
|
const module = modules.find((candidate) => candidate.moduleId === moduleId);
|
|
if (!device || device.revoked || (moduleId !== null && !module)) {
|
|
return json({ code: "NOT_FOUND" }, 404);
|
|
}
|
|
device.permissions = module ? [module.permissionId] : [];
|
|
return json({ moduleId });
|
|
}
|
|
const revoke = /^\/api\/v1\/devices\/([^/]+)\/revoke$/u.exec(path);
|
|
if (revoke) {
|
|
for (const device of devices) {
|
|
if (device.id === revoke[1]) {
|
|
device.revoked = true;
|
|
device.permissions = [];
|
|
}
|
|
}
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
if (path === "/api/v1/device-pairing/lookups") {
|
|
const code = (body as { code: string }).code;
|
|
if (code !== MODULE_CODE && code !== PLATFORM_CODE) {
|
|
return json({ code: "DEVICE_PAIRING_NOT_FOUND" }, 404);
|
|
}
|
|
const module = code === MODULE_CODE;
|
|
return json({
|
|
expiresAt: "2026-10-15T12:10:00.000Z",
|
|
moduleId: module ? "signage" : null,
|
|
permissionId: module ? "signage.display" : null,
|
|
requestedAt: "2026-10-15T11:59:00.000Z",
|
|
});
|
|
}
|
|
if (path === "/api/v1/device-pairing/approvals") {
|
|
const approval = body as { code: string; deviceName: string; moduleId?: string };
|
|
const shows = approval.code === MODULE_CODE ? "signage" : approval.moduleId;
|
|
const module = modules.find((candidate) => candidate.moduleId === shows);
|
|
pending = {
|
|
device: {
|
|
createdAt: "2026-10-15T12:00:00.000Z",
|
|
id: `device-${devices.length + 1}`,
|
|
name: approval.deviceName,
|
|
permissions: module ? [module.permissionId] : [],
|
|
revoked: false,
|
|
},
|
|
listsUntilClaimed: 2,
|
|
};
|
|
return json({ moduleId: shows ?? null, permissionId: module?.permissionId ?? null });
|
|
}
|
|
return json({ code: "NOT_FOUND" }, 404);
|
|
}) as unknown as typeof globalThis.fetch;
|
|
return { devices, fetch, requests };
|
|
}
|
|
|
|
function query(testId: string): HTMLElement | null {
|
|
return container.querySelector(`[data-testid="${testId}"]`);
|
|
}
|
|
|
|
function required(testId: string): HTMLElement {
|
|
const element = query(testId);
|
|
if (!element) {
|
|
throw new Error(`missing ${testId}`);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
async function settle(): Promise<void> {
|
|
for (let index = 0; index < 6; index += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
|
|
/** Waits, in real time, for something the page does on its own (a revalidation). */
|
|
async function eventually(condition: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (!condition()) {
|
|
if (Date.now() > deadline) {
|
|
throw new Error("timed out");
|
|
}
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
});
|
|
}
|
|
}
|
|
|
|
async function click(testId: string): Promise<void> {
|
|
await act(async () => {
|
|
required(testId).click();
|
|
await settle();
|
|
});
|
|
}
|
|
|
|
async function type(testId: string, value: string): Promise<void> {
|
|
const input = required(testId) as HTMLInputElement;
|
|
await act(async () => {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
setter?.call(input, value);
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
async function submit(testId: string): Promise<void> {
|
|
const form = (required(testId) as HTMLButtonElement).form;
|
|
await act(async () => {
|
|
form?.requestSubmit();
|
|
await settle();
|
|
});
|
|
}
|
|
|
|
/** Opens a SelectMenu and chooses an option by its label, as a pointer would. */
|
|
async function choose(testId: string, label: string): Promise<void> {
|
|
await click(testId);
|
|
const listId = required(testId).getAttribute("aria-controls") ?? "";
|
|
const option = [...(container.querySelector(`#${CSS.escape(listId)}`)?.children ?? [])].find(
|
|
(candidate) => candidate.textContent === label,
|
|
) as HTMLElement | undefined;
|
|
if (!option) {
|
|
throw new Error(`no option ${label}`);
|
|
}
|
|
await act(async () => {
|
|
option.click();
|
|
await settle();
|
|
});
|
|
}
|
|
|
|
async function renderPage(options: ServerOptions = {}) {
|
|
const platform = server(options);
|
|
await act(async () => {
|
|
root.render(
|
|
<DisplaysSettingsPage
|
|
back={{ href: "/settings", label: "Back to Settings", onBack: () => undefined }}
|
|
config={{ apiBaseUrl, fetch: platform.fetch }}
|
|
moduleName={(moduleId) => (moduleId === "signage" ? "Signage" : undefined)}
|
|
now={() => NOW}
|
|
pollMs={5}
|
|
refreshMs={0}
|
|
/>,
|
|
);
|
|
await settle();
|
|
});
|
|
return platform;
|
|
}
|
|
|
|
describe("Settings → Displays", () => {
|
|
it("lists each active display with its status, what it shows, and its actions", async () => {
|
|
const before = document.activeElement;
|
|
await renderPage();
|
|
|
|
const card = required("device-device-1");
|
|
expect(card.textContent).toContain("Foyer TV");
|
|
expect(card.textContent).toContain("Online");
|
|
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
|
|
expect(card.textContent).toContain("Last seen within the last hour");
|
|
expect(query("device-change-device-1")?.textContent).toBe("Change");
|
|
expect(query("device-revoke-device-1")?.textContent).toBe("Revoke");
|
|
expect(container.querySelector("h1")?.textContent).toBe("Displays");
|
|
// Setting up a new screen comes first, in one place, at the platform's own setup address.
|
|
const setup = required("display-setup");
|
|
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
|
expect(setup.contains(required("add-display"))).toBe(true);
|
|
expect((required("display-setup-steps") as HTMLDetailsElement).open).toBe(false);
|
|
// No pairing form until someone asks for one, and nothing grabbed focus.
|
|
expect(container.querySelector("input")).toBeNull();
|
|
expect(document.activeElement).toBe(before);
|
|
});
|
|
|
|
it("shows only active displays: revoked ones are history, not the normal list", async () => {
|
|
await renderPage({
|
|
devices: [
|
|
{
|
|
createdAt: "2026-10-01T09:00:00.000Z",
|
|
id: "a",
|
|
name: "Hall",
|
|
permissions: [],
|
|
revoked: false,
|
|
},
|
|
{
|
|
createdAt: "2026-09-01T09:00:00.000Z",
|
|
id: "b",
|
|
name: "Old test screen",
|
|
permissions: [],
|
|
revoked: true,
|
|
},
|
|
{
|
|
createdAt: "2026-09-02T09:00:00.000Z",
|
|
id: "c",
|
|
name: "Another old one",
|
|
permissions: [],
|
|
revoked: true,
|
|
},
|
|
],
|
|
});
|
|
expect(query("device-a")).not.toBeNull();
|
|
expect(query("device-b")).toBeNull();
|
|
expect(query("device-c")).toBeNull();
|
|
expect(container.textContent).not.toContain("Revoked");
|
|
expect(required("device-a").textContent).toContain("Not connected yet");
|
|
});
|
|
|
|
it("says when nothing is paired yet, and when the list is not this person's to see", async () => {
|
|
await renderPage({ devices: [] });
|
|
expect(required("device-list-empty").textContent).toContain("No displays are paired yet");
|
|
|
|
await act(async () => root.unmount());
|
|
root = createRoot(container);
|
|
await renderPage({ denied: true });
|
|
expect(container.textContent).toContain("Displays aren't available to you");
|
|
expect(query("add-display")).toBeNull();
|
|
});
|
|
|
|
it("shows a newly paired display without a page refresh, once its screen connects", async () => {
|
|
const platform = await renderPage();
|
|
|
|
await click("add-display");
|
|
expect(document.activeElement).toBe(required("device-pairing-code-input"));
|
|
await type("device-pairing-code-input", "999999");
|
|
await submit("device-pairing-lookup");
|
|
expect(required("device-pairing-error").textContent).toContain("not waiting for approval");
|
|
|
|
await type("device-pairing-code-input", MODULE_CODE);
|
|
await submit("device-pairing-lookup");
|
|
expect(required("device-pairing-found").textContent).toContain("Signage");
|
|
expect(query("device-pairing-shows")).toBeNull();
|
|
expect(document.activeElement).toBe(required("device-pairing-name"));
|
|
await type("device-pairing-name", "Sanctuary left");
|
|
await submit("device-pairing-approve");
|
|
|
|
// The dialog closes and the page says so at once; the screen has not connected yet.
|
|
expect(query("add-display-dialog")).toBeNull();
|
|
expect(required("device-pairing-success").textContent).toContain(
|
|
"Sanctuary left is paired and shows Signage.",
|
|
);
|
|
expect(required("device-awaiting").textContent).toContain("Waiting for the screen to connect");
|
|
|
|
// The page checks again on its own until the display exists — no reload.
|
|
await eventually(() => query("device-device-2") !== null);
|
|
expect(query("device-awaiting")).toBeNull();
|
|
expect(required("device-device-2-showing").textContent).toBe("Showing: Signage");
|
|
// Never twice, however many times it is fetched again.
|
|
expect(container.querySelectorAll('[data-testid="device-device-2"]')).toHaveLength(1);
|
|
expect(platform.requests.filter((r) => r.path === "/api/v1/devices").length).toBeGreaterThan(2);
|
|
expect(document.activeElement).toBe(required("add-display"));
|
|
});
|
|
|
|
it("closes the Add display flow on Cancel without pairing anything", async () => {
|
|
const platform = await renderPage();
|
|
await click("add-display");
|
|
await click("add-display-cancel");
|
|
|
|
expect(query("add-display-dialog")).toBeNull();
|
|
expect(platform.requests.map((r) => r.path).sort()).toEqual([
|
|
"/api/v1/devices",
|
|
"/api/v1/devices/display-modules",
|
|
]);
|
|
expect(document.activeElement).toBe(required("add-display"));
|
|
});
|
|
});
|
|
|
|
describe("setting up a new screen (ADR 0065)", () => {
|
|
it("copies the platform setup address, and says so when the browser cannot", async () => {
|
|
const writeText = vi.fn(async () => undefined);
|
|
Object.defineProperty(window.navigator, "clipboard", {
|
|
configurable: true,
|
|
value: { writeText },
|
|
});
|
|
await renderPage();
|
|
await click("display-setup-copy");
|
|
expect(writeText).toHaveBeenCalledWith(`${apiBaseUrl}/display/setup`);
|
|
expect(required("display-setup").textContent).toContain("Link copied.");
|
|
|
|
Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: undefined });
|
|
await click("display-setup-copy");
|
|
expect(required("display-setup").textContent).toContain("couldn't copy it");
|
|
});
|
|
|
|
it("pairs a platform display with zero display modules installed", async () => {
|
|
const platform = await renderPage({ modules: [] });
|
|
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
|
|
expect(required("display-setup").textContent).not.toContain("Signage");
|
|
|
|
await click("add-display");
|
|
await type("device-pairing-code-input", PLATFORM_CODE);
|
|
await submit("device-pairing-lookup");
|
|
expect(required("device-pairing-found").textContent).toContain(
|
|
"A new screen is waiting to be paired",
|
|
);
|
|
expect(query("device-pairing-shows")).toBeNull();
|
|
expect(required("device-pairing-no-modules").textContent).toContain(
|
|
"No installed module shows content on screens yet",
|
|
);
|
|
await type("device-pairing-name", "Foyer TV 2");
|
|
await submit("device-pairing-approve");
|
|
|
|
expect(
|
|
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
|
).toEqual({ code: PLATFORM_CODE, deviceName: "Foyer TV 2" });
|
|
await eventually(() => query("device-device-2") !== null);
|
|
expect(required("device-device-2-showing").textContent).toBe("Showing: Nothing yet");
|
|
});
|
|
|
|
it("chooses what a new platform display shows with the themed select, not a native one", async () => {
|
|
const platform = await renderPage();
|
|
await click("add-display");
|
|
await type("device-pairing-code-input", PLATFORM_CODE);
|
|
await submit("device-pairing-lookup");
|
|
|
|
const dialog = required("add-display-dialog");
|
|
expect(dialog.querySelector("select")).toBeNull();
|
|
const shows = required("device-pairing-shows");
|
|
expect(shows.getAttribute("role")).toBe("combobox");
|
|
expect(shows.textContent).toBe("Nothing yet");
|
|
await choose("device-pairing-shows", "Signage");
|
|
expect(shows.textContent).toBe("Signage");
|
|
await type("device-pairing-name", "Lobby screen");
|
|
await submit("device-pairing-approve");
|
|
|
|
expect(
|
|
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
|
).toEqual({ code: PLATFORM_CODE, deviceName: "Lobby screen", moduleId: "signage" });
|
|
await eventually(() => query("device-device-2") !== null);
|
|
expect(required("device-device-2-showing").textContent).toBe("Showing: Signage");
|
|
});
|
|
|
|
it("still pairs, showing nothing yet, when the display modules cannot be read", async () => {
|
|
const platform = await renderPage({ modules: "fails" });
|
|
await click("add-display");
|
|
await type("device-pairing-code-input", PLATFORM_CODE);
|
|
await submit("device-pairing-lookup");
|
|
expect(query("device-pairing-shows")).toBeNull();
|
|
await type("device-pairing-name", "Hall");
|
|
await submit("device-pairing-approve");
|
|
expect(
|
|
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
|
|
).toEqual({ code: PLATFORM_CODE, deviceName: "Hall" });
|
|
// Without the module list the card neither guesses what it shows nor offers to change it.
|
|
expect(query("device-device-1-showing")).toBeNull();
|
|
expect(query("device-change-device-1")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("changing what a display shows (ADR 0065)", () => {
|
|
it("moves a display from nothing to a module, between modules, and back, as the same display", async () => {
|
|
const platform = await renderPage({ modules: [SIGNAGE, BULLETIN] });
|
|
const puts = () =>
|
|
platform.requests.filter((r) => r.method === "PUT").map((r) => [r.path, r.body]);
|
|
|
|
// Nothing yet → Signage.
|
|
await click("device-change-device-1");
|
|
const dialog = required("device-change-dialog");
|
|
expect(dialog.textContent).toContain("Change what Foyer TV shows");
|
|
expect(dialog.querySelector("select")).toBeNull();
|
|
expect(required("device-change-shows").textContent).toBe("Nothing yet");
|
|
// Nothing to save until something changes.
|
|
expect((required("device-change-save") as HTMLButtonElement).disabled).toBe(true);
|
|
await choose("device-change-shows", "Signage");
|
|
await click("device-change-save");
|
|
expect(query("device-change-dialog")).toBeNull();
|
|
expect(required("device-device-1-showing").textContent).toBe("Showing: Signage");
|
|
expect(required("device-change-success").textContent).toBe("Foyer TV now shows Signage.");
|
|
expect(document.activeElement).toBe(required("device-change-device-1"));
|
|
|
|
// Signage → Bulletin.
|
|
await click("device-change-device-1");
|
|
expect(required("device-change-shows").textContent).toBe("Signage");
|
|
await choose("device-change-shows", "Bulletin");
|
|
await click("device-change-save");
|
|
expect(required("device-device-1-showing").textContent).toBe("Showing: Bulletin");
|
|
|
|
// Bulletin → nothing.
|
|
await click("device-change-device-1");
|
|
await choose("device-change-shows", "Nothing yet");
|
|
await click("device-change-save");
|
|
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
|
|
expect(required("device-change-success").textContent).toContain("Foyer TV now shows nothing");
|
|
|
|
expect(puts()).toEqual([
|
|
["/api/v1/devices/device-1/display", { moduleId: "signage" }],
|
|
["/api/v1/devices/device-1/display", { moduleId: "bulletin" }],
|
|
["/api/v1/devices/device-1/display", { moduleId: null }],
|
|
]);
|
|
// Always the one display: no new identity, and the same name.
|
|
expect(platform.devices.map((device) => device.id)).toEqual(["device-1"]);
|
|
expect(container.querySelectorAll('[data-testid^="device-device-"]').length).toBeGreaterThan(0);
|
|
expect(container.querySelectorAll('[data-testid="device-device-1"]')).toHaveLength(1);
|
|
});
|
|
|
|
it("closes on Cancel or Escape without changing anything", async () => {
|
|
const platform = await renderPage();
|
|
await click("device-change-device-1");
|
|
await choose("device-change-shows", "Signage");
|
|
await click("device-change-cancel");
|
|
expect(query("device-change-dialog")).toBeNull();
|
|
expect(platform.requests.some((r) => r.method === "PUT")).toBe(false);
|
|
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Revoking a display strands that screen until someone re-pairs it in person, so the page explains
|
|
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
|
|
*/
|
|
describe("display revocation", () => {
|
|
it("asks in a product dialog before revoking, and does nothing on cancel", async () => {
|
|
const confirm = vi.fn(() => true);
|
|
Object.assign(window, { confirm });
|
|
const platform = await renderPage();
|
|
|
|
await click("device-revoke-device-1");
|
|
const dialog = container.querySelector('[role="dialog"]');
|
|
expect(dialog?.textContent).toContain("Revoke Foyer TV?");
|
|
expect(dialog?.textContent).toContain("pairing code");
|
|
|
|
await click("device-revoke-cancel");
|
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
|
expect(platform.requests.some((r) => r.path.endsWith("/revoke"))).toBe(false);
|
|
expect(confirm).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("removes a revoked display from the list at once, and says what happened", async () => {
|
|
const platform = await renderPage();
|
|
|
|
await click("device-revoke-device-1");
|
|
await click("device-revoke-confirm");
|
|
await act(async () => settle());
|
|
|
|
expect(platform.requests.filter((r) => r.path.endsWith("/revoke"))).toHaveLength(1);
|
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
|
// Gone from the active list without a reload, though the server keeps it as history.
|
|
expect(query("device-device-1")).toBeNull();
|
|
expect(platform.devices[0]?.revoked).toBe(true);
|
|
expect(required("device-list-empty")).not.toBeNull();
|
|
expect(required("device-revoke-success").textContent).toContain("Foyer TV is revoked");
|
|
// Its card is gone, so focus lands on what happened rather than on the page.
|
|
expect(document.activeElement).toBe(required("device-outcome"));
|
|
});
|
|
});
|