/** * Tests for extensionsSlice — global catalog caches. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTestStore, type TestStore } from './storeTestUtils'; // Mock the renderer api module vi.mock('../../../src/renderer/api', () => ({ api: { plugins: { getAll: vi.fn(), getReadme: vi.fn(), install: vi.fn(), uninstall: vi.fn(), }, mcpRegistry: { search: vi.fn(), browse: vi.fn(), getById: vi.fn(), getInstalled: vi.fn(), diagnose: vi.fn(), install: vi.fn(), installCustom: vi.fn(), uninstall: vi.fn(), }, skills: { list: vi.fn(), getDetail: vi.fn(), previewUpsert: vi.fn(), applyUpsert: vi.fn(), previewImport: vi.fn(), applyImport: vi.fn(), deleteSkill: vi.fn(), startWatching: vi.fn(), stopWatching: vi.fn(), onChanged: vi.fn(), }, apiKeys: { list: vi.fn(), save: vi.fn(), delete: vi.fn(), lookup: vi.fn(), getStorageStatus: vi.fn(), }, cliInstaller: { getStatus: vi.fn(), getProviderStatus: vi.fn(), verifyProviderModels: vi.fn(), invalidateStatus: vi.fn(), onProgress: vi.fn(), }, }, })); import { api } from '../../../src/renderer/api'; import type { AppConfig, CliInstallationStatus } from '../../../src/shared/types'; import { getMcpDiagnosticKey, getMcpProjectStateKey, getMcpOperationKey, getPluginOperationKey, } from '../../../src/shared/utils/extensionNormalizers'; import { createDefaultCliExtensionCapabilities } from '../../../src/shared/utils/providerExtensionCapabilities'; import type { EnrichedPlugin, McpCatalogItem, SkillCatalogItem, SkillDetail, } from '../../../src/shared/types/extensions'; const makePlugin = (overrides: Partial): EnrichedPlugin => ({ pluginId: 'test@marketplace', marketplaceId: 'test@marketplace', qualifiedName: 'test@marketplace', name: 'Test Plugin', source: 'official', description: 'A test plugin', category: 'testing', hasLspServers: false, hasMcpServers: false, hasAgents: false, hasCommands: false, hasHooks: false, isExternal: false, installCount: 100, isInstalled: false, installations: [], ...overrides, }); const makeMcpServer = (overrides: Partial): McpCatalogItem => ({ id: 'test-server', name: 'Test Server', description: 'A test MCP server', source: 'official', installSpec: null, envVars: [], tools: [], requiresAuth: false, ...overrides, }); const makeSkill = (overrides: Partial): SkillCatalogItem => ({ id: '/tmp/skills/demo', sourceType: 'filesystem', name: 'Demo Skill', description: 'Helps with demo work', folderName: 'demo', scope: 'user', rootKind: 'claude', projectRoot: null, discoveryRoot: '/tmp/skills', skillDir: '/tmp/skills/demo', skillFile: '/tmp/skills/demo/SKILL.md', metadata: {}, invocationMode: 'auto', flags: { hasScripts: false, hasReferences: false, hasAssets: false, }, isValid: true, issues: [], modifiedAt: 1, ...overrides, }); const makeSkillDetail = (overrides: Partial = {}): SkillDetail => ({ item: makeSkill({ id: '/tmp/skills/demo', skillDir: '/tmp/skills/demo' }), body: 'body', rawContent: '# Demo', rawFrontmatter: null, referencesFiles: [], scriptFiles: [], assetFiles: [], ...overrides, }); const makeReadyCliStatus = (): CliInstallationStatus => ({ flavor: 'claude' as const, displayName: 'Claude', supportsSelfUpdate: true, showVersionDetails: true, showBinaryPath: true, installed: true, installedVersion: '1.0.0', binaryPath: '/usr/local/bin/claude', latestVersion: '1.0.0', updateAvailable: false, authLoggedIn: true, authStatusChecking: false, authMethod: 'oauth_token' as const, providers: [], }); const makeLimitedMultimodelCliStatus = ( section: 'plugins' | 'mcp', reason: string ): CliInstallationStatus => ({ flavor: 'agent_teams_orchestrator' as const, displayName: 'Claude Multimodel', supportsSelfUpdate: false, showVersionDetails: true, showBinaryPath: true, installed: true, installedVersion: '1.0.0', binaryPath: '/usr/local/bin/claude-multimodel', latestVersion: '1.0.0', updateAvailable: false, authLoggedIn: true, authStatusChecking: false, authMethod: null, providers: [ { providerId: 'anthropic' as const, displayName: 'Anthropic', supported: true, authenticated: true, authMethod: 'oauth_token', verificationState: 'verified' as const, models: [], canLoginFromUi: true, capabilities: { teamLaunch: true, oneShot: true, extensions: createDefaultCliExtensionCapabilities({ plugins: { status: section === 'plugins' ? 'unsupported' : 'supported', ownership: 'shared', reason: section === 'plugins' ? reason : null, }, mcp: { status: section === 'mcp' ? 'read-only' : 'supported', ownership: 'shared', reason: section === 'mcp' ? reason : null, }, }), }, statusMessage: null, connection: null, backend: null, }, ], }); function makeAppConfig(multimodelEnabled: boolean): AppConfig { return { notifications: { enabled: true, soundEnabled: false, ignoredRegex: [], ignoredRepositories: [], snoozedUntil: null, snoozeMinutes: 60, includeSubagentErrors: true, notifyOnLeadInbox: true, notifyOnUserInbox: true, notifyOnClarifications: true, notifyOnStatusChange: true, notifyOnTaskComments: true, notifyOnTaskCreated: true, notifyOnAllTasksCompleted: true, notifyOnCrossTeamMessage: true, notifyOnTeamLaunched: true, notifyOnToolApproval: true, autoResumeOnRateLimit: false, statusChangeOnlySolo: false, statusChangeStatuses: [], triggers: [], }, general: { launchAtLogin: false, showDockIcon: true, theme: 'system', defaultTab: 'dashboard', multimodelEnabled, claudeRootPath: null, agentLanguage: 'system', autoExpandAIGroups: true, useNativeTitleBar: false, telemetryEnabled: false, }, providerConnections: { anthropic: { authMode: 'auto', fastModeDefault: false, }, codex: { preferredAuthMode: 'auto', }, }, runtime: { providerBackends: { gemini: 'auto', codex: 'codex-native', }, }, display: { showTimestamps: true, compactMode: false, syntaxHighlighting: true, }, sessions: { pinnedSessions: {}, hiddenSessions: {}, }, }; } const pluginOperationKey = ( pluginId: string, scope: 'user' | 'project' | 'local' = 'user', projectPath?: string ) => getPluginOperationKey(pluginId, scope, projectPath); const mcpOperationKey = ( registryId: string, scope: 'user' | 'project' | 'local' | 'global' = 'user', projectPath?: string ) => getMcpOperationKey(registryId, scope, projectPath); describe('extensionsSlice', () => { let store: TestStore; beforeEach(() => { store = createTestStore(); vi.clearAllMocks(); (api.cliInstaller!.getStatus as ReturnType).mockResolvedValue(makeReadyCliStatus()); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); describe('fetchPluginCatalog', () => { it('fetches and stores plugins', async () => { const plugins = [makePlugin({ pluginId: 'a@m' }), makePlugin({ pluginId: 'b@m' })]; (api.plugins!.getAll as ReturnType).mockResolvedValue(plugins); await store.getState().fetchPluginCatalog(); expect(store.getState().pluginCatalog).toHaveLength(2); expect(store.getState().pluginCatalogLoading).toBe(false); expect(store.getState().pluginCatalogError).toBeNull(); }); it('sets error on failure', async () => { (api.plugins!.getAll as ReturnType).mockRejectedValue(new Error('boom')); await store.getState().fetchPluginCatalog(); expect(store.getState().pluginCatalog).toEqual([]); expect(store.getState().pluginCatalogError).toBe('boom'); expect(store.getState().pluginCatalogLoading).toBe(false); }); it('clears stale catalog when a different project fetch fails', async () => { store.setState({ pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], pluginCatalogProjectPath: '/tmp/project-a', }); (api.plugins!.getAll as ReturnType).mockRejectedValue(new Error('boom')); await store.getState().fetchPluginCatalog('/tmp/project-b'); expect(store.getState().pluginCatalog).toEqual([]); expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); expect(store.getState().pluginCatalogError).toBe('boom'); }); it('clears plugin operation state when switching project context', async () => { store.setState({ pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], pluginCatalogProjectPath: '/tmp/project-a', pluginInstallProgress: { [pluginOperationKey('project-a@m', 'project')]: 'error', }, installErrors: { [pluginOperationKey('project-a@m', 'project')]: 'Install failed', 'mcp-server': 'Keep me', }, }); (api.plugins!.getAll as ReturnType).mockResolvedValue([ makePlugin({ pluginId: 'project-b@m' }), ]); await store.getState().fetchPluginCatalog('/tmp/project-b'); expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); expect( store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')], ).toBeUndefined(); expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined(); expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); }); it('dedups concurrent requests for the same project key', async () => { let resolveFetch!: (plugins: EnrichedPlugin[]) => void; const inFlight = new Promise((resolve) => { resolveFetch = resolve; }); (api.plugins!.getAll as ReturnType).mockImplementation(() => inFlight); const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); expect(api.plugins!.getAll).toHaveBeenCalledTimes(1); resolveFetch([makePlugin({ pluginId: 'same@m' })]); await Promise.all([firstFetch, secondFetch]); expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-a'); expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual(['same@m']); }); it('keeps the newest project catalog when project changes mid-flight', async () => { let resolveProjectA!: (plugins: EnrichedPlugin[]) => void; let resolveProjectB!: (plugins: EnrichedPlugin[]) => void; const projectAFetch = new Promise((resolve) => { resolveProjectA = resolve; }); const projectBFetch = new Promise((resolve) => { resolveProjectB = resolve; }); (api.plugins!.getAll as ReturnType) .mockImplementationOnce(() => projectAFetch) .mockImplementationOnce(() => projectBFetch); const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a'); const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-b'); expect(api.plugins!.getAll).toHaveBeenCalledTimes(2); resolveProjectB([makePlugin({ pluginId: 'project-b@m' })]); await secondFetch; expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([ 'project-b@m', ]); resolveProjectA([makePlugin({ pluginId: 'project-a@m' })]); await firstFetch; expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([ 'project-b@m', ]); }); it('clears plugin operation state when a different project fetch fails', async () => { store.setState({ pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], pluginCatalogProjectPath: '/tmp/project-a', pluginInstallProgress: { [pluginOperationKey('project-a@m', 'project')]: 'error', }, installErrors: { [pluginOperationKey('project-a@m', 'project')]: 'Install failed', 'mcp-server': 'Keep me', }, }); (api.plugins!.getAll as ReturnType).mockRejectedValue(new Error('boom')); await store.getState().fetchPluginCatalog('/tmp/project-b'); expect(store.getState().pluginCatalog).toEqual([]); expect( store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')], ).toBeUndefined(); expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined(); expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); }); }); describe('fetchPluginReadme', () => { it('fetches and caches README', async () => { (api.plugins!.getReadme as ReturnType).mockResolvedValue('# Hello'); store.getState().fetchPluginReadme('test@m'); // Wait for the async to resolve await vi.waitFor(() => { expect(store.getState().pluginReadmes['test@m']).toBe('# Hello'); }); expect(store.getState().pluginReadmeLoading['test@m']).toBe(false); }); it('does not re-fetch cached README', () => { store.setState({ pluginReadmes: { 'test@m': 'cached' } }); store.getState().fetchPluginReadme('test@m'); expect(api.plugins!.getReadme).not.toHaveBeenCalled(); }); it('retries README fetch when the cached value is null', () => { store.setState({ pluginReadmes: { 'test@m': null } }); (api.plugins!.getReadme as ReturnType).mockResolvedValue(null); store.getState().fetchPluginReadme('test@m'); expect(api.plugins!.getReadme).toHaveBeenCalledWith('test@m'); }); }); describe('mcpBrowse', () => { it('fetches initial browse results', async () => { const servers = [makeMcpServer({ id: 's1' }), makeMcpServer({ id: 's2' })]; (api.mcpRegistry!.browse as ReturnType).mockResolvedValue({ servers, nextCursor: 'cursor-abc', }); await store.getState().mcpBrowse(); expect(store.getState().mcpBrowseCatalog).toHaveLength(2); expect(store.getState().mcpBrowseNextCursor).toBe('cursor-abc'); expect(store.getState().mcpBrowseLoading).toBe(false); }); it('appends on cursor-based pagination', async () => { store.setState({ mcpBrowseCatalog: [makeMcpServer({ id: 'existing' })] }); const newServers = [makeMcpServer({ id: 'new1' })]; (api.mcpRegistry!.browse as ReturnType).mockResolvedValue({ servers: newServers, nextCursor: undefined, }); await store.getState().mcpBrowse('cursor-1'); expect(store.getState().mcpBrowseCatalog).toHaveLength(2); expect(store.getState().mcpBrowseNextCursor).toBeUndefined(); }); it('sets error on failure', async () => { (api.mcpRegistry!.browse as ReturnType).mockRejectedValue(new Error('fail')); await store.getState().mcpBrowse(); expect(store.getState().mcpBrowseError).toBe('fail'); expect(store.getState().mcpBrowseLoading).toBe(false); }); }); describe('mcpFetchInstalled', () => { it('fetches installed MCP servers', async () => { const installed = [{ name: 'server-a', scope: 'user' as const }]; (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue(installed); await store.getState().mcpFetchInstalled(); expect(store.getState().mcpInstalledServers).toEqual(installed); }); it('stores installed MCP servers independently per project context', async () => { (api.mcpRegistry!.getInstalled as ReturnType) .mockResolvedValueOnce([{ name: 'global-server', scope: 'global' as const }]) .mockResolvedValueOnce([{ name: 'project-server', scope: 'project' as const }]); await store.getState().mcpFetchInstalled(); await store.getState().mcpFetchInstalled('/tmp/project-a'); expect(store.getState().mcpInstalledServersByProjectPath).toMatchObject({ [getMcpProjectStateKey()]: [{ name: 'global-server', scope: 'global' }], [getMcpProjectStateKey('/tmp/project-a')]: [{ name: 'project-server', scope: 'project' }], }); }); it('clears stale project- and local-scoped MCP operation state when project changes', async () => { store.setState({ mcpInstalledProjectPath: '/tmp/project-a', mcpInstallProgress: { [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'error', [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'success', [mcpOperationKey('user-server', 'user')]: 'pending', }, installErrors: { [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'Project failed', [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'Local failed', [mcpOperationKey('user-server', 'user')]: 'Keep user state', 'plugin:test@marketplace:user': 'Keep plugin state', 'mcp-custom:custom-server:project:/tmp/project-a': 'Clear custom project state', }, }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); await store.getState().mcpFetchInstalled('/tmp/project-b'); expect(store.getState().mcpInstalledProjectPath).toBe('/tmp/project-b'); expect( store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project', '/tmp/project-a')] ).toBeUndefined(); expect( store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local', '/tmp/project-a')] ).toBeUndefined(); expect(store.getState().mcpInstallProgress[mcpOperationKey('user-server', 'user')]).toBe( 'pending', ); expect( store.getState().installErrors[mcpOperationKey('project-server', 'project', '/tmp/project-a')] ).toBeUndefined(); expect( store.getState().installErrors[mcpOperationKey('local-server', 'local', '/tmp/project-a')] ).toBeUndefined(); expect(store.getState().installErrors[mcpOperationKey('user-server', 'user')]).toBe( 'Keep user state', ); expect(store.getState().installErrors['mcp-custom:custom-server:project']).toBeUndefined(); expect(store.getState().installErrors['plugin:test@marketplace:user']).toBe( 'Keep plugin state', ); }); }); describe('openExtensionsTab', () => { it('opens a new extensions tab', () => { // Ensure we have a focused pane expect(store.getState().paneLayout.panes.length).toBeGreaterThan(0); store.getState().openExtensionsTab(); const tabs = store.getState().paneLayout.panes.flatMap((p) => p.tabs); const extTab = tabs.find((t) => t.type === 'extensions'); expect(extTab).toBeDefined(); expect(extTab!.label).toBe('Extensions'); }); it('seeds projectId from activeProjectId when selectedProjectId is null', () => { store.setState({ selectedProjectId: null, activeProjectId: 'project-active' }); store.getState().openExtensionsTab(); const tabs = store.getState().paneLayout.panes.flatMap((p) => p.tabs); const extTab = tabs.find((t) => t.type === 'extensions'); expect(extTab?.projectId).toBe('project-active'); }); it('activates existing extensions tab instead of creating new', () => { store.getState().openExtensionsTab(); const tabs1 = store.getState().paneLayout.panes.flatMap((p) => p.tabs); const count1 = tabs1.filter((t) => t.type === 'extensions').length; store.getState().openExtensionsTab(); const tabs2 = store.getState().paneLayout.panes.flatMap((p) => p.tabs); const count2 = tabs2.filter((t) => t.type === 'extensions').length; expect(count1).toBe(1); expect(count2).toBe(1); // no duplicate }); it('updates projectId on existing tab when selected project changes', () => { // Open Extensions with project-A store.setState({ selectedProjectId: 'project-A', activeProjectId: null }); store.getState().openExtensionsTab(); const tabsBefore = store.getState().paneLayout.panes.flatMap((p) => p.tabs); const extTabBefore = tabsBefore.find((t) => t.type === 'extensions'); expect(extTabBefore?.projectId).toBe('project-A'); // Switch to project-B and reopen Extensions store.setState({ selectedProjectId: 'project-B' }); store.getState().openExtensionsTab(); const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs); const extTabAfter = tabsAfter.find((t) => t.type === 'extensions'); expect(extTabAfter?.projectId).toBe('project-B'); // Still only one extensions tab expect(tabsAfter.filter((t) => t.type === 'extensions')).toHaveLength(1); }); it('does not update projectId when it already matches', () => { store.setState({ selectedProjectId: 'project-A', activeProjectId: null }); store.getState().openExtensionsTab(); const layoutBefore = store.getState().paneLayout; // Reopen with same project — layout should be referentially stable (no set() call) store.getState().openExtensionsTab(); const tabsBefore = layoutBefore.panes.flatMap((p) => p.tabs); const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs); const extBefore = tabsBefore.find((t) => t.type === 'extensions'); const extAfter = tabsAfter.find((t) => t.type === 'extensions'); expect(extAfter?.projectId).toBe(extBefore?.projectId); }); }); describe('installPlugin', () => { it('sets progress to pending then success', async () => { store.setState({ cliStatus: makeReadyCliStatus() }); const plugins = [makePlugin({ pluginId: 'a@m' })]; (api.plugins!.getAll as ReturnType).mockResolvedValue(plugins); (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); const promise = store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); // During execution, should be pending expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending'); await promise; expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); }); it('does not block plugin install when a usable runtime status already exists during background refresh', async () => { store.setState({ cliStatus: makeReadyCliStatus(), cliStatusLoading: true }); (api.plugins!.getAll as ReturnType).mockResolvedValue([]); (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); expect(api.plugins!.install).toHaveBeenCalledWith({ pluginId: 'test@m', scope: 'user' }); expect(api.cliInstaller!.getStatus).not.toHaveBeenCalled(); expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); }); it('sets progress to error on failure', async () => { store.setState({ cliStatus: makeReadyCliStatus() }); (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'error', error: 'Not found', }); await store.getState().installPlugin({ pluginId: 'fail@m', scope: 'user' }); expect(store.getState().pluginInstallProgress[pluginOperationKey('fail@m')]).toBe('error'); }); it('fills missing projectPath from the active Extensions project context', async () => { store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: '/tmp/project-a', }); (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' }); expect(api.plugins!.install).toHaveBeenCalledWith({ pluginId: 'project@m', scope: 'project', projectPath: '/tmp/project-a', }); }); it('keys project-scope install state by project path and refreshes that same project context', async () => { store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: '/tmp/project-b', }); (api.plugins!.getAll as ReturnType).mockResolvedValue([]); (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project', projectPath: '/tmp/project-a', }); expect( store.getState().pluginInstallProgress[ pluginOperationKey('project@m', 'project', '/tmp/project-a') ] ).toBe('success'); expect( store.getState().pluginInstallProgress[ pluginOperationKey('project@m', 'project', '/tmp/project-b') ] ).toBeUndefined(); expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true); }); it('fails fast for project scope when there is no active project path', async () => { store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' }); expect(api.plugins!.install).not.toHaveBeenCalled(); expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe( 'error', ); expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain( 'active project', ); }); it('fails fast when multimodel runtime declares plugin installs unsupported', async () => { store.setState({ cliStatus: makeLimitedMultimodelCliStatus('plugins', 'Plugin writes unavailable'), }); await store.getState().installPlugin({ pluginId: 'unsupported@m', scope: 'user' }); expect(api.plugins!.install).not.toHaveBeenCalled(); expect(store.getState().pluginInstallProgress[pluginOperationKey('unsupported@m')]).toBe( 'error', ); expect(store.getState().installErrors[pluginOperationKey('unsupported@m')]).toContain( 'Plugin writes unavailable', ); }); it('fills missing projectPath for local scope from the active Extensions project context', async () => { store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: '/tmp/project-a', }); (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' }); expect(api.plugins!.install).toHaveBeenCalledWith({ pluginId: 'local@m', scope: 'local', projectPath: '/tmp/project-a', }); }); it('fails fast for local scope when there is no active project path', async () => { store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' }); expect(api.plugins!.install).not.toHaveBeenCalled(); expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe( 'error', ); expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain( 'active project', ); }); it('keeps user-scope state isolated from local-scope failures', async () => { store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); await store.getState().installPlugin({ pluginId: 'shared@m', scope: 'local' }); expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'local')]).toBe( 'error', ); expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'user')]).toBeUndefined(); expect(store.getState().installErrors[pluginOperationKey('shared@m', 'user')]).toBeUndefined(); }); it('clears older success reset timers before a new operation on the same plugin', async () => { vi.useFakeTimers(); store.setState({ cliStatus: makeReadyCliStatus() }); (api.plugins!.getAll as ReturnType).mockResolvedValue([]); (api.plugins!.install as ReturnType) .mockResolvedValueOnce({ state: 'success' }) .mockResolvedValueOnce({ state: 'error', error: 'second failure' }); await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error'); await vi.advanceTimersByTimeAsync(2_000); expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error'); }); }); describe('uninstallPlugin', () => { it('sets progress to pending then success', async () => { store.setState({ cliStatus: makeReadyCliStatus() }); const plugins = [makePlugin({ pluginId: 'a@m', isInstalled: false })]; (api.plugins!.getAll as ReturnType).mockResolvedValue(plugins); (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); const promise = store.getState().uninstallPlugin('test@m', 'user'); expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending'); await promise; expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); }); it('fills missing projectPath from the active Extensions project context', async () => { store.setState({ pluginCatalogProjectPath: '/tmp/project-a' }); (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); await store.getState().uninstallPlugin('project@m', 'project'); expect(api.plugins!.uninstall).toHaveBeenCalledWith('project@m', 'project', '/tmp/project-a'); }); it('keys project-scope uninstall state by project path and refreshes that same project context', async () => { store.setState({ pluginCatalogProjectPath: '/tmp/project-b' }); (api.plugins!.getAll as ReturnType).mockResolvedValue([]); (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); await store.getState().uninstallPlugin('project@m', 'project', '/tmp/project-a'); expect( store.getState().pluginInstallProgress[ pluginOperationKey('project@m', 'project', '/tmp/project-a') ] ).toBe('success'); expect( store.getState().pluginInstallProgress[ pluginOperationKey('project@m', 'project', '/tmp/project-b') ] ).toBeUndefined(); expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true); }); it('fails fast for project uninstall when there is no active project path', async () => { store.setState({ pluginCatalogProjectPath: null }); await store.getState().uninstallPlugin('project@m', 'project'); expect(api.plugins!.uninstall).not.toHaveBeenCalled(); expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe( 'error', ); expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain( 'active project', ); }); it('fills missing projectPath for local uninstall from the active Extensions project context', async () => { store.setState({ pluginCatalogProjectPath: '/tmp/project-a' }); (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); await store.getState().uninstallPlugin('local@m', 'local'); expect(api.plugins!.uninstall).toHaveBeenCalledWith('local@m', 'local', '/tmp/project-a'); }); it('fails fast for local uninstall when there is no active project path', async () => { store.setState({ pluginCatalogProjectPath: null }); await store.getState().uninstallPlugin('local@m', 'local'); expect(api.plugins!.uninstall).not.toHaveBeenCalled(); expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe( 'error', ); expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain( 'active project', ); }); it('does not restore idle state after project switch clears a pending success timer', async () => { vi.useFakeTimers(); store.setState({ pluginCatalogProjectPath: '/tmp/project-a', pluginCatalog: [makePlugin({ pluginId: 'test@m' })], }); (api.plugins!.getAll as ReturnType) .mockResolvedValueOnce([makePlugin({ pluginId: 'test@m' })]) .mockResolvedValueOnce([makePlugin({ pluginId: 'other@m' })]); (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); await store.getState().uninstallPlugin('test@m', 'user'); expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); await store.getState().fetchPluginCatalog('/tmp/project-b'); expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined(); await vi.advanceTimersByTimeAsync(2_000); expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined(); }); }); describe('installMcpServer', () => { it('sets progress to pending then success', async () => { store.setState({ cliStatus: makeReadyCliStatus() }); (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); const promise = store.getState().installMcpServer({ registryId: 'test-id', serverName: 'test-server', scope: 'user', envValues: {}, headers: [], }); expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( 'pending', ); await promise; expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( 'success', ); }); it('does not block MCP install when a usable runtime status already exists during background refresh', async () => { store.setState({ cliStatus: makeReadyCliStatus(), cliStatusLoading: true }); (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); await store.getState().installMcpServer({ registryId: 'test-id', serverName: 'test-server', scope: 'user', envValues: {}, headers: [], }); expect(api.mcpRegistry!.install).toHaveBeenCalledWith({ registryId: 'test-id', serverName: 'test-server', scope: 'user', envValues: {}, headers: [], }); expect(api.cliInstaller!.getStatus).not.toHaveBeenCalled(); expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( 'success', ); }); it('does not restore idle state after project switch clears a pending project-scope success timer', async () => { vi.useFakeTimers(); store.setState({ mcpInstalledProjectPath: '/tmp/project-a', }); (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); await store.getState().installMcpServer({ registryId: 'test-id', serverName: 'test-server', scope: 'project', projectPath: '/tmp/project-a', envValues: {}, headers: [], }); expect( store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] ).toBe('success'); await store.getState().mcpFetchInstalled('/tmp/project-b'); expect( store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] ).toBeUndefined(); await vi.advanceTimersByTimeAsync(2_000); expect( store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] ).toBeUndefined(); }); it('fails fast when multimodel runtime exposes MCP as read-only', async () => { store.setState({ cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'), }); await store.getState().installMcpServer({ registryId: 'test-id', serverName: 'test-server', scope: 'global', envValues: {}, headers: [], }); expect(api.mcpRegistry!.install).not.toHaveBeenCalled(); expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'global')]).toBe( 'error', ); expect(store.getState().installErrors[mcpOperationKey('test-id', 'global')]).toContain( 'MCP writes unavailable', ); }); }); describe('installCustomMcpServer', () => { it('rejects and records an error when MCP writes are unavailable', async () => { store.setState({ cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'), }); await expect( store.getState().installCustomMcpServer({ serverName: 'custom-server', scope: 'global', installSpec: { type: 'stdio', npmPackage: '@example/custom-mcp', }, envValues: {}, headers: [], }), ).rejects.toThrow('MCP writes unavailable'); expect(api.mcpRegistry!.installCustom).not.toHaveBeenCalled(); expect(store.getState().mcpInstallProgress['mcp-custom:custom-server:global']).toBe('error'); expect(store.getState().installErrors['mcp-custom:custom-server:global']).toContain( 'MCP writes unavailable', ); }); }); describe('uninstallMcpServer', () => { it('sets progress to pending then success', async () => { store.setState({ cliStatus: makeReadyCliStatus() }); (api.mcpRegistry!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); const promise = store.getState().uninstallMcpServer('test-id', 'test-server', 'user'); expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( 'pending', ); await promise; expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( 'success', ); }); it('fails fast when multimodel runtime exposes MCP as read-only', async () => { store.setState({ cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'), }); await store.getState().uninstallMcpServer('test-id', 'test-server', 'global'); expect(api.mcpRegistry!.uninstall).not.toHaveBeenCalled(); expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'global')]).toBe( 'error', ); expect(store.getState().installErrors[mcpOperationKey('test-id', 'global')]).toContain( 'MCP writes unavailable', ); }); }); describe('provider-aware runtime refresh', () => { it('passes projectPath through MCP diagnostics', async () => { (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); await store.getState().runMcpDiagnostics('/tmp/project-a'); expect(api.mcpRegistry!.diagnose).toHaveBeenCalledWith('/tmp/project-a'); }); it('refreshes CLI status after saving an API key', async () => { (api.apiKeys!.save as ReturnType).mockResolvedValue({ id: 'k1' }); (api.apiKeys!.list as ReturnType).mockResolvedValue([ { id: 'k1', name: 'Codex key', envVarName: 'OPENAI_API_KEY', maskedValue: '***', scope: 'user', createdAt: '2026-04-17T10:00:00.000Z', }, ]); await store.getState().saveApiKey({ name: 'Codex key', envVarName: 'OPENAI_API_KEY', value: 'secret', scope: 'user', }); expect(api.cliInstaller!.getStatus).toHaveBeenCalled(); expect(store.getState().apiKeys).toHaveLength(1); }); it('keeps saved API keys updated when provider status refresh fails', async () => { store.setState({ appConfig: makeAppConfig(false), }); (api.apiKeys!.save as ReturnType).mockResolvedValue({ id: 'k1', name: 'Codex key', envVarName: 'OPENAI_API_KEY', maskedValue: '***', scope: 'user', createdAt: '2026-04-17T10:00:00.000Z', }); (api.apiKeys!.list as ReturnType).mockResolvedValue([ { id: 'k1', name: 'Codex key', envVarName: 'OPENAI_API_KEY', maskedValue: '***', scope: 'user', createdAt: '2026-04-17T10:00:00.000Z', }, ]); (api.cliInstaller!.getStatus as ReturnType).mockRejectedValue( new Error('refresh boom') ); await store.getState().saveApiKey({ name: 'Codex key', envVarName: 'OPENAI_API_KEY', value: 'secret', scope: 'user', }); expect(store.getState().apiKeys).toHaveLength(1); expect(store.getState().apiKeysError).toContain('API key saved, but failed to refresh provider status.'); }); it('keeps local API key state updated when key list refresh fails after save', async () => { (api.apiKeys!.save as ReturnType).mockResolvedValue({ id: 'k1', name: 'Codex key', envVarName: 'OPENAI_API_KEY', maskedValue: '***', scope: 'user', createdAt: '2026-04-17T10:00:00.000Z', }); (api.apiKeys!.list as ReturnType).mockRejectedValue(new Error('list boom')); await store.getState().saveApiKey({ name: 'Codex key', envVarName: 'OPENAI_API_KEY', value: 'secret', scope: 'user', }); expect(store.getState().apiKeys).toEqual([ { id: 'k1', name: 'Codex key', envVarName: 'OPENAI_API_KEY', maskedValue: '***', scope: 'user', createdAt: '2026-04-17T10:00:00.000Z', }, ]); expect(store.getState().apiKeysError).toContain('API key saved, but failed to refresh key list.'); }); it('refreshes CLI status after deleting an API key', async () => { store.setState({ apiKeys: [ { id: 'k1', name: 'Codex key', envVarName: 'OPENAI_API_KEY', maskedValue: '***', scope: 'user', createdAt: '2026-04-17T10:00:00.000Z', }, ], }); (api.apiKeys!.delete as ReturnType).mockResolvedValue(undefined); await store.getState().deleteApiKey('k1'); expect(api.cliInstaller!.getStatus).toHaveBeenCalled(); expect(store.getState().apiKeys).toEqual([]); }); it('keeps local API key state updated when provider status refresh fails after delete', async () => { store.setState({ appConfig: makeAppConfig(false), }); store.setState({ apiKeys: [ { id: 'k1', name: 'Codex key', envVarName: 'OPENAI_API_KEY', maskedValue: '***', scope: 'user', createdAt: '2026-04-17T10:00:00.000Z', }, ], }); (api.apiKeys!.delete as ReturnType).mockResolvedValue(undefined); (api.cliInstaller!.getStatus as ReturnType).mockRejectedValue( new Error('refresh boom') ); await store.getState().deleteApiKey('k1'); expect(store.getState().apiKeys).toEqual([]); expect(store.getState().apiKeysError).toContain( 'API key deleted, but failed to refresh provider status.' ); }); it('keys MCP diagnostics by scope when the same server exists in multiple scopes', async () => { (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([ { name: 'context7', scope: 'global', target: 'npx -y @upstash/context7-mcp', status: 'connected', statusLabel: 'Connected', rawLine: 'context7: npx -y @upstash/context7-mcp - Connected', checkedAt: 1, }, { name: 'context7', scope: 'project', target: 'uvx context7-project', status: 'failed', statusLabel: 'Failed to connect', rawLine: 'context7: uvx context7-project - Failed to connect', checkedAt: 1, }, ]); await store.getState().runMcpDiagnostics('/tmp/project-a'); expect(store.getState().mcpDiagnostics).toMatchObject({ [getMcpDiagnosticKey('context7', 'global')]: expect.objectContaining({ target: 'npx -y @upstash/context7-mcp', }), [getMcpDiagnosticKey('context7', 'project')]: expect.objectContaining({ target: 'uvx context7-project', }), }); }); it('stores MCP diagnostics independently per project context', async () => { (api.mcpRegistry!.diagnose as ReturnType) .mockResolvedValueOnce([ { name: 'global-server', scope: 'global', target: 'npx global-server', status: 'connected', statusLabel: 'Connected', rawLine: 'global-server: npx global-server - Connected', checkedAt: 1, }, ]) .mockResolvedValueOnce([ { name: 'project-server', scope: 'project', target: 'uvx project-server', status: 'failed', statusLabel: 'Failed to connect', rawLine: 'project-server: uvx project-server - Failed to connect', checkedAt: 2, }, ]); await store.getState().runMcpDiagnostics(); await store.getState().runMcpDiagnostics('/tmp/project-a'); expect(store.getState().mcpDiagnosticsByProjectPath).toMatchObject({ [getMcpProjectStateKey()]: { [getMcpDiagnosticKey('global-server', 'global')]: expect.objectContaining({ target: 'npx global-server', }), }, [getMcpProjectStateKey('/tmp/project-a')]: { [getMcpDiagnosticKey('project-server', 'project')]: expect.objectContaining({ target: 'uvx project-server', }), }, }); }); it('refreshes MCP install state using the operation project context instead of the last viewed tab', async () => { store.setState({ mcpInstalledProjectPath: '/tmp/project-b', }); (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); await store.getState().installMcpServer({ registryId: 'test-id', serverName: 'test-server', scope: 'project', projectPath: '/tmp/project-a', envValues: {}, headers: [], }); expect(api.mcpRegistry!.getInstalled).toHaveBeenLastCalledWith('/tmp/project-a'); expect(api.mcpRegistry!.diagnose).toHaveBeenLastCalledWith('/tmp/project-a'); }); }); describe('skills state hardening', () => { it('ignores stale catalog responses for the same project key', async () => { let resolveFirst!: (value: SkillCatalogItem[]) => void; const firstPromise = new Promise((resolve) => { resolveFirst = resolve; }); const secondResult = [ makeSkill({ id: '/tmp/project/.claude/skills/newer', skillDir: '/tmp/project/.claude/skills/newer', skillFile: '/tmp/project/.claude/skills/newer/SKILL.md', scope: 'project', projectRoot: '/tmp/project', discoveryRoot: '/tmp/project/.claude/skills', name: 'Newer Skill', }), ]; (api.skills!.list as ReturnType) .mockImplementationOnce(() => firstPromise) .mockResolvedValueOnce(secondResult); const firstFetch = store.getState().fetchSkillsCatalog('/tmp/project'); const secondFetch = store.getState().fetchSkillsCatalog('/tmp/project'); await secondFetch; resolveFirst([ makeSkill({ id: '/tmp/project/.claude/skills/older', skillDir: '/tmp/project/.claude/skills/older', skillFile: '/tmp/project/.claude/skills/older/SKILL.md', scope: 'project', projectRoot: '/tmp/project', discoveryRoot: '/tmp/project/.claude/skills', name: 'Older Skill', }), ]); await firstFetch; expect(store.getState().skillsProjectCatalogByProjectPath['/tmp/project']).toEqual( secondResult ); }); it('keeps the previous detail cache when a detail fetch fails', async () => { const cachedDetail = makeSkillDetail(); store.setState({ skillsDetailsById: { [cachedDetail.item.id]: cachedDetail }, }); (api.skills!.getDetail as ReturnType).mockRejectedValue(new Error('detail fail')); await expect( store.getState().fetchSkillDetail(cachedDetail.item.id, '/tmp/project') ).rejects.toThrow('detail fail'); expect(store.getState().skillsDetailsById[cachedDetail.item.id]).toEqual(cachedDetail); expect(store.getState().skillsDetailErrorById[cachedDetail.item.id]).toBe('detail fail'); }); }); });