feat: mount discovered module UI in a minimal Ekklesia browser host
Phase 6C pass 2. Every link in the module UI chain existed except the one that closes it: a module could be authored, built, validated, published at a browser URL, and loaded by a generic loader — and nothing in Ekklesia did any of it. There was no browser application at all. Two pieces were missing. A browser could not discover what to mount, and it must not work that out for itself. GET /api/v1/module-ui is the inventory: per publishable module, its id and version, the server-produced artifact URL, the server-produced root of the module's own API, and the subset of that module's declared permissions the caller holds. Five fields, each because building a ModuleUiHostContext needs it — no filesystem paths, and no name/icon/folder/order, because navigation metadata is a design that does not exist yet. Its module set is the ADR 0025 publication registry rather than a rescan, so the inventory and the artifact route cannot disagree. It requires a new Core permission, core.modules.ui.read (baseline authenticated), because it is precisely the enumeration the anonymous artifact route deliberately withholds. Both URLs are server-produced. moduleUiArtifactUrl already existed; moduleApiBaseUrl is now exported and moduleRoutePath builds on it, so each URL shape has exactly one author. Without it the shell would hardcode /api/v1/modules/<id> — the prefix ADR 0022 forbids a module UI from spelling, reappearing in the host instead. surfaces/shell (@ekklesia/shell) is the repository's first surface: a small React application built to static files by its own esbuild script. It resolves the principal, fetches the inventory, builds the host context entirely from server-derived data, and hands the artifact URL to @ekklesia/module-ui-host. One frame per selection — keyed by module identity — defeats the stale-load race; the host owns the container, the loading state, and every failure, so a broken module costs its own panel rather than the shell. The shell statically imports no module, and both levels are checked: no source import may name a module, and the emitted bundle must contain exactly one dynamic import (the loader's) with no module id, no /api/v1/modules, no eval, and no blob URL. Permission hints come from Core's own evaluator and stay presentation-only — the end-to-end test has a UI holding no grant call its API anyway and still receive 403. Surfaces may now depend only on browser-safe shared roles; @ekklesia/contracts is deliberately excluded, since its barrel pulls Drizzle's types and Node's globals into the browser — the hole ADR 0024 closed one layer down. Not the Dashboard: no navigation, grouping, icons, ordering, design system, or routing. The selector is a list of module ids. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7a76ff6f26
commit
616c1d4834
@@ -0,0 +1,415 @@
|
||||
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();
|
||||
});
|
||||
|
||||
function descriptor(id: string, version = "0.1.0", permissions: readonly string[] = []) {
|
||||
return {
|
||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||
id,
|
||||
permissions,
|
||||
ui: { url: `/api/v1/module-ui/${id}/${version}` },
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
interface PlatformStub {
|
||||
readonly modules?: ReadonlyArray<ReturnType<typeof descriptor>>;
|
||||
readonly principal?: unknown;
|
||||
readonly inventoryStatus?: number;
|
||||
readonly inventoryThrows?: boolean;
|
||||
}
|
||||
|
||||
function platformFetch(stub: PlatformStub = {}): typeof fetch {
|
||||
const modules = stub.modules ?? [descriptor("example")];
|
||||
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/module-ui")) {
|
||||
if (stub.inventoryThrows) {
|
||||
throw new TypeError("network down");
|
||||
}
|
||||
return new Response(JSON.stringify({ modules }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: stub.inventoryStatus ?? 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 inventory returned", async () => {
|
||||
await render({
|
||||
apiBaseUrl,
|
||||
fetch: platformFetch({ modules: [descriptor("example"), descriptor("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 a refused inventory as signed out rather than as a crash", async () => {
|
||||
await render({ apiBaseUrl, fetch: platformFetch({ inventoryStatus: 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({ inventoryThrows: 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({ modules: [] }) });
|
||||
|
||||
expect(query("host-empty")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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 inventory 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({ modules: [descriptor("example"), descriptor("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({ modules: [descriptor("example"), descriptor("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({ modules: [descriptor("example"), descriptor("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({ modules: [descriptor("example"), descriptor("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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user