Files
Ekklesia/surfaces/shell/tests/module-contributions.test.tsx

368 lines
13 KiB
TypeScript

// @vitest-environment happy-dom
import {
MODULE_CONTRIBUTION_ABI_VERSION,
MODULE_UI_ABI_VERSION,
type ModuleContributionContext,
type ModuleContributionInput,
type ModuleUiHostContext,
type MountedModuleContribution,
} from "@ekklesia/module-ui-sdk";
import { act, type ReactElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { beforeEach, describe, expect, it } from "vitest";
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../src/host-protocol.js";
import { parseDashboardNavigation } from "../src/host-protocol.js";
import { createModuleUiContext } from "../src/module-context.js";
import { isolatedText } from "./support/isolated-dom.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
/**
* The generic browser-contribution mechanism (ADR 0041), driven through exactly the relationship it
* exists for:
*
* ```text
* signage provides the capability signage.slide-renderer, and owns the input contract
* announcements contributes a React renderer for it
* signage mounts that renderer in a slot it owns, updates it, and unmounts it
* ```
*
* Neither module imports the other, and neither imports the platform's publication routing. The
* consumer asks the **host** what was contributed and asks the host to mount it; the URL is the
* server's business and never reaches module code. Nothing in this file teaches Ekklesia what a
* "slide" is: the payload is opaque JSON that travels between the two modules.
*/
/** The consuming module's own published input contract — its business, never the platform's. */
interface SlideInput extends Record<string, ModuleContributionInput> {
readonly headline: string;
readonly seconds: number;
}
interface Recorder {
/** Everything the consumer's artifact was told about the contribution. */
readonly seen: unknown[];
/** Every URL the *host* imported. The consumer never sees these. */
readonly imported: string[];
readonly renders: SlideInput[];
}
/**
* The contributed artifact: a real React renderer, exactly as `defineModuleContribution` produces
* (the compiled path through the canonical builder is proven in the module template's own suite).
*/
function contributedArtifact(recorder: Recorder, options: { readonly version?: string } = {}) {
function Slide({ input }: { readonly input: SlideInput }): ReactElement {
return (
<article className="slide" data-testid="contributed-slide">
<h2>{input.headline}</h2>
<p>{`${input.seconds}s`}</p>
</article>
);
}
return {
default: {
abi: MODULE_CONTRIBUTION_ABI_VERSION,
id: "announcements.signage-slide",
version: options.version ?? "1.0.0",
styles: ".slide{color:var(--ekklesia-text-primary)}",
mount(container: Element, context: ModuleContributionContext) {
const root: Root = createRoot(container);
const render = (input: ModuleContributionInput): void => {
recorder.renders.push(input as SlideInput);
root.render(<Slide input={input as SlideInput} />);
};
render(context.input);
return {
update(input: ModuleContributionInput) {
render(input);
},
unmount() {
root.unmount();
},
};
},
},
};
}
/** The consuming module's own UI: it discovers, mounts, updates, and unmounts through the host. */
function consumerArtifact(recorder: Recorder) {
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id: "signage",
version: "2.0.0",
async mount(container: Element, context: ModuleUiHostContext) {
const slot = container.ownerDocument.createElement("div");
slot.dataset.testid = "signage-slot";
container.append(slot);
// Discovery is a question to the host about this module's *own* capability. There is no
// URL, no registry query, and no other module's name involved.
const available = context.contributions.list("signage.slide-renderer");
recorder.seen.push(...available.map((entry) => ({ ...entry })));
const first = available[0];
if (!first) {
return;
}
const mounted: MountedModuleContribution = await context.contributions.mount({
container: slot,
contributionId: first.id,
input: { headline: "Welcome", seconds: 7 } satisfies SlideInput,
});
(globalThis as { __mountedSlide?: MountedModuleContribution }).__mountedSlide = mounted;
return () => {
void mounted.unmount();
slot.remove();
};
},
},
};
}
function descriptor(overrides: Partial<HostModuleUiDescriptor> = {}): HostModuleUiDescriptor {
return {
apiBaseUrl: "/api/v1/modules/signage",
contributions: [
{
capability: "signage.slide-renderer",
id: "announcements.signage-slide",
url: "/api/v1/module-ui/announcements/0.4.0/contributions/announcements.signage-slide",
version: "1.0.0",
},
],
displayPageUrl: "/display/signage",
id: "signage",
permissions: [],
realtimeBaseUrl: "/api/v1/module-realtime/signage",
uiUrl: "/api/v1/module-ui/signage/2.0.0",
version: "2.0.0",
...overrides,
};
}
let container: HTMLDivElement;
let recorder: Recorder;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
recorder = { imported: [], renders: [], seen: [] };
});
function hostConfig(): EkklesiaHostConfig {
return { apiBaseUrl };
}
/** The host's importer, standing in for a browser `import()` of a published URL. */
function importer(): (url: string) => Promise<unknown> {
return async (url: string) => {
recorder.imported.push(url);
return contributedArtifact(recorder);
};
}
async function mountConsumer(
contributionsOverride?: HostModuleUiDescriptor["contributions"],
): Promise<{ unmount: () => void | Promise<void> }> {
const context = createModuleUiContext({
config: hostConfig(),
descriptor:
contributionsOverride === undefined
? descriptor()
: descriptor({ contributions: contributionsOverride }),
importModule: importer(),
principal: { kind: "device", deviceId: "screen-1" },
});
const consumer = consumerArtifact(recorder).default;
let cleanup: (() => void | Promise<void>) | undefined;
await act(async () => {
cleanup = (await consumer.mount(container, context)) ?? undefined;
});
return { unmount: () => cleanup?.() };
}
function mountedSlide(): MountedModuleContribution {
const mounted = (globalThis as { __mountedSlide?: MountedModuleContribution }).__mountedSlide;
if (!mounted) {
throw new Error("the consumer did not mount a contribution");
}
return mounted;
}
describe("a consuming module mounts another module's contributed renderer", () => {
it("discovers, loads, and mounts it inside the consumer's own slot", async () => {
const handle = await mountConsumer();
// Discovery gave the consumer identity only — no URL, no filesystem path, no module internals.
expect(recorder.seen).toEqual([
{
capability: "signage.slide-renderer",
id: "announcements.signage-slide",
version: "1.0.0",
},
]);
expect(JSON.stringify(recorder.seen)).not.toContain("/api/");
const slot = container.querySelector('[data-testid="signage-slot"]');
expect(isolatedText(slot)).toContain("Welcome");
expect(isolatedText(slot)).toContain("7s");
await act(async () => handle.unmount());
});
it("resolves the publication URL in the host, never in module code", async () => {
const handle = await mountConsumer();
// The host turned the server-produced descriptor path into an absolute URL. A module that built
// this itself would be welded to Ekklesia's private deployment detail.
expect(recorder.imported).toEqual([
`${apiBaseUrl}/api/v1/module-ui/announcements/0.4.0/contributions/announcements.signage-slide`,
]);
await act(async () => handle.unmount());
});
it("updates the renderer in place, without remounting it", async () => {
const handle = await mountConsumer();
const slot = container.querySelector('[data-testid="signage-slot"]');
const beforeNode = slot?.querySelector?.('[data-testid="contributed-slide"]');
await act(async () => {
await mountedSlide().update({ headline: "Second slide", seconds: 12 });
});
expect(isolatedText(slot)).toContain("Second slide");
expect(isolatedText(slot)).toContain("12s");
// One mount, two renders — the property a long-running screen depends on.
expect(recorder.renders).toHaveLength(2);
expect(recorder.imported).toHaveLength(1);
expect(slot?.querySelector?.('[data-testid="contributed-slide"]')).toBe(beforeNode);
await act(async () => handle.unmount());
});
it("isolates the contributed renderer's styles from the consumer's own screen", async () => {
const handle = await mountConsumer();
const slot = container.querySelector('[data-testid="signage-slot"]') as Element;
const shadowRoot = slot.querySelector("[data-ekklesia-module-ui]")?.shadowRoot;
expect(shadowRoot?.querySelector("style")?.textContent).toContain(".slide{");
// Neither the consumer's own subtree nor the host document received the renderer's rules.
expect(container.textContent).not.toContain(".slide{");
expect(document.head.textContent ?? "").not.toContain(".slide{");
await act(async () => handle.unmount());
});
it("tears the renderer down when the consumer unmounts, leaving its slot empty", async () => {
const handle = await mountConsumer();
const slot = container.querySelector('[data-testid="signage-slot"]') as Element;
await act(async () => handle.unmount());
expect(mountedSlide().mounted).toBe(false);
expect(slot.children).toHaveLength(0);
expect(container.querySelector('[data-testid="signage-slot"]')).toBeNull();
});
it("refuses a contribution id the server did not offer this module", async () => {
const context = createModuleUiContext({
config: hostConfig(),
descriptor: descriptor(),
importModule: importer(),
principal: { kind: "device", deviceId: "screen-1" },
});
await expect(
context.contributions.mount({
container,
contributionId: "calendar.week-view",
input: null,
}),
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_UNKNOWN" });
// Nothing was fetched: a consumer cannot name its way to an artifact the server withheld.
expect(recorder.imported).toEqual([]);
});
it("finds nothing for a capability nothing was contributed to", async () => {
const context = createModuleUiContext({
config: hostConfig(),
descriptor: descriptor(),
importModule: importer(),
principal: { kind: "device", deviceId: "screen-1" },
});
expect(context.contributions.list("signage.ticker")).toEqual([]);
});
it("mounts nothing when the platform published no compatible contribution", async () => {
// Exactly what a consumer sees when no module contributes, or when every contributor is outside
// the declared compatible range: an empty list, and a screen that renders its own fallback.
const handle = await mountConsumer([]);
expect(recorder.seen).toEqual([]);
expect(recorder.imported).toEqual([]);
await act(async () => handle.unmount());
});
it("refuses an artifact whose declared identity does not match the descriptor", async () => {
const context = createModuleUiContext({
config: hostConfig(),
descriptor: descriptor(),
importModule: async () => contributedArtifact(recorder, { version: "9.9.9" }),
principal: { kind: "device", deviceId: "screen-1" },
});
await expect(
context.contributions.mount({
container,
contributionId: "announcements.signage-slide",
input: { headline: "x", seconds: 1 },
}),
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_IDENTITY_MISMATCH" });
expect(container.children).toHaveLength(0);
});
it("keeps a contribution out of a descriptor the shell refuses to parse", () => {
// The wire is validated on arrival, exactly like every other server-produced URL: a contribution
// URL is something the browser will `import()`, so it gets no weaker treatment.
for (const url of [
"https://evil.example.test/x.js",
"//evil.example.test/x.js",
"../../x.js",
]) {
expect(() =>
parseDashboardNavigation({
groups: [],
ungrouped: [
{
descriptor: {
...descriptor({
contributions: [
{
capability: "signage.slide-renderer",
id: "announcements.signage-slide",
url,
version: "1.0.0",
},
],
}),
api: { baseUrl: "/api/v1/modules/signage" },
realtime: { baseUrl: "/api/v1/module-realtime/signage" },
ui: { url: "/api/v1/module-ui/signage/2.0.0" },
},
name: "Signage",
},
],
}),
).toThrowError(expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }));
}
});
});