Files
Ekklesia/surfaces/shell/tests/host-app.test.tsx
ekklesia 3a26ef49ad
CI / verify (pull_request) Successful in 48s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m47s
feat: establish shared Ekklesia design system
2026-09-30 18:21:09 -05:00

1255 lines
44 KiB
TypeScript

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 type { EkklesiaHostConfig } from "../src/host-protocol.js";
import { isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
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;
window.history.replaceState({}, "", "/");
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
// The shell deliberately reports failures to the browser console; silence them so a passing run
// is quiet, and so a test can assert *that* it reported without the noise.
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
});
/** One navigation entry, in the wire shape the Dashboard navigation endpoint produces: the module's
* display name beside the untouched mount descriptor. */
function entry(id: string, name: string, version = "0.1.0", permissions: readonly string[] = []) {
return {
module: {
contributions: [],
api: { baseUrl: `/api/v1/modules/${id}` },
display: null,
id,
permissions,
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
ui: { url: `/api/v1/module-ui/${id}/${version}` },
version,
},
name,
};
}
function group(id: string, label: string, modules: ReturnType<typeof entry>[], order = 10) {
return { id, label, modules, order };
}
interface PlatformStub {
readonly groups?: ReadonlyArray<ReturnType<typeof group>>;
readonly ungrouped?: ReadonlyArray<ReturnType<typeof entry>>;
readonly principal?: unknown;
readonly navigationStatus?: number;
readonly navigationThrows?: boolean;
}
function platformFetch(stub: PlatformStub = {}): typeof fetch {
const groups = stub.groups ?? [];
const ungrouped = stub.ungrouped ?? (stub.groups ? [] : [entry("example", "Example Module")]);
return vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return new Response(
JSON.stringify(stub.principal ?? { kind: "authenticated", userId: "user-1" }),
{ headers: { "content-type": "application/json" }, status: 200 },
);
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
if (stub.navigationThrows) {
throw new TypeError("network down");
}
return new Response(JSON.stringify({ groups, ungrouped }), {
headers: { "content-type": "application/json" },
status: stub.navigationStatus ?? 200,
});
}
return new Response("{}", { status: 404 });
}) as unknown as typeof fetch;
}
interface ArtifactOptions {
readonly mountThrows?: boolean;
readonly unmountThrows?: boolean;
/** Records the container each mount received, so a test can prove a stale mount never touched the
* newer selection's element. */
readonly mountedInto?: Element[];
}
/** A module UI artifact namespace, exactly the shape the generic loader parses. */
function artifact(id: string, version: string, options: ArtifactOptions = {}) {
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id,
mount(element: Element, context: ModuleUiHostContext) {
options.mountedInto?.push(element);
if (options.mountThrows) {
throw new Error("module mount failed");
}
element.textContent = `${context.moduleId} mounted`;
return () => {
if (options.unmountThrows) {
throw new Error("module cleanup failed");
}
element.textContent = "";
};
},
version,
},
};
}
async function render(
config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>,
): Promise<void> {
await act(async () => {
root.render(<HostApp config={config} {...(importModule ? { importModule } : {})} />);
});
}
function text(): string {
return container.textContent ?? "";
}
function query(testId: string): Element | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
async function setInput(testId: string, value: string): Promise<void> {
const input = query(testId) as HTMLInputElement | null;
if (!input) {
throw new Error(`missing input ${testId}`);
}
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function submitLogin(): Promise<void> {
const form = query("host-login")?.querySelector("form");
if (!form) {
throw new Error("missing login form");
}
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
interface StatefulAuthPlatform {
readonly calls: Array<{ readonly init: RequestInit | undefined; readonly url: string }>;
readonly fetch: typeof fetch;
authenticated(): boolean;
setAuthenticated(value: boolean): void;
}
function statefulAuthPlatform(
options: {
readonly initiallyAuthenticated?: boolean;
readonly moduleStatus?: () => number;
readonly onModuleRequest?: () => Promise<void>;
readonly onPrincipalRequest?: (authenticated: boolean) => Promise<void>;
readonly onSignIn?: () => Promise<void>;
readonly onSignOut?: () => Promise<void>;
} = {},
): StatefulAuthPlatform {
let authenticated = options.initiallyAuthenticated ?? false;
const calls: StatefulAuthPlatform["calls"] = [];
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ init, url });
if (url.endsWith("/api/auth/sign-in/email")) {
await options.onSignIn?.();
const body = JSON.parse(String(init?.body)) as { email?: unknown; password?: unknown };
if (body.email !== "[email protected]" || body.password !== "correct password") {
return Response.json({ code: "INVALID_EMAIL_OR_PASSWORD" }, { status: 401 });
}
authenticated = true;
return Response.json({ ok: true });
}
if (url.endsWith("/api/auth/sign-out")) {
await options.onSignOut?.();
authenticated = false;
return Response.json({ success: true });
}
if (url.endsWith("/api/v1/principal")) {
await options.onPrincipalRequest?.(authenticated);
return Response.json(
authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
);
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return authenticated
? Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] })
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
if (url.includes("/api/v1/modules/example/")) {
await options.onModuleRequest?.();
return new Response("{}", { status: options.moduleStatus?.() ?? 200 });
}
return Response.json({ code: "NOT_FOUND" }, { status: 404 });
}) as unknown as typeof fetch;
return {
calls,
fetch: implementation,
authenticated: () => authenticated,
setAuthenticated: (value) => {
authenticated = value;
},
};
}
describe("Ekklesia shell — bootstrap", () => {
it("renders the module list the navigation returned", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
}),
});
expect(query("select-example")).not.toBeNull();
expect(query("select-calendar")).not.toBeNull();
});
it("renders the host login for an anonymous viewer, and mounts nothing", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ principal: { kind: "anonymous" } }) });
expect(query("host-login")).not.toBeNull();
expect(query("module-container")).toBeNull();
});
it("keeps an authenticated navigation denial distinct from sign-out", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ navigationStatus: 403 }) });
expect(query("host-access-denied")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(query("host-logout")).not.toBeNull();
});
it("renders a host-owned error when the platform cannot be reached", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ navigationThrows: true }) });
expect(query("host-error")).not.toBeNull();
expect(text()).not.toContain("network down");
});
it("says so plainly when no installed module provides a UI", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ groups: [], ungrouped: [] }) });
expect(query("host-empty")).not.toBeNull();
});
});
describe("Ekklesia shell — browser authentication", () => {
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) => {
releasePrincipal = resolve;
});
const calls: string[] = [];
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/api/v1/principal")) {
await principalPending;
return Response.json({ kind: "anonymous" });
}
return Response.json({ groups: [], ungrouped: [] });
}) as unknown as typeof fetch;
await render({ apiBaseUrl, fetch: fetchImplementation });
expect(query("host-loading")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(query("host-logout")).toBeNull();
expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]);
await act(async () => {
releasePrincipal?.();
await principalPending;
});
expect(query("host-login")).not.toBeNull();
expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]);
});
it("renders an accessible semantic email/password form with a real reset action and no signup", async () => {
const platform = statefulAuthPlatform();
await render({ apiBaseUrl, fetch: platform.fetch });
const email = query("login-email") as HTMLInputElement;
const password = query("login-password") as HTMLInputElement;
const form = query("host-login")?.querySelector("form");
const submit = form?.querySelector('button[type="submit"]') as HTMLButtonElement;
expect(email.type).toBe("email");
expect(email.autocomplete).toBe("username");
expect(email.labels?.[0]?.textContent).toContain("Email");
expect(password.type).toBe("password");
expect(password.autocomplete).toBe("current-password");
expect(password.labels?.[0]?.textContent).toContain("Password");
expect(submit.textContent).toBe("Sign in");
expect(form?.tagName).toBe("FORM");
// Public registration remains disabled, so the shell still offers no way to ask for an account.
expect(text()).not.toContain("Create account");
expect(text()).not.toContain("Sign up");
// Password recovery is no longer a dead action: Phase 7C Pass 2 gave it a real backend flow, so
// the control exists and is a real button rather than decorative text.
const forgot = query("login-forgot-password") as HTMLButtonElement;
expect(forgot.tagName).toBe("BUTTON");
expect(forgot.type).toBe("button");
expect(forgot.textContent).toBe("Forgot password?");
});
it("fails invalid credentials generically, clears the password, and never puts it in a URL", async () => {
const platform = statefulAuthPlatform();
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "wrong and secret");
await submitLogin();
const error = query("host-login")?.querySelector('[role="alert"]');
expect(error?.textContent).toBe("Email or password is incorrect.");
expect((query("login-email") as HTMLInputElement).value).toBe("[email protected]");
expect((query("login-password") as HTMLInputElement).value).toBe("");
expect(document.activeElement).toBe(query("login-password"));
expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("wrong and secret");
expect(consoleError).not.toHaveBeenCalled();
});
it("prevents duplicate submissions while one sign-in is pending", async () => {
let releaseSignIn: (() => void) | undefined;
const signInPending = new Promise<void>((resolve) => {
releaseSignIn = resolve;
});
const platform = statefulAuthPlatform({ onSignIn: () => signInPending });
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
await submitLogin();
const signInCalls = platform.calls.filter((call) =>
call.url.endsWith("/api/auth/sign-in/email"),
);
expect(signInCalls).toHaveLength(1);
expect(
query("host-login")?.querySelector('button[type="submit"]')?.hasAttribute("disabled"),
).toBe(true);
await act(async () => {
releaseSignIn?.();
await signInPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
it("clears the password while post-sign-in principal resolution is still pending", async () => {
let releasePrincipal: (() => void) | undefined;
const principalPending = new Promise<void>((resolve) => {
releasePrincipal = resolve;
});
const platform = statefulAuthPlatform({
onPrincipalRequest: (authenticated) => (authenticated ? principalPending : Promise.resolve()),
});
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
expect(platform.authenticated()).toBe(true);
expect((query("login-password") as HTMLInputElement).value).toBe("");
expect(
JSON.parse(
String(
platform.calls.find((call) => call.url.endsWith("/api/auth/sign-in/email"))?.init?.body,
),
),
).toEqual({ email: "[email protected]", password: "correct password" });
expect(query("host-login")?.querySelector("form")?.getAttribute("aria-busy")).toBe("true");
await act(async () => {
releasePrincipal?.();
await principalPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
it("submits from the password field with the Enter key", async () => {
let releaseSignIn: (() => void) | undefined;
const signInPending = new Promise<void>((resolve) => {
releaseSignIn = resolve;
});
const platform = statefulAuthPlatform({ onSignIn: () => signInPending });
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await act(async () => {
query("login-password")?.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }),
);
await Promise.resolve();
});
expect(
platform.calls.filter((call) => call.url.endsWith("/api/auth/sign-in/email")),
).toHaveLength(1);
expect((query("login-password") as HTMLInputElement).value).toBe("");
await act(async () => {
releaseSignIn?.();
await signInPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
it("re-resolves principal and navigation after login without a page reload", async () => {
const platform = statefulAuthPlatform();
const importer = vi.fn(async () => artifact("example", "0.1.0"));
await render({ apiBaseUrl, fetch: platform.fetch }, importer);
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
expect(query("host-login")).toBeNull();
expect(query("host-logout")).not.toBeNull();
expect(isolatedText(query("module-container"))).toBe("example mounted");
expect(importer).toHaveBeenCalledOnce();
expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([
"/api/v1/principal",
"/api/auth/sign-in/email",
"/api/v1/principal",
"/api/v1/dashboard/navigation",
// The host-owned device panel loads with the rest of the shell for an administrator.
"/api/v1/devices",
]);
});
it("keeps the current internal URL in place and ignores external or protocol-relative return inputs", async () => {
window.history.replaceState(
{},
"",
"/?returnTo=%2F%2Fevil.example.test%2Fsteal&next=https%3A%2F%2Fevil.example.test&backslash=%5C%5Cevil.example.test&scheme=javascript%3Aalert%281%29",
);
const expectedLocation = window.location.href;
const platform = statefulAuthPlatform();
await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0"));
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
expect(window.location.href).toBe(expectedLocation);
expect(platform.calls.every((call) => new URL(call.url).origin === apiBaseUrl)).toBe(true);
expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("evil.example.test");
expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("javascript");
});
it("shows a safe retry when principal resolution is malformed or unavailable", async () => {
let healthy = false;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return healthy
? Response.json({ kind: "authenticated", userId: "user-1" })
: new Response("database failed at /private/path", { status: 503 });
}
return Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] });
}) as unknown as typeof fetch;
await render({ apiBaseUrl, fetch: fetchImplementation }, async () =>
artifact("example", "0.1.0"),
);
expect(query("host-error")).not.toBeNull();
expect(text()).not.toContain("private");
healthy = true;
const retry = query("host-error")?.querySelector("button");
expect(retry).not.toBeNull();
await act(async () => {
(retry as HTMLButtonElement).click();
});
expect(query("host-logout")).not.toBeNull();
});
it("fails safely on a malformed successful principal response and recovers on retry", async () => {
let healthy = false;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return Response.json(
healthy
? { kind: "authenticated", userId: "user-1" }
: { kind: "authenticated", privatePath: "/sessions/private" },
);
}
return Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] });
}) as unknown as typeof fetch;
await render({ apiBaseUrl, fetch: fetchImplementation }, async () =>
artifact("example", "0.1.0"),
);
expect(query("host-error")).not.toBeNull();
expect(text()).not.toContain("private");
expect(query("host-login")).toBeNull();
healthy = true;
const retry = query("host-error")?.querySelector("button");
expect(retry).not.toBeNull();
await act(async () => {
(retry as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
});
describe("Ekklesia shell — logout and session expiration", () => {
it("unmounts module UI and closes realtime before completing server-side logout", async () => {
let releaseSignOut: (() => void) | undefined;
const signOutPending = new Promise<void>((resolve) => {
releaseSignOut = resolve;
});
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
onSignOut: () => signOutPending,
});
let unmountCalls = 0;
let closeCalls = 0;
const source = {
CLOSED: 2,
CONNECTING: 0,
OPEN: 1,
addEventListener: vi.fn(),
close: () => {
closeCalls += 1;
},
readyState: 0,
removeEventListener: vi.fn(),
} as unknown as EventSource;
await render(
{
apiBaseUrl,
eventSourceFactory: () => source,
fetch: platform.fetch,
},
async () => ({
default: {
abi: MODULE_UI_ABI_VERSION,
id: "example",
mount(element: Element, context: ModuleUiHostContext) {
element.textContent = "example mounted";
const subscription = context.realtime.subscribe("state", {});
return () => {
unmountCalls += 1;
subscription.close();
};
},
version: "0.1.0",
},
}),
);
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-signing-out")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(unmountCalls).toBe(1);
expect(closeCalls).toBe(1);
expect(platform.authenticated()).toBe(true);
await act(async () => {
releaseSignOut?.();
await signOutPending;
await Promise.resolve();
});
expect(platform.authenticated()).toBe(false);
expect(query("host-login")).not.toBeNull();
// A new shell resolves the invalidated server session as anonymous too.
await act(async () => root.unmount());
root = createRoot(container);
await render({ apiBaseUrl, fetch: platform.fetch });
expect(query("host-login")).not.toBeNull();
});
it("ignores a retained module request that resolves after logout unmounts its frame", async () => {
let authenticatedPrincipalRequests = 0;
let releaseModuleRequest: (() => void) | undefined;
const moduleRequestPending = new Promise<void>((resolve) => {
releaseModuleRequest = resolve;
});
let releaseSignOut: (() => void) | undefined;
const signOutPending = new Promise<void>((resolve) => {
releaseSignOut = resolve;
});
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 401,
onModuleRequest: () => moduleRequestPending,
onPrincipalRequest: (authenticated) => {
if (!authenticated) {
return Promise.resolve();
}
authenticatedPrincipalRequests += 1;
return authenticatedPrincipalRequests === 1
? Promise.resolve()
: Promise.reject(new Error("stale revalidation must never run"));
},
onSignOut: () => signOutPending,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
const lateRequest = capture.context?.api.request("retained-after-unmount");
if (!lateRequest) {
throw new Error("module context was not captured");
}
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-signing-out")).not.toBeNull();
expect(capture.unmountCalls).toBe(1);
expect(platform.authenticated()).toBe(true);
await act(async () => {
releaseModuleRequest?.();
await lateRequest;
await Promise.resolve();
});
// The 401 belongs to a request retained by an already-unmounted module. It must not abort or
// replace the newer sign-out transition while the server still owns an authenticated session.
expect(query("host-signing-out")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(platform.authenticated()).toBe(true);
expect(authenticatedPrincipalRequests).toBe(1);
await act(async () => {
releaseSignOut?.();
await signOutPending;
await Promise.resolve();
});
expect(platform.authenticated()).toBe(false);
expect(query("host-login")).not.toBeNull();
});
it("does not claim logout succeeded when session invalidation fails", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
onSignOut: () => Promise.reject(new Error("private session failure")),
});
await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0"));
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-sign-out-error")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(query("module-container")).toBeNull();
expect(text()).not.toContain("private session failure");
});
function sessionAwareArtifact(
capture: { context?: ModuleUiHostContext; unmountCalls: number },
options: { readonly subscribeToRealtime?: boolean } = {},
) {
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id: "example",
mount(element: Element, context: ModuleUiHostContext) {
capture.context = context;
element.textContent = "example mounted";
const subscription = options.subscribeToRealtime
? context.realtime.subscribe("state", {})
: undefined;
return () => {
capture.unmountCalls += 1;
subscription?.close();
element.textContent = "";
};
},
version: "0.1.0",
},
};
}
it("returns to login when a module API 401 recheck proves the session became anonymous", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 401,
});
let closeCalls = 0;
const source = {
CLOSED: 2,
CONNECTING: 0,
OPEN: 1,
addEventListener: vi.fn(),
close: () => {
closeCalls += 1;
},
readyState: 0,
removeEventListener: vi.fn(),
} as unknown as EventSource;
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render(
{
apiBaseUrl,
eventSourceFactory: () => source,
fetch: platform.fetch,
},
async () => sessionAwareArtifact(capture, { subscribeToRealtime: true }),
);
platform.setAuthenticated(false);
const principalCallsBefore = platform.calls.filter((call) =>
call.url.endsWith("/api/v1/principal"),
).length;
await act(async () => {
await capture.context?.api.request("expired");
});
expect(query("host-login")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(capture.unmountCalls).toBe(1);
expect(closeCalls).toBe(1);
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(
principalCallsBefore + 1,
);
});
it("preserves an authenticated session after an ordinary module API 401", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 401,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
await act(async () => {
const response = await capture.context?.api.request("module-authentication-required");
expect(response?.status).toBe(401);
await Promise.resolve();
});
expect(query("host-login")).toBeNull();
expect(isolatedText(query("module-container"))).toBe("example mounted");
expect(capture.unmountCalls).toBe(0);
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
});
it("rechecks but does not log out an authenticated principal after an ordinary 403", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 403,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
await act(async () => {
const response = await capture.context?.api.request("forbidden");
expect(response?.status).toBe(403);
await Promise.resolve();
});
expect(query("host-login")).toBeNull();
expect(isolatedText(query("module-container"))).toBe("example mounted");
expect(capture.unmountCalls).toBe(0);
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
});
it("returns to login when a 403 recheck proves the session became anonymous", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 403,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
platform.setAuthenticated(false);
await act(async () => {
await capture.context?.api.request("forbidden-after-expiry");
await Promise.resolve();
});
expect(query("host-login")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(capture.unmountCalls).toBe(1);
});
});
describe("Ekklesia shell — Dashboard navigation", () => {
const mediaGroup = () =>
group("media", "Media", [entry("video", "Video"), entry("livestream", "Livestream", "2.0.0")]);
const planningGroup = () => group("planning", "Planning", [entry("calendar", "Calendar")], 20);
it("renders groups with their labels and the modules' display names", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }),
});
expect(query("group-media")?.textContent).toBe("MediaVideoLivestream");
expect(query("group-planning")?.textContent).toBe("PlanningCalendar");
// The module id is never shown; the manifest name is.
expect(text()).not.toContain("livestream");
});
it("renders groups and modules in exactly the server's order", async () => {
// The shell sorts nothing: whatever order arrives is the order shown, so the server stays the
// single authority on arrangement.
await render({
apiBaseUrl,
fetch: platformFetch({
groups: [
group("planning", "Planning", [entry("calendar", "Calendar")]),
group("media", "Media", [
entry("livestream", "Livestream", "2.0.0"),
entry("video", "Video"),
]),
],
}),
});
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"]);
});
it("renders ungrouped modules alongside groups, under a heading", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({
groups: [mediaGroup()],
ungrouped: [entry("roster", "Roster")],
}),
});
expect(query("group-ungrouped")?.textContent).toBe("OtherRoster");
});
it("omits the heading when nothing is grouped, so an unconfigured install reads as a list", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({ ungrouped: [entry("roster", "Roster"), entry("video", "Video")] }),
});
expect(query("group-ungrouped")?.textContent).toBe("RosterVideo");
expect(container.querySelectorAll("h2")).toHaveLength(0);
});
it("keeps an empty group visible", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({
groups: [group("media", "Media", [])],
ungrouped: [entry("video", "Video")],
}),
});
// The administrator created it; hiding it would look like it had been deleted.
expect(query("group-media")?.textContent).toBe("Media");
});
it("selects the first module in the arranged order and marks it active", async () => {
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"),
);
expect(query("select-video")?.getAttribute("aria-current")).toBe("page");
expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull();
});
it("mounts a module selected from inside a group, using the descriptor the group carried", async () => {
const requested: string[] = [];
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async (url) => {
requested.push(url);
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
},
);
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
// The nested descriptor is used unchanged: grouping altered nothing about the artifact URL.
expect(requested).toEqual([
`${apiBaseUrl}/api/v1/module-ui/video/0.1.0`,
`${apiBaseUrl}/api/v1/module-ui/calendar/0.1.0`,
]);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
});
it("mounts the identical artifact whichever group a module is presented in", async () => {
const requestedWhenGrouped: string[] = [];
await render(
{
apiBaseUrl,
fetch: platformFetch({
groups: [group("media", "Media", [entry("livestream", "Livestream")])],
}),
},
async (url) => {
requestedWhenGrouped.push(url);
return artifact("livestream", "0.1.0");
},
);
await act(async () => {
root.unmount();
});
// The same module, presented somewhere else entirely.
root = createRoot(container);
const requestedWhenMoved: string[] = [];
await render(
{
apiBaseUrl,
fetch: platformFetch({
groups: [
group("sunday-production", "Sunday Production", [entry("livestream", "Livestream")], 5),
],
}),
},
async (url) => {
requestedWhenMoved.push(url);
return artifact("livestream", "0.1.0");
},
);
// Presentation changed; the module did not.
expect(requestedWhenMoved).toEqual(requestedWhenGrouped);
expect(query("group-sunday-production")?.textContent).toBe("Sunday ProductionLivestream");
});
});
describe("Ekklesia shell — mount lifecycle", () => {
it("keeps fixed shell tracks while module content changes size", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"));
const shell = query("host-shell") as HTMLElement;
const aside = shell.querySelector("aside") as HTMLElement;
const main = shell.querySelector("main") as HTMLElement;
const frame = query("module-container") as HTMLElement;
expect(shell.style.gridTemplateColumns).toBe("15rem minmax(0, 1fr)");
expect(shell.style.gridTemplateRows).toBe("auto minmax(0, 1fr)");
expect(aside.style.width).toBe("15rem");
expect(main.style.minWidth).toBe("0");
expect(main.style.overflowX).toBe("auto");
expect(frame.style.minWidth).toBe("0");
});
it("mounts the first module into the host's own container", async () => {
const mountedInto: Element[] = [];
await render({ apiBaseUrl, fetch: platformFetch() }, async () =>
artifact("example", "0.1.0", { mountedInto }),
);
expect(isolatedText(query("module-container"))).toBe("example mounted");
expect(query("module-loading")).toBeNull();
// The module was handed the shell's container, never the page root or the shell's own layout.
// The artifact receives the loader's isolated root, never the shell's own element (ADR 0040).
expect(mountedInto[0]).toBe(isolatedMountTarget(query("module-container")));
expect(mountedInto[0]).not.toBe(container);
expect(mountedInto[0]).not.toBe(document.body);
});
it("imports exactly the URL the navigation published, resolved against the API origin", async () => {
const requested: string[] = [];
await render({ apiBaseUrl, fetch: platformFetch() }, async (url) => {
requested.push(url);
return artifact("example", "0.1.0");
});
expect(requested).toEqual([`${apiBaseUrl}/api/v1/module-ui/example/0.1.0`]);
});
it("shows a host-owned loading state until the module mounts", async () => {
let release: (() => void) | undefined;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
await pending;
return artifact("example", "0.1.0");
});
expect(query("module-loading")).not.toBeNull();
expect(isolatedText(query("module-container"))).toBe("");
await act(async () => {
release?.();
await pending;
});
expect(query("module-loading")).toBeNull();
expect(isolatedText(query("module-container"))).toBe("example mounted");
});
it("unmounts the previous module when the selection changes", async () => {
const unmountedContainers: Element[] = [];
const importModule = async (url: string) => {
const id = url.includes("/calendar/") ? "calendar" : "example";
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id,
mount(element: Element) {
element.textContent = `${id} mounted`;
return () => {
unmountedContainers.push(element);
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
};
await render(
{
apiBaseUrl,
fetch: platformFetch({
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
}),
},
importModule,
);
expect(isolatedText(query("module-container"))).toBe("example mounted");
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(unmountedContainers).toHaveLength(1);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
});
it("keeps a stale load from ever reaching the newer selection's container", async () => {
const mountedInto: Element[] = [];
const unmountedContainers: Element[] = [];
let releaseExample: (() => void) | undefined;
const examplePending = new Promise<void>((resolve) => {
releaseExample = resolve;
});
const importModule = async (url: string) => {
const id = url.includes("/calendar/") ? "calendar" : "example";
if (id === "example") {
await examplePending;
}
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id,
mount(element: Element) {
mountedInto.push(element);
element.textContent = `${id} mounted`;
return () => {
unmountedContainers.push(element);
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
};
await render(
{
apiBaseUrl,
fetch: platformFetch({
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
}),
},
importModule,
);
// `example` is still importing; nothing has mounted yet.
expect(mountedInto).toHaveLength(0);
// Switch to `calendar`, which resolves immediately and mounts.
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
const liveContainer = query("module-container");
const liveMountTarget = isolatedMountTarget(liveContainer);
expect(isolatedText(liveContainer)).toBe("calendar mounted");
// Only now does the stale `example` import resolve.
await act(async () => {
releaseExample?.();
await examplePending;
});
// It mounted into its own, already-discarded isolated root — never the live one — and was torn
// down immediately, so the newer selection is untouched.
expect(isolatedText(liveContainer)).toBe("calendar mounted");
const staleContainer = mountedInto.find((element) => element !== liveMountTarget);
expect(staleContainer).toBeDefined();
expect(staleContainer?.isConnected).toBe(false);
expect(unmountedContainers).toContain(staleContainer);
});
});
describe("Ekklesia shell — failure is host-owned", () => {
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");
});
expect(query("module-error")).not.toBeNull();
// Not a path, not a stack, not the caught message.
expect(text()).not.toContain("/srv/ekklesia");
expect(text()).not.toContain("index.js");
// The shell itself is still rendered around the failure.
expect(query("select-example")).not.toBeNull();
// The real error still reaches the browser console for a developer.
expect(consoleError).toHaveBeenCalled();
});
it("renders its own error when the artifact is not a valid module UI", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => ({ notADefault: true }));
expect(query("module-error")).not.toBeNull();
});
it("renders its own error when the artifact claims a different identity", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "9.9.9"));
expect(query("module-error")).not.toBeNull();
expect(isolatedText(query("module-container"))).toBe("");
});
it("renders its own error when the module's mount throws", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () =>
artifact("example", "0.1.0", { mountThrows: true }),
);
expect(query("module-error")).not.toBeNull();
});
it("stays usable after a failure: another module still mounts", async () => {
const importModule = async (url: string) => {
if (url.includes("/example/")) {
throw new Error("boom");
}
return artifact("calendar", "2.0.0");
};
await render(
{
apiBaseUrl,
fetch: platformFetch({
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
}),
},
importModule,
);
expect(query("module-error")).not.toBeNull();
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(query("module-error")).toBeNull();
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
});
it("survives a module whose cleanup throws, and never re-runs it", async () => {
let cleanupCalls = 0;
const importModule = async (url: string) => {
if (url.includes("/calendar/")) {
return artifact("calendar", "2.0.0");
}
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id: "example",
mount(element: Element) {
element.textContent = "example mounted";
return () => {
cleanupCalls += 1;
throw new Error("module cleanup failed");
};
},
version: "0.1.0",
},
};
};
await render(
{
apiBaseUrl,
fetch: platformFetch({
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
}),
},
importModule,
);
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(cleanupCalls).toBe(1);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
// Switch back and away again: the throwing cleanup ran once per mount, never re-invoked for an
// instance already torn down.
await act(async () => {
(query("select-example") as HTMLButtonElement).click();
});
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(cleanupCalls).toBe(2);
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
});
});