248 lines
9.4 KiB
TypeScript
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: 6,
|
|
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"],
|
|
]);
|
|
});
|
|
});
|