feat: close the browser boundary and add the generic UI host loader
Two gaps stood between the module UI contract and a host that could use
it. ADR 0023 recorded the first honestly but never diagnosed it: a module
UI file compiled with `"types": []` could still typecheck `process.env`.
The second was simply missing — nothing imported a compiled artifact,
checked its identity, mounted it, or tore it down.
The leak was one type-only import, bisected against the real type graph
rather than guessed. `@ekklesia/module-ui-sdk` imported
`type { EkklesiaPrincipal } from "@ekklesia/contracts"`. Importing
contracts/identity alone is clean and importing Zod alone is clean, but
contracts exposes a single barrel entry, so that one import reached
module-database, whose Drizzle types re-introduce Node's globals. The
runtime was never affected — the import was erased and host-context.js is
`export {}` — but a type-graph leak is still a real boundary failure,
because it silently permits code that cannot run in its target
environment.
The fix is not a narrower import path. The SDK now has no dependencies at
all, so there is no graph to leak through and no future contracts change
can reopen this. It owns `ModuleUiPrincipal`: the same shape ADR 0022
chose, with the Zod brand dropped. That brand encodes a server-side
parsing guarantee a browser cannot produce, and it is the same reason
`permissions.has` already took a plain string. An Ekklesia host still
passes its own `EkklesiaPrincipal` straight through — a branded string is
assignable to `string` — while a standalone host builds the literal.
Proven the way an author experiences it: two fixtures compiled under the
exact project a module uses, one supported and one deliberately reaching
for `process`, with the second required to fail on that specific name.
`@ekklesia/module-ui-host` is the generic loader, in its own package
because the SDK is bundled into every module artifact and host-only
loader code has no business shipping there. The boundary checker enforces
both directions: modules may not depend on the loader, and the loader may
depend only on the SDK plus dev-time tooling.
The distinction that shaped the design: Core resolves `entrypoints.ui` to
an absolute path on the server's disk, and a browser needs a URL. A
loader that accepted the path would test green on a server and fail in
every real host, and would quietly own asset publication. So the loader
takes a browser-loadable URL and knows nothing about how one is obtained.
Ekklesia has no route that publishes installed artifacts, so this is
precisely: loader complete, publication deferred. Nothing can mount an
installed module UI yet, and the docs say so rather than implying
otherwise.
Dynamic import is `(url) => import(url)` with a non-analyzable specifier,
kept as a runtime import so a host does not statically bundle every
installed module or need a hardcoded registry. It is injectable for
bundlers needing their own escape hatch, and deliberately not `eval` or
`new Function`, which would cost a host `unsafe-eval` in its CSP.
Identity is validated through the SDK's existing parser before `mount`
runs, so a stale or mismatched bundle never renders. Contract violations
propagate unwrapped; the loader adds only import and mount failure codes,
sanitized, with the cause attached. Unmount is idempotent, safe when a
module returned no cleanup, and marks an instance unmounted before
running cleanup so a throwing cleanup surfaces once and never re-runs. A
failed mount registers no cleanup. Each instance owns its own state, so
two mounts coexist. The loader never mutates the host's container — not
on success, failure, or unmount.
Tests prove all of it against real module URLs (`data:`, which both
browsers and Node import natively) rather than filesystem paths,
including one artifact built by the canonical Pass 2 pipeline and run
under both an Ekklesia-shaped and a standalone-shaped context. Core,
apps/api, and the contracts package are untouched; discovery remains
inert.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5d75d910fd
commit
44e676f0df
@@ -0,0 +1,38 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
/**
|
||||
* The host loader runs in a browser too, so it inherits the same rule as the authoring SDK: it must
|
||||
* not reach a backend type graph. Its only dependency is the module-author contract (ADR 0024).
|
||||
*/
|
||||
describe("module UI host loader stays browser-safe", () => {
|
||||
it("declares exactly one runtime dependency: the module UI SDK", async () => {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
|
||||
|
||||
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/module-ui-sdk"]);
|
||||
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual([]);
|
||||
});
|
||||
|
||||
it("imports nothing beyond the module UI SDK in its source", async () => {
|
||||
const source = await readFile(path.join(packageDirectory, "src", "load-module-ui.ts"), "utf8");
|
||||
const external = [...source.matchAll(/from\s+"([^"]+)"/gmu)]
|
||||
.map(([, specifier]) => specifier)
|
||||
.filter((specifier) => specifier !== undefined && !specifier.startsWith("."));
|
||||
|
||||
expect([...new Set(external)]).toEqual(["@ekklesia/module-ui-sdk"]);
|
||||
});
|
||||
|
||||
it("never names a Node built-in or an Ekklesia URL prefix", async () => {
|
||||
const source = await readFile(path.join(packageDirectory, "src", "load-module-ui.ts"), "utf8");
|
||||
|
||||
expect(source).not.toContain('"node:');
|
||||
// The loader forwards a host-supplied URL; it must never construct one.
|
||||
expect(source).not.toMatch(/["'`]\/api\/v1\//u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"manifestVersion": 4,
|
||||
"id": "example",
|
||||
"name": "Example",
|
||||
"version": "0.1.0",
|
||||
"compatibility": {
|
||||
"platform": "^0.1.0",
|
||||
"moduleSdk": "^0.1.0"
|
||||
},
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"ui": "./dist/ui/index.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||
|
||||
/**
|
||||
* A real module UI compiled by the canonical pipeline. It imports the public SDK so the built
|
||||
* artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader.
|
||||
*
|
||||
* React is deliberately absent — the module template already proves the React path end to end, and
|
||||
* omitting it keeps this artifact small enough to serve as a `data:` URL.
|
||||
*/
|
||||
export default defineModuleUi({
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
async mount(container, context) {
|
||||
const response = await context.api.request("records");
|
||||
const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest";
|
||||
container.textContent = `${context.moduleId}:${response.status}:${label}`;
|
||||
return () => {
|
||||
container.textContent = "";
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
// @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 },
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// @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 } from "@ekklesia/module-ui-sdk";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadModuleUi } from "../src/index.js";
|
||||
|
||||
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 {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
|
||||
function standaloneContext(requested: string[]): ModuleUiHostContext {
|
||||
return {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
artifactSource = await readFile(result.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(container.textContent).toBe("example:200:user-123");
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
});
|
||||
|
||||
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(ekklesiaContainer.textContent).toBe("example:200:user-123");
|
||||
expect(standaloneContainer.textContent).toBe("example:200:guest");
|
||||
|
||||
await inEkklesia.unmount();
|
||||
await inStandalone.unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user