Pair displays at the platform's own /display/setup, with or without modules
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 45s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m34s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m52s

Displays are platform-owned, but a screen could only start pairing on a
module's display page: every pairing request named a module, so an
installation with no module that uses displays could not set one up.

- /display/setup is the platform's own display page, served whenever
  the deployment has device support, whatever is installed. It pairs a
  platform display with no module. Once paired, it opens the module it
  is set to show; with none set, it shows its name and waits, rechecking
  so it stays alive and notices revocation. "setup" is now a reserved
  module id.
- Migration core:0009_platform_display_pairing lets a pairing request
  name no module. It relaxes module_id and permission_id and adds a
  both-or-neither check. It is additive and destroys nothing.
- POST /api/v1/device-pairing/requests accepts {} for a platform
  display. Approval accepts an optional moduleId, so the administrator
  can choose an installed display module and the device gets that
  module's declared permission. A module display's own request can't be
  redirected.
- GET /api/v1/device-pairing/self lets a paired device describe itself:
  its name and the module displays it may show. It renews the device's
  cookie.
- GET /api/v1/devices/setup is replaced by
  GET /api/v1/devices/display-modules: the modules a display can be set
  to show.
- Settings -> Displays always offers the /display/setup address, Copy
  setup link and Add display. Add display has an optional "Show on this
  screen" choice, and each paired display says what it shows.
- /display/<module-id> is unchanged, so released Signage 0.1.5 works as
  before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-16 16:43:02 -05:00
