311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { ModuleUiContractError, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
|
import { describe, expect, it } from "vitest";
|
|
import { loadModuleUi, 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 } = {}) => `
|
|
export default {
|
|
id: ${JSON.stringify(options.id ?? "announcements")},
|
|
version: ${JSON.stringify(options.version ?? "1.2.0")},
|
|
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 {
|
|
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;
|
|
}
|
|
|
|
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(element.textContent).toBe("mounted:announcements");
|
|
});
|
|
|
|
it("passes the host's own container and 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;
|
|
expect(mounts?.at(-1)?.container).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 on unmount 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);
|
|
expect(element.textContent).toBe("");
|
|
// 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);
|
|
});
|
|
|
|
it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => {
|
|
const element = container();
|
|
const artifact = `
|
|
export default {
|
|
id: "announcements", version: "1.2.0",
|
|
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);
|
|
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(first.textContent).toBe("mounted:announcements");
|
|
expect(second.textContent).toBe("mounted:announcements");
|
|
|
|
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.textContent).toBe("");
|
|
expect(second.textContent).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.textContent).toBe("");
|
|
});
|
|
|
|
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.textContent).toBe("");
|
|
});
|
|
|
|
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 { 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 and registers no cleanup", async () => {
|
|
const element = container();
|
|
const url = moduleUrl(`
|
|
export default {
|
|
id: "announcements", version: "1.2.0",
|
|
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. The container is left exactly
|
|
// as the module left it: the host owns it and decides what a failed mount looks like.
|
|
expect(element.textContent).toBe("partial");
|
|
});
|
|
|
|
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: { 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);
|
|
});
|
|
});
|