Files
openclaw/src/plugins/runtime.test.ts
T
Peter Steinberger 357087b9c7 refactor(gateway): lifecycle-owned ambient state via factory-registered drains (#118271)
* refactor(runtime): own ambient state lifecycle cleanup

* chore(plugin-sdk): refresh additive API baseline

* fix(runtime): restore agent wait listener lifecycle

Keep agent-job data process-shared while preserving module-local event subscription ownership. Ratchet the public wildcard budget down after replacing text-runtime's wildcard singleton export with named exports.
2026-08-02 18:06:46 -07:00

234 lines
7.4 KiB
TypeScript

/** Covers plugin runtime registration API behavior and registry mutation guards. */
import { beforeEach, describe, expect, it } from "vitest";
import { getPluginRunContext, setPluginRunContext } from "./host-hook-runtime.js";
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);
});
it("clears plugin host run contexts with the active registry", async () => {
setPluginRunContext({
pluginId: "runtime-test",
patch: { runId: "run-1", namespace: "state", value: { ready: true } },
});
await clearActivePluginRegistry();
expect(
getPluginRunContext({
pluginId: "runtime-test",
get: { runId: "run-1", namespace: "state" },
}),
).toBeUndefined();
});
});