Files
Ekklesia/packages/module-ui-host/tests/load-module-ui.test.ts

491 lines
18 KiB
TypeScript

// @vitest-environment happy-dom
import {
MODULE_UI_ABI_VERSION,
ModuleUiContractError,
type ModuleUiHostContext,
NO_MODULE_UI_CONTRIBUTIONS,
} from "@ekklesia/module-ui-sdk";
import { describe, expect, it } from "vitest";
import {
loadModuleUi,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
MODULE_UI_ISOLATION_STYLE_ATTRIBUTE,
ModuleUiHostError,
type ModuleUiImporter,
} from "../src/index.js";
/**
* A browser-loadable URL for a module built from source text.
*
* `data:` URLs are genuine module URLs that both browsers and Node import natively, so these tests
* exercise the loader's **default** `import(url)` against a real URL rather than a filesystem path.
* That matters: Core resolves `entrypoints.ui` to a server filesystem path, and a loader proven only
* against such a path would not prove anything a browser host could rely on (ADR 0024).
*/
function moduleUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
}
const validArtifact = (
options: { id?: string; version?: string; cleanup?: boolean; styles?: string } = {},
) => `
export default {
abi: ${MODULE_UI_ABI_VERSION},
id: ${JSON.stringify(options.id ?? "announcements")},
version: ${JSON.stringify(options.version ?? "1.2.0")},
${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`}
mount(container, context) {
globalThis.__mounts ??= [];
globalThis.__mounts.push({ moduleId: context.moduleId, container });
container.textContent = "mounted:" + context.moduleId;
void context.api.request("posts");
${
options.cleanup === false
? ""
: `return () => { globalThis.__unmounts ??= []; globalThis.__unmounts.push(context.moduleId); container.textContent = ""; };`
}
},
};
`;
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId,
api: {
async request(path) {
requested.push(path);
return new Response(null, { status: 200 });
},
},
principal: { kind: "anonymous" },
permissions: { has: () => true },
realtime: { subscribe: () => ({ close: () => undefined }) },
};
}
function container(): Element {
const element = document.createElement("div");
document.body.append(element);
return element;
}
/**
* What the artifact actually rendered, reached the way a host's own diagnostics would: through the
* open shadow root the loader created. The host's container itself holds only the loader's one
* wrapper element, which is exactly the isolation ADR 0040 introduced.
*/
function artifactRoot(element: Element): Element | null {
const shadowHost = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`) ?? null;
}
function artifactText(element: Element): string {
return artifactRoot(element)?.textContent ?? "";
}
const source = { moduleId: "announcements", version: "1.2.0" };
describe("generic module UI host loader", () => {
it("imports a browser URL with the default importer, mounts, and reports identity", async () => {
const element = container();
const requested: string[] = [];
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext(requested),
});
expect(mounted.moduleId).toBe("announcements");
expect(mounted.version).toBe("1.2.0");
expect(mounted.mounted).toBe(true);
expect(artifactText(element)).toBe("mounted:announcements");
});
it("mounts the artifact into an isolated root rather than the host's own container", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
// The host's container gained exactly one child: the loader's shadow host.
expect(element.children).toHaveLength(1);
const shadowHost = element.firstElementChild as Element;
expect(shadowHost.hasAttribute(MODULE_UI_ISOLATION_ATTRIBUTE)).toBe(true);
expect(shadowHost.shadowRoot).toBe(mounted.shadowRoot);
// Open, deliberately: this is a correctness boundary for styles, not a sandbox — an installed
// module is trusted code in the host's own realm and could reach `document` regardless.
expect(mounted.shadowRoot.mode).toBe("open");
// The artifact's DOM is not reachable from the host's own document tree.
expect(element.textContent).toBe("");
});
it("installs a declared stylesheet inside the isolated root, exactly once", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact({ styles: ".posts{color:red}" })) },
container: element,
context: hostContext([]),
});
const styles = mounted.shadowRoot.querySelectorAll(
`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`,
);
expect(styles).toHaveLength(1);
expect(styles[0]?.textContent).toContain(".posts{color:red}");
// The host document never receives the artifact's rules.
expect(document.head.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)).toBeNull();
});
it("installs the host's base rules before the artifact's own, so an artifact can override them", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact({ styles: ":host{display:block}" })) },
container: element,
context: hostContext([]),
});
const stylesheet =
mounted.shadowRoot.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)
?.textContent ?? "";
expect(stylesheet.indexOf(":host{display:contents}")).toBeLessThan(
stylesheet.indexOf(":host{display:block}"),
);
});
it("still mounts an artifact that declares no stylesheet", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
expect(artifactText(element)).toBe("mounted:announcements");
expect(
mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`),
).toHaveLength(1);
});
it("never accumulates styles across repeated mounts of the same container", async () => {
const element = container();
const url = moduleUrl(validArtifact({ styles: ".posts{color:red}" }));
for (let attempt = 0; attempt < 3; attempt += 1) {
const mounted = await loadModuleUi({
source: { ...source, url },
container: element,
context: hostContext([]),
});
expect(
mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`),
).toHaveLength(1);
await mounted.unmount();
expect(element.children).toHaveLength(0);
}
});
it("passes the isolated mount target and the host's context straight through", async () => {
const element = container();
const requested: string[] = [];
const context = hostContext(requested);
await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context,
});
const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] })
.__mounts;
// The artifact receives the loader's isolated root, never the host's own element.
expect(mounts?.at(-1)?.container).toBe(artifactRoot(element));
expect(mounts?.at(-1)?.container).not.toBe(element);
expect(mounts?.at(-1)?.moduleId).toBe("announcements");
});
it("keeps the module's API calls relative — the loader adds no URL knowledge", async () => {
const element = container();
const requested: string[] = [];
await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext(requested),
});
expect(requested).toEqual(["posts"]);
expect(requested.join(" ")).not.toContain("/api/v1/modules");
});
it("runs the module's cleanup and removes the isolated root without touching the host container", async () => {
const element = container();
const parent = element.parentElement;
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
await mounted.unmount();
expect(mounted.mounted).toBe(false);
// The artifact's root, and with it its styles, are gone.
expect(element.children).toHaveLength(0);
// The host owns the container; unmounting a module never removes it.
expect(element.parentElement).toBe(parent);
expect(document.body.contains(element)).toBe(true);
});
it("is idempotent: a second unmount does nothing", async () => {
const element = container();
const before = ((globalThis as { __unmounts?: string[] }).__unmounts ?? []).length;
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
await mounted.unmount();
await mounted.unmount();
await mounted.unmount();
const after = ((globalThis as { __unmounts?: string[] }).__unmounts ?? []).length;
expect(after - before).toBe(1);
expect(mounted.mounted).toBe(false);
});
it("unmounts safely when the module returned no cleanup function", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact({ cleanup: false })) },
container: element,
context: hostContext([]),
});
await expect(mounted.unmount()).resolves.toBeUndefined();
expect(mounted.mounted).toBe(false);
expect(element.children).toHaveLength(0);
});
it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => {
const element = container();
const artifact = `
export default {
abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0",
styles: ".x{color:red}",
mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; },
};`;
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(artifact) },
container: element,
context: hostContext([]),
});
await expect(mounted.unmount()).rejects.toThrow("cleanup exploded");
expect(mounted.mounted).toBe(false);
// A module that fails to clean up must not leave its stylesheet installed in the page.
expect(element.children).toHaveLength(0);
await expect(mounted.unmount()).resolves.toBeUndefined();
expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1);
});
it("supports two independent instances in two containers", async () => {
const first = container();
const second = container();
const url = moduleUrl(validArtifact());
const a = await loadModuleUi({
source: { ...source, url },
container: first,
context: hostContext([]),
});
const b = await loadModuleUi({
source: { ...source, url },
container: second,
context: hostContext([]),
});
expect(artifactText(first)).toBe("mounted:announcements");
expect(artifactText(second)).toBe("mounted:announcements");
expect(a.shadowRoot).not.toBe(b.shadowRoot);
await a.unmount();
// Each handle owns its own cleanup state; unmounting one leaves the other alone.
expect(a.mounted).toBe(false);
expect(b.mounted).toBe(true);
expect(first.children).toHaveLength(0);
expect(artifactText(second)).toBe("mounted:announcements");
});
describe("rejects an artifact before mounting it", () => {
it("when the module id does not match the manifest", async () => {
const element = container();
const url = moduleUrl(validArtifact({ id: "calendar" }));
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
expect(element.children).toHaveLength(0);
});
it("when the version does not match the manifest", async () => {
const element = container();
const url = moduleUrl(validArtifact({ version: "1.1.0" }));
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
expect(element.children).toHaveLength(0);
});
it("when it was built against the previous mount ABI", async () => {
const element = container();
const url = moduleUrl(
'export default { id: "announcements", version: "1.2.0", mount() {} };',
);
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_ABI_UNSUPPORTED" });
expect(element.children).toHaveLength(0);
});
it("when there is no default export", async () => {
const element = container();
const url = moduleUrl("export const ui = {};");
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_EXPORT_MISSING" });
});
it("when the default export is not a module UI definition", async () => {
const element = container();
const url = moduleUrl(
`export default { abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0" };`,
);
const failure = loadModuleUi({
source: { ...source, url },
container: element,
context: hostContext([]),
});
await expect(failure).rejects.toBeInstanceOf(ModuleUiContractError);
await expect(failure).rejects.toMatchObject({ code: "MODULE_UI_DEFINITION_INVALID" });
});
});
it("reports an unreachable artifact as an import failure, with the cause attached", async () => {
const element = container();
try {
await loadModuleUi({
source: { ...source, url: "data:text/javascript,this is not(valid javascript" },
container: element,
context: hostContext([]),
});
expect.unreachable("expected the import to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiHostError);
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_IMPORT_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("data:");
expect((error as ModuleUiHostError).cause).toBeDefined();
}
});
it("reports a throwing mount as a mount failure, registers no cleanup, and removes the root", async () => {
const element = container();
const url = moduleUrl(`
export default {
abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0",
styles: ".x{color:red}",
mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); },
};`);
try {
await loadModuleUi({
source: { ...source, url },
container: element,
context: hostContext([]),
});
expect.unreachable("expected mount to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiHostError);
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_MOUNT_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("exploded");
expect((error as { cause?: Error }).cause?.message).toBe("mount exploded");
}
// No handle exists, so nothing can claim the module is mounted — and a failed mount leaves
// neither partial DOM nor an installed stylesheet behind in the host's container.
expect(element.children).toHaveLength(0);
});
it("reports an environment with no Shadow DOM as an isolation failure, never as an unisolated mount", async () => {
const element = container();
const detached = {
ownerDocument: document,
appendChild: element.appendChild.bind(element),
} as unknown as Element;
// A container whose created element cannot take a shadow root: the loader must refuse rather
// than fall back to mounting the artifact — and its styles — straight into the host's page.
const noShadow = new Proxy(detached, {
get(target, property, receiver) {
if (property === "ownerDocument") {
return {
createElement: () => ({ setAttribute: () => undefined }),
};
}
return Reflect.get(target, property, receiver);
},
});
await expect(
loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: noShadow,
context: hostContext([]),
}),
).rejects.toMatchObject({ code: "MODULE_UI_ISOLATION_UNAVAILABLE" });
expect(element.children).toHaveLength(0);
});
it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => {
const element = container();
const seen: string[] = [];
const importModule: ModuleUiImporter = async (url) => {
seen.push(url);
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id: "announcements",
version: "1.2.0",
mount: () => undefined,
},
};
};
const mounted = await loadModuleUi({
source: { ...source, url: "https://assets.example.test/modules/announcements/ui.js" },
container: element,
context: hostContext([]),
importModule,
});
expect(seen).toEqual(["https://assets.example.test/modules/announcements/ui.js"]);
expect(mounted.mounted).toBe(true);
});
});