586 lines
20 KiB
TypeScript
586 lines
20 KiB
TypeScript
import 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";
|
|
|
|
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;
|
|
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: {
|
|
api: { baseUrl: `/api/v1/modules/${id}` },
|
|
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: {
|
|
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}"]`);
|
|
}
|
|
|
|
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("asks an anonymous viewer to sign in, and mounts nothing", async () => {
|
|
await render({ apiBaseUrl, fetch: platformFetch({ principal: { kind: "anonymous" } }) });
|
|
|
|
expect(query("host-signed-out")).not.toBeNull();
|
|
expect(query("module-container")).toBeNull();
|
|
});
|
|
|
|
it("treats refused navigation as signed out rather than as a crash", async () => {
|
|
await render({ apiBaseUrl, fetch: platformFetch({ navigationStatus: 403 }) });
|
|
|
|
expect(query("host-signed-out")).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 — 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("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(query("module-container")?.textContent).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("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(query("module-container")?.textContent).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.
|
|
expect(mountedInto[0]).toBe(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(query("module-container")?.textContent).toBe("");
|
|
|
|
await act(async () => {
|
|
release?.();
|
|
await pending;
|
|
});
|
|
|
|
expect(query("module-loading")).toBeNull();
|
|
expect(query("module-container")?.textContent).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: {
|
|
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(query("module-container")?.textContent).toBe("example mounted");
|
|
|
|
await act(async () => {
|
|
(query("select-calendar") as HTMLButtonElement).click();
|
|
});
|
|
|
|
expect(unmountedContainers).toHaveLength(1);
|
|
expect(query("module-container")?.textContent).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: {
|
|
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");
|
|
expect(liveContainer?.textContent).toBe("calendar mounted");
|
|
|
|
// Only now does the stale `example` import resolve.
|
|
await act(async () => {
|
|
releaseExample?.();
|
|
await examplePending;
|
|
});
|
|
|
|
// It mounted into its own, already-discarded container — never the live one — and was torn
|
|
// down immediately, so the newer selection is untouched.
|
|
expect(liveContainer?.textContent).toBe("calendar mounted");
|
|
const staleContainer = mountedInto.find((element) => element !== liveContainer);
|
|
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(query("module-container")?.textContent).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(query("module-container")?.textContent).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: {
|
|
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(query("module-container")?.textContent).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(query("module-container")?.textContent).toBe("calendar mounted");
|
|
});
|
|
});
|