diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index e92b22372e4..be498e3c584 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -9,6 +9,7 @@ import { expectInstallUsesIgnoreScripts } from "../test-utils/npm-spec-install-t import { initializeGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; import { createMockPluginRegistry } from "./hooks.test-helpers.js"; import * as installSecurityScan from "./install-security-scan.js"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { installPluginFromArchive, installPluginFromDir, @@ -21,6 +22,10 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: vi.fn(), })); +vi.mock("../infra/openclaw-root.js", () => ({ + resolveOpenClawPackageRootSync: vi.fn(), +})); + const resolveCompatibilityHostVersionMock = vi.fn(); vi.mock("./install.runtime.js", async () => { @@ -2350,3 +2355,96 @@ describe("installPluginFromDir", () => { }); }); }); + +describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { + const resolveRootMock = vi.mocked(resolveOpenClawPackageRootSync); + + function writePluginWithPeerDeps( + pluginDir: string, + peerDependencies: Record, + ): void { + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "peer-dep-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + peerDependencies, + }), + "utf-8", + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n", "utf-8"); + } + + it("creates a node_modules/openclaw symlink when peerDependencies declares openclaw", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + const fakeHostRoot = suiteTempRootTracker.makeTempDir(); + resolveRootMock.mockReturnValue(fakeHostRoot); + + writePluginWithPeerDeps(pluginDir, { openclaw: "*" }); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const symlinkPath = path.join(result.targetDir, "node_modules", "openclaw"); + const stat = fs.lstatSync(symlinkPath); + expect(stat.isSymbolicLink()).toBe(true); + expect(fs.realpathSync(symlinkPath)).toBe(fs.realpathSync(fakeHostRoot)); + }); + + it("does not create a symlink when peerDependencies is empty", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + resolveRootMock.mockReturnValue(suiteTempRootTracker.makeTempDir()); + + writePluginWithPeerDeps(pluginDir, {}); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const nodeModulesDir = path.join(result.targetDir, "node_modules"); + const symlinkPath = path.join(nodeModulesDir, "openclaw"); + expect(fs.existsSync(symlinkPath)).toBe(false); + }); + + it("is idempotent — re-installing replaces an existing symlink without error", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + const fakeHostRoot = suiteTempRootTracker.makeTempDir(); + resolveRootMock.mockReturnValue(fakeHostRoot); + + writePluginWithPeerDeps(pluginDir, { openclaw: "*" }); + + // First install + const { result: first } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + expect(first.ok).toBe(true); + + // Second install (update mode) — should replace symlink, not throw + const { result: second, warnings } = await installFromDirWithWarnings({ + pluginDir, + extensionsDir, + mode: "update", + }); + expect(second.ok).toBe(true); + expect(warnings).toHaveLength(0); + + if (!second.ok) return; + const symlinkPath = path.join(second.targetDir, "node_modules", "openclaw"); + expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true); + }); + + it("warns and skips when resolveOpenClawPackageRootSync returns null", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + resolveRootMock.mockReturnValue(null); + + writePluginWithPeerDeps(pluginDir, { openclaw: "*" }); + + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(true); + expect(warnings.some((w) => w.includes("Could not locate openclaw package root"))).toBe(true); + }); +}); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index fef8ce9b18e..0ddc527dae3 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -621,10 +621,7 @@ async function linkOpenClawPeerDependencies(params: { peerDependencies: Record; logger: PluginInstallLogger; }): Promise { - const OPENCLAW_PEER_NAMES = new Set(["openclaw"]); - const peers = Object.keys(params.peerDependencies).filter((name) => - OPENCLAW_PEER_NAMES.has(name), - ); + const peers = Object.keys(params.peerDependencies).filter((name) => name === "openclaw"); if (peers.length === 0) { return; }