Files
Ekklesia/core/tests/module-lifecycle.test.ts
T
ekklesia 2e0fe059c3
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s
feat: add display kiosk host
2026-08-23 20:50:56 -05:00

530 lines
20 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { parseModuleManifest } from "@ekklesia/contracts";
import { afterEach, describe, expect, it } from "vitest";
import { type DiscoveredModule, discoverModules } from "../src/modules/discovery.js";
import {
type ModuleLifecycleConfiguration,
resolveModuleEligibility,
resolveModuleLifecycleView,
} from "../src/modules/lifecycle.js";
/** A minimal, in-memory `DiscoveredModule` double, built the same way `module-permissions.test.ts`
* builds one: the manifest still goes through `parseModuleManifest`, so a fixture real discovery
* would reject can never silently stand in for one it would accept. Only `manifest` is read by
* eligibility resolution; the rest exist to satisfy the type. */
function fakeModule(
id: string,
options: {
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
readonly dependencies?: Record<string, unknown>;
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
capabilities: options.capabilities ?? [],
...(options.dependencies ? { dependencies: options.dependencies } : {}),
entrypoints: {
// Deliberately a path nothing will ever resolve: eligibility resolution must never import,
// evaluate, or otherwise touch a module's server entrypoint.
server: "./dist/does-not-exist/index.js",
},
});
return {
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
function configuration(
...records: readonly (readonly [string, boolean])[]
): ModuleLifecycleConfiguration {
return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) };
}
function ids(modules: readonly DiscoveredModule[]): readonly string[] {
return modules.map((module) => module.manifest.id);
}
function entry(
resolution: ReturnType<typeof resolveModuleEligibility>,
moduleId: string,
): ReturnType<typeof resolveModuleEligibility>["entries"][number] {
const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId);
if (!found) {
throw new Error(`No eligibility entry for ${moduleId}.`);
}
return found;
}
describe("resolveModuleEligibility — default policy for a newly discovered module", () => {
it("treats a module with no stored record as enabled, so deploying a module is enough to run it", () => {
const resolution = resolveModuleEligibility([fakeModule("announcements")], configuration());
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
expect(entry(resolution, "announcements").configured).toEqual({
enabled: true,
explicit: false,
});
});
it("distinguishes the default from a decision somebody actually made", () => {
const resolution = resolveModuleEligibility(
[fakeModule("announcements")],
configuration(["announcements", true]),
);
// Same effect, different provenance — which is exactly what an operator needs to know when
// they are wondering whether a module is deliberately on.
expect(entry(resolution, "announcements").configured).toEqual({
enabled: true,
explicit: true,
});
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
});
it("never writes, and never enables anything: an explicit disable always wins over the default", () => {
const resolution = resolveModuleEligibility(
[fakeModule("announcements")],
configuration(["announcements", false]),
);
expect(resolution.eligibleModules).toEqual([]);
expect(entry(resolution, "announcements")).toEqual({
configured: { enabled: false, explicit: true },
eligible: false,
moduleId: "announcements",
version: "0.1.0",
});
// A disabled module is not "blocked" — it is doing exactly what it was told.
expect(entry(resolution, "announcements").blocked).toBeUndefined();
});
it("ignores a stored record for a module that is not deployed, rather than failing startup", () => {
const resolution = resolveModuleEligibility(
[fakeModule("announcements")],
configuration(["announcements", true], ["removed-long-ago", false]),
);
expect(resolution.entries.map((candidate) => candidate.moduleId)).toEqual(["announcements"]);
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
});
it("restores a retained decision when the module is deployed again", () => {
const stale = configuration(["announcements", false]);
// Absent: the record is inert.
expect(resolveModuleEligibility([], stale).entries).toEqual([]);
// Redeployed: the same record decides again, with no new write.
expect(resolveModuleEligibility([fakeModule("announcements")], stale).eligibleModules).toEqual(
[],
);
});
});
describe("resolveModuleEligibility — required dependencies", () => {
it("keeps a dependent eligible while its required dependency is enabled", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("announcements", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
}),
],
configuration(),
);
expect(ids(resolution.eligibleModules)).toEqual(["storage", "announcements"]);
});
it("blocks a dependent when its required module dependency is disabled, and names the dependency", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("announcements", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
}),
],
configuration(["storage", false]),
);
expect(resolution.eligibleModules).toEqual([]);
expect(entry(resolution, "announcements").blocked).toEqual({
dependency: { kind: "module", moduleId: "storage" },
reason: "required_dependency_disabled",
});
// Administrator intent is never overridden: disabling `storage` does not silently re-enable it
// because something needs it.
expect(entry(resolution, "storage").configured.enabled).toBe(false);
});
it("blocks a dependent when the required capability's only provider is disabled", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage", { capabilities: [{ id: "storage.objects", version: "1.0.0" }] }),
fakeModule("announcements", {
dependencies: { capabilities: { required: { "storage.objects": "^1.0.0" } } },
}),
],
configuration(["storage", false]),
);
expect(resolution.eligibleModules).toEqual([]);
// A required capability is exactly as required as a required module, and the reason names the
// module an administrator would have to enable — not just the capability id.
expect(entry(resolution, "announcements").blocked).toEqual({
dependency: { capabilityId: "storage.objects", kind: "capability", moduleId: "storage" },
reason: "required_dependency_disabled",
});
});
it("propagates blocking transitively, and distinguishes the direct cause from the inherited one", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("media", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
}),
fakeModule("announcements", {
dependencies: { modules: { required: { media: "^0.1.0" } } },
}),
],
configuration(["storage", false]),
);
expect(resolution.eligibleModules).toEqual([]);
expect(entry(resolution, "media").blocked?.reason).toBe("required_dependency_disabled");
// Following the chain always terminates at a `..._disabled` entry, so an operator can walk from
// any blocked module to the one decision that caused it.
expect(entry(resolution, "announcements").blocked).toEqual({
dependency: { kind: "module", moduleId: "media" },
reason: "required_dependency_blocked",
});
});
it("blocks only the dependents of the disabled module, never an unrelated one", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("announcements", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
}),
fakeModule("calendar"),
],
configuration(["storage", false]),
);
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
expect(entry(resolution, "calendar").blocked).toBeUndefined();
});
it("preserves discovery order in the eligible list rather than re-sorting it", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("media", { dependencies: { modules: { required: { storage: "^0.1.0" } } } }),
fakeModule("announcements", {
dependencies: { modules: { required: { media: "^0.1.0" } } },
}),
],
configuration(),
);
// Filtering, never re-ordering: a started module's required dependencies still precede it.
expect(ids(resolution.eligibleModules)).toEqual(["storage", "media", "announcements"]);
});
});
describe("resolveModuleEligibility — optional dependencies", () => {
it("leaves a dependent eligible when its optional module dependency is disabled", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("analytics"),
fakeModule("announcements", {
dependencies: { modules: { optional: { analytics: "^0.1.0" } } },
}),
],
configuration(["analytics", false]),
);
// A module declaring an optional dependency already accepted that it may be absent — discovery
// permits an optional dependency to not be installed at all — and "disabled" is that same
// situation arriving a different way.
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
expect(entry(resolution, "announcements").blocked).toBeUndefined();
});
it("leaves a dependent eligible when its optional capability's provider is disabled", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("analytics", { capabilities: [{ id: "analytics.events", version: "1.0.0" }] }),
fakeModule("announcements", {
dependencies: { capabilities: { optional: { "analytics.events": "^1.0.0" } } },
}),
],
configuration(["analytics", false]),
);
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
});
it("leaves a dependent eligible when its optional dependency is itself blocked", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("analytics", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
}),
fakeModule("announcements", {
dependencies: { modules: { optional: { analytics: "^0.1.0" } } },
}),
],
configuration(["storage", false]),
);
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
});
it("blocks on the required edge only, when a module declares both kinds", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("analytics"),
fakeModule("storage"),
fakeModule("announcements", {
dependencies: {
modules: { optional: { analytics: "^0.1.0" }, required: { storage: "^0.1.0" } },
},
}),
],
configuration(["analytics", false], ["storage", false]),
);
expect(entry(resolution, "announcements").blocked).toEqual({
dependency: { kind: "module", moduleId: "storage" },
reason: "required_dependency_disabled",
});
});
});
describe("resolveModuleEligibility — over a real discovered inventory", () => {
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function writeModule(
modulesDirectory: string,
id: string,
dependencies?: Record<string, unknown>,
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version: "0.1.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(dependencies ? { dependencies } : {}),
entrypoints: { server: "./dist/server/index.js" },
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
'throw new Error("eligibility resolution must never import a module server entrypoint");',
),
]);
}
it("resolves against the exact list real discovery produces, without importing any entrypoint", async () => {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-"));
temporaryDirectories.push(modulesDirectory);
await writeModule(modulesDirectory, "storage");
await writeModule(modulesDirectory, "announcements", {
modules: { required: { storage: "^0.1.0" } },
});
const discovered = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
// Discovery's own topological order, which eligibility resolution relies on and never redoes.
expect(ids(discovered)).toEqual(["storage", "announcements"]);
const blocked = resolveModuleEligibility(discovered, configuration(["storage", false]));
expect(blocked.eligibleModules).toEqual([]);
// Every fixture entrypoint throws on import; reaching this assertion is direct evidence that
// nothing here imported one.
expect(entry(blocked, "announcements").blocked?.reason).toBe("required_dependency_disabled");
});
it("leaves a missing required dependency to discovery, rather than modelling it as a lifecycle state", async () => {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-"));
temporaryDirectories.push(modulesDirectory);
await writeModule(modulesDirectory, "announcements", {
modules: { required: { storage: "^0.1.0" } },
});
// There is deliberately no eligibility state for "required dependency not installed": discovery
// rejects the whole inventory long before enablement is consulted, so there is exactly one place
// the dependency graph is validated.
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({ name: "ModuleDiscoveryError" });
});
});
describe("resolveModuleLifecycleView — configuration versus what this process actually did", () => {
const storage = fakeModule("storage");
const announcements = fakeModule("announcements", {
dependencies: { modules: { required: { storage: "^0.1.0" } } },
});
const discoveredModules = [storage, announcements];
function snapshotFor(bootConfiguration: ModuleLifecycleConfiguration) {
const resolution = resolveModuleEligibility(discoveredModules, bootConfiguration);
return {
discoveredModules,
eligibility: resolution.entries,
startedModuleIds: resolution.eligibleModules.map((module) => module.manifest.id),
};
}
it("reports a started module as running, with no restart pending", () => {
const view = resolveModuleLifecycleView(snapshotFor(configuration()), configuration());
expect(view).toEqual([
{
configured: { enabled: true, explicit: false },
moduleId: "storage",
restartRequired: false,
runtime: { state: "running" },
version: "0.1.0",
},
{
configured: { enabled: true, explicit: false },
moduleId: "announcements",
restartRequired: false,
runtime: { state: "running" },
version: "0.1.0",
},
]);
});
it("does not claim a module stopped when configuration changed but the process did not", () => {
const view = resolveModuleLifecycleView(
snapshotFor(configuration()),
configuration(["announcements", false]),
);
const changed = view.find((entry) => entry.moduleId === "announcements");
// The honest triple: configured off, still running, restart pending. Reporting `disabled` here
// would be a lie about a process that is still serving that module's routes.
expect(changed?.configured).toEqual({ enabled: false, explicit: true });
expect(changed?.runtime).toEqual({ state: "running" });
expect(changed?.restartRequired).toBe(true);
// An unrelated module is untouched.
expect(view.find((entry) => entry.moduleId === "storage")?.restartRequired).toBe(false);
});
it("marks restart pending for a dependent too, because it really will stop on the next boot", () => {
const view = resolveModuleLifecycleView(
snapshotFor(configuration()),
configuration(["storage", false]),
);
expect(view.map((entry) => [entry.moduleId, entry.restartRequired])).toEqual([
["storage", true],
["announcements", true],
]);
expect(view.every((entry) => entry.runtime.state === "running")).toBe(true);
});
it("marks restart pending in the other direction, without claiming the module started", () => {
const view = resolveModuleLifecycleView(
snapshotFor(configuration(["announcements", false])),
configuration(["announcements", true]),
);
const changed = view.find((entry) => entry.moduleId === "announcements");
expect(changed?.configured).toEqual({ enabled: true, explicit: true });
expect(changed?.runtime).toEqual({ state: "disabled" });
expect(changed?.restartRequired).toBe(true);
});
it("explains a blocked module with the boot-time reason, not a recomputed one", () => {
const view = resolveModuleLifecycleView(
snapshotFor(configuration(["storage", false])),
configuration(["storage", false]),
);
expect(view.find((entry) => entry.moduleId === "storage")?.runtime).toEqual({
state: "disabled",
});
expect(view.find((entry) => entry.moduleId === "announcements")?.runtime).toEqual({
dependency: { kind: "module", moduleId: "storage" },
reason: "required_dependency_disabled",
state: "blocked",
});
expect(view.every((entry) => entry.restartRequired === false)).toBe(true);
});
it("reports what the runtime actually started, not what eligibility predicted", () => {
// A snapshot whose started set disagrees with its own eligibility is not reachable while
// startup stays all-or-nothing. It is constructed here deliberately: the read model must be an
// observation of the runtime, so if that guarantee ever changes this can never silently report
// a module as `running` because it was merely eligible.
const eligible = resolveModuleEligibility(discoveredModules, configuration());
const view = resolveModuleLifecycleView(
{
discoveredModules,
eligibility: eligible.entries,
startedModuleIds: ["storage"],
},
configuration(),
);
expect(view.find((entry) => entry.moduleId === "storage")?.runtime.state).toBe("running");
expect(view.find((entry) => entry.moduleId === "announcements")?.runtime.state).toBe("blocked");
expect(view.find((entry) => entry.moduleId === "announcements")?.restartRequired).toBe(true);
});
it("ignores a stored record for an undeployed module rather than inventing a row for it", () => {
const view = resolveModuleLifecycleView(
snapshotFor(configuration()),
configuration(["removed-long-ago", false]),
);
expect(view.map((entry) => entry.moduleId)).toEqual(["storage", "announcements"]);
});
});