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
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:
co-authored by
Claude Opus 5.5
parent
44430b5d59
commit
9afe9376c4
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user