CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 28s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
@ekklesia/contracts still emitted a type-only import of drizzle-orm/node-postgres for the legacy ModuleDatabase facade, so a standalone module type-checked only because skipLibCheck hid it. - ModuleDatabase, LegacyModuleRuntimeServices and AnyModuleRuntimeServices move to core/src/modules/database.ts, beside the facade Core builds; the facade itself is unchanged. - contracts' runtime, route, and services generics no longer name an ORM: the database type is a plain parameter defaulting to the ABI v1 executor, and the legacy definition's database is opaque until Core specializes it. Core and apps/api name the legacy types from Core. - contracts drops its drizzle-orm optional peer and devDependency; Drizzle stays public only through @ekklesia/module-database/drizzle. - The facade's type-level test moves from contracts to Core. - Regression proof: the standalone fixture type-checks with skipLibCheck: false against the packed tarballs, with no drizzle-orm installed (the smoke asserts both). Against the previous contracts tarball the same check fails with TS2307 on drizzle-orm/node-postgres. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1577 lines
63 KiB
TypeScript
1577 lines
63 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import type { ModuleConfiguration, ModuleDatabaseExecutor } from "@ekklesia/contracts";
|
|
import type {
|
|
AnyModuleRuntimeServices,
|
|
LegacyModuleRuntimeServices,
|
|
ModuleDatabase,
|
|
} from "../src/modules/database.js";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { discoverModules } from "../src/modules/discovery.js";
|
|
import {
|
|
ModuleDiscoveryError,
|
|
ModuleRuntimeError,
|
|
type ModuleRuntimeIssueCode,
|
|
type ModuleRuntimeServicesLookup,
|
|
startDiscoveredModuleRuntime,
|
|
startModuleRuntime,
|
|
} from "../src/modules/index.js";
|
|
import { resolveModuleEligibility } from "../src/modules/lifecycle.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 }>;
|
|
database?: { abi: 1 };
|
|
dependencies?: Record<string, unknown>;
|
|
permissions?: Array<{ id: string; baseline: string; description: string }>;
|
|
} = {},
|
|
): Record<string, unknown> {
|
|
return {
|
|
manifestVersion: 8,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "0.1.0",
|
|
compatibility: {
|
|
platform: "^0.1.0",
|
|
moduleSdk: "^0.1.0",
|
|
},
|
|
capabilities: options.capabilities ?? [],
|
|
permissions: options.permissions ?? [],
|
|
...(options.database ? { database: options.database } : {}),
|
|
...(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(): ModuleRuntimeServicesLookup {
|
|
const services = new Map<string, LegacyModuleRuntimeServices>();
|
|
const database = fakeModuleDatabase();
|
|
return new Proxy(services, {
|
|
get(target, property, receiver) {
|
|
if (property === "get") {
|
|
return (moduleId: string) => {
|
|
let value = target.get(moduleId);
|
|
if (!value) {
|
|
value = Object.freeze({ database });
|
|
target.set(moduleId, value);
|
|
}
|
|
return value;
|
|
};
|
|
}
|
|
return Reflect.get(target, property, receiver);
|
|
},
|
|
});
|
|
}
|
|
|
|
function runtimeOptions(
|
|
modulesDirectory: string,
|
|
services: ModuleRuntimeServicesLookup = 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([
|
|
"bodyMode",
|
|
"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("delivers only the manifest-selected database mode and distinct ABI executors", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const recorder = createReferenceRecorder();
|
|
const legacyDatabase = fakeModuleDatabase();
|
|
const alphaExecutor = Object.freeze({
|
|
abiVersion: 1,
|
|
query: vi.fn(),
|
|
transaction: vi.fn(),
|
|
}) as unknown as ModuleDatabaseExecutor;
|
|
const betaExecutor = Object.freeze({
|
|
abiVersion: 1,
|
|
query: vi.fn(),
|
|
transaction: vi.fn(),
|
|
}) as unknown as ModuleDatabaseExecutor;
|
|
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>([
|
|
["legacy", Object.freeze({ database: legacyDatabase })],
|
|
["alpha", Object.freeze({ database: alphaExecutor })],
|
|
["beta", Object.freeze({ database: betaExecutor })],
|
|
]);
|
|
await createRuntimeModule(modulesDirectory, "legacy", {
|
|
serverSource: `export default { id: "legacy", version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
|
|
});
|
|
for (const id of ["alpha", "beta"]) {
|
|
await createRuntimeModule(modulesDirectory, id, {
|
|
manifest: manifestFor(id, { database: { abi: 1 } }),
|
|
serverSource: `export default { database: { abi: 1 }, id: ${JSON.stringify(id)}, version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
|
|
});
|
|
}
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
|
|
|
expect(recorder.values).toEqual([alphaExecutor, betaExecutor, legacyDatabase]);
|
|
expect("select" in alphaExecutor).toBe(false);
|
|
expect(alphaExecutor).not.toBe(betaExecutor);
|
|
await runtime.stop();
|
|
});
|
|
|
|
it("rejects a runtime database declaration that disagrees with its manifest", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createRuntimeModule(modulesDirectory, "mismatch", {
|
|
manifest: manifestFor("mismatch", { database: { abi: 1 } }),
|
|
serverSource: `export default { id: "mismatch", version: "0.1.0" };`,
|
|
});
|
|
|
|
const error = await captureRuntimeError(modulesDirectory);
|
|
expect(runtimeIssueCodes(error)).toContain("MODULE_RUNTIME_DATABASE_MISMATCH");
|
|
});
|
|
|
|
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.get("wired"));
|
|
expect(lifecycleServices.values[1]).toBe(services.get("wired"));
|
|
});
|
|
|
|
it("selects a stable services object per module rather than sharing one across modules", 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.get("alpha"), services.get("beta")]);
|
|
expect(recorder.values[0]).not.toBe(recorder.values[1]);
|
|
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.get("started"), services.get("started")]);
|
|
});
|
|
|
|
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const database = fakeModuleDatabase();
|
|
const services: ModuleRuntimeServicesLookup = new Map([
|
|
["unused", Object.freeze({ database })],
|
|
]);
|
|
|
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
|
await runtime.stop();
|
|
|
|
expect(runtime.modules).toEqual([]);
|
|
expect(vi.mocked(database.select)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(database.insert)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(database.update)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(database.delete)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(database.execute)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(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 each module's services object stable while configuration remains 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;
|
|
// Configuration remains a sibling of `services`: both are selected per module, but they are
|
|
// independently composed snapshots with separate contracts and lifetimes.
|
|
expect(alphaServices).toBe(services.get("alpha"));
|
|
expect(bravoServices).toBe(services.get("bravo"));
|
|
expect(alphaServices).not.toBe(bravoServices);
|
|
expect(alphaConfig).not.toBe(bravoConfig);
|
|
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
|
|
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
|
|
});
|
|
});
|