diff --git a/src/features/claude-code-plugin-loader/loader.test.ts b/src/features/claude-code-plugin-loader/loader.test.ts index d1e8dd878..094c7d46c 100644 --- a/src/features/claude-code-plugin-loader/loader.test.ts +++ b/src/features/claude-code-plugin-loader/loader.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test" -import type { PluginComponentsResult } from "./loader" +import { + clearPluginComponentsCache, + loadAllPluginComponents, + loadAllPluginComponentsWithDeps, + type PluginComponentsResult, +} from "./loader" function createPluginComponentsResult(): PluginComponentsResult { return { @@ -13,10 +18,6 @@ function createPluginComponentsResult(): PluginComponentsResult { } } -async function importFreshLoaderModule() { - return import(`./loader?plugin-loader-cache-test=${Date.now()}-${Math.random()}`) -} - describe("loadAllPluginComponents", () => { const originalEnv = { ...process.env } @@ -138,20 +139,20 @@ describe("loadAllPluginComponents", () => { const loadPluginMcpServers = mock(async () => result.mcpServers) const loadPluginHooksConfigs = mock(() => result.hooksConfigs) - mock.module("./discovery", () => ({ discoverInstalledPlugins })) - mock.module("./command-loader", () => ({ loadPluginCommands })) - mock.module("./skill-loader", () => ({ loadPluginSkillsAsCommands })) - mock.module("./agent-loader", () => ({ loadPluginAgents })) - mock.module("./mcp-server-loader", () => ({ loadPluginMcpServers })) - mock.module("./hook-loader", () => ({ loadPluginHooksConfigs })) - - const { clearPluginComponentsCache, loadAllPluginComponents } = await importFreshLoaderModule() clearPluginComponentsCache() const enabledPluginsOverride = { "demo@test": true } // when - const firstResult = await loadAllPluginComponents({ enabledPluginsOverride }) - const secondResult = await loadAllPluginComponents({ enabledPluginsOverride }) + const deps = { + discoverInstalledPlugins, + loadPluginCommands, + loadPluginSkillsAsCommands, + loadPluginAgents, + loadPluginMcpServers, + loadPluginHooksConfigs, + } + const firstResult = await loadAllPluginComponentsWithDeps({ enabledPluginsOverride }, deps) + const secondResult = await loadAllPluginComponentsWithDeps({ enabledPluginsOverride }, deps) // then expect(firstResult).toEqual(result) @@ -176,19 +177,19 @@ describe("loadAllPluginComponents", () => { const loadPluginMcpServers = mock(async () => result.mcpServers) const loadPluginHooksConfigs = mock(() => result.hooksConfigs) - mock.module("./discovery", () => ({ discoverInstalledPlugins })) - mock.module("./command-loader", () => ({ loadPluginCommands })) - mock.module("./skill-loader", () => ({ loadPluginSkillsAsCommands })) - mock.module("./agent-loader", () => ({ loadPluginAgents })) - mock.module("./mcp-server-loader", () => ({ loadPluginMcpServers })) - mock.module("./hook-loader", () => ({ loadPluginHooksConfigs })) - - const { clearPluginComponentsCache, loadAllPluginComponents } = await importFreshLoaderModule() clearPluginComponentsCache() // when - await loadAllPluginComponents({ enabledPluginsOverride: { "demo@test": true } }) - await loadAllPluginComponents({ enabledPluginsOverride: { "demo@test": false } }) + const deps = { + discoverInstalledPlugins, + loadPluginCommands, + loadPluginSkillsAsCommands, + loadPluginAgents, + loadPluginMcpServers, + loadPluginHooksConfigs, + } + await loadAllPluginComponentsWithDeps({ enabledPluginsOverride: { "demo@test": true } }, deps) + await loadAllPluginComponentsWithDeps({ enabledPluginsOverride: { "demo@test": false } }, deps) // then expect(discoverInstalledPlugins).toHaveBeenCalledTimes(2) @@ -211,20 +212,20 @@ describe("loadAllPluginComponents", () => { const loadPluginMcpServers = mock(async () => result.mcpServers) const loadPluginHooksConfigs = mock(() => result.hooksConfigs) - mock.module("./discovery", () => ({ discoverInstalledPlugins })) - mock.module("./command-loader", () => ({ loadPluginCommands })) - mock.module("./skill-loader", () => ({ loadPluginSkillsAsCommands })) - mock.module("./agent-loader", () => ({ loadPluginAgents })) - mock.module("./mcp-server-loader", () => ({ loadPluginMcpServers })) - mock.module("./hook-loader", () => ({ loadPluginHooksConfigs })) - - const { clearPluginComponentsCache, loadAllPluginComponents } = await importFreshLoaderModule() clearPluginComponentsCache() // when - await loadAllPluginComponents() + const deps = { + discoverInstalledPlugins, + loadPluginCommands, + loadPluginSkillsAsCommands, + loadPluginAgents, + loadPluginMcpServers, + loadPluginHooksConfigs, + } + await loadAllPluginComponentsWithDeps(undefined, deps) clearPluginComponentsCache() - await loadAllPluginComponents() + await loadAllPluginComponentsWithDeps(undefined, deps) // then expect(discoverInstalledPlugins).toHaveBeenCalledTimes(2) @@ -247,20 +248,20 @@ describe("loadAllPluginComponents", () => { const loadPluginMcpServers = mock(async () => result.mcpServers) const loadPluginHooksConfigs = mock(() => result.hooksConfigs) - mock.module("./discovery", () => ({ discoverInstalledPlugins })) - mock.module("./command-loader", () => ({ loadPluginCommands })) - mock.module("./skill-loader", () => ({ loadPluginSkillsAsCommands })) - mock.module("./agent-loader", () => ({ loadPluginAgents })) - mock.module("./mcp-server-loader", () => ({ loadPluginMcpServers })) - mock.module("./hook-loader", () => ({ loadPluginHooksConfigs })) - - const { clearPluginComponentsCache, loadAllPluginComponents } = await importFreshLoaderModule() clearPluginComponentsCache() // when - const firstResult = await loadAllPluginComponents() + const deps = { + discoverInstalledPlugins, + loadPluginCommands, + loadPluginSkillsAsCommands, + loadPluginAgents, + loadPluginMcpServers, + loadPluginHooksConfigs, + } + const firstResult = await loadAllPluginComponentsWithDeps(undefined, deps) firstResult.commands["demo:command"]!.description = "mutated" - const secondResult = await loadAllPluginComponents() + const secondResult = await loadAllPluginComponentsWithDeps(undefined, deps) // then expect(secondResult.commands["demo:command"]!.description).toBe("demo") diff --git a/src/features/claude-code-plugin-loader/loader.ts b/src/features/claude-code-plugin-loader/loader.ts index 95a9065ff..bf0783504 100644 --- a/src/features/claude-code-plugin-loader/loader.ts +++ b/src/features/claude-code-plugin-loader/loader.ts @@ -27,8 +27,26 @@ export interface PluginComponentsResult { errors: PluginLoadError[] } +export interface PluginComponentLoadDeps { + discoverInstalledPlugins: typeof discoverInstalledPlugins + loadPluginCommands: typeof loadPluginCommands + loadPluginSkillsAsCommands: typeof loadPluginSkillsAsCommands + loadPluginAgents: typeof loadPluginAgents + loadPluginMcpServers: typeof loadPluginMcpServers + loadPluginHooksConfigs: typeof loadPluginHooksConfigs +} + const cachedPluginComponentsByKey = new Map() +const defaultPluginComponentLoadDeps: PluginComponentLoadDeps = { + discoverInstalledPlugins, + loadPluginCommands, + loadPluginSkillsAsCommands, + loadPluginAgents, + loadPluginMcpServers, + loadPluginHooksConfigs, +} + function clonePluginComponentsResult( result: PluginComponentsResult, ): PluginComponentsResult { @@ -54,7 +72,10 @@ export function clearPluginComponentsCache(): void { cachedPluginComponentsByKey.clear() } -export async function loadAllPluginComponents(options?: PluginLoaderOptions): Promise { +async function loadAllPluginComponentsInternal( + options?: PluginLoaderOptions, + deps: PluginComponentLoadDeps = defaultPluginComponentLoadDeps, +): Promise { if (isClaudeCodePluginsDisabled()) { log("Claude Code plugin loading disabled via OPENCODE_DISABLE_CLAUDE_CODE env var") return { @@ -74,14 +95,14 @@ export async function loadAllPluginComponents(options?: PluginLoaderOptions): Pr return clonePluginComponentsResult(cachedPluginComponents) } - const { plugins, errors } = discoverInstalledPlugins(options) + const { plugins, errors } = deps.discoverInstalledPlugins(options) const [commands, skills, agents, mcpServers, hooksConfigs] = await Promise.all([ - Promise.resolve(loadPluginCommands(plugins)), - Promise.resolve(loadPluginSkillsAsCommands(plugins)), - Promise.resolve(loadPluginAgents(plugins)), - loadPluginMcpServers(plugins), - Promise.resolve(loadPluginHooksConfigs(plugins)), + Promise.resolve(deps.loadPluginCommands(plugins)), + Promise.resolve(deps.loadPluginSkillsAsCommands(plugins)), + Promise.resolve(deps.loadPluginAgents(plugins)), + deps.loadPluginMcpServers(plugins), + Promise.resolve(deps.loadPluginHooksConfigs(plugins)), ]) log(`Loaded ${plugins.length} plugins with ${Object.keys(commands).length} commands, ${Object.keys(skills).length} skills, ${Object.keys(agents).length} agents, ${Object.keys(mcpServers).length} MCP servers`) @@ -100,3 +121,14 @@ export async function loadAllPluginComponents(options?: PluginLoaderOptions): Pr return clonePluginComponentsResult(result) } + +export async function loadAllPluginComponents(options?: PluginLoaderOptions): Promise { + return loadAllPluginComponentsInternal(options) +} + +export async function loadAllPluginComponentsWithDeps( + options: PluginLoaderOptions | undefined, + deps: PluginComponentLoadDeps, +): Promise { + return loadAllPluginComponentsInternal(options, deps) +}