feat: add the minimal operational module lifecycle
Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
db67cf7700
commit
7729c99e24
@@ -0,0 +1,529 @@
|
||||
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: 4,
|
||||
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: 4,
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user