Files
openclaw/src/plugins/runtime.test.ts
T
Peter Steinberger ccee629359 refactor(plugins): delete registry compat scaffolding (#117749)
* refactor(plugins): delete registry compat scaffolding

* test(plugins): update CLI registry handle mock

* fix(plugins): preserve explicitly initialized hook registries

* test(plugins): update registry ownership fixtures

* fix(channels): restore registry snapshot memo
2026-08-01 21:18:47 -07:00

217 lines
6.8 KiB
TypeScript

/** Covers plugin runtime registration API behavior and registry mutation guards. */
import { beforeEach, describe, expect, it } from "vitest";
import { createEmptyPluginRegistry } from "./registry.js";
import type { PluginHttpRouteRegistration } from "./registry.js";
import {
clearActivePluginRegistry,
getActivePluginRegistry,
listImportedRuntimePluginIds,
recordImportedPluginId,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "./runtime.js";
import { createPluginRecord } from "./status.test-fixtures.js";
async function waitForCleanupSignal(signal: Promise<void>, label: string): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
await Promise.race([
signal,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), 500);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
const makeRoute = (path: string): PluginHttpRouteRegistration => ({
path,
handler: () => {},
auth: "gateway",
match: "exact",
});
describe("setActivePluginRegistry", () => {
beforeEach(() => {
resetPluginRuntimeStateForTest();
setActivePluginRegistry(createEmptyPluginRegistry());
});
it("does not carry forward httpRoutes when new registry has none", () => {
const oldRegistry = createEmptyPluginRegistry();
const fakeRoute = makeRoute("/test");
oldRegistry.httpRoutes.push(fakeRoute);
setActivePluginRegistry(oldRegistry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
const newRegistry = createEmptyPluginRegistry();
expect(newRegistry.httpRoutes).toHaveLength(0);
setActivePluginRegistry(newRegistry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(0);
});
it("does not carry forward when new registry already has routes", () => {
const oldRegistry = createEmptyPluginRegistry();
oldRegistry.httpRoutes.push(makeRoute("/old"));
setActivePluginRegistry(oldRegistry);
const newRegistry = createEmptyPluginRegistry();
const newRoute = makeRoute("/new");
newRegistry.httpRoutes.push(newRoute);
setActivePluginRegistry(newRegistry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
expect(getActivePluginRegistry()?.httpRoutes[0]).toEqual(newRoute);
});
it("does not carry forward when same registry is set again", () => {
const registry = createEmptyPluginRegistry();
registry.httpRoutes.push(makeRoute("/test"));
setActivePluginRegistry(registry);
setActivePluginRegistry(registry);
expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1);
});
it("does not treat bundle-only loaded entries as imported runtime plugins", () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push(
createPluginRecord({
id: "bundle-only",
name: "Bundle Only",
source: "/tmp/bundle",
origin: "bundled",
format: "bundle",
configSchema: true,
}),
createPluginRecord({
id: "runtime-plugin",
name: "Runtime Plugin",
source: "/tmp/runtime",
format: "openclaw",
configSchema: true,
}),
);
setActivePluginRegistry(registry);
expect(listImportedRuntimePluginIds()).toEqual(["runtime-plugin"]);
});
it.each([
{
name: "same active registry is refreshed",
refresh: (nextRegistry: ReturnType<typeof createEmptyPluginRegistry>) => {
setActivePluginRegistry(nextRegistry);
},
},
{
name: "active registry advances again",
refresh: () => {
setActivePluginRegistry(createEmptyPluginRegistry());
},
},
] as const)("continues cleanup when the $name", async ({ refresh }) => {
let releaseFirstCleanup: (() => void) | undefined;
let markFirstCleanupStarted: (() => void) | undefined;
let markSecondCleanupCalled: (() => void) | undefined;
const firstCleanupStarted = new Promise<void>((resolve) => {
markFirstCleanupStarted = resolve;
});
const secondCleanupCalled = new Promise<void>((resolve) => {
markSecondCleanupCalled = resolve;
});
if (!markFirstCleanupStarted || !markSecondCleanupCalled) {
throw new Error("Expected cleanup signal callbacks to be initialized");
}
const notifyFirstCleanupStarted = markFirstCleanupStarted;
const notifySecondCleanupCalled = markSecondCleanupCalled;
const previous = createEmptyPluginRegistry();
previous.plugins.push(
createPluginRecord({
id: "cleanup-refresh-race",
name: "Cleanup Refresh Race",
status: "loaded",
}),
);
previous.runtimeLifecycles = [
{
pluginId: "cleanup-refresh-race",
pluginName: "Cleanup Refresh Race",
lifecycle: {
id: "first-cleanup",
async cleanup() {
notifyFirstCleanupStarted();
await new Promise<void>((resolve) => {
releaseFirstCleanup = resolve;
});
},
},
source: "/virtual/cleanup-refresh-race/index.ts",
rootDir: "/virtual/cleanup-refresh-race",
},
{
pluginId: "cleanup-refresh-race",
pluginName: "Cleanup Refresh Race",
lifecycle: {
id: "second-cleanup",
cleanup() {
notifySecondCleanupCalled();
},
},
source: "/virtual/cleanup-refresh-race/index.ts",
rootDir: "/virtual/cleanup-refresh-race",
},
];
const next = createEmptyPluginRegistry();
setActivePluginRegistry(previous);
setActivePluginRegistry(next);
await waitForCleanupSignal(firstCleanupStarted, "first cleanup start");
refresh(next);
if (!releaseFirstCleanup) {
throw new Error("Expected first cleanup release callback to be initialized");
}
releaseFirstCleanup();
await waitForCleanupSignal(secondCleanupCalled, "second cleanup");
});
it("includes plugin ids imported before registration failed", () => {
recordImportedPluginId("broken-plugin");
expect(listImportedRuntimePluginIds()).toEqual(["broken-plugin"]);
});
it("clears the root only after its host cleanup completes", async () => {
let cleanupCount = 0;
const registry = createEmptyPluginRegistry();
registry.plugins.push(
createPluginRecord({ id: "cleanup-on-close", name: "Cleanup on close", status: "loaded" }),
);
registry.runtimeLifecycles = [
{
pluginId: "cleanup-on-close",
pluginName: "Cleanup on close",
lifecycle: {
id: "cleanup-on-close",
cleanup() {
cleanupCount += 1;
},
},
source: "/virtual/cleanup-on-close/index.ts",
rootDir: "/virtual/cleanup-on-close",
},
];
setActivePluginRegistry(registry);
await clearActivePluginRegistry();
expect(getActivePluginRegistry()).toBeNull();
expect(cleanupCount).toBe(1);
});
});