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
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:
co-authored by
Claude Opus 5.5
parent
9afe9376c4
commit
ba60f70835
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,7 +398,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
root.render(
|
||||
<HostApp
|
||||
config={config}
|
||||
history={createMemoryHostHistory("/modules/announcements")}
|
||||
history={createMemoryHostHistory("/announcements")}
|
||||
importModule={importer}
|
||||
/>,
|
||||
),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user