/** * 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, 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 = () => ({ 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: [], }); describe('extensionsSlice', () => { let store: TestStore; beforeEach(() => { store = createTestStore(); vi.clearAllMocks(); }); 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: { 'project-a@m': 'error', }, installErrors: { 'project-a@m': '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['project-a@m']).toBeUndefined(); expect(store.getState().installErrors['project-a@m']).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: { 'project-a@m': 'error', }, installErrors: { 'project-a@m': '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['project-a@m']).toBeUndefined(); expect(store.getState().installErrors['project-a@m']).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); }); }); 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['test@m']).toBe('pending'); await promise; expect(store.getState().pluginInstallProgress['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['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('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['project@m']).toBe('error'); expect(store.getState().installErrors['project@m']).toContain('active project'); }); 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['local@m']).toBe('error'); expect(store.getState().installErrors['local@m']).toContain('active project'); }); 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['test@m']).toBe('success'); await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); await vi.advanceTimersByTimeAsync(2_000); expect(store.getState().pluginInstallProgress['test@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'); }); 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('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['project@m']).toBe('error'); expect(store.getState().installErrors['project@m']).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['local@m']).toBe('error'); expect(store.getState().installErrors['local@m']).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['test@m']).toBe('success'); await store.getState().fetchPluginCatalog('/tmp/project-b'); expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); await vi.advanceTimersByTimeAsync(2_000); expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); }); }); 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'); }); }); 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'); }); }); });