491 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|