A module can register both `GET /events/:id` and `GET /events/new`. Driven through the pinned router, `/events/new` reached the parameter handler under `demo.events.read` when the parameter route registered first, and the static handler under `demo.events.create` when it registered second. Nothing rejected the pair, so declaration order decided which permission guarded the path. That is a permission policy, not a routing preference, and it must not be inferred from array position. Two routes of one module now overlap when they share a method and a segment count and, at every position, either both segments are the same static literal or at least one is a parameter. Under this grammar — static literals or `:name` parameters, no wildcard, regex, optional, or catch-all — that is exactly "some concrete request path matches both". `moduleRoutesOverlap` implements it in the contracts package, where path semantics already live and Hono cannot reach. Core checks it during staged registration, after the existing duplicate check, so identical shapes (including paths differing only in parameter name) keep returning MODULE_ROUTE_DUPLICATE and a genuine intersection returns the new MODULE_ROUTE_OVERLAP. The rejected module publishes none of its staged routes or capabilities, no start hook runs, and no listener binds. Rejection rather than a specificity rule: ranking static above parameter would still silently pick which permission applies. Disjoint routes keep their order; different methods, different segment counts, and the same local path in two different modules (each has its own platform-owned mount prefix) all remain legal. The same requests also pinned down what handlers actually receive. Route declarations are validated; route *parameters* are not, and the two are unrelated. A single encoded segment arrives decoded as `/../admin`, `a\b`, an embedded NUL, CR/LF, `1; drop`, or non-ASCII text — none of which any declared path could contain. The contract now says so explicitly: decoded values are untrusted, module-owned input that a handler must validate against its own exact domain grammar before any database, filesystem, command, or identifier use, and re-encoding is not a substitute. No generic validator, path-normalization layer, or route field was added, because module parameters are module-owned domain types and one platform-wide pattern would either reject legitimate values or approve meaningless ones. Co-Authored-By: Claude Opus 5 <[email protected]>
1154 lines
46 KiB
TypeScript
1154 lines
46 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import type { ModuleDatabase, ModuleRuntimeServices } from "@ekklesia/contracts";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { discoverModules } from "../src/modules/discovery.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: 3,
|
|
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"]);
|
|
expect(recorder.events).toEqual([
|
|
"one:start",
|
|
"two:start",
|
|
"three:start",
|
|
"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();
|
|
});
|
|
});
|