mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 01:51:39 -06:00
357087b9c7
* 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.
234 lines
7.4 KiB
TypeScript
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();
|
|
});
|
|
});
|