import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { clearPluginLoaderCache, loadOpenClawPlugins } from "./loader.js"; import { resetPluginRuntimeStateForTest } from "./runtime.js"; const tempDirs: string[] = []; function makeTempDir(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-prefer-over-")); if (process.platform !== "win32") { fs.chmodSync(dir, 0o755); } tempDirs.push(dir); return dir; } function writeChannelToolPlugin(params: { rootDir: string; id: string; channelId: string; enabledByDefault?: boolean; preferOver?: string[]; }): string { const pluginDir = path.join(params.rootDir, params.id); fs.mkdirSync(pluginDir, { recursive: true }); if (process.platform !== "win32") { fs.chmodSync(pluginDir, 0o755); } fs.writeFileSync( path.join(pluginDir, "openclaw.plugin.json"), JSON.stringify( { id: params.id, channels: [params.channelId], contracts: { tools: ["qqbot_remind"] }, ...(params.enabledByDefault ? { enabledByDefault: true } : {}), channelConfigs: { [params.channelId]: { schema: { type: "object" }, ...(params.preferOver ? { preferOver: params.preferOver } : {}), }, }, configSchema: { type: "object", additionalProperties: false, properties: {} }, }, null, 2, ), "utf-8", ); fs.writeFileSync( path.join(pluginDir, "index.cjs"), `module.exports = { id: ${JSON.stringify(params.id)}, register(api) { api.registerChannel({ plugin: { id: ${JSON.stringify(params.channelId)}, meta: { id: ${JSON.stringify(params.channelId)}, label: ${JSON.stringify(params.channelId)}, selectionLabel: ${JSON.stringify(params.channelId)}, docsPath: ${JSON.stringify(`/channels/${params.channelId}`)}, blurb: "fixture channel", }, capabilities: { chatTypes: ["direct"] }, config: { listAccountIds: () => [], resolveAccount: () => ({ accountId: "default" }), }, outbound: { deliveryMode: "direct" }, }, }); api.registerTool({ name: "qqbot_remind", description: "fixture", parameters: { type: "object", properties: {} }, execute() { return { content: [{ type: "text", text: "ok" }] }; }, }, { name: "qqbot_remind" }); }, };`, "utf-8", ); return pluginDir; } afterEach(() => { clearPluginLoaderCache(); resetPluginRuntimeStateForTest(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); describe("plugin loader preferOver activation", () => { it("loads the preferred external channel plugin without the replaced bundled plugin tools", () => { const bundledRoot = makeTempDir(); writeChannelToolPlugin({ rootDir: bundledRoot, id: "qqbot", channelId: "qqbot", enabledByDefault: true, }); const externalRoot = makeTempDir(); const externalPluginDir = writeChannelToolPlugin({ rootDir: externalRoot, id: "openclaw-qqbot", channelId: "qqbot", preferOver: ["qqbot"], }); const env = { OPENCLAW_STATE_DIR: makeTempDir(), OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, }; const rawConfig = { channels: { qqbot: { appId: "app", clientSecret: "secret" } }, plugins: { load: { paths: [externalPluginDir] } }, }; const autoEnabled = applyPluginAutoEnable({ config: rawConfig, env }); const registry = loadOpenClawPlugins({ cache: false, config: autoEnabled.config, activationSourceConfig: rawConfig, autoEnabledReasons: autoEnabled.autoEnabledReasons, env, }); expect(autoEnabled.config.plugins?.entries?.["openclaw-qqbot"]?.enabled).toBe(true); expect(autoEnabled.config.plugins?.entries?.qqbot?.enabled).toBe(false); expect(registry.plugins.find((plugin) => plugin.id === "openclaw-qqbot")?.status).toBe( "loaded", ); expect(registry.plugins.find((plugin) => plugin.id === "qqbot")?.status).toBe("disabled"); expect(registry.tools.map((tool) => tool.pluginId)).toEqual(["openclaw-qqbot"]); expect(registry.diagnostics.map((diag) => diag.message).join("\n")).not.toContain( "plugin tool name conflict", ); }); it("blocks tools from a plugin that loses a duplicate channel registration", () => { const bundledRoot = makeTempDir(); writeChannelToolPlugin({ rootDir: bundledRoot, id: "qqbot", channelId: "qqbot", enabledByDefault: true, }); const externalRoot = makeTempDir(); const externalPluginDir = writeChannelToolPlugin({ rootDir: externalRoot, id: "openclaw-qqbot", channelId: "qqbot", }); const env = { OPENCLAW_STATE_DIR: makeTempDir(), OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, }; const registry = loadOpenClawPlugins({ cache: false, config: { channels: { qqbot: { appId: "app", clientSecret: "secret" } }, plugins: { load: { paths: [externalPluginDir] }, entries: { qqbot: { enabled: true }, "openclaw-qqbot": { enabled: true }, }, }, }, env, }); const diagnostics = registry.diagnostics.map((diag) => diag.message).join("\n"); expect(diagnostics).toContain("channel already registered: qqbot"); expect(diagnostics).not.toContain("plugin tool name conflict"); expect(registry.tools.map((tool) => tool.pluginId)).toHaveLength(1); }); });