feat: define module HTTP routes
Phase 5B, pass 1: framework-neutral module HTTP route contracts and runtime registration. Adds @ekklesia/contracts' module-http.ts (methods, canonical path grammar, route definition/context/handler types, and validation) and Core's module runtime staging/publication of a module's declared routes alongside its capabilities, gated on the module's own manifest permissions. Routes are collected into StartedModuleRuntime as an immutable snapshot but are not yet mounted into apps/api; that is pass 2. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c9cf1f08e3
commit
e296e083ec
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readdir = vi.fn();
|
||||
const readFile = vi.fn();
|
||||
const realpath = vi.fn();
|
||||
const stat = vi.fn();
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:fs/promises")>()),
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
stat,
|
||||
}));
|
||||
|
||||
describe("module HTTP contract and runtime import safety", () => {
|
||||
it("performs no filesystem I/O merely by importing the module runtime or the module-http contract", async () => {
|
||||
await import("../src/modules/index.js");
|
||||
await import("@ekklesia/contracts");
|
||||
|
||||
expect(readdir).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(realpath).not.toHaveBeenCalled();
|
||||
expect(stat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,21 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
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>;
|
||||
@@ -40,6 +47,7 @@ function manifestFor(
|
||||
options: {
|
||||
capabilities?: Array<{ id: string; version: string }>;
|
||||
dependencies?: Record<string, unknown>;
|
||||
permissions?: Array<{ id: string; baseline: string; description: string }>;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
@@ -52,7 +60,7 @@ function manifestFor(
|
||||
moduleSdk: "^0.1.0",
|
||||
},
|
||||
capabilities: options.capabilities ?? [],
|
||||
permissions: [],
|
||||
permissions: options.permissions ?? [],
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
@@ -370,3 +378,482 @@ describe("module runtime", () => {
|
||||
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",
|
||||
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", handler: ${noopHandlerSource()} });
|
||||
http.route({ method: "GET", path: "/second", permission: "alpha.second.read", 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", 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", handler: ${noopHandlerSource()} });
|
||||
http.route({ method: "POST", path: "/items", permission: "same-path.items.write", 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", handler: ${noopHandlerSource()} });
|
||||
http.route({ method: "GET", path: "/items", permission: "dup.items.read", 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", handler: ${noopHandlerSource()} });
|
||||
http.route({ method: "GET", path: "/events/:id", permission: "param-shape.events.read", handler: ${noopHandlerSource()} });
|
||||
}
|
||||
};`,
|
||||
});
|
||||
|
||||
const error = await captureRuntimeError(modulesDirectory);
|
||||
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
|
||||
});
|
||||
|
||||
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", 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", handler: ${noopHandlerSource()} });
|
||||
}
|
||||
};`,
|
||||
});
|
||||
expect(runtimeIssueCodes(await captureRuntimeError(badPathDirectory))).toEqual([
|
||||
"MODULE_ROUTE_PATH_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", 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",
|
||||
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", 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", handler: ${noopHandlerSource()} });
|
||||
http.route({ method: "GET", path: "/authenticated", permission: "baselines.authenticated.read", handler: ${noopHandlerSource()} });
|
||||
http.route({ method: "GET", path: "/restricted", permission: "baselines.restricted.read", 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",
|
||||
"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",
|
||||
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", 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",
|
||||
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", 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", 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", 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);
|
||||
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());
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user