The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.
Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.
Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.
A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.
The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.
The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.
The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.
Co-Authored-By: Claude Opus 5 <[email protected]>
531 lines
20 KiB
TypeScript
531 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: 7,
|
|
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 {
|
|
contributions: [],
|
|
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: 7,
|
|
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"]);
|
|
});
|
|
});
|