diff --git a/src/plugins/memory-embedding-providers.test.ts b/src/plugins/memory-embedding-providers.test.ts index e64b975f8cd..d1f787990a5 100644 --- a/src/plugins/memory-embedding-providers.test.ts +++ b/src/plugins/memory-embedding-providers.test.ts @@ -20,6 +20,16 @@ function createAdapter(id: string): MemoryEmbeddingProviderAdapter { }; } +function expectRegisteredProviderEntry( + id: string, + entry: { + adapter: MemoryEmbeddingProviderAdapter; + ownerPluginId?: string; + }, +) { + expect(getRegisteredMemoryEmbeddingProvider(id)).toEqual(entry); +} + afterEach(() => { clearMemoryEmbeddingProviders(); }); @@ -44,36 +54,39 @@ describe("memory embedding provider registry", () => { expect(getMemoryEmbeddingProvider("beta")).toBe(beta); }); - it("tracks owner plugin ids in registered snapshots", () => { - const alpha = createAdapter("alpha"); - registerMemoryEmbeddingProvider(alpha, { ownerPluginId: "memory-core" }); - - expect(getRegisteredMemoryEmbeddingProvider("alpha")).toEqual({ - adapter: alpha, + it.each([ + { + name: "tracks owner plugin ids in registered snapshots", + id: "alpha", + setup: (adapter: MemoryEmbeddingProviderAdapter) => + registerMemoryEmbeddingProvider(adapter, { ownerPluginId: "memory-core" }), + expectList: true, + }, + { + name: "restores registered snapshots with owner metadata", + id: "beta", + setup: (adapter: MemoryEmbeddingProviderAdapter) => + restoreRegisteredMemoryEmbeddingProviders([ + { + adapter, + ownerPluginId: "memory-core", + }, + ]), + expectList: false, + }, + ] as const)("$name", ({ id, setup, expectList }) => { + const adapter = createAdapter(id); + const expectedEntry = { + adapter, ownerPluginId: "memory-core", - }); - expect(listRegisteredMemoryEmbeddingProviders()).toEqual([ - { - adapter: alpha, - ownerPluginId: "memory-core", - }, - ]); - }); + }; - it("restores registered snapshots with owner metadata", () => { - const beta = createAdapter("beta"); + setup(adapter); - restoreRegisteredMemoryEmbeddingProviders([ - { - adapter: beta, - ownerPluginId: "memory-core", - }, - ]); - - expect(getRegisteredMemoryEmbeddingProvider("beta")).toEqual({ - adapter: beta, - ownerPluginId: "memory-core", - }); + expectRegisteredProviderEntry(id, expectedEntry); + if (expectList) { + expect(listRegisteredMemoryEmbeddingProviders()).toEqual([expectedEntry]); + } }); it("clears the registry", () => { diff --git a/src/plugins/runtime-live-state-guardrails.test.ts b/src/plugins/runtime-live-state-guardrails.test.ts index 30f7521076d..20f7d8af589 100644 --- a/src/plugins/runtime-live-state-guardrails.test.ts +++ b/src/plugins/runtime-live-state-guardrails.test.ts @@ -18,25 +18,30 @@ const LIVE_RUNTIME_STATE_GUARDS: Record< }, }; +function guardAssertions() { + return Object.entries(LIVE_RUNTIME_STATE_GUARDS).flatMap(([relativePath, guard]) => [ + ...guard.required.map((needle) => ({ + relativePath, + type: "required" as const, + needle, + message: `${relativePath} missing ${needle}`, + })), + ...guard.forbidden.map((needle) => ({ + relativePath, + type: "forbidden" as const, + needle, + message: `${relativePath} must not contain ${needle}`, + })), + ]); +} + describe("runtime live state guardrails", () => { it("keeps split-runtime state holders on explicit direct globals", () => { - for (const [relativePath, guard] of Object.entries(LIVE_RUNTIME_STATE_GUARDS)) { + for (const relativePath of Object.keys(LIVE_RUNTIME_STATE_GUARDS)) { const source = readFileSync(resolve(repoRoot, relativePath), "utf8"); - - const assertions = [ - ...guard.required.map((needle) => ({ - type: "required" as const, - needle, - message: `${relativePath} missing ${needle}`, - })), - ...guard.forbidden.map((needle) => ({ - type: "forbidden" as const, - needle, - message: `${relativePath} must not contain ${needle}`, - })), - ]; - - for (const assertion of assertions) { + for (const assertion of guardAssertions().filter( + (entry) => entry.relativePath === relativePath, + )) { if (assertion.type === "required") { expect(source, assertion.message).toContain(assertion.needle); } else { diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts index 2a1d17dde56..1e85922d7ae 100644 --- a/src/plugins/runtime.test.ts +++ b/src/plugins/runtime.test.ts @@ -24,6 +24,13 @@ function createRegistryWithRoute(path: string) { return registry; } +function createRuntimeRegistryPair() { + return { + startupRegistry: createEmptyPluginRegistry(), + laterRegistry: createEmptyPluginRegistry(), + }; +} + describe("plugin runtime route registry", () => { afterEach(() => { releasePinnedPluginHttpRouteRegistry(); @@ -37,8 +44,7 @@ describe("plugin runtime route registry", () => { }); it("keeps the pinned route registry when the active plugin registry changes", () => { - const startupRegistry = createEmptyPluginRegistry(); - const laterRegistry = createEmptyPluginRegistry(); + const { startupRegistry, laterRegistry } = createRuntimeRegistryPair(); setActivePluginRegistry(startupRegistry); pinActivePluginHttpRouteRegistry(startupRegistry); @@ -48,8 +54,7 @@ describe("plugin runtime route registry", () => { }); it("tracks route registry repins separately from the active registry version", () => { - const startupRegistry = createEmptyPluginRegistry(); - const laterRegistry = createEmptyPluginRegistry(); + const { startupRegistry, laterRegistry } = createRuntimeRegistryPair(); const repinnedRegistry = createEmptyPluginRegistry(); setActivePluginRegistry(startupRegistry); diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 30252ffa411..b092bc58e57 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -24,6 +24,16 @@ function createCommandResult() { }; } +function createGatewaySubagentRuntime() { + return { + run: vi.fn(), + waitForRun: vi.fn(), + getSessionMessages: vi.fn(), + getSession: vi.fn(), + deleteSession: vi.fn(), + }; +} + describe("plugin runtime command execution", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -135,13 +145,7 @@ describe("plugin runtime command execution", () => { it("keeps subagent unavailable by default even after gateway initialization", async () => { const runtime = createPluginRuntime(); - setGatewaySubagentRuntime({ - run: vi.fn(), - waitForRun: vi.fn(), - getSessionMessages: vi.fn(), - getSession: vi.fn(), - deleteSession: vi.fn(), - }); + setGatewaySubagentRuntime(createGatewaySubagentRuntime()); expect(() => runtime.subagent.run({ sessionKey: "s-1", message: "hello" })).toThrow( "Plugin runtime subagent methods are only available during a gateway request.", @@ -153,11 +157,8 @@ describe("plugin runtime command execution", () => { const runtime = createPluginRuntime({ allowGatewaySubagentBinding: true }); setGatewaySubagentRuntime({ + ...createGatewaySubagentRuntime(), run, - waitForRun: vi.fn(), - getSessionMessages: vi.fn(), - getSession: vi.fn(), - deleteSession: vi.fn(), }); await expect(runtime.subagent.run({ sessionKey: "s-2", message: "hello" })).resolves.toEqual({ diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 56c9fba63a1..21c5bd0084f 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -60,6 +60,22 @@ function setPluginLoadResult(overrides: Partial, + overrides: Omit>, "plugins"> = {}, +) { + setPluginLoadResult({ + plugins: [plugin], + ...overrides, + }); +} + +function expectInspectReport(pluginId: string) { + const inspect = buildPluginInspectReport({ id: pluginId }); + expect(inspect).not.toBeNull(); + return inspect; +} + describe("buildPluginStatusReport", () => { beforeEach(async () => { vi.resetModules(); @@ -139,18 +155,16 @@ describe("buildPluginStatusReport", () => { }); it("normalizes bundled plugin versions to the core base release", () => { - setPluginLoadResult({ - plugins: [ - createPluginRecord({ - id: "whatsapp", - name: "WhatsApp", - description: "Bundled channel plugin", - version: "2026.3.22", - origin: "bundled", - channelIds: ["whatsapp"], - }), - ], - }); + setSinglePluginLoadResult( + createPluginRecord({ + id: "whatsapp", + name: "WhatsApp", + description: "Bundled channel plugin", + version: "2026.3.22", + origin: "bundled", + channelIds: ["whatsapp"], + }), + ); const report = buildPluginStatusReport({ config: {}, @@ -255,17 +269,15 @@ describe("buildPluginStatusReport", () => { }); it("treats a CLI-backend-only plugin as a plain capability", () => { - setPluginLoadResult({ - plugins: [ - createPluginRecord({ - id: "anthropic", - name: "Anthropic", - cliBackendIds: ["claude-cli"], - }), - ], - }); + setSinglePluginLoadResult( + createPluginRecord({ + id: "anthropic", + name: "Anthropic", + cliBackendIds: ["claude-cli"], + }), + ); - const inspect = buildPluginInspectReport({ id: "anthropic" }); + const inspect = expectInspectReport("anthropic"); expect(inspect?.shape).toBe("plain-capability"); expect(inspect?.capabilityMode).toBe("plain"); @@ -317,61 +329,55 @@ describe("buildPluginStatusReport", () => { }); it("returns no compatibility warnings for modern capability plugins", () => { - setPluginLoadResult({ - plugins: [ - createPluginRecord({ - id: "modern", - name: "Modern", - providerIds: ["modern"], - }), - ], - }); + setSinglePluginLoadResult( + createPluginRecord({ + id: "modern", + name: "Modern", + providerIds: ["modern"], + }), + ); expect(buildPluginCompatibilityNotices()).toEqual([]); expect(buildPluginCompatibilityWarnings()).toEqual([]); }); - it("populates bundleCapabilities from plugin record", () => { - setPluginLoadResult({ - plugins: [ - createPluginRecord({ - id: "claude-bundle", - name: "Claude Bundle", - description: "A bundle plugin with skills and commands", - source: "/tmp/claude-bundle/.claude-plugin/plugin.json", - format: "bundle", - bundleFormat: "claude", - bundleCapabilities: ["skills", "commands", "agents", "settings"], - rootDir: "/tmp/claude-bundle", - }), - ], - }); + it.each([ + { + name: "populates bundleCapabilities from plugin record", + plugin: createPluginRecord({ + id: "claude-bundle", + name: "Claude Bundle", + description: "A bundle plugin with skills and commands", + source: "/tmp/claude-bundle/.claude-plugin/plugin.json", + format: "bundle", + bundleFormat: "claude", + bundleCapabilities: ["skills", "commands", "agents", "settings"], + rootDir: "/tmp/claude-bundle", + }), + expectedId: "claude-bundle", + expectedBundleCapabilities: ["skills", "commands", "agents", "settings"], + expectedShape: "non-capability", + }, + { + name: "returns empty bundleCapabilities and mcpServers for non-bundle plugins", + plugin: createPluginRecord({ + id: "plain-plugin", + name: "Plain Plugin", + description: "A regular plugin", + providerIds: ["plain"], + }), + expectedId: "plain-plugin", + expectedBundleCapabilities: [], + expectedShape: "plain-capability", + }, + ])("$name", ({ plugin, expectedId, expectedBundleCapabilities, expectedShape }) => { + setSinglePluginLoadResult(plugin); - const inspect = buildPluginInspectReport({ id: "claude-bundle" }); + const inspect = expectInspectReport(expectedId); - expect(inspect).not.toBeNull(); - expect(inspect?.bundleCapabilities).toEqual(["skills", "commands", "agents", "settings"]); - expect(inspect?.mcpServers).toEqual([]); - expect(inspect?.shape).toBe("non-capability"); - }); - - it("returns empty bundleCapabilities and mcpServers for non-bundle plugins", () => { - setPluginLoadResult({ - plugins: [ - createPluginRecord({ - id: "plain-plugin", - name: "Plain Plugin", - description: "A regular plugin", - providerIds: ["plain"], - }), - ], - }); - - const inspect = buildPluginInspectReport({ id: "plain-plugin" }); - - expect(inspect).not.toBeNull(); - expect(inspect?.bundleCapabilities).toEqual([]); + expect(inspect?.bundleCapabilities).toEqual(expectedBundleCapabilities); expect(inspect?.mcpServers).toEqual([]); + expect(inspect?.shape).toBe(expectedShape); }); it("formats and summarizes compatibility notices", () => { diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index 7aaa759ad35..41244079628 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -11,6 +11,9 @@ import { uninstallPlugin, } from "./uninstall.js"; +type PluginConfig = NonNullable; +type PluginInstallRecord = NonNullable[string]; + async function createInstalledNpmPluginFixture(params: { baseDir: string; pluginId?: string; @@ -71,30 +74,86 @@ function createSinglePluginEntries(pluginId = "my-plugin") { }; } -function createSinglePluginWithEmptySlotsConfig(): OpenClawConfig { +function createNpmInstallRecord(pluginId = "my-plugin", installPath?: string): PluginInstallRecord { return { - plugins: { - entries: createSinglePluginEntries(), - slots: {}, - }, + source: "npm", + spec: `${pluginId}@1.0.0`, + ...(installPath ? { installPath } : {}), }; } -function createSingleNpmInstallConfig(installPath: string): OpenClawConfig { +function createPathInstallRecord( + installPath = "/path/to/plugin", + sourcePath = installPath, +): PluginInstallRecord { return { - plugins: { - entries: createSinglePluginEntries(), - installs: { - "my-plugin": { - source: "npm", - spec: "my-plugin@1.0.0", - installPath, - }, - }, - }, + source: "path", + sourcePath, + installPath, }; } +function createPluginConfig(params: { + entries?: Record; + installs?: Record; + 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 | undefined, +) { + expect(channels as Record | undefined).toEqual(expected); +} + +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 }); @@ -122,14 +181,12 @@ describe("resolveUninstallChannelConfigKeys", () => { describe("removePluginFromConfig", () => { it("removes plugin from entries", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - "my-plugin": { enabled: true }, - "other-plugin": { enabled: true }, - }, + const config = createPluginConfig({ + entries: { + ...createSinglePluginEntries(), + "other-plugin": { enabled: true }, }, - }; + }); const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); @@ -138,29 +195,25 @@ describe("removePluginFromConfig", () => { }); it("removes plugin from installs", () => { - const config: OpenClawConfig = { - plugins: { - installs: { - "my-plugin": { source: "npm", spec: "my-plugin@1.0.0" }, - "other-plugin": { source: "npm", spec: "other-plugin@1.0.0" }, - }, + 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": { source: "npm", spec: "other-plugin@1.0.0" }, + "other-plugin": createNpmInstallRecord("other-plugin"), }); expect(actions.install).toBe(true); }); it("removes plugin from allowlist", () => { - const config: OpenClawConfig = { - plugins: { - allow: ["my-plugin", "other-plugin"], - }, - }; + const config = createPluginConfig({ + allow: ["my-plugin", "other-plugin"], + }); const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); @@ -168,61 +221,40 @@ describe("removePluginFromConfig", () => { expect(actions.allowlist).toBe(true); }); - it("removes linked path from load.paths", () => { - const config: OpenClawConfig = { - plugins: { - installs: { - "my-plugin": { - source: "path", - sourcePath: "/path/to/plugin", - installPath: "/path/to/plugin", - }, - }, - load: { - paths: ["/path/to/plugin", "/other/path"], - }, + 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(["/other/path"]); - expect(actions.loadPath).toBe(true); - }); - - it("cleans up load when removing the only linked path", () => { - const config: OpenClawConfig = { - plugins: { - installs: { - "my-plugin": { - source: "path", - sourcePath: "/path/to/plugin", - installPath: "/path/to/plugin", - }, - }, - load: { - paths: ["/path/to/plugin"], - }, - }, - }; - - const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); - - expect(result.plugins?.load).toBeUndefined(); + expect(result.plugins?.load?.paths).toEqual(expectedPaths); expect(actions.loadPath).toBe(true); }); it("clears memory slot when uninstalling active memory plugin", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - "memory-plugin": { enabled: true }, - }, - slots: { - memory: "memory-plugin", - }, + const config = createPluginConfig({ + entries: { + "memory-plugin": { enabled: true }, }, - }; + slots: { + memory: "memory-plugin", + }, + }); const { config: result, actions } = removePluginFromConfig(config, "memory-plugin"); @@ -231,16 +263,12 @@ describe("removePluginFromConfig", () => { }); it("does not modify memory slot when uninstalling non-memory plugin", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - "my-plugin": { enabled: true }, - }, - slots: { - memory: "memory-core", - }, + const config = createPluginConfig({ + entries: createSinglePluginEntries(), + slots: { + memory: "memory-core", }, - }; + }); const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); @@ -264,63 +292,54 @@ describe("removePluginFromConfig", () => { expect(result.plugins).toBeUndefined(); }); - it("handles plugin that only exists in entries", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - "my-plugin": { enabled: true }, - }, - }, - }; - - const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); - - expect(result.plugins?.entries).toBeUndefined(); - expect(actions.entry).toBe(true); - expect(actions.install).toBe(false); - }); - - it("handles plugin that only exists in installs", () => { - const config: OpenClawConfig = { - plugins: { + 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": { source: "npm", spec: "my-plugin@1.0.0" }, + "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?.installs).toBeUndefined(); - expect(actions.install).toBe(true); - expect(actions.entry).toBe(false); + 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: OpenClawConfig = { - plugins: { - entries: { - "my-plugin": { enabled: true }, - }, - }, - }; + const config = createPluginConfig({ + entries: createSinglePluginEntries(), + }); const { config: result } = removePluginFromConfig(config, "my-plugin"); - // After removing the only entry, entries should be undefined expect(result.plugins?.entries).toBeUndefined(); }); it("preserves other config values", () => { - const config: OpenClawConfig = { - plugins: { - enabled: true, - deny: ["denied-plugin"], - entries: { - "my-plugin": { enabled: true }, - }, - }, - }; + const config = createPluginConfig({ + enabled: true, + deny: ["denied-plugin"], + entries: createSinglePluginEntries(), + }); const { config: result } = removePluginFromConfig(config, "my-plugin"); @@ -329,62 +348,59 @@ describe("removePluginFromConfig", () => { }); it("removes channel config for installed extension plugin", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - timbot: { enabled: true }, - }, - installs: { - timbot: { source: "npm", spec: "timbot@1.0.0" }, - }, + const config = createPluginConfig({ + entries: { + timbot: { enabled: true }, + }, + installs: { + timbot: createNpmInstallRecord("timbot"), }, channels: { timbot: { sdkAppId: "123", secretKey: "abc" }, telegram: { enabled: true }, }, - }; + }); const { config: result, actions } = removePluginFromConfig(config, "timbot"); - expect((result.channels as Record)?.timbot).toBeUndefined(); - expect((result.channels as Record)?.telegram).toEqual({ enabled: true }); + expectRemainingChannels(result.channels, { + telegram: { enabled: true }, + }); expect(actions.channelConfig).toBe(true); }); it("does not remove channel config for built-in channel without install record", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - telegram: { enabled: true }, - }, + const config = createPluginConfig({ + entries: { + telegram: { enabled: true }, }, channels: { telegram: { enabled: true }, discord: { enabled: true }, }, - }; + }); const { config: result, actions } = removePluginFromConfig(config, "telegram"); - // Built-in channels have no install record, so channel config must be preserved. - expect((result.channels as Record)?.telegram).toEqual({ enabled: true }); + expectRemainingChannels(result.channels, { + telegram: { enabled: true }, + discord: { enabled: true }, + }); expect(actions.channelConfig).toBe(false); }); it("cleans up channels object when removing the only channel config", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - timbot: { enabled: true }, - }, - installs: { - timbot: { source: "npm", spec: "timbot@1.0.0" }, - }, + const config = createPluginConfig({ + entries: { + timbot: { enabled: true }, + }, + installs: { + timbot: createNpmInstallRecord("timbot"), }, channels: { timbot: { sdkAppId: "123" }, }, - }; + }); const { config: result, actions } = removePluginFromConfig(config, "timbot"); @@ -393,16 +409,12 @@ describe("removePluginFromConfig", () => { }); it("does not set channelConfig action when no channel config exists", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - "my-plugin": { enabled: true }, - }, - installs: { - "my-plugin": { source: "npm", spec: "my-plugin@1.0.0" }, - }, + const config = createPluginConfig({ + entries: createSinglePluginEntries(), + installs: { + "my-plugin": createNpmInstallRecord(), }, - }; + }); const { actions } = removePluginFromConfig(config, "my-plugin"); @@ -410,126 +422,118 @@ describe("removePluginFromConfig", () => { }); it("does not remove channel config when plugin has no install record", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - discord: { enabled: true }, - }, + const config = createPluginConfig({ + entries: { + discord: { enabled: true }, }, channels: { discord: { enabled: true, token: "abc" }, }, - }; + }); const { config: result, actions } = removePluginFromConfig(config, "discord"); - // No install record means this is a built-in channel; config must stay. - expect((result.channels as Record)?.discord).toEqual({ - enabled: true, - token: "abc", + expectRemainingChannels(result.channels, { + discord: { + enabled: true, + token: "abc", + }, }); expect(actions.channelConfig).toBe(false); }); it("removes channel config using explicit channelIds when pluginId differs", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - "timbot-plugin": { enabled: true }, - }, - installs: { - "timbot-plugin": { source: "npm", spec: "timbot-plugin@1.0.0" }, - }, + const config = createPluginConfig({ + entries: { + "timbot-plugin": { enabled: true }, + }, + installs: { + "timbot-plugin": createNpmInstallRecord("timbot-plugin"), }, channels: { timbot: { sdkAppId: "123" }, "timbot-v2": { sdkAppId: "456" }, telegram: { enabled: true }, }, - }; + }); const { config: result, actions } = removePluginFromConfig(config, "timbot-plugin", { channelIds: ["timbot", "timbot-v2"], }); - const ch = result.channels as Record | undefined; - expect(ch?.timbot).toBeUndefined(); - expect(ch?.["timbot-v2"]).toBeUndefined(); - expect(ch?.telegram).toEqual({ enabled: true }); + expectRemainingChannels(result.channels, { + telegram: { enabled: true }, + }); expect(actions.channelConfig).toBe(true); }); it("preserves shared channel keys (defaults, modelByChannel)", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - timbot: { enabled: true }, - }, - installs: { - timbot: { source: "npm", spec: "timbot@1.0.0" }, - }, + const config = createPluginConfig({ + entries: { + timbot: { enabled: true }, + }, + installs: { + timbot: createNpmInstallRecord("timbot"), }, channels: { defaults: { groupPolicy: "opt-in" }, modelByChannel: { timbot: "gpt-3.5" } as Record, timbot: { sdkAppId: "123" }, } as unknown as OpenClawConfig["channels"], - }; + }); const { config: result, actions } = removePluginFromConfig(config, "timbot"); - const ch = result.channels as Record | undefined; - expect(ch?.timbot).toBeUndefined(); - expect(ch?.defaults).toEqual({ groupPolicy: "opt-in" }); - expect(ch?.modelByChannel).toEqual({ timbot: "gpt-3.5" }); + expectRemainingChannels(result.channels, { + defaults: { groupPolicy: "opt-in" }, + modelByChannel: { timbot: "gpt-3.5" }, + }); expect(actions.channelConfig).toBe(true); }); it("does not remove shared keys even when passed as channelIds", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - "bad-plugin": { enabled: true }, - }, - installs: { - "bad-plugin": { source: "npm", spec: "bad-plugin@1.0.0" }, - }, + const config = createPluginConfig({ + entries: { + "bad-plugin": { enabled: true }, + }, + installs: { + "bad-plugin": createNpmInstallRecord("bad-plugin"), }, channels: { defaults: { groupPolicy: "opt-in" }, } as unknown as OpenClawConfig["channels"], - }; + }); const { config: result, actions } = removePluginFromConfig(config, "bad-plugin", { channelIds: ["defaults"], }); - const ch = result.channels as Record | undefined; - expect(ch?.defaults).toEqual({ groupPolicy: "opt-in" }); + expectRemainingChannels(result.channels, { + defaults: { groupPolicy: "opt-in" }, + }); expect(actions.channelConfig).toBe(false); }); it("skips channel cleanup when channelIds is empty array (non-channel plugin)", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - telegram: { enabled: true }, - }, - installs: { - telegram: { source: "npm", spec: "telegram@1.0.0" }, - }, + const config = createPluginConfig({ + entries: { + telegram: { enabled: true }, + }, + installs: { + telegram: createNpmInstallRecord("telegram"), }, channels: { telegram: { enabled: true }, }, - }; + }); const { config: result, actions } = removePluginFromConfig(config, "telegram", { channelIds: [], }); - // Empty channelIds means the plugin declares no channels, so channel config must stay. - expect((result.channels as Record)?.telegram).toEqual({ enabled: true }); + expectRemainingChannels(result.channels, { + telegram: { enabled: true }, + }); expect(actions.channelConfig).toBe(false); }); }); @@ -546,7 +550,7 @@ describe("uninstallPlugin", () => { }); it("returns error when plugin not found", async () => { - const config: OpenClawConfig = {}; + const config = createPluginConfig({}); const result = await uninstallPlugin({ config, @@ -560,16 +564,12 @@ describe("uninstallPlugin", () => { }); it("removes config entries", async () => { - const config: OpenClawConfig = { - plugins: { - entries: { - "my-plugin": { enabled: true }, - }, - installs: { - "my-plugin": { source: "npm", spec: "my-plugin@1.0.0" }, - }, + const config = createPluginConfig({ + entries: createSinglePluginEntries(), + installs: { + "my-plugin": createNpmInstallRecord(), }, - }; + }); const result = await uninstallPlugin({ config, @@ -603,21 +603,13 @@ describe("uninstallPlugin", () => { it("preserves directory for linked plugins", async () => { const pluginDir = await createPluginDirFixture(tempDir); - const config: OpenClawConfig = { - plugins: { - entries: createSinglePluginEntries(), - installs: { - "my-plugin": { - source: "path", - sourcePath: pluginDir, - installPath: pluginDir, - }, - }, - load: { - paths: [pluginDir], - }, + const config = createPluginConfig({ + entries: createSinglePluginEntries(), + installs: { + "my-plugin": createPathInstallRecord(pluginDir), }, - }; + loadPaths: [pluginDir], + }); const result = await uninstallPlugin({ config,