feat(shell): make module pages first-class top-level routes
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m12s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s

Module pages move from `/modules/<id>` to `/<module-id>` (`/signage`),
beside `/settings` and `/account`, so the implementation concept
"module" never appears in a user-facing address. ADR 0057 is rewritten
to match.

- Core reserves the top-level names the platform actually serves today
  (`account`, `api`, `assets`, `display`, `settings`), and discovery
  refuses a module that declares one with `MODULE_ID_RESERVED`. That
  covers startup, `modules:validate`, and package verification. Tests in
  `apps/api` hold the set to every top-level route the API registers and
  every page the shell interprets.
- The API serves the Dashboard page at `/<module-id>` and below only for
  modules discovered with a Dashboard UI, enabled or not: a disabled
  module's bookmark still opens the Dashboard, which says it is
  unavailable. An arbitrary root path such as
  `/definitely-not-a-real-module` stays the sanitized 404.
- Both reference proxies forward every top-level path shaped like a
  module id and let the API decide; the split Dashboard hostname still
  refuses `/api`, `/display`, and `/assets`. Installing a module needs no
  proxy change.
- `/<module-id>/<page>` is already served and forwarded, so a later
  module sub-page contract needs no routing or deployment change; the
  shell shows "Page not found" there until it exists.
- The deploy smoke checks a module's address before install, while
  installed, while disabled, after removal, and after reinstall, plus
  unknown names and the old `/modules/…` form through the real proxy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 08:42:34 -05:00
