fix(plugins): invalidate replaced web runtime artifacts (#126867)

This commit is contained in:
Peter Steinberger
2026-08-20 15:43:21 -07:00
committed by GitHub
parent c19bdb3a1d
commit b00734bd4c
2 changed files with 62 additions and 1 deletions
@@ -1,7 +1,13 @@
// Runtime web-channel plugin tests cover web channel plugin activation and runtime behavior.
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTempDirTracker } from "../../../test/helpers/temp-dir.js";
const tempDirs = createTempDirTracker();
afterEach(() => {
tempDirs.cleanup();
vi.doUnmock("./runtime-plugin-boundary.js");
vi.resetModules();
});
@@ -51,6 +57,57 @@ describe("runtime web channel plugin", () => {
expect(resolvePluginRuntimeRecordByEntryBaseNames).toHaveBeenCalledOnce();
});
it.each(["light", "heavy"] as const)(
"reloads replaced %s runtime artifacts and dependencies after plugin lifecycle clears",
async (kind) => {
const pluginRoot = fs.realpathSync(tempDirs.make("openclaw-web-runtime-replacement-"));
const modulePath = path.join(
pluginRoot,
kind === "light" ? "light-runtime-api.js" : "runtime-api.js",
);
const dependencyPath = path.join(pluginRoot, "dependency.js");
fs.writeFileSync(path.join(pluginRoot, "package.json"), '{"type":"commonjs"}\n', "utf8");
const writeRuntime = (marker: string) => {
fs.writeFileSync(dependencyPath, `module.exports = ${JSON.stringify(marker)};\n`, "utf8");
const exportName = kind === "light" ? "resolveDefaultWebAuthDir" : "startWebLoginWithQr";
fs.writeFileSync(
modulePath,
`module.exports = { ${exportName}: () => ${JSON.stringify(marker)} + ":" + require("./dependency.js") };\n`,
"utf8",
);
};
writeRuntime("retired");
vi.doMock("./runtime-plugin-boundary.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./runtime-plugin-boundary.js")>()),
resolvePluginRuntimeRecordByEntryBaseNames: () => ({
origin: "global",
rootDir: pluginRoot,
source: path.join(pluginRoot, "index.js"),
}),
resolvePluginRuntimeModulePath: () => modulePath,
}));
const runtime = await import("./runtime-web-channel-plugin.js");
const { clearPluginMetadataLifecycleCaches } =
await import("../plugin-metadata-lifecycle.js");
const invoke = () =>
kind === "light"
? Promise.resolve(runtime.resolveWebChannelAuthDir())
: runtime.startWebLoginWithQr();
await expect(invoke()).resolves.toBe("retired:retired");
writeRuntime("replacement");
await expect(invoke()).resolves.toBe("retired:retired");
clearPluginMetadataLifecycleCaches();
await expect(invoke()).resolves.toBe("replacement:replacement");
await expect(invoke()).resolves.toBe("replacement:replacement");
},
);
it("reports heavy runtime load failures as promise rejections", async () => {
vi.doMock("./runtime-plugin-boundary.js", () => ({
loadPluginBoundaryModule: () => {
@@ -1,7 +1,9 @@
// Runtime web-channel plugin helpers expose web-channel tools through activated plugin runtimes.
import path from "node:path";
import { getDefaultLocalRootsCore } from "../../media/web-media.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugin-metadata-lifecycle.js";
import {
clearPluginModuleLoaderLifecycleCache,
createPluginModuleLoaderCache,
type PluginModuleLoaderCache,
} from "../plugin-module-loader-cache.js";
@@ -64,10 +66,11 @@ const webChannelRuntimeModuleCache = new Map<
>();
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
const moduleRoots = new Map<string, string>();
registerPluginMetadataProcessMemoLifecycleClear(() => {
webChannelRuntimeModuleCache.clear();
moduleLoaders.clear();
clearPluginModuleLoaderLifecycleCache({ moduleLoaders, moduleRoots });
});
/** Resolves the active web-channel plugin record that provides runtime APIs. */
@@ -89,6 +92,7 @@ function resolveWebChannelRuntimeModulePath(
if (!modulePath) {
throw new Error(`web channel plugin runtime is unavailable: missing ${entryBaseName}`);
}
moduleRoots.set(modulePath, record.rootDir ?? path.dirname(record.source));
return modulePath;
}