1255 lines
44 KiB
TypeScript
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("13rem minmax(0, 1fr)");
|
|
expect(shell.style.gridTemplateRows).toBe("auto minmax(0, 1fr)");
|
|
expect(aside.style.width).toBe("13rem");
|
|
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");
|
|
});
|
|
});
|