test(plugins): cover missing plugin uninstall teardown

This commit is contained in:
Vincent Koc
2026-05-02 11:17:00 -07:00
parent f6aedd33e5
commit e06e2d8c4c
2 changed files with 252 additions and 0 deletions

View File

@@ -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: {

View File

@@ -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(),