Files
Ekklesia/surfaces/shell/tests/host-routing.test.tsx
T
ekklesiaandClaude Opus 5.5 89ef4b5ff6
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s
feat(shell): Settings hierarchy, Discover, and /settings/<module-id>
The Settings foundation (ADR 0061):

- URL model. /settings is a real landing page. The platform sections
  are /settings/general, /branding, /displays, and /system. Module
  lifecycle is Discover: /settings/discover/browse, /installed, and
  /updates. /settings/discover is respelled in place to Browse's one
  canonical address. A module's settings are /settings/<module-id>,
  never nested under Discover. Deeper Settings addresses are
  reserved. "Modules" stays the user-facing word: "Discover modules",
  "Installed modules", "Module updates".
- Collisions. Core's one reserved list gains the Settings section
  names (branding, discover, displays, general, system) as a second
  documented group, so no module id can shadow a section; `modules` is
  an ordinary id. apps/api tests hold it equal to the shell's sections.
- Navigation. One Settings item in the same sidebar expands in place
  into Platform and Modules (and Discover's three views) while in
  Settings, and the phone drawer has the same hierarchy. The rest of
  the navigation stays.
- Permissions. GET /api/v1/dashboard/settings evaluates, through
  Core's evaluator, which destinations the viewer may see:
  - Displays: core.devices.manage
  - Discover: core.modules.lifecycle.manage
  - a module's settings: core.modules.configuration.manage, for each
    module that declares configuration (ADR 0029)
  - General, Branding, System: the platform owner, until each has its
    own permission
  These are presentation hints; every data route keeps its own
  enforcement. With nothing visible, there is no Settings link.
- Pages are honest placeholders with Back to Settings. A disabled
  module's settings say it is turned off; a section or module the
  person cannot see says so without detail; an unknown name is not
  found; a failed load withholds the link and offers Retry.
- The deploy smoke opens Settings by deep link, refreshes a section,
  enters Discover, opens the module's settings, uses the phone
  drawer, and returns to the Dashboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:22:50 -05:00

364 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedText } from "./support/isolated-dom.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* The Dashboard's URL is the source of truth for which page is open (ADR 0057). Every test drives
* the real shell through an in-memory history, so it can assert on the exact entries a person's
* Back and Forward buttons would walk.
*/
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
});
function entry(id: string, name: string, version = "0.1.0") {
return {
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
contributions: [],
display: null,
id,
permissions: [],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
ui: { url: `/api/v1/module-ui/${id}/${version}` },
version,
},
name,
};
}
const NAVIGATION = {
groups: [{ id: "media", label: "Media", modules: [entry("signage", "Signage")], order: 10 }],
ungrouped: [entry("calendar", "Calendar", "2.0.0")],
};
function platform(options: { authenticated?: boolean } = {}) {
let authenticated = options.authenticated ?? true;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/auth/sign-in/email")) {
void init;
authenticated = true;
return Response.json({ ok: true });
}
if (url.endsWith("/api/v1/principal")) {
return Response.json(
authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
);
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return authenticated
? Response.json(NAVIGATION)
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
return Response.json({ code: "NOT_FOUND" }, { status: 404 });
}) as unknown as typeof fetch;
return fetchImplementation;
}
function artifactFor(url: string) {
const id = url.includes("/calendar/") ? "calendar" : "signage";
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id,
mount(element: Element, context: ModuleUiHostContext) {
element.textContent = `${context.moduleId} mounted`;
return () => {
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
}
interface Rendered {
readonly history: MemoryHostHistory;
readonly imported: string[];
readonly titles: string[];
}
async function render(
path: string,
options: { authenticated?: boolean; history?: MemoryHostHistory } = {},
): Promise<Rendered> {
const history = options.history ?? createMemoryHostHistory(path);
const imported: string[] = [];
const titles: string[] = [];
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: platform(options) };
await act(async () => {
root.render(
<HostApp
config={config}
history={history}
importModule={async (url) => {
imported.push(url);
return artifactFor(url);
}}
setDocumentTitle={(title) => titles.push(title)}
/>,
);
});
return { history, imported, titles };
}
function query(testId: string): HTMLElement | null {
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
}
async function click(element: Element | null, init: MouseEventInit = {}): Promise<MouseEvent> {
if (!element) {
throw new Error("missing element");
}
const event = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init });
await act(async () => {
element.dispatchEvent(event);
});
return event;
}
async function go(history: MemoryHostHistory, delta: number): Promise<void> {
await act(async () => {
history.go(delta);
});
}
describe("the Dashboard opens on the page its URL names", () => {
it("lands on the Overview at the root, and mounts no module", async () => {
const { imported, titles } = await render("/");
expect(query("host-overview")).not.toBeNull();
expect(query("host-overview")?.querySelector("h1")?.textContent).toBe("Overview");
expect(query("module-container")).toBeNull();
expect(imported).toEqual([]);
expect(query("select-overview")?.getAttribute("aria-current")).toBe("page");
expect(titles.at(-1)).toBe("Overview");
});
it("opens a module's page directly from its deep link — what a refresh or a bookmark does", async () => {
const { imported, titles } = await render("/calendar");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
expect(query("select-calendar")?.getAttribute("aria-current")).toBe("page");
expect(query("select-overview")?.getAttribute("aria-current")).toBeNull();
expect(query("host-overview")).toBeNull();
expect(titles.at(-1)).toBe("Calendar");
});
it("opens a module from inside a group exactly as one outside it", async () => {
const { imported } = await render("/signage");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`]);
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
});
it("says a module page is unavailable when no navigable module has that id, and loads nothing", async () => {
const { imported, titles } = await render("/disabled-module");
expect(query("host-module-unavailable")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(imported).toEqual([]);
// The address is never echoed back into the page.
expect(container.textContent).not.toContain("disabled-module");
expect(titles.at(-1)).toBe("This page isn’t available");
expect(query("host-go-to-overview")?.getAttribute("href")).toBe("/");
});
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
for (const path of ["/settings/displays/add", "/calendar/events", "/account"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
const { imported, titles } = await render(path);
expect(query("host-not-found"), path).not.toBeNull();
expect(imported, path).toEqual([]);
expect(titles.at(-1), path).toBe("Page not found");
// Nothing in the navigation claims to be the open page.
expect(container.querySelector('[aria-current="page"]'), path).toBeNull();
}
});
it("addresses module pages at the top level, beside the platform's own pages, and never confuses the two", async () => {
// Core refuses a module whose id is a platform page, but even a navigation that somehow listed
// one could not turn `/settings` into a module page.
const { imported } = await render("/settings");
// `/settings` is the Settings area (ADR 0061) — here its view could not be loaded, since this
// stub answers nothing for it — and never a module page.
expect(query("settings-load-failed")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(imported).toEqual([]);
expect(query("select-signage")?.getAttribute("href")).toBe("/signage");
expect(container.innerHTML).not.toContain("/modules/");
});
it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => {
const { history, imported } = await render("/calendar/");
expect(history.entries).toEqual(["/calendar"]);
expect(imported).toHaveLength(1);
});
it("opens the deep link it was given once the person signs in, with no redirect", async () => {
const history = createMemoryHostHistory("/calendar");
const { imported } = await render("/calendar", { authenticated: false, history });
expect(query("host-login")).not.toBeNull();
const email = query("login-email") as HTMLInputElement;
const password = query("login-password") as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
await act(async () => {
setter?.call(email, "[email protected]");
email.dispatchEvent(new Event("input", { bubbles: true }));
setter?.call(password, "correct password");
password.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
query("host-login")
?.querySelector("form")
?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(history.entries).toEqual(["/calendar"]);
});
});
describe("moving between pages", () => {
it("renders navigation as real links to canonical addresses", async () => {
await render("/");
for (const [testId, href] of [
["select-overview", "/"],
["select-signage", "/signage"],
["select-calendar", "/calendar"],
] as const) {
const link = query(testId);
expect(link?.tagName, testId).toBe("A");
expect(link?.getAttribute("href"), testId).toBe(href);
}
expect(container.querySelector('nav[aria-label="Dashboard"]')).not.toBeNull();
});
it("opens a page in place on an ordinary click, adding one history entry", async () => {
const { history, imported } = await render("/");
const event = await click(query("select-calendar"));
expect(event.defaultPrevented).toBe(true);
expect(history.entries).toEqual(["/", "/calendar"]);
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
});
it("leaves a modified or middle click to the browser, so a page can open in a new tab", async () => {
const { history } = await render("/");
for (const init of [
{ ctrlKey: true },
{ metaKey: true },
{ shiftKey: true },
{ altKey: true },
{ button: 1 },
]) {
const event = await click(query("select-calendar"), init);
expect(event.defaultPrevented, JSON.stringify(init)).toBe(false);
}
expect(history.entries).toEqual(["/"]);
});
it("adds no entry when the open page's own link is clicked again", async () => {
const { history } = await render("/calendar");
await click(query("select-calendar"));
expect(history.entries).toEqual(["/calendar"]);
});
it("walks Back and Forward through exactly the pages that were open", async () => {
const { history, imported } = await render("/");
await click(query("select-signage"));
await click(query("select-calendar"));
await go(history, -1);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(query("select-signage")?.getAttribute("aria-current")).toBe("page");
await go(history, -1);
expect(query("host-overview")).not.toBeNull();
expect(query("module-container")).toBeNull();
await go(history, 1);
expect(isolatedText(query("module-container"))).toBe("signage mounted");
expect(imported).toEqual([
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`,
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`,
]);
});
it("returns to the Overview from an unavailable page through its own link", async () => {
const { history } = await render("/missing");
await click(query("host-go-to-overview"));
expect(history.entries).toEqual(["/missing", "/"]);
expect(query("host-overview")).not.toBeNull();
});
it("moves focus to the new page's content after a page change, but not on first load", async () => {
await render("/");
const main = container.querySelector("main");
expect(main?.getAttribute("tabindex")).toBe("-1");
expect(document.activeElement).not.toBe(main);
await click(query("select-calendar"));
expect(document.activeElement).toBe(main);
expect(main?.getAttribute("aria-label")).toBe("Calendar");
});
it("names the browser tab after each page as it opens", async () => {
const { titles } = await render("/");
await click(query("select-signage"));
await click(query("select-overview"));
expect(titles).toEqual(["Overview", "Signage", "Overview"]);
});
});