/** * 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(), 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(), }, }, })); import { api } from '../../../src/renderer/api'; import type { EnrichedPlugin, McpCatalogItem } 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, }); describe('extensionsSlice', () => { let store: TestStore; beforeEach(() => { store = createTestStore(); vi.clearAllMocks(); }); afterEach(() => { 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); }); }); 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(); }); }); 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); }); }); 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 }); }); describe('installPlugin', () => { it('sets progress to pending then success', async () => { 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['test@m']).toBe('pending'); await promise; expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); }); it('sets progress to error on failure', async () => { (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'error', error: 'Not found', }); await store.getState().installPlugin({ pluginId: 'fail@m', scope: 'user' }); expect(store.getState().pluginInstallProgress['fail@m']).toBe('error'); }); }); describe('uninstallPlugin', () => { it('sets progress to pending then success', async () => { 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['test@m']).toBe('pending'); await promise; expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); }); }); describe('installMcpServer', () => { it('sets progress to pending then success', async () => { (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['test-id']).toBe('pending'); await promise; expect(store.getState().mcpInstallProgress['test-id']).toBe('success'); }); }); describe('uninstallMcpServer', () => { it('sets progress to pending then success', async () => { (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['test-id']).toBe('pending'); await promise; expect(store.getState().mcpInstallProgress['test-id']).toBe('success'); }); }); });