368 lines
13 KiB
TypeScript
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" }));
|
|
}
|
|
});
|
|
});
|