247 lines
9.3 KiB
TypeScript
247 lines
9.3 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
defineModuleUi,
|
|
isModuleUiDefinition,
|
|
ModuleUiContractError,
|
|
type ModuleUiApiClient,
|
|
type ModuleUiDefinition,
|
|
type ModuleUiHostContext,
|
|
type ModuleUiRealtimeClient,
|
|
parseModuleUiDefinition,
|
|
parseModuleUiExport,
|
|
} from "../src/index.js";
|
|
|
|
function validDefinition(): ModuleUiDefinition {
|
|
return { id: "announcements", version: "0.1.0", mount: (): void => undefined };
|
|
}
|
|
|
|
/** The contract treats the container as an opaque host-supplied mount target, so a plain object
|
|
* with the one property the fixture touches is a faithful stand-in. No DOM is required to prove the
|
|
* contract, and this package deliberately does not take a DOM dependency to obtain one. */
|
|
const noop = (): void => undefined;
|
|
|
|
const noopRealtime: ModuleUiRealtimeClient = Object.freeze({
|
|
subscribe: () => Object.freeze({ close: noop }),
|
|
});
|
|
|
|
function fakeContainer(): Element & { textContent: string } {
|
|
return { textContent: "" } as unknown as Element & { textContent: string };
|
|
}
|
|
|
|
describe("module UI definition contract", () => {
|
|
it("accepts and freezes a valid definition", () => {
|
|
const definition = defineModuleUi(validDefinition());
|
|
|
|
expect(definition.id).toBe("announcements");
|
|
expect(definition.version).toBe("0.1.0");
|
|
expect(Object.isFrozen(definition)).toBe(true);
|
|
});
|
|
|
|
it.each<[string, unknown]>([
|
|
["a missing id", { version: "0.1.0", mount: noop }],
|
|
["a blank id", { id: " ", version: "0.1.0", mount: noop }],
|
|
["a non-string id", { id: 7, version: "0.1.0", mount: noop }],
|
|
["a missing version", { id: "announcements", mount: noop }],
|
|
["a blank version", { id: "announcements", version: "", mount: noop }],
|
|
["a missing mount", { id: "announcements", version: "0.1.0" }],
|
|
["a non-function mount", { id: "announcements", version: "0.1.0", mount: "render" }],
|
|
["an unknown property", { id: "announcements", version: "0.1.0", mount: noop, routes: [] }],
|
|
])("rejects a definition with %s", (_label, candidate) => {
|
|
expect(isModuleUiDefinition(candidate)).toBe(false);
|
|
expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError);
|
|
});
|
|
|
|
it.each<unknown>([
|
|
null,
|
|
undefined,
|
|
42,
|
|
"ui",
|
|
[],
|
|
noop,
|
|
])("rejects a non-object definition %s", (candidate) => {
|
|
expect(isModuleUiDefinition(candidate)).toBe(false);
|
|
});
|
|
|
|
it("reports a malformed definition with a stable, sanitized code", () => {
|
|
try {
|
|
parseModuleUiDefinition({ id: "announcements" });
|
|
expect.unreachable("expected a contract error");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ModuleUiContractError);
|
|
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_DEFINITION_INVALID");
|
|
expect((error as ModuleUiContractError).stack).not.toContain("announcements");
|
|
}
|
|
});
|
|
|
|
it("rejects an artifact namespace with no default export", () => {
|
|
expect(() =>
|
|
parseModuleUiExport({ ui: validDefinition() }, { id: "a", version: "1.0.0" }),
|
|
).toThrowError(ModuleUiContractError);
|
|
try {
|
|
parseModuleUiExport({}, { id: "a", version: "1.0.0" });
|
|
} catch (error) {
|
|
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_EXPORT_MISSING");
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["id", { id: "calendar", version: "0.1.0" }],
|
|
["version", { id: "announcements", version: "0.2.0" }],
|
|
])("rejects an artifact whose %s does not match the manifest", (_label, expected) => {
|
|
try {
|
|
parseModuleUiExport({ default: defineModuleUi(validDefinition()) }, expected);
|
|
expect.unreachable("expected an identity mismatch");
|
|
} catch (error) {
|
|
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_IDENTITY_MISMATCH");
|
|
}
|
|
});
|
|
|
|
it("accepts an artifact whose identity matches the manifest exactly", () => {
|
|
const definition = parseModuleUiExport(
|
|
{ default: defineModuleUi(validDefinition()) },
|
|
{ id: "announcements", version: "0.1.0" },
|
|
);
|
|
|
|
expect(definition.id).toBe("announcements");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The portability proof. One artifact, imported once, mounted by two hosts that share nothing but
|
|
* this package's public interface. If module UI ever hardcoded Ekklesia's `/api/v1/modules/<id>`
|
|
* prefix, the standalone host below could not serve it.
|
|
*/
|
|
describe("the same module UI artifact hosted by two different hosts", () => {
|
|
interface RecordingHost {
|
|
readonly name: string;
|
|
readonly requestedUrls: string[];
|
|
readonly context: ModuleUiHostContext;
|
|
}
|
|
|
|
/** Mirrors how Ekklesia mounts module routes today: `/api/v1/modules/<module-id>/<path>`. */
|
|
function ekklesiaHost(moduleId: string, canPublish: boolean): RecordingHost {
|
|
const requestedUrls: string[] = [];
|
|
const api: ModuleUiApiClient = {
|
|
async request(path) {
|
|
const url = `/api/v1/modules/${moduleId}/${path.replace(/^\//u, "")}`;
|
|
requestedUrls.push(url);
|
|
return new Response(null, { status: 200 });
|
|
},
|
|
};
|
|
return {
|
|
name: "ekklesia",
|
|
requestedUrls,
|
|
context: {
|
|
moduleId,
|
|
api,
|
|
principal: { kind: "anonymous" },
|
|
permissions: { has: () => canPublish },
|
|
realtime: noopRealtime,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A standalone host that serves exactly one module from a different origin and a different
|
|
* path shape, and never runs Core at all. */
|
|
function standaloneHost(moduleId: string, canPublish: boolean): RecordingHost {
|
|
const requestedUrls: string[] = [];
|
|
const api: ModuleUiApiClient = {
|
|
async request(path) {
|
|
const url = `https://announcements.example.org/api/${path.replace(/^\//u, "")}`;
|
|
requestedUrls.push(url);
|
|
return new Response(null, { status: 200 });
|
|
},
|
|
};
|
|
return {
|
|
name: "standalone",
|
|
requestedUrls,
|
|
context: {
|
|
moduleId,
|
|
api,
|
|
principal: { kind: "anonymous" },
|
|
permissions: { has: () => canPublish },
|
|
realtime: noopRealtime,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function loadFixtureUi(): Promise<ModuleUiDefinition> {
|
|
// Each host performs its own dynamic import; this package never imports an artifact itself.
|
|
const namespace = (await import("./fixtures/announcements-ui.js")) as unknown;
|
|
return parseModuleUiExport(namespace, { id: "announcements", version: "0.1.0" });
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
const { trace } = await import("./fixtures/announcements-ui.js");
|
|
trace.mounted.length = 0;
|
|
trace.unmounted.length = 0;
|
|
trace.requestedPaths.length = 0;
|
|
trace.canPublish.length = 0;
|
|
});
|
|
|
|
it("validates a real imported artifact's export against its manifest identity", async () => {
|
|
const definition = await loadFixtureUi();
|
|
|
|
expect(definition.id).toBe("announcements");
|
|
expect(definition.version).toBe("0.1.0");
|
|
expect(typeof definition.mount).toBe("function");
|
|
});
|
|
|
|
it("mounts and unmounts identically under both hosts, and resolves URLs only in the host", async () => {
|
|
const definition = await loadFixtureUi();
|
|
const { trace } = await import("./fixtures/announcements-ui.js");
|
|
|
|
for (const host of [
|
|
ekklesiaHost("announcements", true),
|
|
standaloneHost("announcements", true),
|
|
]) {
|
|
const container = fakeContainer();
|
|
const unmount = await definition.mount(container, host.context);
|
|
|
|
expect(container.textContent).toBe("announcements:200:editor");
|
|
expect(typeof unmount).toBe("function");
|
|
await (unmount as () => Promise<void>)();
|
|
expect(container.textContent).toBe("");
|
|
}
|
|
|
|
// The module asked for the same relative path both times and never built a URL itself.
|
|
expect(trace.requestedPaths).toEqual(["posts", "posts"]);
|
|
expect(trace.mounted).toEqual(["announcements", "announcements"]);
|
|
expect(trace.unmounted).toEqual(["announcements", "announcements"]);
|
|
});
|
|
|
|
it("lets each host resolve the module's relative path to its own URL shape", async () => {
|
|
const definition = await loadFixtureUi();
|
|
const ekklesia = ekklesiaHost("announcements", true);
|
|
const standalone = standaloneHost("announcements", true);
|
|
|
|
await definition.mount(fakeContainer(), ekklesia.context);
|
|
await definition.mount(fakeContainer(), standalone.context);
|
|
|
|
expect(ekklesia.requestedUrls).toEqual(["/api/v1/modules/announcements/posts"]);
|
|
expect(standalone.requestedUrls).toEqual(["https://announcements.example.org/api/posts"]);
|
|
});
|
|
|
|
it("renders a different presentation when the host reports a permission is absent", async () => {
|
|
const definition = await loadFixtureUi();
|
|
const container = fakeContainer();
|
|
|
|
await definition.mount(container, ekklesiaHost("announcements", false).context);
|
|
|
|
expect(container.textContent).toBe("announcements:200:reader");
|
|
});
|
|
|
|
it("treats a denied permission hint as presentation only, never as enforcement", async () => {
|
|
// The host reports the permission as absent. The module still reaches its API — nothing in this
|
|
// contract blocks the request, and nothing in it could. Server-side `requirePermission` remains
|
|
// the only authority, exactly as it is for any other caller.
|
|
const definition = await loadFixtureUi();
|
|
const host = ekklesiaHost("announcements", false);
|
|
|
|
await definition.mount(fakeContainer(), host.context);
|
|
|
|
expect(host.context.permissions.has("announcements.posts.publish")).toBe(false);
|
|
expect(host.requestedUrls).toEqual(["/api/v1/modules/announcements/posts"]);
|
|
});
|
|
});
|