co-authored by Claude Opus 5.5
parent 906c015ca8
commit 300d9eab51
44 changed files with 3169 additions and 396 deletions
+113 -36
View File
@@ -600,33 +600,112 @@ check(
),
"the sidebar is navigation only, and arriving at Displays focuses no field",
);
// ADR 0065: how to set up a new screen, in one place — the platform display page of every module
// that uses displays, on the API origin where the display host lives, with Add display right under
// it; or a plain note, and no Add display, when no installed module uses displays.
// ADR 0065: how to set up a new screen, in one place — the platform's own /display/setup on the API
// origin, whatever is installed, with Add display right under it.
check(
await waitFor(
`${selector("display-setup")} && !${selector("display-setup-failed")} && (${selector("display-setup-none")} || (${selector("display-setup")}.contains(${selector("add-display")}) && [...document.querySelectorAll('[data-testid="display-setup-url"]')].every((element) => element.textContent.startsWith(${JSON.stringify(`${apiOrigin}/display/`)}))))`,
`${selector("display-setup")}?.contains(${selector("add-display")}) && ${selector("display-setup-url")}?.textContent === ${JSON.stringify(`${apiOrigin}/display/setup`)}`,
"the display setup steps",
),
"Settings → Displays explains how to set up a new screen, from the platform's own display page",
"Settings → Displays sets up a new screen at the platform's own /display/setup, with Add display",
);
const displayModuleInstalled = !(await evaluate(`Boolean(${selector("display-setup-none")})`));
if (displayModuleInstalled) {
await evaluate(`${selector("add-display")}.click()`);
check(
await waitFor(
`${selector("add-display-dialog")} && document.activeElement === ${selector("device-pairing-code-input")}`,
"the Add display dialog with its code field focused",
),
"Add display opens a dialog that focuses the pairing code only once asked",
);
await evaluate(`${selector("add-display-cancel")}.click()`);
await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing");
} else {
check(
!(await evaluate(`Boolean(${selector("add-display")})`)),
"with no module that uses displays, Settings → Displays offers no Add display that could only fail",
);
await evaluate(`${selector("add-display")}.click()`);
check(
await waitFor(
`${selector("add-display-dialog")} && document.activeElement === ${selector("device-pairing-code-input")}`,
"the Add display dialog with its code field focused",
),
"Add display opens a dialog that focuses the pairing code only once asked",
);
await evaluate(`${selector("add-display-cancel")}.click()`);
await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing");
// ADR 0065, end to end: a new screen — its own browser context, holding no session, as a real
// screen would — opens the platform's /display/setup, shows a code, and is added through the real
// Settings form. It needs no module: chosen, it opens that module's display by itself; not chosen,
// it shows its name and waits.
{
const { browserContextId } = await cdp.send("Target.createBrowserContext", {});
const kiosk = await cdp.send("Target.createTarget", { browserContextId, url: "about:blank" });
const { sessionId: kioskSession } = await cdp.send("Target.attachToTarget", {
flatten: true,
targetId: kiosk.targetId,
});
const kioskEvaluate = async (expression) => {
const result = await cdp.send(
"Runtime.evaluate",
{ awaitPromise: true, expression, returnByValue: true },
kioskSession,
);
return result.exceptionDetails ? undefined : result.result.value;
};
const kioskWaitFor = async (expression) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await kioskEvaluate(`Boolean(${expression})`).catch(() => false)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
return false;
};
await cdp.send("Page.navigate", { url: `${apiOrigin}/display/setup` }, kioskSession);
const shown = (await kioskWaitFor(
`/^[0-9]{6}$/.test(${selector("display-pairing-code")}?.textContent ?? "")`,
))
? await kioskEvaluate(`${selector("display-pairing-code")}.textContent`)
: undefined;
check(shown !== undefined, "a new screen at /display/setup shows a six-digit pairing code");
if (shown !== undefined) {
await evaluate(`${selector("add-display")}.click()`);
await waitFor(selector("device-pairing-code-input"), "the Add display code field");
await evaluate(`(() => {
const input = ${selector("device-pairing-code-input")};
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, ${JSON.stringify(shown)});
input.dispatchEvent(new Event("input", { bubbles: true }));
input.form.requestSubmit();
})()`);
check(
await waitFor(
`${selector("device-pairing-found")}?.textContent.includes("A new screen is waiting to be paired")`,
"the platform screen found by its code",
),
"Add display finds the screen from /display/setup as a new platform display, with no module",
);
const chosen = await evaluate(`(() => {
const name = ${selector("device-pairing-name")};
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(name, "Smoke platform display");
name.dispatchEvent(new Event("input", { bubbles: true }));
const shows = ${selector("device-pairing-shows")};
const option = shows ? [...shows.options].find((candidate) => candidate.value !== "") : undefined;
if (option) {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set.call(shows, option.value);
shows.dispatchEvent(new Event("change", { bubbles: true }));
}
return option ? option.value : "";
})()`);
await waitFor(`!${selector("device-pairing-approve")}?.disabled`, "Pair display enabled");
await evaluate(`${selector("device-pairing-approve")}.click()`);
check(
await waitFor(
`!${selector("add-display-dialog")} && [...document.querySelectorAll('[data-testid^="device-"]')].some((card) => card.textContent.includes("Smoke platform display"))`,
"the new platform display in the list",
),
"the paired platform display appears under Paired displays without a reload",
);
check(
await kioskWaitFor(
chosen === ""
? `${selector("display-setup-waiting-headline")}?.textContent === "Smoke platform display"`
: `location.pathname === ${JSON.stringify(`/display/${chosen}`)}`,
),
chosen === ""
? "with no module to show, the paired screen shows its name and waits"
: "the paired screen opens the module display it was set to show, by itself",
);
}
await cdp.send("Target.closeTarget", { targetId: kiosk.targetId });
await cdp.send("Target.disposeBrowserContext", { browserContextId });
}
await navigate(`${dashboardOrigin}/settings/discover`);
check(
@@ -698,20 +777,18 @@ check(
"choosing a page from the drawer releases the page lock",
);
// ADR 0065: on a phone the pairing code is typed at 16px or more, so iOS Safari does not zoom.
if (displayModuleInstalled) {
await navigate(`${dashboardOrigin}/settings/displays`);
await waitFor(selector("add-display"), "Settings → Displays on a phone");
await evaluate(`${selector("add-display")}.click()`);
check(
await waitFor(
`${selector("device-pairing-code-input")} && parseFloat(getComputedStyle(${selector("device-pairing-code-input")}).fontSize) >= 16`,
"the pairing code field's size on a phone",
),
"the pairing code field is at least 16px on a phone, so iOS does not zoom into it",
);
await evaluate(`${selector("add-display-cancel")}.click()`);
await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing on a phone");
}
await navigate(`${dashboardOrigin}/settings/displays`);
await waitFor(selector("add-display"), "Settings → Displays on a phone");
await evaluate(`${selector("add-display")}.click()`);
check(
await waitFor(
`${selector("device-pairing-code-input")} && parseFloat(getComputedStyle(${selector("device-pairing-code-input")}).fontSize) >= 16`,
"the pairing code field's size on a phone",
),
"the pairing code field is at least 16px on a phone, so iOS does not zoom into it",
);
await evaluate(`${selector("add-display-cancel")}.click()`);
await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing on a phone");
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 1,
height: 1080,