165 lines
6.3 KiB
TypeScript
165 lines
6.3 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",
|
|
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",
|
|
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();
|
|
});
|
|
});
|