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
+4
View File
@@ -16,6 +16,10 @@
"types": "./dist/dashboard-assets.d.ts",
"import": "./dist/dashboard-assets.js"
},
"./dashboard-routes": {
"types": "./dist/dashboard-routes.d.ts",
"import": "./dist/dashboard-routes.js"
},
"./display-assets": {
"types": "./dist/display-assets.d.ts",
"import": "./dist/display-assets.js"
+7 -1
View File
@@ -89,7 +89,13 @@ await copyFile(
if (serve) {
const context = await esbuild.context(buildOptions);
await context.watch();
const { host, port } = await context.serve({ servedir: outputDirectory });
// Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as
// `/modules/example` reaches the shell's router here as it does from `apps/api` (ADR 0057).
// Development only: the API serves the page at exactly the Dashboard's reserved paths instead.
const { host, port } = await context.serve({
fallback: path.join(outputDirectory, "index.html"),
servedir: outputDirectory,
});
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
console.log(
apiBaseUrl === ""
@@ -0,0 +1,82 @@
import { designToken } from "@ekklesia/design-tokens";
import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from "react";
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
import type { HostHistory } from "./host-history.js";
export interface DashboardLinkProps {
readonly history: HostHistory;
readonly to: DashboardDestination;
readonly children: ReactNode;
/** Marks the link as the page currently open, for assistive technology and visually. */
readonly current?: boolean;
readonly variant?: "navigation" | "action";
readonly "data-testid"?: string;
}
const variantClass = {
action: "ek-button ek-focusable ek-button--secondary",
navigation: "ek-button ek-focusable ek-button--quiet",
} as const;
const navigationStyle: CSSProperties = {
justifyContent: "flex-start",
textDecoration: "none",
width: "100%",
};
/** The open page is shown by weight as well as colour, so it never depends on colour alone. */
const currentStyle: CSSProperties = {
...navigationStyle,
background: designToken("state.selected-background"),
color: designToken("state.selected-foreground"),
fontWeight: designToken("typography.weight-bold"),
};
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
return (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
);
}
/**
* A link to a Dashboard page, as a real `<a href>` (ADR 0057).
*
* A real link rather than a button, because it is one: it can be opened in a new tab, copied, and
* bookmarked, and assistive technology announces it as a link. An ordinary click opens the page in
* place through the shell's history instead of reloading the document; every other click — with a
* modifier key, or the middle button — is left to the browser.
*/
export function DashboardLink(props: DashboardLinkProps): ReactElement {
const href = dashboardPath(props.to);
const variant = props.variant ?? "navigation";
return (
<a
aria-current={props.current ? "page" : undefined}
className={variantClass[variant]}
data-testid={props["data-testid"]}
href={href}
onClick={(event) => {
if (isBrowserHandledClick(event)) {
return;
}
event.preventDefault();
props.history.push(href);
}}
style={
variant === "action"
? { textDecoration: "none" }
: props.current
? currentStyle
: navigationStyle
}
>
{props.children}
</a>
);
}
+160 -79
View File
@@ -1,7 +1,15 @@
import { designToken } from "@ekklesia/design-tokens";
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { Button } from "@ekklesia/ui";
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import {
type ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import {
HostAuthError,
type HostAuthLanding,
@@ -12,6 +20,7 @@ import {
signInWithEmail,
signOutHost,
} from "../auth-protocol.js";
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
import {
bootstrapHost,
type DashboardNavigationEntry,
@@ -20,8 +29,11 @@ import {
HostProtocolError,
loadHostPrincipal,
} from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import type { HostHistory } from "./host-history.js";
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js";
@@ -101,6 +113,15 @@ export interface HostAppProps {
* so there is nothing an open redirect could use.
*/
readonly authLanding?: HostAuthLanding;
/**
* The address bar (ADR 0057). The current path decides which page is open, so a refresh, a
* bookmark, and Back and Forward all land where the person was. The entry point passes the
* browser's; tests pass an in-memory one. Must be a stable reference.
*/
readonly history: HostHistory;
/** Names the browser tab after the open page. Supplied by the entry point, which owns `document`;
* left unset, the shell names nothing. */
readonly setDocumentTitle?: (title: string) => void;
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter;
}
@@ -167,6 +188,8 @@ const shellStyles = {
main: {
boxSizing: "border-box",
minWidth: 0,
// A programmatic focus target after a page change (never in the tab order), not a control.
outline: "none",
overflowX: "auto",
padding: designToken("spacing.xl"),
},
@@ -194,25 +217,30 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
];
}
function ModuleButton(props: {
function ModuleLink(props: {
readonly entry: DashboardNavigationEntry;
readonly selected: boolean;
readonly onSelect: () => void;
readonly history: HostHistory;
readonly route: DashboardRoute;
}): ReactElement {
const moduleId = props.entry.descriptor.id;
return (
<Button
aria-current={props.selected ? "page" : undefined}
aria-pressed={props.selected}
data-testid={`select-${props.entry.descriptor.id}`}
onClick={props.onSelect}
style={{ justifyContent: "flex-start", width: "100%" }}
variant="quiet"
<DashboardLink
current={props.route.kind === "module" && props.route.moduleId === moduleId}
data-testid={`select-${moduleId}`}
history={props.history}
to={{ kind: "module", moduleId }}
>
{props.entry.name}
</Button>
</DashboardLink>
);
}
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
* not a page change. */
function routeKey(route: DashboardRoute): string {
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
}
function HostMessage(props: {
readonly children: string;
readonly testId: string;
@@ -265,21 +293,27 @@ async function resolveBootstrapState(
/**
* The Ekklesia Dashboard shell and its host-owned authentication boundary.
*
* The login form replaces the protected application in place. There is no redirect parameter, URL
* parser, location assignment, or client router in this shell, so the current internal Dashboard URL
* simply remains the current URL through sign-in and no external/protocol-relative return value can
* become a navigation sink. Modules receive the same five-field context after authentication; they
* neither render platform login nor receive sign-in/sign-out controls.
* The login form replaces the protected application in place. There is no redirect parameter,
* return value, or location assignment in this shell, so the current internal Dashboard URL simply
* remains the current URL through sign-in — a deep link opens on its own page once the person has
* signed in — and no external/protocol-relative value can become a navigation sink. The shell only
* ever moves between its own paths (ADR 0057). Modules receive the same context after
* authentication; they neither render platform login nor receive sign-in/sign-out controls.
*/
export function HostApp(props: HostAppProps): ReactElement {
const [state, setState] = useState<BootstrapState>({ status: "loading" });
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
const activeTransitionRef = useRef<AbortController | undefined>(undefined);
const revalidationRef = useRef<AbortController | undefined>(undefined);
const generationRef = useRef(0);
const mountedRef = useRef(true);
const { config, importModule } = props;
const mainRef = useRef<HTMLElement>(null);
const shownRouteRef = useRef<string | undefined>(undefined);
const { config, history, importModule, setDocumentTitle } = props;
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
const readPathname = useCallback(() => history.pathname(), [history]);
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
const route = useMemo(() => parseDashboardPath(pathname), [pathname]);
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
// cannot resurrect a screen the person has left.
@@ -306,14 +340,16 @@ export function HostApp(props: HostAppProps): ReactElement {
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
setState(resolved);
if (resolved.status === "ready") {
const first = allEntries(resolved.bootstrap)[0];
setSelectedKey(first ? moduleKey(first) : undefined);
} else {
setSelectedKey(undefined);
}
}, []);
// A non-canonical spelling of a real page (`/modules/example/`) is corrected in place, adding no
// history entry, so what is bookmarked or shared next is the one canonical address.
useEffect(() => {
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
history.replace(dashboardPath(route));
}
}, [history, pathname, route]);
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -329,7 +365,6 @@ export function HostApp(props: HostAppProps): ReactElement {
void bootstrapAttempt;
const { controller, generation } = beginTransition();
setState({ status: "loading" });
setSelectedKey(undefined);
void resolveBootstrapState(config, controller.signal).then((resolved) => {
if (!isCurrent(controller, generation)) {
return;
@@ -381,7 +416,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const { controller, generation } = beginTransition();
// The authenticated subtree disappears before the request starts. That synchronously schedules
// ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions.
setSelectedKey(undefined);
setState({ status: "signing-out" });
try {
await signOutHost(config, controller.signal);
@@ -435,7 +469,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition();
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
setAuthView({ kind: "sign-in" });
setState({ status: "anonymous" });
}
@@ -451,7 +484,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const transition = beginTransition();
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
console.error("[ekklesia] session revalidation failed");
setState({ status: "failed" });
})
@@ -464,6 +496,41 @@ export function HostApp(props: HostAppProps): ReactElement {
[beginTransition, config],
);
const openEntry =
state.status === "ready" && route.kind === "module"
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
: undefined;
const pageTitle =
state.status !== "ready"
? undefined
: route.kind === "overview"
? "Overview"
: route.kind === "module"
? (openEntry?.name ?? "This page isn’t available")
: "Page not found";
const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle;
useEffect(() => {
if (documentTitle !== undefined) {
setDocumentTitle?.(documentTitle);
}
}, [documentTitle, setDocumentTitle]);
// Moving to another page moves keyboard and screen-reader focus to the new page's content, as a
// full page load would have. The first page shown after sign-in or a refresh is left alone, and a
// canonical respelling of the same page is not a move.
const currentRouteKey = routeKey(route);
useEffect(() => {
if (state.status !== "ready") {
shownRouteRef.current = undefined;
return;
}
if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) {
mainRef.current?.focus();
}
shownRouteRef.current = currentRouteKey;
}, [currentRouteKey, state.status]);
const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
if (state.status === "loading") {
@@ -553,7 +620,6 @@ export function HostApp(props: HostAppProps): ReactElement {
const { bootstrap } = state;
const entries = allEntries(bootstrap);
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
const { groups, ungrouped } = bootstrap.navigation;
return (
@@ -569,56 +635,71 @@ export function HostApp(props: HostAppProps): ReactElement {
</header>
<aside style={shellStyles.aside}>
<DevicePanel config={config} />
{entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : (
<nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}>
{groups.map((group) => (
<section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
<div style={{ display: "grid", gap: "0.125rem" }}>
{group.modules.map((entry) => (
<ModuleButton
entry={entry}
key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))}
selected={moduleKey(entry) === selectedKey}
/>
))}
</div>
</section>
))}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
<div style={{ display: "grid", gap: "0.125rem" }}>
{ungrouped.map((entry) => (
<ModuleButton
entry={entry}
key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))}
selected={moduleKey(entry) === selectedKey}
/>
))}
</div>
</section>
) : undefined}
</nav>
)}
<nav aria-label="Dashboard" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<DashboardLink
current={route.kind === "overview"}
data-testid="select-overview"
history={history}
to={{ kind: "overview" }}
>
Overview
</DashboardLink>
{entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : undefined}
{groups.map((group) => (
<section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
<div style={{ display: "grid", gap: "0.125rem" }}>
{group.modules.map((entry) => (
<ModuleLink
entry={entry}
history={history}
key={moduleKey(entry)}
route={route}
/>
))}
</div>
</section>
))}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
<div style={{ display: "grid", gap: "0.125rem" }}>
{ungrouped.map((entry) => (
<ModuleLink
entry={entry}
history={history}
key={moduleKey(entry)}
route={route}
/>
))}
</div>
</section>
) : undefined}
</nav>
</aside>
<main style={shellStyles.main}>
{selected ? (
<ModuleUiFrame
config={config}
descriptor={selected.descriptor}
{...(importModule ? { importModule } : {})}
key={moduleKey(selected)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal}
/>
) : undefined}
<main aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}>
{route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
<ModuleUiFrame
config={config}
descriptor={openEntry.descriptor}
{...(importModule ? { importModule } : {})}
key={moduleKey(openEntry)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal}
/>
) : (
<ModuleUnavailablePage history={history} />
)
) : (
<NotFoundPage history={history} />
)}
</main>
</div>
);
+66
View File
@@ -0,0 +1,66 @@
import { EmptyState, PageHeader } from "@ekklesia/ui";
import type { ReactElement } from "react";
import { DashboardLink } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
/**
* The Dashboard's landing page (DESIGN.md §5, ADR 0057).
*
* The Overview is where the Dashboard opens. Its cards will come from modules through a contract
* that does not exist yet, so for now it honestly says it is empty rather than inventing content.
*/
export function OverviewPage(): ReactElement {
return (
<div data-testid="host-overview">
<PageHeader title="Overview" />
<EmptyState
description="Summaries from your modules will appear here as they become available. Choose a module in the navigation to open it."
title="Nothing to show yet"
/>
</div>
);
}
/**
* A module page whose module is not available to this Dashboard: it was disabled or removed, it
* provides no user interface, or the address was mistyped. The server's navigation is the only
* authority on what exists, and it does not say which of these applies, so neither does this page.
* It never echoes the address back.
*/
export function ModuleUnavailablePage(props: { readonly history: HostHistory }): ReactElement {
return (
<div data-testid="host-module-unavailable">
<PageHeader
actions={<OverviewAction history={props.history} />}
description="The module it belongs to may have been turned off or removed. If you expected to see it, ask an administrator."
title="This page isn’t available"
/>
</div>
);
}
/** Any other address inside the Dashboard. */
export function NotFoundPage(props: { readonly history: HostHistory }): ReactElement {
return (
<div data-testid="host-not-found">
<PageHeader
actions={<OverviewAction history={props.history} />}
description="There is nothing at this address. Check it, or start again from the Overview."
title="Page not found"
/>
</div>
);
}
function OverviewAction(props: { readonly history: HostHistory }): ReactElement {
return (
<DashboardLink
data-testid="host-go-to-overview"
history={props.history}
to={{ kind: "overview" }}
variant="action"
>
Go to Overview
</DashboardLink>
);
}
+128
View File
@@ -0,0 +1,128 @@
/**
* The shell's one view of the address bar (ADR 0057).
*
* The Dashboard's URL is the source of truth for which page is open: a refresh, a bookmark, and the
* browser's Back and Forward buttons all have to land on the same page that was showing. Everything
* in the shell reads the current path and moves between pages through this small interface rather
* than through `window.history`, for two reasons. It keeps the entry point the only file in this
* package that touches a browser global — the browser implementation is handed the window's pieces,
* it does not reach for them. And it lets every routing behaviour be tested against an in-memory
* history whose entries a test can inspect.
*
* Deliberately not a routing library. The shell's pages are a handful of host-owned screens plus one
* mounted module, and `react-router-dom` is refused to modules precisely because routing belongs to
* the host (ADR 0026); adopting it here would prejudge how a module's own pages are addressed, which
* a later decision owns.
*/
export interface HostHistory {
/** The current path, without query or fragment. */
pathname(): string;
/** Opens a new page, adding a history entry. Opening the page already showing adds none. */
push(path: string): void;
/** Corrects the current entry in place, adding none — for a non-canonical spelling of the same
* page. */
replace(path: string): void;
/** Notifies after every change, whether made here or by the browser's Back and Forward. */
subscribe(listener: () => void): () => void;
}
/** The parts of a `Window` the browser history needs, passed in by the entry point. */
export interface HostHistoryWindow {
readonly location: Pick<Location, "pathname">;
readonly history: Pick<History, "pushState" | "replaceState">;
addEventListener(type: "popstate", listener: () => void): void;
removeEventListener(type: "popstate", listener: () => void): void;
}
/**
* The address bar itself. `pushState` and `replaceState` fire no event of their own, so this
* notifies its subscribers after each one; `popstate` covers Back and Forward.
*/
export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory {
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) {
listener();
}
};
return {
pathname: () => view.location.pathname,
push(path) {
if (path === view.location.pathname) {
return;
}
view.history.pushState(null, "", path);
notify();
},
replace(path) {
view.history.replaceState(null, "", path);
notify();
},
subscribe(listener) {
if (listeners.size === 0) {
view.addEventListener("popstate", notify);
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
view.removeEventListener("popstate", notify);
}
};
},
};
}
/** An in-memory history with the same semantics, whose entries a caller can inspect and step
* through. Used by tests; production always uses {@link createBrowserHostHistory}. */
export interface MemoryHostHistory extends HostHistory {
readonly entries: readonly string[];
readonly index: number;
/** Steps like the browser's Back (`-1`) and Forward (`1`) buttons. */
go(delta: number): void;
}
export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory {
const entries: string[] = [initialPath];
let index = 0;
const listeners = new Set<() => void>();
const notify = (): void => {
for (const listener of [...listeners]) {
listener();
}
};
return {
get entries() {
return [...entries];
},
get index() {
return index;
},
pathname: () => entries[index] ?? "/",
push(path) {
if (path === entries[index]) {
return;
}
entries.splice(index + 1, entries.length, path);
index = entries.length - 1;
notify();
},
replace(path) {
entries[index] = path;
notify();
},
go(delta) {
const next = Math.min(Math.max(index + delta, 0), entries.length - 1);
if (next !== index) {
index = next;
notify();
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
+15 -2
View File
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import { consumeAuthLanding } from "../auth-protocol.js";
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
import { HostApp } from "./HostApp.js";
import { createBrowserHostHistory } from "./host-history.js";
/**
* The one place the shell touches a browser global.
@@ -23,6 +24,13 @@ const apiBaseUrl = resolveApiOrigin(
* `consumeAuthLanding` for why the token must not stay in the URL. */
const authLanding = consumeAuthLanding(window.location, window.history);
/** The address bar, handed to the shell once so no other file reads `window.location` (ADR 0057). */
const history = createBrowserHostHistory(window);
const setDocumentTitle = (title: string): void => {
document.title = title;
};
const container = document.getElementById("ekklesia-host");
if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
@@ -53,9 +61,14 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
);
}
return authLanding ? (
<HostApp authLanding={authLanding} config={config} />
<HostApp
authLanding={authLanding}
config={config}
history={history}
setDocumentTitle={setDocumentTitle}
/>
) : (
<HostApp config={config} />
<HostApp config={config} history={history} setDocumentTitle={setDocumentTitle} />
);
}
+88
View File
@@ -0,0 +1,88 @@
/**
* The Dashboard's URL space (ADR 0057): which paths are Dashboard pages, and what each one means.
*
* Framework-free and free of browser globals, because two programs read it. The shell parses the
* address bar with it, and `apps/api` serves the Dashboard page at exactly the namespaces it lists,
* so a refreshed or bookmarked deep link reaches the shell instead of a `404`. One list, imported by
* both, is what keeps the server from serving a path the shell cannot interpret, or the shell from
* linking to a path the server would refuse.
*/
/**
* Every namespace the Dashboard owns besides `/` itself. Reserved whole, including paths no page uses
* yet, so that adding a Settings or Account page never needs another server or proxy change.
*
* None of these overlaps a namespace something else owns: `/api` (the API), `/display` (the display
* host), and `/assets` (both hosts' scripts).
*/
export const DASHBOARD_PAGE_PREFIXES = Object.freeze([
"/modules",
"/settings",
"/account",
] as const);
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
export type DashboardRoute =
| { readonly kind: "overview" }
| { readonly kind: "module"; readonly moduleId: string }
| { readonly kind: "not-found" };
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
/** Whether the Dashboard page is served at this path: the root, or anything inside a reserved
* namespace. Says nothing about whether a page exists there; the shell decides that. */
export function isDashboardPagePath(pathname: string): boolean {
return (
pathname === "/" ||
DASHBOARD_PAGE_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
)
);
}
function decodeSegment(segment: string): string | undefined {
try {
const decoded = decodeURIComponent(segment);
return decoded.length > 0 ? decoded : undefined;
} catch {
return undefined;
}
}
/**
* Reads a path into a route.
*
* `/modules/<id>` names a module's page. The module id is only decoded here, never validated against
* a pattern: the shell compares it with the navigation the server resolved, and an id that matches
* no entry is an unavailable module, not a malformed URL. Everything below `/modules/<id>/` belongs
* to that module's own pages, which need a contract that does not exist yet, so it is `not-found`
* for now rather than silently showing the module's first page. One trailing slash is tolerated, as
* a person typing a URL expects; the shell then replaces it with the canonical spelling.
*/
export function parseDashboardPath(pathname: string): DashboardRoute {
if (pathname === "/") {
return { kind: "overview" };
}
const segments = pathname.split("/");
if (segments[0] !== "") {
return { kind: "not-found" };
}
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
if (meaningful.length === 2 && meaningful[0] === "modules") {
const moduleId = decodeSegment(meaningful[1] ?? "");
if (moduleId !== undefined) {
return { kind: "module", moduleId };
}
}
return { kind: "not-found" };
}
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
* own path segment. */
export function dashboardPath(route: DashboardDestination): string {
if (route.kind === "overview") {
return "/";
}
return `/modules/${encodeURIComponent(route.moduleId)}`;
}
+8 -1
View File
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
/**
@@ -94,7 +95,13 @@ function platform(stub: AuthStub = {}): Platform {
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
await act(async () => {
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />);
root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/")}
{...(authLanding ? { authLanding } : {})}
/>,
);
});
}
@@ -0,0 +1,144 @@
// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DASHBOARD_PAGE_PREFIXES,
dashboardPath,
isDashboardPagePath,
parseDashboardPath,
} from "../src/dashboard-routes.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("the Dashboard URL space", () => {
it("reserves exactly the platform's page namespaces, frozen", () => {
expect(Object.isFrozen(DASHBOARD_PAGE_PREFIXES)).toBe(true);
expect(DASHBOARD_PAGE_PREFIXES).toEqual(["/modules", "/settings", "/account"]);
});
it("never reserves a namespace the API, display host, or host assets already own", () => {
for (const prefix of DASHBOARD_PAGE_PREFIXES) {
for (const owned of ["/api", "/display", "/assets"]) {
expect(prefix === owned || prefix.startsWith(`${owned}/`), prefix).toBe(false);
expect(owned.startsWith(`${prefix}/`), prefix).toBe(false);
}
}
});
it("recognizes the root and every path inside a reserved namespace as a Dashboard page", () => {
for (const pathname of [
"/",
"/modules",
"/modules/",
"/modules/signage",
"/modules/signage/timelines/abc",
"/settings",
"/settings/displays/add",
"/account",
"/account/appearance",
]) {
expect(isDashboardPagePath(pathname), pathname).toBe(true);
}
});
it("does not claim anything outside them, including look-alike prefixes", () => {
for (const pathname of [
"",
"/api/v1/principal",
"/display/signage",
"/assets/ekklesia-dashboard/app.js",
"/modulesx",
"/settings-old",
"/accounts",
"/index.html",
"/login",
"modules/signage",
]) {
expect(isDashboardPagePath(pathname), pathname).toBe(false);
}
});
});
describe("parseDashboardPath", () => {
it("makes the root the Overview", () => {
expect(parseDashboardPath("/")).toEqual({ kind: "overview" });
});
it("names a module page by its id, tolerating one trailing slash", () => {
expect(parseDashboardPath("/modules/signage")).toEqual({
kind: "module",
moduleId: "signage",
});
expect(parseDashboardPath("/modules/signage/")).toEqual({
kind: "module",
moduleId: "signage",
});
});
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
expect(parseDashboardPath("/modules/sign%61ge")).toEqual({
kind: "module",
moduleId: "signage",
});
});
it("treats every other shape as not found rather than guessing", () => {
for (const pathname of [
"",
"/modules",
"/modules/",
"/modules//",
"//modules/signage",
"/modules/signage//",
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
"/modules/signage/timelines",
"/modules/%E0%A4%A",
"/settings",
"/settings/displays",
"/account",
"/overview",
"/index.html",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
});
describe("dashboardPath", () => {
it("spells each route canonically", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/modules/signage");
});
it("encodes a module id so it can never escape its own segment", () => {
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/modules/a%2F..%2Fb");
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
kind: "module",
moduleId: "a/../b",
});
});
it("round-trips through the parser", () => {
for (const route of [
{ kind: "overview" } as const,
{ kind: "module", moduleId: "example" } as const,
]) {
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
expect(isDashboardPagePath(dashboardPath(route))).toBe(true);
}
});
});
describe("the route locator is published for the API that serves the page", () => {
it("is a narrow package subpath, not the shell root barrel", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { exports?: Record<string, unknown> };
expect(manifest.exports?.["./dashboard-routes"]).toEqual({
import: "./dist/dashboard-routes.js",
types: "./dist/dashboard-routes.d.ts",
});
});
});
+10 -1
View File
@@ -3,6 +3,7 @@ import { act, Component, type ReactElement, type ReactNode } 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 } from "../src/browser/host-history.js";
import { DisplayApp } from "../src/display/DisplayApp.js";
import type {
EkklesiaHostConfig,
@@ -393,7 +394,15 @@ describe("display host — full-viewport boundary", () => {
});
};
await act(async () => root.render(<HostApp config={config} importModule={importer} />));
await act(async () =>
root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/modules/announcements")}
importModule={importer}
/>,
),
);
// The management artifact rendered inside its own isolated root, not the shell's own tree.
expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain(
"announcement management",
+59 -9
View File
@@ -3,6 +3,7 @@ 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 { createBrowserHostHistory } from "../src/browser/host-history.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
@@ -122,12 +123,27 @@ function artifact(id: string, version: string, options: ArtifactOptions = {}) {
};
}
/**
* Renders the shell over the real (happy-dom) address bar. `path` opens that page first, exactly as
* a deep link or a refresh would (ADR 0057); left out, the shell opens wherever the location already
* is — `/`, the Overview, unless a test put something else there.
*/
async function render(
config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>,
path?: string,
): Promise<void> {
if (path !== undefined) {
window.history.replaceState(null, "", path);
}
await act(async () => {
root.render(<HostApp config={config} {...(importModule ? { importModule } : {})} />);
root.render(
<HostApp
config={config}
history={createBrowserHostHistory(window)}
{...(importModule ? { importModule } : {})}
/>,
);
});
}
@@ -268,6 +284,11 @@ describe("Ekklesia shell — bootstrap", () => {
});
describe("Ekklesia shell — browser authentication", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/modules/example");
});
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
let releasePrincipal: (() => void) | undefined;
const principalPending = new Promise<void>((resolve) => {
@@ -539,6 +560,11 @@ describe("Ekklesia shell — browser authentication", () => {
});
describe("Ekklesia shell — logout and session expiration", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/modules/example");
});
it("unmounts module UI and closes realtime before completing server-side logout", async () => {
let releaseSignOut: (() => void) | undefined;
const signOutPending = new Promise<void>((resolve) => {
@@ -856,10 +882,8 @@ describe("Ekklesia shell — Dashboard navigation", () => {
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
expect(labels).toEqual(["Planning", "Media"]);
const buttons = [...container.querySelectorAll("nav button")].map(
(button) => button.textContent,
);
expect(buttons).toEqual(["Calendar", "Livestream", "Video"]);
const links = [...container.querySelectorAll("nav section a")].map((link) => link.textContent);
expect(links).toEqual(["Calendar", "Livestream", "Video"]);
});
it("renders ungrouped modules alongside groups, under a heading", async () => {
@@ -897,14 +921,29 @@ describe("Ekklesia shell — Dashboard navigation", () => {
expect(query("group-media")?.textContent).toBe("Media");
});
it("selects the first module in the arranged order and marks it active", async () => {
it("opens on the Overview rather than choosing a module, whatever the arrangement", async () => {
const importModule = vi.fn(async () => artifact("video", "0.1.0"));
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"),
importModule,
);
expect(query("select-video")?.getAttribute("aria-current")).toBe("page");
expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull();
expect(query("select-overview")?.getAttribute("aria-current")).toBe("page");
expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
expect(query("module-container")).toBeNull();
expect(importModule).not.toHaveBeenCalled();
});
it("marks the open module's link as the current page", async () => {
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async () => artifact("livestream", "2.0.0"),
"/modules/livestream",
);
expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page");
expect(query("select-video")?.getAttribute("aria-current")).toBeNull();
expect(query("select-overview")?.getAttribute("aria-current")).toBeNull();
});
it("mounts a module selected from inside a group, using the descriptor the group carried", async () => {
@@ -915,6 +954,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
requested.push(url);
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
},
"/modules/video",
);
await act(async () => {
@@ -972,6 +1012,11 @@ describe("Ekklesia shell — Dashboard navigation", () => {
});
describe("Ekklesia shell — mount lifecycle", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/modules/example");
});
it("keeps fixed shell tracks while module content changes size", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"));
@@ -1140,6 +1185,11 @@ describe("Ekklesia shell — mount lifecycle", () => {
});
describe("Ekklesia shell — failure is host-owned", () => {
// These tests exercise a mounted module, so each opens the default fixture's own page, exactly as
// its deep link would (ADR 0057). The Dashboard root is the Overview and mounts nothing.
beforeEach(() => {
window.history.replaceState(null, "", "/modules/example");
});
it("renders its own error when the artifact cannot be imported", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing");
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserHostHistory, createMemoryHostHistory } from "../src/browser/host-history.js";
describe("the browser host history", () => {
beforeEach(() => {
window.history.replaceState(null, "", "/");
});
afterEach(() => {
window.history.replaceState(null, "", "/");
});
it("reads the address bar's path and moves it with real history entries", () => {
const history = createBrowserHostHistory(window);
const lengthBefore = window.history.length;
history.push("/modules/example");
expect(history.pathname()).toBe("/modules/example");
expect(window.location.pathname).toBe("/modules/example");
expect(window.history.length).toBe(lengthBefore + 1);
});
it("adds no entry when the page asked for is already open", () => {
const history = createBrowserHostHistory(window);
history.push("/modules/example");
const length = window.history.length;
const listener = vi.fn();
history.subscribe(listener);
history.push("/modules/example");
expect(window.history.length).toBe(length);
expect(listener).not.toHaveBeenCalled();
});
it("corrects the current entry in place with replace", () => {
const history = createBrowserHostHistory(window);
history.push("/modules/example/");
const length = window.history.length;
history.replace("/modules/example");
expect(window.location.pathname).toBe("/modules/example");
expect(window.history.length).toBe(length);
});
it("notifies after its own changes and after the browser's Back and Forward", () => {
const history = createBrowserHostHistory(window);
const listener = vi.fn();
const unsubscribe = history.subscribe(listener);
history.push("/modules/a");
history.replace("/modules/b");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(listener).toHaveBeenCalledTimes(3);
unsubscribe();
window.dispatchEvent(new PopStateEvent("popstate"));
history.push("/modules/c");
expect(listener).toHaveBeenCalledTimes(3);
});
it("listens to popstate only while something is subscribed", () => {
const view = {
addEventListener: vi.fn(),
history: { pushState: vi.fn(), replaceState: vi.fn() },
location: { pathname: "/" },
removeEventListener: vi.fn(),
};
const history = createBrowserHostHistory(view);
const first = history.subscribe(() => undefined);
const second = history.subscribe(() => undefined);
expect(view.addEventListener).toHaveBeenCalledTimes(1);
first();
expect(view.removeEventListener).not.toHaveBeenCalled();
second();
expect(view.removeEventListener).toHaveBeenCalledTimes(1);
expect(view.removeEventListener.mock.calls[0]?.[1]).toBe(
view.addEventListener.mock.calls[0]?.[1],
);
});
it("stores no state object, so nothing a page put in history can be read back as authority", () => {
const view = {
addEventListener: vi.fn(),
history: { pushState: vi.fn(), replaceState: vi.fn() },
location: { pathname: "/" },
removeEventListener: vi.fn(),
};
const history = createBrowserHostHistory(view);
history.push("/modules/a");
history.replace("/modules/b");
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/modules/a");
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/modules/b");
});
});
describe("the in-memory host history", () => {
it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => {
const history = createMemoryHostHistory("/");
history.push("/modules/a");
history.push("/modules/b");
history.go(-1);
expect(history.pathname()).toBe("/modules/a");
history.push("/modules/c");
expect(history.entries).toEqual(["/", "/modules/a", "/modules/c"]);
history.go(-10);
expect(history.pathname()).toBe("/");
history.go(10);
expect(history.pathname()).toBe("/modules/c");
});
});
+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"]);
});
});
+1
View File
@@ -11,6 +11,7 @@
"include": [
"src/index.ts",
"src/dashboard-assets.ts",
"src/dashboard-routes.ts",
"src/display-assets.ts",
"src/host-protocol.ts",
"src/module-context.ts"