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]>
1504 lines
60 KiB
TypeScript
1504 lines
60 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import type {
|
|
ModuleConfiguration,
|
|
ModuleDatabase,
|
|
ModuleRuntimeServices,
|
|
} from "@ekklesia/contracts";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { discoverModules } from "../src/modules/discovery.js";
|
|
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
|
import {
|
|
ModuleDiscoveryError,
|
|
ModuleRuntimeError,
|
|
type ModuleRuntimeIssueCode,
|
|
startDiscoveredModuleRuntime,
|
|
startModuleRuntime,
|
|
} from "../src/modules/index.js";
|
|
|
|
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
|
|
return { ...actual, discoverModules: vi.fn(actual.discoverModules) };
|
|
});
|
|
|
|
interface RuntimeModuleFixture {
|
|
readonly directory?: string;
|
|
readonly manifest?: Record<string, unknown>;
|
|
readonly packageJson?: Record<string, unknown>;
|
|
readonly serverSource: string;
|
|
}
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
const eventSymbols: symbol[] = [];
|
|
|
|
afterEach(async () => {
|
|
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
for (const symbol of eventSymbols.splice(0)) {
|
|
delete globalValues[symbol];
|
|
}
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
|
);
|
|
});
|
|
|
|
async function createModulesDirectory(): Promise<string> {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-runtime-"));
|
|
temporaryDirectories.push(directory);
|
|
return directory;
|
|
}
|
|
|
|
function manifestFor(
|
|
id: string,
|
|
options: {
|
|
capabilities?: Array<{ id: string; version: string }>;
|
|
dependencies?: Record<string, unknown>;
|
|
permissions?: Array<{ id: string; baseline: string; description: string }>;
|
|
} = {},
|
|
): Record<string, unknown> {
|
|
return {
|
|
manifestVersion: 7,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "0.1.0",
|
|
compatibility: {
|
|
platform: "^0.1.0",
|
|
moduleSdk: "^0.1.0",
|
|
},
|
|
capabilities: options.capabilities ?? [],
|
|
permissions: options.permissions ?? [],
|
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
|
entrypoints: {
|
|
server: "./dist/server/index.js",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function createRuntimeModule(
|
|
modulesDirectory: string,
|
|
id: string,
|
|
fixture: RuntimeModuleFixture,
|
|
): Promise<void> {
|
|
const directoryName = fixture.directory ?? id;
|
|
const moduleDirectory = path.join(modulesDirectory, directoryName);
|
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
await Promise.all([
|
|
writeFile(
|
|
path.join(moduleDirectory, "package.json"),
|
|
JSON.stringify(
|
|
fixture.packageJson ?? {
|
|
name: `@test/module-${id}`,
|
|
version: "0.1.0",
|
|
type: "module",
|
|
ekklesia: { layer: "module" },
|
|
},
|
|
),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
JSON.stringify(fixture.manifest ?? manifestFor(id)),
|
|
),
|
|
writeFile(path.join(moduleDirectory, "dist/server/index.js"), fixture.serverSource),
|
|
]);
|
|
}
|
|
|
|
function createEventRecorder(): { events: string[]; expression: string } {
|
|
const key = `ekklesia.runtime.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
|
const symbol = Symbol.for(key);
|
|
const events: string[] = [];
|
|
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
|
|
eventSymbols.push(symbol);
|
|
return {
|
|
events,
|
|
expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`,
|
|
};
|
|
}
|
|
|
|
/** Same globalThis-symbol technique as `createEventRecorder`, but for capturing an actual object
|
|
* reference (such as `context.services`) pushed from inside a dynamically imported fixture module,
|
|
* rather than a string label — dynamic `import()` of a file:// URL still shares this process's
|
|
* heap, so a reference pushed from the fixture and read back here is the exact same object. */
|
|
function createReferenceRecorder(): { values: unknown[]; expression: string } {
|
|
const key = `ekklesia.runtime.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
|
const symbol = Symbol.for(key);
|
|
const values: unknown[] = [];
|
|
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
|
|
eventSymbols.push(symbol);
|
|
return {
|
|
values,
|
|
expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`,
|
|
};
|
|
}
|
|
|
|
/** A small typed fake `ModuleDatabase` — no PostgreSQL required for these unit tests, which only
|
|
* ever assert on context wiring and object identity, never on actual query behavior. */
|
|
function fakeModuleDatabase(): ModuleDatabase {
|
|
return Object.freeze({
|
|
delete: vi.fn(),
|
|
execute: vi.fn(),
|
|
insert: vi.fn(),
|
|
select: vi.fn(),
|
|
selectDistinct: vi.fn(),
|
|
selectDistinctOn: vi.fn(),
|
|
transaction: vi.fn(),
|
|
update: vi.fn(),
|
|
}) as unknown as ModuleDatabase;
|
|
}
|
|
|
|
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
|
|
return Object.freeze({ database: fakeModuleDatabase() });
|
|
}
|
|
|
|
function runtimeOptions(
|
|
modulesDirectory: string,
|
|
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
|
|
) {
|
|
return {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
services,
|
|
};
|
|
}
|
|
|
|
function runtimeIssueCodes(error: ModuleRuntimeError): ModuleRuntimeIssueCode[] {
|
|
return error.issues.map((issue) => issue.code);
|
|
}
|
|
|
|
async function captureRuntimeError(modulesDirectory: string): Promise<ModuleRuntimeError> {
|
|
try {
|
|
await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
} catch (error) {
|
|
if (error instanceof ModuleRuntimeError) {
|
|
return error;
|
|
}
|
|
throw error;
|
|
}
|
|
throw new Error("Expected module runtime startup to fail.");
|
|
}
|
|
|
|
describe("module runtime", () => {
|
|
it("discovers, loads, registers, starts, and stops dependency-first modules end to end", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createRuntimeModule(modulesDirectory, "people", {
|
|
manifest: manifestFor("people", {
|
|
capabilities: [{ id: "people.records", version: "2.1.0" }],
|
|
}),
|
|
serverSource: `
|
|
export default {
|
|
id: "people",
|
|
version: "0.1.0",
|
|
register({ capabilities }) {
|
|
${recorder.expression}.push("people:register");
|
|
capabilities.provide("people.records", { kind: "people-records" });
|
|
},
|
|
start() { ${recorder.expression}.push("people:start"); },
|
|
stop() { ${recorder.expression}.push("people:stop"); }
|
|
};
|
|
`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "calendar", {
|
|
manifest: manifestFor("calendar", {
|
|
capabilities: [{ id: "calendar.events", version: "1.0.0" }],
|
|
dependencies: {
|
|
capabilities: { required: { "people.records": "^2.0.0" } },
|
|
},
|
|
}),
|
|
serverSource: `
|
|
export default {
|
|
id: "calendar",
|
|
version: "0.1.0",
|
|
register({ capabilities }) {
|
|
${recorder.expression}.push("calendar:register");
|
|
const people = capabilities.resolve("people.records");
|
|
if (!people || people.version !== "2.1.0") throw new Error("people unavailable");
|
|
capabilities.provide("calendar.events", { kind: "calendar-events" });
|
|
},
|
|
start({ capabilities }) {
|
|
if (!capabilities.resolve("people.records")) throw new Error("people unavailable");
|
|
${recorder.expression}.push("calendar:start");
|
|
},
|
|
stop() { ${recorder.expression}.push("calendar:stop"); }
|
|
};
|
|
`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
|
|
expect(recorder.events).toEqual([
|
|
"people:register",
|
|
"calendar:register",
|
|
"people:start",
|
|
"calendar:start",
|
|
]);
|
|
expect(runtime.modules.map((module) => module.id)).toEqual(["people", "calendar"]);
|
|
expect(runtime.capabilities.resolve("people.records")).toMatchObject({
|
|
moduleId: "people",
|
|
version: "2.1.0",
|
|
});
|
|
expect(runtime.capabilities.list().map((capability) => capability.id)).toEqual([
|
|
"calendar.events",
|
|
"people.records",
|
|
]);
|
|
|
|
await Promise.all([runtime.stop(), runtime.stop()]);
|
|
expect(recorder.events.slice(-2)).toEqual(["calendar:stop", "people:stop"]);
|
|
});
|
|
|
|
it("completes discovery before importing any server entrypoint", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createRuntimeModule(modulesDirectory, "valid", {
|
|
serverSource: `${recorder.expression}.push("valid:import"); export default { id: "valid", version: "0.1.0" };`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "invalid", {
|
|
manifest: { ...manifestFor("invalid"), manifestVersion: 1 },
|
|
serverSource: `${recorder.expression}.push("invalid:import"); export default { id: "invalid", version: "0.1.0" };`,
|
|
});
|
|
|
|
await expect(startModuleRuntime(runtimeOptions(modulesDirectory))).rejects.toBeInstanceOf(
|
|
ModuleDiscoveryError,
|
|
);
|
|
expect(recorder.events).toEqual([]);
|
|
});
|
|
|
|
it("validates every imported definition before running registration hooks", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createRuntimeModule(modulesDirectory, "alpha", {
|
|
serverSource: `export default {
|
|
id: "alpha", version: "0.1.0",
|
|
register() { ${recorder.expression}.push("alpha:register"); }
|
|
};`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "beta", {
|
|
serverSource: `export default { id: "wrong-id", version: "0.1.0" };`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "gamma", {
|
|
serverSource: `export default { id: "gamma", version: "9.0.0" };`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
|
|
expect(runtimeIssueCodes(error)).toEqual([
|
|
"MODULE_RUNTIME_ID_MISMATCH",
|
|
"MODULE_RUNTIME_VERSION_MISMATCH",
|
|
]);
|
|
expect(recorder.events).toEqual([]);
|
|
});
|
|
|
|
it("rejects incomplete and undeclared capability registration before startup", async () => {
|
|
const missingDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(missingDirectory, "missing", {
|
|
manifest: manifestFor("missing", {
|
|
capabilities: [{ id: "missing.records", version: "1.0.0" }],
|
|
}),
|
|
serverSource: `export default { id: "missing", version: "0.1.0", register() {} };`,
|
|
});
|
|
let error = await captureRuntimeError(missingDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_UNREGISTERED"]);
|
|
|
|
const undeclaredDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(undeclaredDirectory, "undeclared", {
|
|
serverSource: `export default {
|
|
id: "undeclared", version: "0.1.0",
|
|
register({ capabilities }) { capabilities.provide("undeclared.extra", {}); }
|
|
};`,
|
|
});
|
|
error = await captureRuntimeError(undeclaredDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_UNDECLARED"]);
|
|
});
|
|
|
|
it("restricts module capability lookup to declared ownership and dependencies", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "snooper", {
|
|
serverSource: `export default {
|
|
id: "snooper", version: "0.1.0",
|
|
register({ capabilities }) { capabilities.resolve("people.records"); }
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_ACCESS_UNDECLARED"]);
|
|
});
|
|
|
|
it("rejects malformed runtime capability IDs without echoing their contents", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const unsafeCapabilityId = "secret-token at /machine/private/path";
|
|
await createRuntimeModule(modulesDirectory, "unsafe", {
|
|
serverSource: `export default {
|
|
id: "unsafe", version: "0.1.0",
|
|
register({ capabilities }) {
|
|
capabilities.resolve(${JSON.stringify(unsafeCapabilityId)});
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_ID_INVALID"]);
|
|
expect(`${error.message}\n${error.stack}\n${JSON.stringify(error.issues)}`).not.toContain(
|
|
unsafeCapabilityId,
|
|
);
|
|
expect(error.stack).not.toContain(modulesDirectory);
|
|
});
|
|
|
|
it("rolls back started modules in reverse order and aggregates cleanup failures", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
for (const [id, dependency, stopFails] of [
|
|
["one", undefined, false],
|
|
["two", "one", true],
|
|
["three", "two", false],
|
|
] as const) {
|
|
await createRuntimeModule(modulesDirectory, id, {
|
|
manifest: manifestFor(id, {
|
|
...(dependency
|
|
? { dependencies: { modules: { required: { [dependency]: "^0.1.0" } } } }
|
|
: {}),
|
|
}),
|
|
serverSource: `export default {
|
|
id: ${JSON.stringify(id)}, version: "0.1.0",
|
|
start() {
|
|
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
|
|
${id === "three" ? 'throw new Error("secret-token at /private/path");' : ""}
|
|
},
|
|
stop() {
|
|
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
|
|
${stopFails ? 'throw new Error("rollback secret");' : ""}
|
|
}
|
|
};`,
|
|
});
|
|
}
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED", "MODULE_ROLLBACK_FAILED"]);
|
|
// The module whose `start` threw is rolled back *first*, then the previously started modules in
|
|
// reverse order — its own hook may have partial initialization to release before it threw.
|
|
expect(recorder.events).toEqual([
|
|
"one:start",
|
|
"two:start",
|
|
"three:start",
|
|
"three:stop",
|
|
"two:stop",
|
|
"one:stop",
|
|
]);
|
|
expect(JSON.stringify(error.issues)).not.toContain("secret");
|
|
expect(JSON.stringify(error.issues)).not.toContain("/private/path");
|
|
});
|
|
|
|
it("continues reverse shutdown after a module stop failure", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createRuntimeModule(modulesDirectory, "provider", {
|
|
serverSource: `export default {
|
|
id: "provider", version: "0.1.0",
|
|
stop() { ${recorder.expression}.push("provider:stop"); }
|
|
};`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "consumer", {
|
|
manifest: manifestFor("consumer", {
|
|
dependencies: { modules: { required: { provider: "^0.1.0" } } },
|
|
}),
|
|
serverSource: `export default {
|
|
id: "consumer", version: "0.1.0",
|
|
stop() { ${recorder.expression}.push("consumer:stop"); throw new Error("hidden"); }
|
|
};`,
|
|
});
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
|
|
await expect(runtime.stop()).rejects.toMatchObject({
|
|
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "consumer" })],
|
|
});
|
|
expect(recorder.events).toEqual(["consumer:stop", "provider:stop"]);
|
|
});
|
|
|
|
it("reports import failures without leaking exception text or machine paths", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "broken", {
|
|
serverSource: `throw new Error("database-password at /machine/private/path");`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_RUNTIME_IMPORT_FAILED"]);
|
|
expect(JSON.stringify(error.issues)).not.toContain("database-password");
|
|
expect(JSON.stringify(error.issues)).not.toContain(modulesDirectory);
|
|
});
|
|
});
|
|
|
|
function noopHandlerSource(): string {
|
|
return "() => new Response(null, { status: 204 })";
|
|
}
|
|
|
|
function permissionDeclaration(
|
|
id: string,
|
|
baseline: "authenticated" | "public" | "restricted" = "restricted",
|
|
): { id: string; baseline: string; description: string } {
|
|
return { id, baseline, description: `Test permission ${id}.` };
|
|
}
|
|
|
|
describe("module HTTP routes", () => {
|
|
it("registers one route and publishes it on the started runtime", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "single", {
|
|
manifest: manifestFor("single", {
|
|
permissions: [permissionDeclaration("single.items.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "single", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({
|
|
method: "GET",
|
|
path: "/items",
|
|
permission: "single.items.read",
|
|
maxBodyBytes: 1024,
|
|
handler: ${noopHandlerSource()},
|
|
});
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
expect(runtime.routes).toHaveLength(1);
|
|
expect(runtime.routes[0]).toMatchObject({
|
|
moduleId: "single",
|
|
method: "GET",
|
|
path: "/items",
|
|
permission: "single.items.read",
|
|
});
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("retains registration order within a module and discovery order across modules", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "alpha", {
|
|
manifest: manifestFor("alpha", {
|
|
permissions: [
|
|
permissionDeclaration("alpha.first.read"),
|
|
permissionDeclaration("alpha.second.read"),
|
|
],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "alpha", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/first", permission: "alpha.first.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
http.route({ method: "GET", path: "/second", permission: "alpha.second.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "beta", {
|
|
manifest: manifestFor("beta", {
|
|
permissions: [permissionDeclaration("beta.third.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "beta", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/third", permission: "beta.third.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
expect(
|
|
runtime.routes.map((route) => `${route.moduleId}:${route.method}:${route.path}`),
|
|
).toEqual(["alpha:GET:/first", "alpha:GET:/second", "beta:GET:/third"]);
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("allows the same path under different methods", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "same-path", {
|
|
manifest: manifestFor("same-path", {
|
|
permissions: [
|
|
permissionDeclaration("same-path.items.read"),
|
|
permissionDeclaration("same-path.items.write"),
|
|
],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "same-path", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/items", permission: "same-path.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
http.route({ method: "POST", path: "/items", permission: "same-path.items.write", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
expect(runtime.routes.map((route) => `${route.method} ${route.path}`)).toEqual([
|
|
"GET /items",
|
|
"POST /items",
|
|
]);
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("rejects a duplicate method/path route registered by the same module", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "dup", {
|
|
manifest: manifestFor("dup", {
|
|
permissions: [permissionDeclaration("dup.items.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "dup", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/items", permission: "dup.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
http.route({ method: "GET", path: "/items", permission: "dup.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
|
|
});
|
|
|
|
it("collides parameter-name-only path variants under the same method", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "param-shape", {
|
|
manifest: manifestFor("param-shape", {
|
|
permissions: [permissionDeclaration("param-shape.events.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "param-shape", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/events/:eventId", permission: "param-shape.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
http.route({ method: "GET", path: "/events/:id", permission: "param-shape.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
|
|
});
|
|
|
|
it.each([
|
|
["parameter registered first", "/events/:id", "/events/new"],
|
|
["static registered first", "/events/new", "/events/:id"],
|
|
["multi-position, parameter first", "/events/:id/edit", "/events/new/:action"],
|
|
["multi-position, static first", "/events/new/:action", "/events/:id/edit"],
|
|
["parameters on both sides", "/a/:left/c", "/a/b/:right"],
|
|
])("rejects routes that can both match one request path (%s) regardless of registration order", async (_label, firstPath, secondPath) => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
// Deliberately *different* permissions: the defect this rejects is that whichever route
|
|
// registered first would decide which permission governs the ambiguous path.
|
|
await createRuntimeModule(modulesDirectory, "overlap", {
|
|
manifest: manifestFor("overlap", {
|
|
permissions: [
|
|
permissionDeclaration("overlap.events.read"),
|
|
permissionDeclaration("overlap.events.create"),
|
|
],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "overlap", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "${firstPath}", permission: "overlap.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
http.route({ method: "GET", path: "${secondPath}", permission: "overlap.events.create", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_OVERLAP"]);
|
|
});
|
|
|
|
it.each([
|
|
["two different static literals", "/events/new", "/events/archive"],
|
|
["different segment counts", "/events/:id", "/events/:id/edit"],
|
|
["a different static prefix", "/events/:id", "/people/:id"],
|
|
["module root against a one-segment path", "/", "/events"],
|
|
])("still allows disjoint routes (%s) and preserves their registration order", async (_label, firstPath, secondPath) => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "disjoint", {
|
|
manifest: manifestFor("disjoint", {
|
|
permissions: [permissionDeclaration("disjoint.events.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "disjoint", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "${firstPath}", permission: "disjoint.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
http.route({ method: "GET", path: "${secondPath}", permission: "disjoint.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
services: fakeModuleRuntimeServices(),
|
|
});
|
|
expect(runtime.routes.map((route) => route.path)).toEqual([firstPath, secondPath]);
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("allows the same overlapping local path under different methods", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "methods", {
|
|
manifest: manifestFor("methods", {
|
|
permissions: [permissionDeclaration("methods.events.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "methods", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/events/:id", permission: "methods.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
http.route({ method: "POST", path: "/events/new", permission: "methods.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
services: fakeModuleRuntimeServices(),
|
|
});
|
|
expect(runtime.routes).toHaveLength(2);
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("allows two different modules to declare the same overlapping local paths", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
for (const moduleId of ["alpha", "beta"]) {
|
|
await createRuntimeModule(modulesDirectory, moduleId, {
|
|
manifest: manifestFor(moduleId, {
|
|
permissions: [permissionDeclaration(`${moduleId}.events.read`)],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "${moduleId}", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/events/:id", permission: "${moduleId}.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
}
|
|
|
|
// Each module has its own platform-owned mount prefix, so these can never collide.
|
|
const runtime = await startModuleRuntime({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
services: fakeModuleRuntimeServices(),
|
|
});
|
|
expect(runtime.routes.map((route) => route.moduleId).sort()).toEqual(["alpha", "beta"]);
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("publishes no route from a module whose registration was rejected for an overlap", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "partial", {
|
|
manifest: manifestFor("partial", {
|
|
permissions: [permissionDeclaration("partial.events.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "partial", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/healthy", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
http.route({ method: "GET", path: "/events/:id", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
http.route({ method: "GET", path: "/events/new", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
},
|
|
start() { throw new Error("start must never run for a rejected registration"); }
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_OVERLAP"]);
|
|
// The earlier, perfectly valid `/healthy` route is not published either: registration is
|
|
// all-or-nothing, and the start hook above would have thrown had it ever run.
|
|
});
|
|
|
|
it("fails a malformed route with its specific structured issue code", async () => {
|
|
const badMethodDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(badMethodDirectory, "bad-method", {
|
|
manifest: manifestFor("bad-method", {
|
|
permissions: [permissionDeclaration("bad-method.items.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "bad-method", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "get", path: "/items", permission: "bad-method.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
expect(runtimeIssueCodes(await captureRuntimeError(badMethodDirectory))).toEqual([
|
|
"MODULE_ROUTE_METHOD_INVALID",
|
|
]);
|
|
|
|
const badPathDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(badPathDirectory, "bad-path", {
|
|
manifest: manifestFor("bad-path", {
|
|
permissions: [permissionDeclaration("bad-path.items.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "bad-path", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "items", permission: "bad-path.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
expect(runtimeIssueCodes(await captureRuntimeError(badPathDirectory))).toEqual([
|
|
"MODULE_ROUTE_PATH_INVALID",
|
|
]);
|
|
|
|
const badMaxBodyBytesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(badMaxBodyBytesDirectory, "bad-max-body-bytes", {
|
|
manifest: manifestFor("bad-max-body-bytes", {
|
|
permissions: [permissionDeclaration("bad-max-body-bytes.items.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "bad-max-body-bytes", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/items", permission: "bad-max-body-bytes.items.read", maxBodyBytes: -1, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
expect(runtimeIssueCodes(await captureRuntimeError(badMaxBodyBytesDirectory))).toEqual([
|
|
"MODULE_ROUTE_MAX_BODY_BYTES_INVALID",
|
|
]);
|
|
|
|
const badDefinitionDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(badDefinitionDirectory, "bad-shape", {
|
|
manifest: manifestFor("bad-shape", {
|
|
permissions: [permissionDeclaration("bad-shape.items.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "bad-shape", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({
|
|
method: "GET",
|
|
path: "/items",
|
|
permission: "bad-shape.items.read",
|
|
baseline: "public",
|
|
handler: ${noopHandlerSource()},
|
|
});
|
|
}
|
|
};`,
|
|
});
|
|
expect(runtimeIssueCodes(await captureRuntimeError(badDefinitionDirectory))).toEqual([
|
|
"MODULE_ROUTE_DEFINITION_INVALID",
|
|
]);
|
|
});
|
|
|
|
it("rejects a route permission the registering module does not itself declare", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "undeclared", {
|
|
manifest: manifestFor("undeclared"),
|
|
serverSource: `export default {
|
|
id: "undeclared", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/items", permission: "undeclared.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
|
|
});
|
|
|
|
it("rejects a module gating a route with a Core-owned permission it cannot declare", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "coreattempt", {
|
|
manifest: manifestFor("coreattempt"),
|
|
serverSource: `export default {
|
|
id: "coreattempt", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({
|
|
method: "GET",
|
|
path: "/items",
|
|
permission: "core.authorization.grants.manage",
|
|
maxBodyBytes: 1024,
|
|
handler: ${noopHandlerSource()},
|
|
});
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
|
|
});
|
|
|
|
it("rejects a module gating a route with another module's permission", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "alpha", {
|
|
manifest: manifestFor("alpha", {
|
|
permissions: [permissionDeclaration("alpha.items.read")],
|
|
}),
|
|
serverSource: `export default { id: "alpha", version: "0.1.0" };`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "beta", {
|
|
manifest: manifestFor("beta"),
|
|
serverSource: `export default {
|
|
id: "beta", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/items", permission: "alpha.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
|
|
});
|
|
|
|
it("accepts a declared public, authenticated, or restricted permission without interpreting its baseline", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "baselines", {
|
|
manifest: manifestFor("baselines", {
|
|
permissions: [
|
|
permissionDeclaration("baselines.public.read", "public"),
|
|
permissionDeclaration("baselines.authenticated.read", "authenticated"),
|
|
permissionDeclaration("baselines.restricted.read", "restricted"),
|
|
],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "baselines", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/public", permission: "baselines.public.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
http.route({ method: "GET", path: "/authenticated", permission: "baselines.authenticated.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
http.route({ method: "GET", path: "/restricted", permission: "baselines.restricted.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
expect(runtime.routes).toHaveLength(3);
|
|
for (const route of runtime.routes) {
|
|
expect(Object.keys(route).sort()).toEqual([
|
|
"handler",
|
|
"maxBodyBytes",
|
|
"method",
|
|
"moduleId",
|
|
"path",
|
|
"permission",
|
|
]);
|
|
}
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("never invokes a route handler while registering or starting", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createRuntimeModule(modulesDirectory, "never-invoked", {
|
|
manifest: manifestFor("never-invoked", {
|
|
permissions: [permissionDeclaration("never-invoked.items.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "never-invoked", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({
|
|
method: "GET",
|
|
path: "/items",
|
|
permission: "never-invoked.items.read",
|
|
maxBodyBytes: 1024,
|
|
handler: () => { ${recorder.expression}.push("handler:called"); return new Response(null, { status: 204 }); },
|
|
});
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
expect(recorder.events).toEqual([]);
|
|
await runtime.stop();
|
|
expect(recorder.events).toEqual([]);
|
|
});
|
|
|
|
it("publishes frozen, immutable routes as a frozen route list", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "frozen", {
|
|
manifest: manifestFor("frozen", {
|
|
permissions: [permissionDeclaration("frozen.items.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "frozen", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/items", permission: "frozen.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
expect(Object.isFrozen(runtime.routes)).toBe(true);
|
|
expect(Object.isFrozen(runtime.routes[0])).toBe(true);
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("does not let a module mutate the published route by mutating its own definition afterward", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "mutate", {
|
|
manifest: manifestFor("mutate", {
|
|
permissions: [
|
|
permissionDeclaration("mutate.items.read"),
|
|
permissionDeclaration("mutate.other.read"),
|
|
],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "mutate", version: "0.1.0",
|
|
register({ http }) {
|
|
const definition = {
|
|
method: "GET",
|
|
path: "/items",
|
|
permission: "mutate.items.read",
|
|
maxBodyBytes: 1024,
|
|
handler: ${noopHandlerSource()},
|
|
};
|
|
http.route(definition);
|
|
definition.method = "POST";
|
|
definition.path = "/mutated";
|
|
definition.permission = "mutate.other.read";
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
expect(runtime.routes[0]).toMatchObject({
|
|
method: "GET",
|
|
path: "/items",
|
|
permission: "mutate.items.read",
|
|
});
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("publishes no runtime when a later module's route registration fails", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "first-ok", {
|
|
manifest: manifestFor("first-ok", {
|
|
permissions: [permissionDeclaration("first-ok.items.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "first-ok", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/items", permission: "first-ok.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "second-bad", {
|
|
manifest: manifestFor("second-bad"),
|
|
serverSource: `export default {
|
|
id: "second-bad", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/items", permission: "second-bad.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_PERMISSION_UNDECLARED"]);
|
|
expect(error.issues[0]?.moduleId).toBe("second-bad");
|
|
});
|
|
|
|
it("preserves existing rollback behavior when a registered route's module later fails to start", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createRuntimeModule(modulesDirectory, "routed", {
|
|
manifest: manifestFor("routed", {
|
|
permissions: [permissionDeclaration("routed.items.read")],
|
|
}),
|
|
serverSource: `export default {
|
|
id: "routed", version: "0.1.0",
|
|
register({ http }) {
|
|
http.route({ method: "GET", path: "/items", permission: "routed.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
|
},
|
|
start() { ${recorder.expression}.push("routed:start"); },
|
|
stop() { ${recorder.expression}.push("routed:stop"); }
|
|
};`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "failing", {
|
|
manifest: manifestFor("failing", {
|
|
dependencies: { modules: { required: { routed: "^0.1.0" } } },
|
|
}),
|
|
serverSource: `export default {
|
|
id: "failing", version: "0.1.0",
|
|
start() { throw new Error("boom"); }
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED"]);
|
|
expect(recorder.events).toEqual(["routed:start", "routed:stop"]);
|
|
});
|
|
});
|
|
|
|
describe("already-discovered module runtime", () => {
|
|
it("starts from an already-discovered inventory without a second directory scan", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "already-discovered", {
|
|
serverSource: `export default { id: "already-discovered", version: "0.1.0" };`,
|
|
});
|
|
|
|
vi.mocked(discoverModules).mockClear();
|
|
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
|
expect(discoverModules).toHaveBeenCalledTimes(1);
|
|
|
|
const runtime = await startDiscoveredModuleRuntime(discovered, fakeModuleRuntimeServices());
|
|
expect(discoverModules).toHaveBeenCalledTimes(1);
|
|
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("preserves the given discovered-module list order exactly, without re-sorting", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "first", {
|
|
serverSource: `export default { id: "first", version: "0.1.0" };`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "second", {
|
|
serverSource: `export default { id: "second", version: "0.1.0" };`,
|
|
});
|
|
|
|
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
|
expect(discovered.map((module) => module.manifest.id)).toEqual(["first", "second"]);
|
|
|
|
const runtime = await startDiscoveredModuleRuntime(
|
|
[...discovered].reverse(),
|
|
fakeModuleRuntimeServices(),
|
|
);
|
|
expect(runtime.modules.map((module) => module.id)).toEqual(["second", "first"]);
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("startModuleRuntime remains backward-compatible and discovers exactly once", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "compat", {
|
|
serverSource: `export default { id: "compat", version: "0.1.0" };`,
|
|
});
|
|
|
|
vi.mocked(discoverModules).mockClear();
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
expect(discoverModules).toHaveBeenCalledTimes(1);
|
|
await runtime.stop();
|
|
});
|
|
});
|
|
|
|
describe("module runtime services", () => {
|
|
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const services = fakeModuleRuntimeServices();
|
|
const registerServices = createReferenceRecorder();
|
|
const lifecycleServices = createReferenceRecorder();
|
|
await createRuntimeModule(modulesDirectory, "wired", {
|
|
serverSource: `export default {
|
|
id: "wired", version: "0.1.0",
|
|
register(context) { ${registerServices.expression}.push(context.services); },
|
|
start(context) { ${lifecycleServices.expression}.push(context.services); },
|
|
stop(context) { ${lifecycleServices.expression}.push(context.services); }
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
|
await runtime.stop();
|
|
|
|
expect(registerServices.values).toEqual([undefined]);
|
|
expect(lifecycleServices.values).toHaveLength(2);
|
|
expect(lifecycleServices.values[0]).toBe(services);
|
|
expect(lifecycleServices.values[1]).toBe(services);
|
|
});
|
|
|
|
it("shares the exact same services object identity across every module's start hook", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const services = fakeModuleRuntimeServices();
|
|
const recorder = createReferenceRecorder();
|
|
await createRuntimeModule(modulesDirectory, "alpha", {
|
|
serverSource: `export default {
|
|
id: "alpha", version: "0.1.0",
|
|
start(context) { ${recorder.expression}.push(context.services); }
|
|
};`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "beta", {
|
|
manifest: manifestFor("beta", {
|
|
dependencies: { modules: { required: { alpha: "^0.1.0" } } },
|
|
}),
|
|
serverSource: `export default {
|
|
id: "beta", version: "0.1.0",
|
|
start(context) { ${recorder.expression}.push(context.services); }
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
|
|
|
expect(recorder.values).toEqual([services, services]);
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("gives a rollback stop hook the same services object a start failure left available", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const services = fakeModuleRuntimeServices();
|
|
const recorder = createReferenceRecorder();
|
|
await createRuntimeModule(modulesDirectory, "started", {
|
|
serverSource: `export default {
|
|
id: "started", version: "0.1.0",
|
|
start(context) { ${recorder.expression}.push(context.services); },
|
|
stop(context) { ${recorder.expression}.push(context.services); }
|
|
};`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "failing", {
|
|
manifest: manifestFor("failing", {
|
|
dependencies: { modules: { required: { started: "^0.1.0" } } },
|
|
}),
|
|
serverSource: `export default {
|
|
id: "failing", version: "0.1.0",
|
|
start() { throw new Error("boom"); }
|
|
};`,
|
|
});
|
|
|
|
await expect(
|
|
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
|
|
).rejects.toBeInstanceOf(ModuleRuntimeError);
|
|
|
|
expect(recorder.values).toEqual([services, services]);
|
|
});
|
|
|
|
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const services = fakeModuleRuntimeServices();
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
|
await runtime.stop();
|
|
|
|
expect(runtime.modules).toEqual([]);
|
|
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Pass 4: a module whose `start` hook fails owns cleanup of whatever it managed to acquire, and
|
|
* every stop/rollback hook runs under a finite platform deadline.
|
|
*/
|
|
describe("module startup rollback and bounded stop hooks", () => {
|
|
it("releases a partially initialized resource through the failing module's own stop hook", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createRuntimeModule(modulesDirectory, "partial", {
|
|
serverSource: `let acquired = false;
|
|
export default {
|
|
id: "partial", version: "0.1.0",
|
|
start() {
|
|
acquired = true;
|
|
${recorder.expression}.push("partial:acquired");
|
|
throw new Error("secret-token at /private/path");
|
|
},
|
|
stop() {
|
|
if (acquired) { acquired = false; ${recorder.expression}.push("partial:released"); }
|
|
}
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED"]);
|
|
expect(recorder.events).toEqual(["partial:acquired", "partial:released"]);
|
|
expect(JSON.stringify(error.issues)).not.toContain("secret-token");
|
|
expect(JSON.stringify(error.issues)).not.toContain("/private/path");
|
|
});
|
|
|
|
it("reports the failing module's own rollback failure and still rolls back earlier modules", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createRuntimeModule(modulesDirectory, "earlier", {
|
|
serverSource: `export default {
|
|
id: "earlier", version: "0.1.0",
|
|
stop() { ${recorder.expression}.push("earlier:stop"); }
|
|
};`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "failing", {
|
|
manifest: manifestFor("failing", {
|
|
dependencies: { modules: { required: { earlier: "^0.1.0" } } },
|
|
}),
|
|
serverSource: `export default {
|
|
id: "failing", version: "0.1.0",
|
|
start() { throw new Error("start blew up"); },
|
|
stop() { ${recorder.expression}.push("failing:stop"); throw new Error("rollback blew up"); }
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
|
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED", "MODULE_ROLLBACK_FAILED"]);
|
|
expect(recorder.events).toEqual(["failing:stop", "earlier:stop"]);
|
|
});
|
|
|
|
it("gives a ModuleRuntimeSignal thrown during start the same rollback treatment", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
// `capabilities.require` for a capability this module never declared throws a
|
|
// `ModuleRuntimeSignal` from inside `start`, rather than an ordinary Error.
|
|
await createRuntimeModule(modulesDirectory, "signal", {
|
|
serverSource: `export default {
|
|
id: "signal", version: "0.1.0",
|
|
start({ capabilities }) { capabilities.require("undeclared.thing"); },
|
|
stop() { ${recorder.expression}.push("signal:stop"); }
|
|
};`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
|
|
expect(error.issues[0]?.phase).toBe("start");
|
|
expect(recorder.events).toEqual(["signal:stop"]);
|
|
});
|
|
|
|
it("invokes each stop hook at most once across rollback", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createRuntimeModule(modulesDirectory, "counted", {
|
|
serverSource: `export default {
|
|
id: "counted", version: "0.1.0",
|
|
start() { throw new Error("nope"); },
|
|
stop() { ${recorder.expression}.push("counted:stop"); }
|
|
};`,
|
|
});
|
|
|
|
await captureRuntimeError(modulesDirectory);
|
|
|
|
expect(recorder.events.filter((event) => event === "counted:stop")).toHaveLength(1);
|
|
});
|
|
|
|
it("bounds a never-settling rollback hook and continues with the earlier module", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createRuntimeModule(modulesDirectory, "earlier", {
|
|
serverSource: `export default {
|
|
id: "earlier", version: "0.1.0",
|
|
stop() { ${recorder.expression}.push("earlier:stop"); }
|
|
};`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "hanging", {
|
|
manifest: manifestFor("hanging", {
|
|
dependencies: { modules: { required: { earlier: "^0.1.0" } } },
|
|
}),
|
|
serverSource: `export default {
|
|
id: "hanging", version: "0.1.0",
|
|
start() { throw new Error("nope"); },
|
|
stop() { ${recorder.expression}.push("hanging:stop"); return new Promise(() => {}); }
|
|
};`,
|
|
});
|
|
|
|
// A short test-only deadline; production uses MODULE_STOP_HOOK_TIMEOUT_MS unchanged.
|
|
let captured: ModuleRuntimeError | undefined;
|
|
try {
|
|
await startModuleRuntime({ ...runtimeOptions(modulesDirectory), hookTimeoutMs: 25 });
|
|
} catch (error) {
|
|
captured = error as ModuleRuntimeError;
|
|
}
|
|
|
|
expect(captured).toBeInstanceOf(ModuleRuntimeError);
|
|
expect(runtimeIssueCodes(captured as ModuleRuntimeError)).toEqual([
|
|
"MODULE_START_FAILED",
|
|
"MODULE_ROLLBACK_TIMEOUT",
|
|
]);
|
|
// The timed-out hook did not block the earlier module's rollback.
|
|
expect(recorder.events).toEqual(["hanging:stop", "earlier:stop"]);
|
|
});
|
|
|
|
it("bounds a never-settling normal stop hook and continues with the earlier module", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createRuntimeModule(modulesDirectory, "earlier", {
|
|
serverSource: `export default {
|
|
id: "earlier", version: "0.1.0",
|
|
stop() { ${recorder.expression}.push("earlier:stop"); }
|
|
};`,
|
|
});
|
|
await createRuntimeModule(modulesDirectory, "hanging", {
|
|
manifest: manifestFor("hanging", {
|
|
dependencies: { modules: { required: { earlier: "^0.1.0" } } },
|
|
}),
|
|
serverSource: `export default {
|
|
id: "hanging", version: "0.1.0",
|
|
stop() { ${recorder.expression}.push("hanging:stop"); return new Promise(() => {}); }
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime({
|
|
...runtimeOptions(modulesDirectory),
|
|
hookTimeoutMs: 25,
|
|
});
|
|
let captured: ModuleRuntimeError | undefined;
|
|
try {
|
|
await runtime.stop();
|
|
} catch (error) {
|
|
captured = error as ModuleRuntimeError;
|
|
}
|
|
|
|
expect(runtimeIssueCodes(captured as ModuleRuntimeError)).toEqual(["MODULE_STOP_TIMEOUT"]);
|
|
expect(recorder.events).toEqual(["hanging:stop", "earlier:stop"]);
|
|
// Repeated and concurrent callers share the memoized promise and never restart cleanup.
|
|
const [first, second] = await Promise.allSettled([runtime.stop(), runtime.stop()]);
|
|
expect(first.status).toBe("rejected");
|
|
expect(second.status).toBe("rejected");
|
|
expect(recorder.events.filter((event) => event === "hanging:stop")).toHaveLength(1);
|
|
});
|
|
|
|
it("never lets a hook that rejects after its deadline become an unhandled rejection", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "late", {
|
|
serverSource: `export default {
|
|
id: "late", version: "0.1.0",
|
|
stop() { return new Promise((_resolve, reject) => { setTimeout(() => reject(new Error("late")), 40); }); }
|
|
};`,
|
|
});
|
|
|
|
const unhandled: unknown[] = [];
|
|
const onUnhandled = (reason: unknown): void => {
|
|
unhandled.push(reason);
|
|
};
|
|
process.on("unhandledRejection", onUnhandled);
|
|
try {
|
|
const runtime = await startModuleRuntime({
|
|
...runtimeOptions(modulesDirectory),
|
|
hookTimeoutMs: 10,
|
|
});
|
|
await expect(runtime.stop()).rejects.toBeInstanceOf(ModuleRuntimeError);
|
|
// Give the late rejection time to fire, then confirm nothing escaped.
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
expect(unhandled).toEqual([]);
|
|
} finally {
|
|
process.off("unhandledRejection", onUnhandled);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("module configuration delivery (ADR 0029)", () => {
|
|
it("hands each module its own frozen configuration in register, start, and stop", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createReferenceRecorder();
|
|
await createRuntimeModule(modulesDirectory, "announcements", {
|
|
manifest: {
|
|
...manifestFor("announcements"),
|
|
configuration: [
|
|
{ key: "slideSeconds", type: "integer", default: 7 },
|
|
{ key: "bucket", type: "string", required: true },
|
|
],
|
|
},
|
|
serverSource: `export default {
|
|
id: "announcements", version: "0.1.0",
|
|
register(context) { ${recorder.expression}.push(context.config); },
|
|
start(context) { ${recorder.expression}.push(context.config); },
|
|
stop(context) { ${recorder.expression}.push(context.config); }
|
|
};`,
|
|
});
|
|
|
|
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
|
const resolution = resolveModuleEligibility(
|
|
discovered,
|
|
{ modules: [] },
|
|
{ settings: { records: [{ key: "bucket", moduleId: "announcements", value: "slides" }] } },
|
|
);
|
|
const runtime = await startDiscoveredModuleRuntime(
|
|
resolution.eligibleModules,
|
|
fakeModuleRuntimeServices(),
|
|
{ configurations: resolution.configurations },
|
|
);
|
|
await runtime.stop();
|
|
|
|
expect(recorder.values).toHaveLength(3);
|
|
const [registerConfig, startConfig, stopConfig] = recorder.values as ModuleConfiguration[];
|
|
// Configuration is available during `register` too — it is an already-resolved in-memory
|
|
// snapshot, not a database-backed getter, so it reintroduces no I/O into a hook that must stay
|
|
// declarative.
|
|
expect(registerConfig?.getInteger("slideSeconds")).toBe(7);
|
|
expect(registerConfig?.getString("bucket")).toBe("slides");
|
|
// The exact same object across every hook, so two hooks can never disagree about a value.
|
|
expect(startConfig).toBe(registerConfig);
|
|
expect(stopConfig).toBe(registerConfig);
|
|
expect(Object.isFrozen(registerConfig)).toBe(true);
|
|
});
|
|
|
|
it("gives each module only its own configuration, never another module's", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createReferenceRecorder();
|
|
for (const id of ["alpha", "bravo"]) {
|
|
await createRuntimeModule(modulesDirectory, id, {
|
|
manifest: {
|
|
...manifestFor(id),
|
|
configuration: [{ key: "label", type: "string", required: true }],
|
|
},
|
|
serverSource: `export default {
|
|
id: ${JSON.stringify(id)}, version: "0.1.0",
|
|
start(context) { ${recorder.expression}.push([context.moduleId, context.config.getString("label"), context.config.keys()]); }
|
|
};`,
|
|
});
|
|
}
|
|
|
|
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
|
const resolution = resolveModuleEligibility(
|
|
discovered,
|
|
{ modules: [] },
|
|
{
|
|
settings: {
|
|
records: [
|
|
{ key: "label", moduleId: "alpha", value: "alpha-value" },
|
|
{ key: "label", moduleId: "bravo", value: "bravo-value" },
|
|
],
|
|
},
|
|
},
|
|
);
|
|
const runtime = await startDiscoveredModuleRuntime(
|
|
resolution.eligibleModules,
|
|
fakeModuleRuntimeServices(),
|
|
{ configurations: resolution.configurations },
|
|
);
|
|
await runtime.stop();
|
|
|
|
expect(recorder.values).toEqual([
|
|
["alpha", "alpha-value", ["label"]],
|
|
["bravo", "bravo-value", ["label"]],
|
|
]);
|
|
});
|
|
|
|
it("supplies an empty configuration, never undefined, to a module that declares none", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createReferenceRecorder();
|
|
await createRuntimeModule(modulesDirectory, "plain", {
|
|
serverSource: `export default {
|
|
id: "plain", version: "0.1.0",
|
|
start(context) { ${recorder.expression}.push([typeof context.config, context.config.keys()]); }
|
|
};`,
|
|
});
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory));
|
|
await runtime.stop();
|
|
|
|
// Module code never has to branch on whether the platform supplied a configuration.
|
|
expect(recorder.values).toEqual([["object", []]]);
|
|
});
|
|
|
|
it("keeps the services object one shared identity even though configuration is per-module", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const services = fakeModuleRuntimeServices();
|
|
const recorder = createReferenceRecorder();
|
|
for (const id of ["alpha", "bravo"]) {
|
|
await createRuntimeModule(modulesDirectory, id, {
|
|
manifest: {
|
|
...manifestFor(id),
|
|
configuration: [{ key: "label", type: "string", default: id }],
|
|
},
|
|
serverSource: `export default {
|
|
id: ${JSON.stringify(id)}, version: "0.1.0",
|
|
start(context) { ${recorder.expression}.push(context.services); ${recorder.expression}.push(context.config); }
|
|
};`,
|
|
});
|
|
}
|
|
|
|
const discovered = await discoverModules(runtimeOptions(modulesDirectory, services));
|
|
const resolution = resolveModuleEligibility(
|
|
discovered,
|
|
{ modules: [] },
|
|
{ settings: { records: [] } },
|
|
);
|
|
const runtime = await startDiscoveredModuleRuntime(resolution.eligibleModules, services, {
|
|
configurations: resolution.configurations,
|
|
});
|
|
await runtime.stop();
|
|
|
|
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
|
|
// This is the reason configuration is a sibling of `services` rather than a field inside it:
|
|
// `services` is deliberately one shared object for the whole runtime, while configuration must
|
|
// differ per module. Both properties hold simultaneously.
|
|
expect(alphaServices).toBe(services);
|
|
expect(bravoServices).toBe(services);
|
|
expect(alphaConfig).not.toBe(bravoConfig);
|
|
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
|
|
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
|
|
});
|
|
});
|