From c8d4fefe18e1af9972bf23fbec1798b631c5d13c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 1 May 2026 15:44:18 -0700 Subject: [PATCH] test(plugins): cover install lifecycle edges --- src/plugins/install.npm-spec.test.ts | 34 ++++++++++++++++++++++++++++ src/plugins/uninstall.test.ts | 31 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index fca7a432dda..f6f7301b9a7 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -299,6 +299,40 @@ describe("installPluginFromNpmSpec", () => { ).toBe(false); }); + it("allows duplicate npm installs in update mode", async () => { + const stateDir = suiteTempRootTracker.makeTempDir(); + const npmRoot = path.join(stateDir, "npm"); + const installRoot = path.join(npmRoot, "node_modules", "@openclaw", "voice-call"); + fs.mkdirSync(installRoot, { recursive: true }); + fs.writeFileSync(path.join(installRoot, "old.txt"), "old", "utf-8"); + mockNpmViewAndInstall({ + spec: "@openclaw/voice-call@0.0.2", + packageName: "@openclaw/voice-call", + version: "0.0.2", + pluginId: "voice-call", + npmRoot, + }); + + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/voice-call@0.0.2", + npmDir: npmRoot, + mode: "update", + logger: { info: () => {}, warn: () => {} }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error(result.error); + } + expect(result.targetDir).toBe(installRoot); + expect(result.npmResolution?.version).toBe("0.0.2"); + expectNpmInstallIntoRoot({ + calls: runCommandWithTimeoutMock.mock.calls, + npmRoot, + spec: "@openclaw/voice-call@0.0.2", + }); + }); + it("aborts when integrity drift callback rejects the fetched artifact", async () => { mockNpmViewMetadataResult(runCommandWithTimeoutMock, { name: "@openclaw/voice-call", diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index 76d18f8056a..546c0ace0bf 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -1061,6 +1061,37 @@ describe("uninstallPlugin", () => { }); await expect(fs.access(installPath)).rejects.toThrow(); }); + + it("does not delete symlinked git install targets that resolve outside the managed git root", async () => { + const stateDir = path.join(tempDir, "state"); + const extensionsDir = path.join(stateDir, "extensions"); + const linkParentDir = path.join(stateDir, "git", "git-abc123"); + const installPath = path.join(linkParentDir, "repo"); + const outsideDir = path.join(tempDir, "outside"); + await fs.mkdir(linkParentDir, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(path.join(outsideDir, "index.js"), "// keep me"); + await fs.symlink(outsideDir, installPath, "dir"); + + const result = await uninstallPlugin({ + config: createPluginConfig({ + entries: createSinglePluginEntries(), + installs: { + "my-plugin": createGitInstallRecord("my-plugin", installPath), + }, + }), + pluginId: "my-plugin", + deleteFiles: true, + extensionsDir, + }); + + expectSuccessfulUninstallActions(result, { + directory: false, + }); + await expect(fs.access(outsideDir)).resolves.toBeUndefined(); + const linkStat = await fs.lstat(installPath); + expect(linkStat.isSymbolicLink()).toBe(true); + }); }); describe("resolveUninstallDirectoryTarget", () => {