co-authored by Claude Opus 5.5
parent 9afe9376c4
commit ba60f70835
26 changed files with 745 additions and 334 deletions
+2 -2
View File
@@ -90,8 +90,8 @@ if (serve) {
const context = await esbuild.context(buildOptions);
await context.watch();
// 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.
// `/example` reaches the shell's router here as it does from `apps/api` (ADR 0057).
// Development only: the API serves the page only at its platform pages and installed modules'.
const { host, port } = await context.serve({
fallback: path.join(outputDirectory, "index.html"),
servedir: outputDirectory,
+1 -1
View File
@@ -342,7 +342,7 @@ export function HostApp(props: HostAppProps): ReactElement {
setState(resolved);
}, []);
// A non-canonical spelling of a real page (`/modules/example/`) is corrected in place, adding no
// A non-canonical spelling of a real page (`/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) {
+38 -37
View File
@@ -1,25 +1,31 @@
/**
* The Dashboard's URL space (ADR 0057): which paths are Dashboard pages, and what each one means.
* The Dashboard's URL space (ADR 0057): what each Dashboard path 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.
* address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a
* refreshed or bookmarked deep link reaches the shell instead of a `404`.
*
* Module pages and platform pages share one top-level namespace:
*
* ```text
* / Overview
* /settings[/…] Settings
* /account[/…] the account area
* /<module-id>[/…] a module's page, and later its own sub-pages
* ```
*
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
* Dashboard UI, and Core refuses any module whose id is a platform-owned name (`RESERVED_MODULE_IDS`
* in `@ekklesia/core/modules`), so a module page can never shadow a platform page and an arbitrary
* root path never becomes one.
*/
/**
* 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).
* The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page
* uses yet, so adding a Settings or Account page needs no server or proxy change. Each is also a
* reserved module id in Core; `apps/api` tests hold the two lists together.
*/
export const DASHBOARD_PAGE_PREFIXES = Object.freeze([
"/modules",
"/settings",
"/account",
] as const);
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["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 =
@@ -30,16 +36,7 @@ export type DashboardRoute =
/** 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}/`),
)
);
}
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
function decodeSegment(segment: string): string | undefined {
try {
@@ -53,12 +50,15 @@ function decodeSegment(segment: string): string | 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.
* A single top-level segment that is not a platform page names a module's page. The 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 `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), 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. Settings and Account pages are likewise `not-found` until they exist. 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 === "/") {
@@ -69,11 +69,12 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
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 };
}
const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
if (first === undefined || platformPages.has(first)) {
return { kind: "not-found" };
}
if (meaningful.length === 1) {
return { kind: "module", moduleId: first };
}
return { kind: "not-found" };
}
@@ -84,5 +85,5 @@ export function dashboardPath(route: DashboardDestination): string {
if (route.kind === "overview") {
return "/";
}
return `/modules/${encodeURIComponent(route.moduleId)}`;
return `/${encodeURIComponent(route.moduleId)}`;
}
+27 -77
View File
@@ -4,60 +4,17 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DASHBOARD_PAGE_PREFIXES,
DASHBOARD_PLATFORM_PAGES,
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("the Dashboard's own top-level pages", () => {
it("are exactly Settings and Account, frozen", () => {
expect(Object.isFrozen(DASHBOARD_PLATFORM_PAGES)).toBe(true);
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
});
@@ -66,40 +23,34 @@ describe("parseDashboardPath", () => {
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("names a module page by its id at the top level, tolerating one trailing slash", () => {
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
});
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",
});
expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
});
it("never reads a platform page as a module page", () => {
for (const pathname of ["/settings", "/settings/", "/account", "/account/appearance"]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("treats every other shape as not found rather than guessing", () => {
for (const pathname of [
"",
"/modules",
"/modules/",
"/modules//",
"//modules/signage",
"/modules/signage//",
"//",
"//signage",
"/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",
"/signage/timelines",
"/signage/timelines/abc",
"/%E0%A4%A",
"/settings/displays/add",
"signage",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
@@ -107,13 +58,13 @@ describe("parseDashboardPath", () => {
});
describe("dashboardPath", () => {
it("spells each route canonically", () => {
it("spells each route canonically, with no implementation prefix", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/modules/signage");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/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(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
kind: "module",
moduleId: "a/../b",
@@ -126,7 +77,6 @@ describe("dashboardPath", () => {
{ kind: "module", moduleId: "example" } as const,
]) {
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
expect(isDashboardPagePath(dashboardPath(route))).toBe(true);
}
});
});
+1 -1
View File
@@ -398,7 +398,7 @@ describe("display host — full-viewport boundary", () => {
root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/modules/announcements")}
history={createMemoryHostHistory("/announcements")}
importModule={importer}
/>,
),
+6 -6
View File
@@ -287,7 +287,7 @@ 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");
window.history.replaceState(null, "", "/example");
});
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
let releasePrincipal: (() => void) | undefined;
@@ -563,7 +563,7 @@ 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");
window.history.replaceState(null, "", "/example");
});
it("unmounts module UI and closes realtime before completing server-side logout", async () => {
let releaseSignOut: (() => void) | undefined;
@@ -938,7 +938,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async () => artifact("livestream", "2.0.0"),
"/modules/livestream",
"/livestream",
);
expect(query("select-livestream")?.getAttribute("aria-current")).toBe("page");
@@ -954,7 +954,7 @@ describe("Ekklesia shell — Dashboard navigation", () => {
requested.push(url);
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
},
"/modules/video",
"/video",
);
await act(async () => {
@@ -1015,7 +1015,7 @@ 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");
window.history.replaceState(null, "", "/example");
});
it("keeps fixed shell tracks while module content changes size", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"));
@@ -1188,7 +1188,7 @@ 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");
window.history.replaceState(null, "", "/example");
});
it("renders its own error when the artifact cannot be imported", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
+21 -21
View File
@@ -14,21 +14,21 @@ describe("the browser host history", () => {
const history = createBrowserHostHistory(window);
const lengthBefore = window.history.length;
history.push("/modules/example");
history.push("/example");
expect(history.pathname()).toBe("/modules/example");
expect(window.location.pathname).toBe("/modules/example");
expect(history.pathname()).toBe("/example");
expect(window.location.pathname).toBe("/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");
history.push("/example");
const length = window.history.length;
const listener = vi.fn();
history.subscribe(listener);
history.push("/modules/example");
history.push("/example");
expect(window.history.length).toBe(length);
expect(listener).not.toHaveBeenCalled();
@@ -36,12 +36,12 @@ describe("the browser host history", () => {
it("corrects the current entry in place with replace", () => {
const history = createBrowserHostHistory(window);
history.push("/modules/example/");
history.push("/example/");
const length = window.history.length;
history.replace("/modules/example");
history.replace("/example");
expect(window.location.pathname).toBe("/modules/example");
expect(window.location.pathname).toBe("/example");
expect(window.history.length).toBe(length);
});
@@ -50,14 +50,14 @@ describe("the browser host history", () => {
const listener = vi.fn();
const unsubscribe = history.subscribe(listener);
history.push("/modules/a");
history.replace("/modules/b");
history.push("/a");
history.replace("/b");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(listener).toHaveBeenCalledTimes(3);
unsubscribe();
window.dispatchEvent(new PopStateEvent("popstate"));
history.push("/modules/c");
history.push("/c");
expect(listener).toHaveBeenCalledTimes(3);
});
@@ -92,28 +92,28 @@ describe("the browser host history", () => {
};
const history = createBrowserHostHistory(view);
history.push("/modules/a");
history.replace("/modules/b");
history.push("/a");
history.replace("/b");
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/modules/a");
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/modules/b");
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/a");
expect(view.history.replaceState).toHaveBeenCalledWith(null, "", "/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.push("/a");
history.push("/b");
history.go(-1);
expect(history.pathname()).toBe("/modules/a");
expect(history.pathname()).toBe("/a");
history.push("/modules/c");
expect(history.entries).toEqual(["/", "/modules/a", "/modules/c"]);
history.push("/c");
expect(history.entries).toEqual(["/", "/a", "/c"]);
history.go(-10);
expect(history.pathname()).toBe("/");
history.go(10);
expect(history.pathname()).toBe("/modules/c");
expect(history.pathname()).toBe("/c");
});
});
+28 -17
View File
@@ -166,7 +166,7 @@ describe("the Dashboard opens on the page its URL names", () => {
});
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");
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");
@@ -177,14 +177,14 @@ describe("the Dashboard opens on the page its URL names", () => {
});
it("opens a module from inside a group exactly as one outside it", async () => {
const { imported } = await render("/modules/signage");
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("/modules/disabled-module");
const { imported, titles } = await render("/disabled-module");
expect(query("host-module-unavailable")).not.toBeNull();
expect(query("module-container")).toBeNull();
@@ -196,7 +196,7 @@ describe("the Dashboard opens on the page its URL names", () => {
});
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"]) {
for (const path of ["/settings/displays", "/calendar/events", "/account"]) {
await act(async () => {
root.unmount();
});
@@ -211,16 +211,27 @@ describe("the Dashboard opens on the page its URL names", () => {
}
});
it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => {
const { history, imported } = await render("/modules/calendar/");
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");
expect(history.entries).toEqual(["/modules/calendar"]);
expect(query("host-not-found")).not.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("/modules/calendar");
const { imported } = await render("/modules/calendar", { authenticated: false, history });
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;
@@ -240,7 +251,7 @@ describe("the Dashboard opens on the page its URL names", () => {
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"]);
expect(history.entries).toEqual(["/calendar"]);
});
});
@@ -250,8 +261,8 @@ describe("moving between pages", () => {
for (const [testId, href] of [
["select-overview", "/"],
["select-signage", "/modules/signage"],
["select-calendar", "/modules/calendar"],
["select-signage", "/signage"],
["select-calendar", "/calendar"],
] as const) {
const link = query(testId);
expect(link?.tagName, testId).toBe("A");
@@ -266,7 +277,7 @@ describe("moving between pages", () => {
const event = await click(query("select-calendar"));
expect(event.defaultPrevented).toBe(true);
expect(history.entries).toEqual(["/", "/modules/calendar"]);
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");
});
@@ -288,11 +299,11 @@ describe("moving between pages", () => {
});
it("adds no entry when the open page's own link is clicked again", async () => {
const { history } = await render("/modules/calendar");
const { history } = await render("/calendar");
await click(query("select-calendar"));
expect(history.entries).toEqual(["/modules/calendar"]);
expect(history.entries).toEqual(["/calendar"]);
});
it("walks Back and Forward through exactly the pages that were open", async () => {
@@ -319,11 +330,11 @@ describe("moving between pages", () => {
});
it("returns to the Overview from an unavailable page through its own link", async () => {
const { history } = await render("/modules/missing");
const { history } = await render("/missing");
await click(query("host-go-to-overview"));
expect(history.entries).toEqual(["/modules/missing", "/"]);
expect(history.entries).toEqual(["/missing", "/"]);
expect(query("host-overview")).not.toBeNull();
});