test(plugin-sdk): add unit tests for linkOpenClawPeerDependencies

Tests three cases via installPluginFromDir:
- symlink created when peerDependencies declares openclaw
- no symlink when peer list is empty
- idempotent re-install replaces existing symlink
- warns and skips when host root cannot be resolved

Also removes the single-element Set in favour of a direct name
comparison (peerName === "openclaw"), and adds Closes #54428 to
address the same root cause in the weixin connector.

Closes #54428
This commit is contained in:
Anish Kataria
2026-04-23 00:50:12 -04:00
committed by Peter Steinberger
parent 2e9c1faef6
commit 56dd249a07
2 changed files with 99 additions and 4 deletions

View File

@@ -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<string, string>,
): 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);
});
});

View File

@@ -621,10 +621,7 @@ async function linkOpenClawPeerDependencies(params: {
peerDependencies: Record<string, string>;
logger: PluginInstallLogger;
}): Promise<void> {
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;
}