diff --git a/src/plugins/runtime/runtime-web-channel-plugin.test.ts b/src/plugins/runtime/runtime-web-channel-plugin.test.ts index ad2cf3c23e2a..f52ba95d0623 100644 --- a/src/plugins/runtime/runtime-web-channel-plugin.test.ts +++ b/src/plugins/runtime/runtime-web-channel-plugin.test.ts @@ -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()), + 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: () => { diff --git a/src/plugins/runtime/runtime-web-channel-plugin.ts b/src/plugins/runtime/runtime-web-channel-plugin.ts index af6f4bb2549a..b2e0d48d1de8 100644 --- a/src/plugins/runtime/runtime-web-channel-plugin.ts +++ b/src/plugins/runtime/runtime-web-channel-plugin.ts @@ -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(); 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; }