From 5fcf7e75de3b6b95f8afb56a309b0294bf21feb2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 15:28:46 -0700 Subject: [PATCH] test: clear plugin runtimes between shared files (#111556) --- src/plugin-sdk/runtime-store-registry.ts | 30 +++++++++ src/plugin-sdk/runtime-store.test.ts | 32 ++++++++++ src/plugin-sdk/runtime-store.ts | 21 +------ test/non-isolated-runner.test.ts | 77 ++++++++++++++++++++++++ test/non-isolated-runner.ts | 4 ++ 5 files changed, 145 insertions(+), 19 deletions(-) create mode 100644 src/plugin-sdk/runtime-store-registry.ts diff --git a/src/plugin-sdk/runtime-store-registry.ts b/src/plugin-sdk/runtime-store-registry.ts new file mode 100644 index 00000000000..7688447fc6d --- /dev/null +++ b/src/plugin-sdk/runtime-store-registry.ts @@ -0,0 +1,30 @@ +type NamedPluginRuntimeStoreSlot = { runtime: unknown }; +type NamedPluginRuntimeStoreRegistry = Map; + +const pluginRuntimeStoreRegistryKey = Symbol.for("openclaw.plugin-sdk.runtime-store-registry"); + +function getNamedPluginRuntimeStoreRegistry(): NamedPluginRuntimeStoreRegistry { + const globalRecord = globalThis as typeof globalThis & { + [pluginRuntimeStoreRegistryKey]?: NamedPluginRuntimeStoreRegistry; + }; + globalRecord[pluginRuntimeStoreRegistryKey] ??= new Map(); + return globalRecord[pluginRuntimeStoreRegistryKey]; +} + +export function getNamedPluginRuntimeStoreSlot(key: string): NamedPluginRuntimeStoreSlot { + const registry = getNamedPluginRuntimeStoreRegistry(); + let slot = registry.get(key); + if (!slot) { + slot = { runtime: null }; + registry.set(key, slot); + } + return slot; +} + +export function clearNamedPluginRuntimeStoresForTest(): void { + const registry = getNamedPluginRuntimeStoreRegistry(); + for (const slot of registry.values()) { + slot.runtime = null; + } + registry.clear(); +} diff --git a/src/plugin-sdk/runtime-store.test.ts b/src/plugin-sdk/runtime-store.test.ts index 226eb1070b5..3a854aa1bf4 100644 --- a/src/plugin-sdk/runtime-store.test.ts +++ b/src/plugin-sdk/runtime-store.test.ts @@ -3,6 +3,7 @@ */ import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, test } from "vitest"; +import { clearNamedPluginRuntimeStoresForTest } from "./runtime-store-registry.js"; import { createPluginRuntimeStore } from "./runtime-store.js"; describe("createPluginRuntimeStore", () => { @@ -118,4 +119,35 @@ describe("createPluginRuntimeStore", () => { expect(secondStore.getRuntime()).toEqual({ value: "shared" }); }); + + test("clears and detaches every named runtime slot without changing legacy stores", () => { + const firstNamedStore = createPluginRuntimeStore<{ value: string }>({ + pluginId: "first-reset-plugin", + errorMessage: "first runtime not initialized", + }); + const secondNamedStore = createPluginRuntimeStore<{ value: string }>({ + pluginId: "second-reset-plugin", + errorMessage: "second runtime not initialized", + }); + const legacyStore = createPluginRuntimeStore<{ value: string }>( + "legacy runtime not initialized", + ); + + firstNamedStore.setRuntime({ value: "first" }); + secondNamedStore.setRuntime({ value: "second" }); + legacyStore.setRuntime({ value: "legacy" }); + + clearNamedPluginRuntimeStoresForTest(); + + expect(firstNamedStore.tryGetRuntime()).toBeNull(); + expect(secondNamedStore.tryGetRuntime()).toBeNull(); + expect(legacyStore.getRuntime()).toEqual({ value: "legacy" }); + + const replacementStore = createPluginRuntimeStore<{ value: string }>({ + pluginId: "first-reset-plugin", + errorMessage: "replacement runtime not initialized", + }); + firstNamedStore.setRuntime({ value: "stale" }); + expect(replacementStore.tryGetRuntime()).toBeNull(); + }); }); diff --git a/src/plugin-sdk/runtime-store.ts b/src/plugin-sdk/runtime-store.ts index 02842f55e2e..117e2d0a7a9 100644 --- a/src/plugin-sdk/runtime-store.ts +++ b/src/plugin-sdk/runtime-store.ts @@ -1,9 +1,6 @@ // Runtime store exports expose plugin runtime type contracts without loading runtime code. export type { PluginRuntime } from "../plugins/runtime/types.js"; - -const pluginRuntimeStoreRegistryKey = Symbol.for("openclaw.plugin-sdk.runtime-store-registry"); - -type PluginRuntimeStoreRegistry = Map; +import { getNamedPluginRuntimeStoreSlot } from "./runtime-store-registry.js"; type PluginRuntimeStoreKeyOptions = { /** Explicit global registry key for shared runtime slots. */ key: string; @@ -18,14 +15,6 @@ type PluginRuntimeStorePluginOptions = { }; type PluginRuntimeStoreOptions = PluginRuntimeStoreKeyOptions | PluginRuntimeStorePluginOptions; -function getPluginRuntimeStoreRegistry(): PluginRuntimeStoreRegistry { - const globalRecord = globalThis as typeof globalThis & { - [pluginRuntimeStoreRegistryKey]?: PluginRuntimeStoreRegistry; - }; - globalRecord[pluginRuntimeStoreRegistryKey] ??= new Map(); - return globalRecord[pluginRuntimeStoreRegistryKey]; -} - function pluginRuntimeStoreKeyForPluginId(pluginId: string): string { const normalizedPluginId = pluginId.trim(); if (!normalizedPluginId) { @@ -82,13 +71,7 @@ export function createPluginRuntimeStore(options: string | PluginRuntimeStore : (() => { // Store named slots on globalThis so duplicate SDK module instances // still share one runtime for the same plugin id or explicit key. - const registry = getPluginRuntimeStoreRegistry(); - let existingSlot = registry.get(resolved.key); - if (!existingSlot) { - existingSlot = { runtime: null }; - registry.set(resolved.key, existingSlot); - } - return existingSlot; + return getNamedPluginRuntimeStoreSlot(resolved.key); })(); return { diff --git a/test/non-isolated-runner.test.ts b/test/non-isolated-runner.test.ts index 545ea83ebdc..4f4a69caaf5 100644 --- a/test/non-isolated-runner.test.ts +++ b/test/non-isolated-runner.test.ts @@ -118,3 +118,80 @@ it("applies vi.mock factories after a sibling file fails during collection", asy await fs.rm(root, { recursive: true, force: true }); } }); + +it("clears named plugin runtime slots between files", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-runtime-store-runner-")); + try { + const write = (name: string, content: string) => + fs.writeFile(path.join(root, name), content, "utf-8"); + const runtimeStorePath = JSON.stringify( + path.join(repoRoot, "src", "plugin-sdk", "runtime-store.ts"), + ); + await fs.symlink( + path.join(repoRoot, "node_modules"), + path.join(root, "node_modules"), + "junction", + ); + await write( + "a-seed.test.ts", + [ + `import { createPluginRuntimeStore } from ${runtimeStorePath};`, + 'import { expect, it } from "vitest";', + 'const store = createPluginRuntimeStore({ pluginId: "fixture", errorMessage: "missing" });', + 'it("seeds a named runtime slot", () => {', + ' store.setRuntime({ source: "first-file" });', + ' expect(store.getRuntime()).toEqual({ source: "first-file" });', + "});", + "", + ].join("\n"), + ); + await write( + "b-observe.test.ts", + [ + `import { createPluginRuntimeStore } from ${runtimeStorePath};`, + 'import { expect, it } from "vitest";', + 'const store = createPluginRuntimeStore({ pluginId: "fixture", errorMessage: "missing" });', + 'it("starts without a runtime from the previous file", () => {', + " expect(store.tryGetRuntime()).toBeNull();", + "});", + "", + ].join("\n"), + ); + await write( + "vitest.config.ts", + [ + 'import { defineConfig } from "vitest/config";', + 'import { BaseSequencer } from "vitest/node";', + "class AlphabeticalSequencer extends BaseSequencer {", + ' override async sort(files: Parameters[0]) {', + " return [...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId));", + " }", + "}", + "export default defineConfig({", + ` cacheDir: ${JSON.stringify(path.join(root, ".vite"))},`, + " test: {", + " isolate: false,", + " fileParallelism: false,", + " maxWorkers: 1,", + " sequence: { sequencer: AlphabeticalSequencer },", + ` runner: ${JSON.stringify(path.join(repoRoot, "test", "non-isolated-runner.ts"))},`, + " },", + "});", + "", + ].join("\n"), + ); + + const vitestEntry = path.join(repoRoot, "node_modules", "vitest", "vitest.mjs"); + const result = await execFileAsync( + process.execPath, + [vitestEntry, "run", "--root", root, "--config", path.join(root, "vitest.config.ts")], + { cwd: repoRoot, env: childEnv(), maxBuffer: 16 * 1024 * 1024 }, + ).catch((error: unknown) => error as { stdout?: string; stderr?: string }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + + expect(output).toContain("2 passed"); + expect(output).not.toContain("first-file"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/non-isolated-runner.ts b/test/non-isolated-runner.ts index 1fe5bae43f3..7f520cb25aa 100644 --- a/test/non-isolated-runner.ts +++ b/test/non-isolated-runner.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { TestRunner, type RunnerTask, type RunnerTestFile, vi } from "vitest"; +import { clearNamedPluginRuntimeStoresForTest } from "../src/plugin-sdk/runtime-store-registry.js"; type EvaluatedModuleNode = { promise?: unknown; @@ -363,6 +364,9 @@ export default class OpenClawNonIsolatedRunner extends TestRunner { vi.clearAllMocks(); resetOpenClawGlobalRunState(); resetOpenClawGlobalDiagnosticState(); + // Named plugin runtimes intentionally survive duplicate module evaluation in production. + // Clear their shared slots here so one test file cannot lend a partial runtime to the next. + clearNamedPluginRuntimeStoresForTest(); vi.resetModules(); const internals = this as unknown as TestRunnerInternals; internals.moduleRunner?.mocker?.reset?.();