Files
Ekklesia/core/tests/dashboard-flat-modules.test.ts
ekklesia 49b4d4165d
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
feat: deliver module database ABI by manifest
2026-10-07 17:29:32 -05:00

248 lines
9.4 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type DashboardConfiguration, resolveDashboardNavigation } from "../src/dashboard/index.js";
import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
} from "../src/modules/index.js";
/**
* The executable form of the architecture's central rule: **Dashboard grouping is presentation
* configuration and can never become module structure** (ADR 0021, ADR 0027).
*
* These run real discovery over real fixtures, including a genuine module dependency, and then
* arrange those modules into folders in several different ways. Everything discovery decides —
* which modules exist, what depends on what, and the order they must start in — has to come out
* byte-identical every time. A future change that let a group influence any of that fails here.
*/
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly id: string;
readonly name: string;
/** A *real* technical dependency — one module calling another's functionality — expressed through
* the existing module dependency contract, which is the only kind of module relationship that
* exists. */
readonly dependsOn?: string;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version: "1.0.0",
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
...(fixture.dependsOn
? { dependencies: { modules: { required: { [fixture.dependsOn]: "^1.0.0" } } } }
: {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 8,
name: fixture.name,
version: "1.0.0",
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
);
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
);
}
/** Livestream genuinely depends on Video — it calls Video's functionality — so discovery must order
* Video first. Calendar depends on nothing. This is the *only* kind of relationship modules have. */
async function discoverFixtures(): Promise<readonly DiscoveredModule[]> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-flat-modules-"));
temporaryDirectories.push(modulesDirectory);
await createModule(modulesDirectory, { id: "video", name: "Video" });
await createModule(modulesDirectory, {
dependsOn: "video",
id: "livestream",
name: "Livestream",
});
await createModule(modulesDirectory, { id: "calendar", name: "Calendar" });
return discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
}
/**
* Four arrangements of the same three modules, including the one the architecture warns about:
* Livestream and Video together under "Media", which must **not** create a Media module or a
* dependency between them.
*/
const arrangements: ReadonlyArray<readonly [string, DashboardConfiguration]> = [
["nothing configured", { groups: [], placements: [] }],
[
"Media over Planning",
{
groups: [
{ id: "media", label: "Media", sortOrder: 10 },
{ id: "planning", label: "Planning", sortOrder: 20 },
],
placements: [
{ groupId: "media", moduleId: "livestream", sortOrder: 10 },
{ groupId: "media", moduleId: "video", sortOrder: 20 },
{ groupId: "planning", moduleId: "calendar", sortOrder: 10 },
],
},
],
[
"Livestream moved to Sunday Production, away from Video",
{
groups: [
{ id: "media", label: "Media", sortOrder: 10 },
{ id: "sunday-production", label: "Sunday Production", sortOrder: 5 },
],
placements: [
{ groupId: "sunday-production", moduleId: "livestream", sortOrder: 10 },
{ groupId: "media", moduleId: "video", sortOrder: 10 },
],
},
],
[
"the dependency inverted in presentation: Video grouped under Livestream's folder alone",
{
groups: [{ id: "streaming", label: "Streaming", sortOrder: 1 }],
placements: [{ groupId: "streaming", moduleId: "video", sortOrder: 10 }],
},
],
];
describe("Dashboard grouping never becomes module structure", () => {
it("orders discovery by real dependencies, which is the only module relationship", async () => {
const discovered = await discoverFixtures();
// Video before Livestream because Livestream *depends* on it — not because of any folder.
expect(discovered.map((module) => module.manifest.id)).toEqual([
"calendar",
"video",
"livestream",
]);
expect(
discovered.find((module) => module.manifest.id === "livestream")?.manifest.dependencies,
).toMatchObject({ modules: { required: { video: "^1.0.0" } } });
});
it("leaves discovery, dependencies, and start order identical under every arrangement", async () => {
const discovered = await discoverFixtures();
const registry = createModuleUiPublicationRegistry(discovered);
// The exact facts the runtime consumes, captured before any Dashboard configuration exists.
const baseline = JSON.stringify(
discovered.map((module) => ({
capabilities: module.manifest.capabilities,
dependencies: module.manifest.dependencies,
id: module.manifest.id,
permissions: module.manifest.permissions,
version: module.manifest.version,
})),
);
for (const [label, configuration] of arrangements) {
const view = resolveDashboardNavigation(
configuration,
registry.list().map((entry) => ({ id: entry.moduleId, name: entry.name })),
);
// Discovery order — which is module *startup* order — is untouched by presentation.
expect(
discovered.map((module) => module.manifest.id),
label,
).toEqual(["calendar", "video", "livestream"]);
// So is every fact the runtime reads.
expect(
JSON.stringify(
discovered.map((module) => ({
capabilities: module.manifest.capabilities,
dependencies: module.manifest.dependencies,
id: module.manifest.id,
permissions: module.manifest.permissions,
version: module.manifest.version,
})),
),
label,
).toBe(baseline);
// And every module is still rendered exactly once, whatever the folders say.
expect(
[...view.groups.flatMap((group) => group.modules), ...view.ungrouped]
.map((entry) => entry.id)
.sort(),
label,
).toEqual(["calendar", "livestream", "video"]);
}
});
it("never invents a module for a group, however the group is named", async () => {
const discovered = await discoverFixtures();
const registry = createModuleUiPublicationRegistry(discovered);
const [, mediaArrangement] = arrangements[1] as readonly [string, DashboardConfiguration];
const view = resolveDashboardNavigation(
mediaArrangement,
registry.list().map((entry) => ({ id: entry.moduleId, name: entry.name })),
);
// "Media" and "Planning" are group labels. There is no `media` module, no manifest for one, no
// artifact, no API root, no permission, and nothing depends on one.
const moduleIds = discovered.map((module) => module.manifest.id);
expect(moduleIds).toEqual(["calendar", "video", "livestream"]);
for (const groupId of view.groups.map((group) => group.id)) {
expect(moduleIds).not.toContain(groupId);
expect(registry.resolve(groupId, "1.0.0")).toBeUndefined();
}
});
it("keeps the publication record free of any grouping, ordering, or navigation field", async () => {
const registry = createModuleUiPublicationRegistry(await discoverFixtures());
const published = registry.resolve("livestream", "1.0.0");
// The record a host mounts from carries identity, the validated artifact, declared permissions,
// and the module's own name — never a group, an order, a parent, or a folder.
expect(Object.keys(published ?? {}).sort()).toEqual([
"artifactPath",
"moduleId",
"name",
"permissions",
"version",
]);
});
it("takes the display name from the module's own manifest, not from any folder", async () => {
const registry = createModuleUiPublicationRegistry(await discoverFixtures());
expect(registry.list().map((entry) => [entry.moduleId, entry.name])).toEqual([
["calendar", "Calendar"],
["video", "Video"],
["livestream", "Livestream"],
]);
});
});