Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard. - Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe label, kebab-case path relative to the module root; no traversal, schemes, queries, duplicates, or nesting). Validated before activation. - Version 8 stays accepted beside version 9, each read strictly as itself (discriminated union); no released manifest is rewritten. The template moves to v9; the released Signage 0.1.5 manifest is a test fixture. - Real module routes: /<id> is the landing view, /<id>/<page> a declared page, anything beneath a page a module-owned detail route; anything else is not-found and mounts nothing. api/assets/display are never pages. - Sidebar: declared icons, pages nested under an open module, one aria-current, collapsed and drawer behaviour; v8 modules unchanged. - module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe, navigate, link, settings), refusing anything outside the module's routes; undefined on the display host. Mount ABI unchanged. - Return-to-origin: one `?return=` carrier on Settings URLs, validated by parseReturnTarget and against running modules' declared routes; Settings Back returns to exactly the originating module page, across refreshes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
167 lines
6.4 KiB
TypeScript
167 lines
6.4 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
|
import { type ModuleUiHostContext, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
loadModuleUi,
|
|
MODULE_UI_ISOLATION_ATTRIBUTE,
|
|
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
|
} from "../src/index.js";
|
|
|
|
/** What the artifact rendered, reached through the open shadow root the loader created (ADR 0040). */
|
|
function artifactText(container: Element): string {
|
|
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
|
return (
|
|
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
|
|
""
|
|
);
|
|
}
|
|
|
|
const fixtureDirectory = path.join(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"fixtures",
|
|
"artifact-module",
|
|
);
|
|
|
|
let artifactUrl: string;
|
|
let artifactSource: string;
|
|
|
|
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
|
|
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
|
return {
|
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
|
display: undefined,
|
|
moduleId: "example",
|
|
navigation: undefined,
|
|
api: {
|
|
async request(pathname) {
|
|
requested.push(`/api/v1/modules/example/${pathname.replace(/^\//u, "")}`);
|
|
return new Response(null, { status: 200 });
|
|
},
|
|
},
|
|
principal: { kind: "authenticated", userId: "user-123" },
|
|
permissions: { has: () => true },
|
|
realtime: { subscribe: () => ({ close: () => undefined }) },
|
|
};
|
|
}
|
|
|
|
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
|
|
function standaloneContext(requested: string[]): ModuleUiHostContext {
|
|
return {
|
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
|
display: { url: "https://example.test/display" },
|
|
moduleId: "example",
|
|
navigation: undefined,
|
|
api: {
|
|
async request(pathname) {
|
|
requested.push(`https://example.test/api/${pathname.replace(/^\//u, "")}`);
|
|
return new Response(null, { status: 200 });
|
|
},
|
|
},
|
|
principal: { kind: "anonymous" },
|
|
permissions: { has: () => false },
|
|
realtime: { subscribe: () => ({ close: () => undefined }) },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Proves the loader consumes exactly the browser ESM artifact `@ekklesia/module-ui-build` produces
|
|
* (ADR 0023) — no second output format, no checked-in build output. The artifact is rebuilt from
|
|
* source each run and served through a `data:` URL, a real module URL rather than a filesystem path.
|
|
*/
|
|
describe("loading a real compiled artifact", () => {
|
|
beforeAll(async () => {
|
|
const result = await buildModuleUi({ moduleDirectory: fixtureDirectory });
|
|
const primaryArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
|
|
if (!primaryArtifact) {
|
|
throw new Error("The real-artifact fixture must declare a primary UI artifact.");
|
|
}
|
|
artifactSource = await readFile(primaryArtifact.outputFile, "utf8");
|
|
artifactUrl = `data:text/javascript;base64,${Buffer.from(artifactSource, "utf8").toString("base64")}`;
|
|
}, 60_000);
|
|
|
|
it("produces a self-contained artifact with no bare imports for a host to resolve", () => {
|
|
expect(artifactSource).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
|
|
expect(artifactSource).toContain("export {");
|
|
expect(artifactSource).not.toContain("/api/v1/modules");
|
|
});
|
|
|
|
it("mounts the real artifact through the generic loader and unmounts it", async () => {
|
|
const container = document.createElement("div");
|
|
const requested: string[] = [];
|
|
|
|
const mounted = await loadModuleUi({
|
|
source: { moduleId: "example", version: "0.1.0", url: artifactUrl },
|
|
container,
|
|
context: ekklesiaContext(requested),
|
|
});
|
|
|
|
expect(artifactText(container)).toBe("example:200:user-123");
|
|
await mounted.unmount();
|
|
expect(container.children).toHaveLength(0);
|
|
expect(mounted.mounted).toBe(false);
|
|
});
|
|
|
|
it("compiles the fixture's stylesheet into the artifact and installs it in the isolated root", async () => {
|
|
const container = document.createElement("div");
|
|
|
|
// No sidecar: the whole stylesheet travels inside the one declared JavaScript artifact.
|
|
expect(artifactSource).toContain(".artifact-label");
|
|
|
|
const mounted = await loadModuleUi({
|
|
source: { moduleId: "example", version: "0.1.0", url: artifactUrl },
|
|
container,
|
|
context: ekklesiaContext([]),
|
|
});
|
|
|
|
const stylesheet = mounted.shadowRoot.querySelector("style")?.textContent ?? "";
|
|
expect(stylesheet).toContain(".artifact-label");
|
|
expect(stylesheet).toContain("var(--ekklesia-text-primary)");
|
|
expect(document.head.textContent ?? "").not.toContain(".artifact-label");
|
|
|
|
await mounted.unmount();
|
|
});
|
|
|
|
it("still rejects the real artifact when the host expects a different version", async () => {
|
|
await expect(
|
|
loadModuleUi({
|
|
source: { moduleId: "example", version: "0.2.0", url: artifactUrl },
|
|
container: document.createElement("div"),
|
|
context: ekklesiaContext([]),
|
|
}),
|
|
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
|
});
|
|
|
|
it("runs the same artifact under an Ekklesia host and a standalone host", async () => {
|
|
const ekklesiaRequests: string[] = [];
|
|
const standaloneRequests: string[] = [];
|
|
const ekklesiaContainer = document.createElement("div");
|
|
const standaloneContainer = document.createElement("div");
|
|
const source = { moduleId: "example", version: "0.1.0", url: artifactUrl };
|
|
|
|
const inEkklesia = await loadModuleUi({
|
|
source,
|
|
container: ekklesiaContainer,
|
|
context: ekklesiaContext(ekklesiaRequests),
|
|
});
|
|
const inStandalone = await loadModuleUi({
|
|
source,
|
|
container: standaloneContainer,
|
|
context: standaloneContext(standaloneRequests),
|
|
});
|
|
|
|
// One artifact, one loader, two hosts. Every difference lives in the context the host supplied:
|
|
// the module asked for "records" both times and resolved nothing itself.
|
|
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
|
|
expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
|
|
expect(artifactText(ekklesiaContainer)).toBe("example:200:user-123");
|
|
expect(artifactText(standaloneContainer)).toBe("example:200:guest");
|
|
|
|
await inEkklesia.unmount();
|
|
await inStandalone.unmount();
|
|
});
|
|
});
|