feat(shell): give every Dashboard page a real URL
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m17s
CI / image-build (pull_request) Successful in 1m10s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s

The Dashboard had one address. The API served the page only at `/`, both
reference proxies forwarded only `/`, and the open module lived in React
state, so a refresh went back to the first module, nothing could be
bookmarked, and Back left the Dashboard (DESIGN.md §2.7).

ADR 0057 makes the address bar the source of truth:

- `/` is a host-owned Overview, now the landing page; `/modules/<id>`
  mounts that module. An id the server's navigation does not list shows
  "This page isn't available" and loads nothing; any other Dashboard
  address is a host-owned "Page not found".
- `/modules`, `/settings`, and `/account` are reserved whole, from one
  list (`@ekklesia/shell/dashboard-routes`) that the shell's router and
  the API's page routes both read. The API serves the same frozen page,
  ETag, and security headers anywhere inside them; look-alikes and other
  methods stay the sanitized 404.
- A small host-owned history replaces the selected-module state, built in
  the entry point so it stays the only file touching `window`. Navigation
  items are real links; modified clicks are left to the browser. Focus
  moves to the page after an in-place change, and the tab is named after
  the page.
- Sign-in still happens in place with no return value (ADR 0034), so a
  deep link opens its own page after sign-in.
- Both reference Caddyfiles forward the new paths; the deploy smoke now
  reloads a module's address through the real proxy and requires it to
  mount again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 08:11:02 -05:00
co-authored by Claude Opus 5.5
parent 44430b5d59
commit 9afe9376c4
31 changed files with 1634 additions and 120 deletions
+349
View File
@@ -0,0 +1,349 @@
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("/modules/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("/modules/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("/modules/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", "/modules/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("corrects a trailing slash to the canonical address in place, adding no history entry", async () => {
const { history, imported } = await render("/modules/calendar/");
expect(history.entries).toEqual(["/modules/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("/modules/calendar");
const { imported } = await render("/modules/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(["/modules/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", "/modules/signage"],
["select-calendar", "/modules/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(["/", "/modules/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("/modules/calendar");
await click(query("select-calendar"));
expect(history.entries).toEqual(["/modules/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("/modules/missing");
await click(query("host-go-to-overview"));
expect(history.entries).toEqual(["/modules/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"]);
});
});