mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-31 03:41:51 +00:00
788 lines
22 KiB
TypeScript
788 lines
22 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { resolvePluginInstallDir } from "./install.js";
|
|
import {
|
|
removePluginFromConfig,
|
|
resolveUninstallChannelConfigKeys,
|
|
resolveUninstallDirectoryTarget,
|
|
uninstallPlugin,
|
|
} from "./uninstall.js";
|
|
|
|
type PluginConfig = NonNullable<OpenClawConfig["plugins"]>;
|
|
type PluginInstallRecord = NonNullable<PluginConfig["installs"]>[string];
|
|
|
|
async function createInstalledNpmPluginFixture(params: {
|
|
baseDir: string;
|
|
pluginId?: string;
|
|
}): Promise<{
|
|
pluginId: string;
|
|
extensionsDir: string;
|
|
pluginDir: string;
|
|
config: OpenClawConfig;
|
|
}> {
|
|
const pluginId = params.pluginId ?? "my-plugin";
|
|
const extensionsDir = path.join(params.baseDir, "extensions");
|
|
const pluginDir = resolvePluginInstallDir(pluginId, extensionsDir);
|
|
await fs.mkdir(pluginDir, { recursive: true });
|
|
await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin");
|
|
|
|
return {
|
|
pluginId,
|
|
extensionsDir,
|
|
pluginDir,
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
[pluginId]: { enabled: true },
|
|
},
|
|
installs: {
|
|
[pluginId]: {
|
|
source: "npm",
|
|
spec: `${pluginId}@1.0.0`,
|
|
installPath: pluginDir,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
type UninstallResult = Awaited<ReturnType<typeof uninstallPlugin>>;
|
|
|
|
async function runDeleteInstalledNpmPluginFixture(baseDir: string): Promise<{
|
|
pluginDir: string;
|
|
result: UninstallResult;
|
|
}> {
|
|
const { pluginId, extensionsDir, pluginDir, config } = await createInstalledNpmPluginFixture({
|
|
baseDir,
|
|
});
|
|
const result = await uninstallPlugin({
|
|
config,
|
|
pluginId,
|
|
deleteFiles: true,
|
|
extensionsDir,
|
|
});
|
|
return { pluginDir, result };
|
|
}
|
|
|
|
function expectSuccessfulUninstall(result: UninstallResult) {
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
throw new Error(`expected uninstall success, got: ${result.error}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function expectSuccessfulUninstallActions(
|
|
result: UninstallResult,
|
|
params: {
|
|
directory: boolean;
|
|
loadPath?: boolean;
|
|
warnings?: string[];
|
|
},
|
|
) {
|
|
const successfulResult = expectSuccessfulUninstall(result);
|
|
expect(successfulResult.actions.directory).toBe(params.directory);
|
|
if (params.loadPath !== undefined) {
|
|
expect(successfulResult.actions.loadPath).toBe(params.loadPath);
|
|
}
|
|
if (params.warnings) {
|
|
expect(successfulResult.warnings).toEqual(params.warnings);
|
|
}
|
|
return successfulResult;
|
|
}
|
|
|
|
function createSinglePluginEntries(pluginId = "my-plugin") {
|
|
return {
|
|
[pluginId]: { enabled: true },
|
|
};
|
|
}
|
|
|
|
function createNpmInstallRecord(pluginId = "my-plugin", installPath?: string): PluginInstallRecord {
|
|
return {
|
|
source: "npm",
|
|
spec: `${pluginId}@1.0.0`,
|
|
...(installPath ? { installPath } : {}),
|
|
};
|
|
}
|
|
|
|
function createPathInstallRecord(
|
|
installPath = "/path/to/plugin",
|
|
sourcePath = installPath,
|
|
): PluginInstallRecord {
|
|
return {
|
|
source: "path",
|
|
sourcePath,
|
|
installPath,
|
|
};
|
|
}
|
|
|
|
function createPluginConfig(params: {
|
|
entries?: Record<string, { enabled: boolean }>;
|
|
installs?: Record<string, PluginInstallRecord>;
|
|
allow?: string[];
|
|
deny?: string[];
|
|
enabled?: boolean;
|
|
slots?: PluginConfig["slots"];
|
|
loadPaths?: string[];
|
|
channels?: OpenClawConfig["channels"];
|
|
}): OpenClawConfig {
|
|
const plugins: PluginConfig = {};
|
|
if (params.entries) {
|
|
plugins.entries = params.entries;
|
|
}
|
|
if (params.installs) {
|
|
plugins.installs = params.installs;
|
|
}
|
|
if (params.allow) {
|
|
plugins.allow = params.allow;
|
|
}
|
|
if (params.deny) {
|
|
plugins.deny = params.deny;
|
|
}
|
|
if (params.enabled !== undefined) {
|
|
plugins.enabled = params.enabled;
|
|
}
|
|
if (params.slots) {
|
|
plugins.slots = params.slots;
|
|
}
|
|
if (params.loadPaths) {
|
|
plugins.load = { paths: params.loadPaths };
|
|
}
|
|
return {
|
|
...(Object.keys(plugins).length > 0 ? { plugins } : {}),
|
|
...(params.channels ? { channels: params.channels } : {}),
|
|
};
|
|
}
|
|
|
|
function expectRemainingChannels(
|
|
channels: OpenClawConfig["channels"],
|
|
expected: Record<string, unknown> | undefined,
|
|
) {
|
|
expect(channels as Record<string, unknown> | undefined).toEqual(expected);
|
|
}
|
|
|
|
function expectChannelCleanupResult(params: {
|
|
config: OpenClawConfig;
|
|
pluginId: string;
|
|
expectedChannels: Record<string, unknown> | undefined;
|
|
expectedChanged: boolean;
|
|
options?: { channelIds?: readonly string[] };
|
|
}) {
|
|
const { config: result, actions } = removePluginFromConfig(
|
|
params.config,
|
|
params.pluginId,
|
|
params.options
|
|
? params.options.channelIds
|
|
? { channelIds: [...params.options.channelIds] }
|
|
: {}
|
|
: undefined,
|
|
);
|
|
expectRemainingChannels(result.channels, params.expectedChannels);
|
|
expect(actions.channelConfig).toBe(params.expectedChanged);
|
|
}
|
|
|
|
function createSinglePluginWithEmptySlotsConfig(): OpenClawConfig {
|
|
return createPluginConfig({
|
|
entries: createSinglePluginEntries(),
|
|
slots: {},
|
|
});
|
|
}
|
|
|
|
function createSingleNpmInstallConfig(installPath: string): OpenClawConfig {
|
|
return createPluginConfig({
|
|
entries: createSinglePluginEntries(),
|
|
installs: {
|
|
"my-plugin": createNpmInstallRecord("my-plugin", installPath),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function createPluginDirFixture(baseDir: string, pluginId = "my-plugin") {
|
|
const pluginDir = path.join(baseDir, pluginId);
|
|
await fs.mkdir(pluginDir, { recursive: true });
|
|
await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin");
|
|
return pluginDir;
|
|
}
|
|
|
|
async function expectPathAccessState(pathToCheck: string, expected: "exists" | "missing") {
|
|
const accessExpectation = fs.access(pathToCheck);
|
|
if (expected === "exists") {
|
|
await expect(accessExpectation).resolves.toBeUndefined();
|
|
return;
|
|
}
|
|
await expect(accessExpectation).rejects.toThrow();
|
|
}
|
|
|
|
describe("resolveUninstallChannelConfigKeys", () => {
|
|
it("falls back to pluginId when channelIds are unknown", () => {
|
|
expect(resolveUninstallChannelConfigKeys("timbot")).toEqual(["timbot"]);
|
|
});
|
|
|
|
it("keeps explicit empty channelIds as remove-nothing", () => {
|
|
expect(resolveUninstallChannelConfigKeys("telegram", { channelIds: [] })).toEqual([]);
|
|
});
|
|
|
|
it("filters shared keys and duplicate channel ids", () => {
|
|
expect(
|
|
resolveUninstallChannelConfigKeys("bad-plugin", {
|
|
channelIds: ["defaults", "discord", "discord", "modelByChannel", "slack"],
|
|
}),
|
|
).toEqual(["discord", "slack"]);
|
|
});
|
|
});
|
|
|
|
describe("removePluginFromConfig", () => {
|
|
it("removes plugin from entries", () => {
|
|
const config = createPluginConfig({
|
|
entries: {
|
|
...createSinglePluginEntries(),
|
|
"other-plugin": { enabled: true },
|
|
},
|
|
});
|
|
|
|
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
|
|
|
expect(result.plugins?.entries).toEqual({ "other-plugin": { enabled: true } });
|
|
expect(actions.entry).toBe(true);
|
|
});
|
|
|
|
it("removes plugin from installs", () => {
|
|
const config = createPluginConfig({
|
|
installs: {
|
|
"my-plugin": createNpmInstallRecord(),
|
|
"other-plugin": createNpmInstallRecord("other-plugin"),
|
|
},
|
|
});
|
|
|
|
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
|
|
|
expect(result.plugins?.installs).toEqual({
|
|
"other-plugin": createNpmInstallRecord("other-plugin"),
|
|
});
|
|
expect(actions.install).toBe(true);
|
|
});
|
|
|
|
it("removes plugin from allowlist", () => {
|
|
const config = createPluginConfig({
|
|
allow: ["my-plugin", "other-plugin"],
|
|
});
|
|
|
|
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
|
|
|
expect(result.plugins?.allow).toEqual(["other-plugin"]);
|
|
expect(actions.allowlist).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "removes linked path from load.paths",
|
|
loadPaths: ["/path/to/plugin", "/other/path"],
|
|
expectedPaths: ["/other/path"],
|
|
},
|
|
{
|
|
name: "cleans up load when removing the only linked path",
|
|
loadPaths: ["/path/to/plugin"],
|
|
expectedPaths: undefined,
|
|
},
|
|
])("$name", ({ loadPaths, expectedPaths }) => {
|
|
const config = createPluginConfig({
|
|
installs: {
|
|
"my-plugin": createPathInstallRecord(),
|
|
},
|
|
loadPaths,
|
|
});
|
|
|
|
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
|
|
|
expect(result.plugins?.load?.paths).toEqual(expectedPaths);
|
|
expect(actions.loadPath).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "clears memory slot when uninstalling active memory plugin",
|
|
config: createPluginConfig({
|
|
entries: {
|
|
"memory-plugin": { enabled: true },
|
|
},
|
|
slots: {
|
|
memory: "memory-plugin",
|
|
},
|
|
}),
|
|
pluginId: "memory-plugin",
|
|
expectedMemory: "memory-core",
|
|
expectedChanged: true,
|
|
},
|
|
{
|
|
name: "does not modify memory slot when uninstalling non-memory plugin",
|
|
config: createPluginConfig({
|
|
entries: createSinglePluginEntries(),
|
|
slots: {
|
|
memory: "memory-core",
|
|
},
|
|
}),
|
|
pluginId: "my-plugin",
|
|
expectedMemory: "memory-core",
|
|
expectedChanged: false,
|
|
},
|
|
] as const)("$name", ({ config, pluginId, expectedMemory, expectedChanged }) => {
|
|
const { config: result, actions } = removePluginFromConfig(config, pluginId);
|
|
|
|
expect(result.plugins?.slots?.memory).toBe(expectedMemory);
|
|
expect(actions.memorySlot).toBe(expectedChanged);
|
|
});
|
|
|
|
it("removes plugins object when uninstall leaves only empty slots", () => {
|
|
const config = createSinglePluginWithEmptySlotsConfig();
|
|
|
|
const { config: result } = removePluginFromConfig(config, "my-plugin");
|
|
|
|
expect(result.plugins?.slots).toBeUndefined();
|
|
});
|
|
|
|
it("cleans up empty slots object", () => {
|
|
const config = createSinglePluginWithEmptySlotsConfig();
|
|
|
|
const { config: result } = removePluginFromConfig(config, "my-plugin");
|
|
|
|
expect(result.plugins).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "handles plugin that only exists in entries",
|
|
config: createPluginConfig({
|
|
entries: createSinglePluginEntries(),
|
|
}),
|
|
expectedEntries: undefined,
|
|
expectedInstalls: undefined,
|
|
entryChanged: true,
|
|
installChanged: false,
|
|
},
|
|
{
|
|
name: "handles plugin that only exists in installs",
|
|
config: createPluginConfig({
|
|
installs: {
|
|
"my-plugin": createNpmInstallRecord(),
|
|
},
|
|
}),
|
|
expectedEntries: undefined,
|
|
expectedInstalls: undefined,
|
|
entryChanged: false,
|
|
installChanged: true,
|
|
},
|
|
])("$name", ({ config, expectedEntries, expectedInstalls, entryChanged, installChanged }) => {
|
|
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
|
|
|
|
expect(result.plugins?.entries).toEqual(expectedEntries);
|
|
expect(result.plugins?.installs).toEqual(expectedInstalls);
|
|
expect(actions.entry).toBe(entryChanged);
|
|
expect(actions.install).toBe(installChanged);
|
|
});
|
|
|
|
it("cleans up empty plugins object", () => {
|
|
const config = createPluginConfig({
|
|
entries: createSinglePluginEntries(),
|
|
});
|
|
|
|
const { config: result } = removePluginFromConfig(config, "my-plugin");
|
|
|
|
expect(result.plugins?.entries).toBeUndefined();
|
|
});
|
|
|
|
it("preserves other config values", () => {
|
|
const config = createPluginConfig({
|
|
enabled: true,
|
|
deny: ["denied-plugin"],
|
|
entries: createSinglePluginEntries(),
|
|
});
|
|
|
|
const { config: result } = removePluginFromConfig(config, "my-plugin");
|
|
|
|
expect(result.plugins?.enabled).toBe(true);
|
|
expect(result.plugins?.deny).toEqual(["denied-plugin"]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "removes channel config for installed extension plugin",
|
|
config: createPluginConfig({
|
|
entries: {
|
|
timbot: { enabled: true },
|
|
},
|
|
installs: {
|
|
timbot: createNpmInstallRecord("timbot"),
|
|
},
|
|
channels: {
|
|
timbot: { sdkAppId: "123", secretKey: "abc" },
|
|
telegram: { enabled: true },
|
|
},
|
|
}),
|
|
pluginId: "timbot",
|
|
expectedChannels: {
|
|
telegram: { enabled: true },
|
|
},
|
|
expectedChanged: true,
|
|
},
|
|
{
|
|
name: "does not remove channel config for built-in channel without install record",
|
|
config: createPluginConfig({
|
|
entries: {
|
|
telegram: { enabled: true },
|
|
},
|
|
channels: {
|
|
telegram: { enabled: true },
|
|
discord: { enabled: true },
|
|
},
|
|
}),
|
|
pluginId: "telegram",
|
|
expectedChannels: {
|
|
telegram: { enabled: true },
|
|
discord: { enabled: true },
|
|
},
|
|
expectedChanged: false,
|
|
},
|
|
{
|
|
name: "cleans up channels object when removing the only channel config",
|
|
config: createPluginConfig({
|
|
entries: {
|
|
timbot: { enabled: true },
|
|
},
|
|
installs: {
|
|
timbot: createNpmInstallRecord("timbot"),
|
|
},
|
|
channels: {
|
|
timbot: { sdkAppId: "123" },
|
|
},
|
|
}),
|
|
pluginId: "timbot",
|
|
expectedChannels: undefined,
|
|
expectedChanged: true,
|
|
},
|
|
{
|
|
name: "does not set channelConfig action when no channel config exists",
|
|
config: createPluginConfig({
|
|
entries: createSinglePluginEntries(),
|
|
installs: {
|
|
"my-plugin": createNpmInstallRecord(),
|
|
},
|
|
}),
|
|
pluginId: "my-plugin",
|
|
expectedChannels: undefined,
|
|
expectedChanged: false,
|
|
},
|
|
{
|
|
name: "does not remove channel config when plugin has no install record",
|
|
config: createPluginConfig({
|
|
entries: {
|
|
discord: { enabled: true },
|
|
},
|
|
channels: {
|
|
discord: { enabled: true, token: "abc" },
|
|
},
|
|
}),
|
|
pluginId: "discord",
|
|
expectedChannels: {
|
|
discord: {
|
|
enabled: true,
|
|
token: "abc",
|
|
},
|
|
},
|
|
expectedChanged: false,
|
|
},
|
|
{
|
|
name: "removes channel config using explicit channelIds when pluginId differs",
|
|
config: createPluginConfig({
|
|
entries: {
|
|
"timbot-plugin": { enabled: true },
|
|
},
|
|
installs: {
|
|
"timbot-plugin": createNpmInstallRecord("timbot-plugin"),
|
|
},
|
|
channels: {
|
|
timbot: { sdkAppId: "123" },
|
|
"timbot-v2": { sdkAppId: "456" },
|
|
telegram: { enabled: true },
|
|
},
|
|
}),
|
|
pluginId: "timbot-plugin",
|
|
options: {
|
|
channelIds: ["timbot", "timbot-v2"],
|
|
},
|
|
expectedChannels: {
|
|
telegram: { enabled: true },
|
|
},
|
|
expectedChanged: true,
|
|
},
|
|
{
|
|
name: "preserves shared channel keys (defaults, modelByChannel)",
|
|
config: createPluginConfig({
|
|
entries: {
|
|
timbot: { enabled: true },
|
|
},
|
|
installs: {
|
|
timbot: createNpmInstallRecord("timbot"),
|
|
},
|
|
channels: {
|
|
defaults: { groupPolicy: "opt-in" },
|
|
modelByChannel: { timbot: "gpt-3.5" } as Record<string, string>,
|
|
timbot: { sdkAppId: "123" },
|
|
} as unknown as OpenClawConfig["channels"],
|
|
}),
|
|
pluginId: "timbot",
|
|
expectedChannels: {
|
|
defaults: { groupPolicy: "opt-in" },
|
|
modelByChannel: { timbot: "gpt-3.5" },
|
|
},
|
|
expectedChanged: true,
|
|
},
|
|
{
|
|
name: "does not remove shared keys even when passed as channelIds",
|
|
config: createPluginConfig({
|
|
entries: {
|
|
"bad-plugin": { enabled: true },
|
|
},
|
|
installs: {
|
|
"bad-plugin": createNpmInstallRecord("bad-plugin"),
|
|
},
|
|
channels: {
|
|
defaults: { groupPolicy: "opt-in" },
|
|
} as unknown as OpenClawConfig["channels"],
|
|
}),
|
|
pluginId: "bad-plugin",
|
|
options: {
|
|
channelIds: ["defaults"],
|
|
},
|
|
expectedChannels: {
|
|
defaults: { groupPolicy: "opt-in" },
|
|
},
|
|
expectedChanged: false,
|
|
},
|
|
{
|
|
name: "skips channel cleanup when channelIds is empty array (non-channel plugin)",
|
|
config: createPluginConfig({
|
|
entries: {
|
|
telegram: { enabled: true },
|
|
},
|
|
installs: {
|
|
telegram: createNpmInstallRecord("telegram"),
|
|
},
|
|
channels: {
|
|
telegram: { enabled: true },
|
|
},
|
|
}),
|
|
pluginId: "telegram",
|
|
options: {
|
|
channelIds: [],
|
|
},
|
|
expectedChannels: {
|
|
telegram: { enabled: true },
|
|
},
|
|
expectedChanged: false,
|
|
},
|
|
] as const)("$name", ({ config, pluginId, expectedChannels, expectedChanged, options }) => {
|
|
expectChannelCleanupResult({
|
|
config,
|
|
pluginId,
|
|
expectedChannels,
|
|
expectedChanged,
|
|
options,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("uninstallPlugin", () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "uninstall-test-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns error when plugin not found", async () => {
|
|
const config = createPluginConfig({});
|
|
|
|
const result = await uninstallPlugin({
|
|
config,
|
|
pluginId: "nonexistent",
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toBe("Plugin not found: nonexistent");
|
|
}
|
|
});
|
|
|
|
it("removes config entries", async () => {
|
|
const config = createPluginConfig({
|
|
entries: createSinglePluginEntries(),
|
|
installs: {
|
|
"my-plugin": createNpmInstallRecord(),
|
|
},
|
|
});
|
|
|
|
const result = await uninstallPlugin({
|
|
config,
|
|
pluginId: "my-plugin",
|
|
deleteFiles: false,
|
|
});
|
|
|
|
const successfulResult = expectSuccessfulUninstall(result);
|
|
expect(successfulResult.config.plugins?.entries).toBeUndefined();
|
|
expect(successfulResult.config.plugins?.installs).toBeUndefined();
|
|
expect(successfulResult.actions.entry).toBe(true);
|
|
expect(successfulResult.actions.install).toBe(true);
|
|
});
|
|
|
|
it("deletes directory when deleteFiles is true", async () => {
|
|
const { pluginDir, result } = await runDeleteInstalledNpmPluginFixture(tempDir);
|
|
|
|
try {
|
|
expectSuccessfulUninstallActions(result, {
|
|
directory: true,
|
|
});
|
|
await expect(fs.access(pluginDir)).rejects.toThrow();
|
|
} finally {
|
|
await fs.rm(pluginDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "preserves directory for linked plugins",
|
|
setup: async (baseDir: string) => {
|
|
const pluginDir = await createPluginDirFixture(baseDir);
|
|
return {
|
|
config: createPluginConfig({
|
|
entries: createSinglePluginEntries(),
|
|
installs: {
|
|
"my-plugin": createPathInstallRecord(pluginDir),
|
|
},
|
|
loadPaths: [pluginDir],
|
|
}),
|
|
deleteFiles: true,
|
|
accessPath: pluginDir,
|
|
expectedAccess: "exists" as const,
|
|
expectedActions: {
|
|
directory: false,
|
|
loadPath: true,
|
|
},
|
|
};
|
|
},
|
|
},
|
|
{
|
|
name: "does not delete directory when deleteFiles is false",
|
|
setup: async (baseDir: string) => {
|
|
const pluginDir = await createPluginDirFixture(baseDir);
|
|
return {
|
|
config: createSingleNpmInstallConfig(pluginDir),
|
|
deleteFiles: false,
|
|
accessPath: pluginDir,
|
|
expectedAccess: "exists" as const,
|
|
expectedActions: {
|
|
directory: false,
|
|
},
|
|
};
|
|
},
|
|
},
|
|
{
|
|
name: "succeeds even if directory does not exist",
|
|
setup: async () => ({
|
|
config: createSingleNpmInstallConfig("/nonexistent/path"),
|
|
deleteFiles: true,
|
|
expectedActions: {
|
|
directory: false,
|
|
warnings: [],
|
|
},
|
|
}),
|
|
},
|
|
] as const)("$name", async ({ setup }) => {
|
|
const params = await setup(tempDir);
|
|
const result = await uninstallPlugin({
|
|
config: params.config,
|
|
pluginId: "my-plugin",
|
|
deleteFiles: params.deleteFiles,
|
|
});
|
|
|
|
expectSuccessfulUninstallActions(result, params.expectedActions);
|
|
if ("accessPath" in params && "expectedAccess" in params) {
|
|
await expectPathAccessState(params.accessPath, params.expectedAccess);
|
|
}
|
|
});
|
|
|
|
it("returns a warning when directory deletion fails unexpectedly", async () => {
|
|
const rmSpy = vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("permission denied"));
|
|
try {
|
|
const { result } = await runDeleteInstalledNpmPluginFixture(tempDir);
|
|
|
|
const successfulResult = expectSuccessfulUninstallActions(result, {
|
|
directory: false,
|
|
});
|
|
expect(successfulResult.warnings).toHaveLength(1);
|
|
expect(successfulResult.warnings[0]).toContain("Failed to remove plugin directory");
|
|
} finally {
|
|
rmSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("never deletes arbitrary configured install paths", async () => {
|
|
const outsideDir = path.join(tempDir, "outside-dir");
|
|
const extensionsDir = path.join(tempDir, "extensions");
|
|
await fs.mkdir(outsideDir, { recursive: true });
|
|
await fs.writeFile(path.join(outsideDir, "index.js"), "// keep me");
|
|
|
|
const config = createSingleNpmInstallConfig(outsideDir);
|
|
|
|
const result = await uninstallPlugin({
|
|
config,
|
|
pluginId: "my-plugin",
|
|
deleteFiles: true,
|
|
extensionsDir,
|
|
});
|
|
|
|
expectSuccessfulUninstallActions(result, {
|
|
directory: false,
|
|
});
|
|
await expect(fs.access(outsideDir)).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("resolveUninstallDirectoryTarget", () => {
|
|
it("returns null for linked plugins", () => {
|
|
expect(
|
|
resolveUninstallDirectoryTarget({
|
|
pluginId: "my-plugin",
|
|
hasInstall: true,
|
|
installRecord: {
|
|
source: "path",
|
|
sourcePath: "/tmp/my-plugin",
|
|
installPath: "/tmp/my-plugin",
|
|
},
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("falls back to default path when configured installPath is untrusted", () => {
|
|
const extensionsDir = path.join(os.tmpdir(), "openclaw-uninstall-safe");
|
|
const target = resolveUninstallDirectoryTarget({
|
|
pluginId: "my-plugin",
|
|
hasInstall: true,
|
|
installRecord: {
|
|
source: "npm",
|
|
spec: "my-plugin@1.0.0",
|
|
installPath: "/tmp/not-openclaw-extensions/my-plugin",
|
|
},
|
|
extensionsDir,
|
|
});
|
|
|
|
expect(target).toBe(resolvePluginInstallDir("my-plugin", extensionsDir));
|
|
});
|
|
});
|