diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index 2aadd048134..e9ec46a1955 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -318,6 +318,126 @@ describe("plugins cli uninstall", () => { expect(runtimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"'); }); + it("uninstalls stale enabled entries when plugin is absent from the current registry", async () => { + const baseConfig = { + plugins: { + entries: { + alpha: { enabled: true }, + }, + }, + } as OpenClawConfig; + const nextConfig = {} as OpenClawConfig; + + loadConfig.mockReturnValue(baseConfig); + buildPluginSnapshotReport.mockReturnValue({ + plugins: [], + diagnostics: [], + }); + planPluginUninstall.mockReturnValue({ + ok: true, + config: nextConfig, + actions: { + entry: true, + install: false, + allowlist: false, + denylist: false, + loadPath: false, + memorySlot: false, + contextEngineSlot: false, + channelConfig: false, + directory: false, + }, + directoryRemoval: null, + }); + + await runPluginsCommand(["plugins", "uninstall", "alpha", "--force"]); + + expect(planPluginUninstall).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "alpha", + deleteFiles: true, + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + expect(refreshPluginRegistry).toHaveBeenCalledWith({ + config: nextConfig, + installRecords: {}, + reason: "source-changed", + }); + expect(runtimeErrors).not.toContain("Plugin not found: alpha"); + expect(runtimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"'); + }); + + it("removes installed channel config when plugin code is absent from the current registry", async () => { + const installRecords = { + alpha: { + source: "npm", + spec: "alpha@1.0.0", + installPath: ALPHA_INSTALL_PATH, + }, + } as const; + const baseConfig = { + plugins: { + entries: { + alpha: { enabled: true }, + }, + installs: installRecords, + }, + channels: { + alpha: { + enabled: true, + }, + discord: { + enabled: true, + }, + }, + } as OpenClawConfig; + const nextConfig = { + channels: { + discord: { + enabled: true, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(baseConfig); + setInstalledPluginIndexInstallRecords(installRecords); + buildPluginSnapshotReport.mockReturnValue({ + plugins: [], + diagnostics: [], + }); + planPluginUninstall.mockReturnValue({ + ok: true, + config: nextConfig, + actions: { + entry: true, + install: true, + allowlist: false, + denylist: false, + loadPath: false, + memorySlot: false, + contextEngineSlot: false, + channelConfig: true, + directory: false, + }, + directoryRemoval: null, + }); + + await runPluginsCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]); + + expect(planPluginUninstall).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "alpha", + channelIds: undefined, + deleteFiles: false, + }), + ); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({}); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + expect(runtimeLogs.some((line) => line.includes("channel config (channels.alpha)"))).toBe(true); + expect(runtimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"'); + }); + it("exits when uninstall target is not managed by plugin install records", async () => { loadConfig.mockReturnValue({ plugins: { diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index 237db47e319..5310ca10ace 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -735,6 +735,138 @@ describe("uninstallPlugin", () => { expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); }); + it.each([ + { + name: "enabled entry only, no installed code", + pluginId: "missing-entry-plugin", + config: createPluginConfig({ + entries: { + "missing-entry-plugin": { enabled: true }, + }, + }), + expectedActions: { + entry: true, + install: false, + allowlist: false, + denylist: false, + loadPath: false, + memorySlot: false, + contextEngineSlot: false, + channelConfig: false, + directory: false, + }, + expectedConfig: {}, + }, + { + name: "install record and channel config, no runtime plugin", + pluginId: "missing-channel-plugin", + config: createPluginConfig({ + installs: { + "missing-channel-plugin": createNpmInstallRecord("missing-channel-plugin"), + }, + channels: { + "missing-channel-plugin": { enabled: true, token: "stale" }, + discord: { enabled: true }, + }, + }), + expectedActions: { + entry: false, + install: true, + allowlist: false, + denylist: false, + loadPath: false, + memorySlot: false, + contextEngineSlot: false, + channelConfig: true, + directory: false, + }, + expectedConfig: { + channels: { + discord: { enabled: true }, + }, + }, + }, + { + name: "linked path record, missing source directory", + pluginId: "missing-linked-plugin", + config: createPluginConfig({ + installs: { + "missing-linked-plugin": createPathInstallRecord( + "/missing/openclaw/plugin", + "/missing/openclaw/plugin", + ), + }, + loadPaths: ["/missing/openclaw/plugin", "/keep/this/plugin"], + }), + expectedActions: { + entry: false, + install: true, + allowlist: false, + denylist: false, + loadPath: true, + memorySlot: false, + contextEngineSlot: false, + channelConfig: false, + directory: false, + }, + expectedConfig: { + plugins: { + load: { + paths: ["/keep/this/plugin"], + }, + }, + }, + }, + { + name: "policy and slots only, no entry or install record", + pluginId: "missing-policy-plugin", + config: createPluginConfig({ + allow: ["missing-policy-plugin", "other-plugin"], + deny: ["missing-policy-plugin"], + slots: { + memory: "missing-policy-plugin", + contextEngine: "missing-policy-plugin", + }, + }), + expectedActions: { + entry: false, + install: false, + allowlist: true, + denylist: true, + loadPath: false, + memorySlot: true, + contextEngineSlot: true, + channelConfig: false, + directory: false, + }, + expectedConfig: { + plugins: { + allow: ["other-plugin"], + slots: { + memory: "memory-core", + contextEngine: "legacy", + }, + }, + }, + }, + ] as const)( + "uninstall teardown matrix: $name", + async ({ pluginId, config, expectedActions, expectedConfig }) => { + const result = await uninstallPlugin({ + config, + pluginId, + deleteFiles: true, + extensionsDir: path.join(tempDir, "extensions"), + }); + + const successfulResult = expectSuccessfulUninstall(result); + expect(successfulResult.actions).toEqual(expectedActions); + expect(successfulResult.config).toEqual(expectedConfig); + expect(successfulResult.warnings).toEqual([]); + expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); + }, + ); + it("removes config entries", async () => { const config = createPluginConfig({ entries: createSinglePluginEntries(),