From acabe52ae79090ab987f4d2b359491abd93a8df4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:28:08 +0300 Subject: [PATCH] fix(extensions): stop MCP browse auto-retries after errors --- .../extensions/mcp/McpServersPanel.tsx | 4 +- .../extensions/mcp/McpServersPanel.test.ts | 211 ++++++++++++++++++ 2 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 test/renderer/components/extensions/mcp/McpServersPanel.test.ts diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 60ae4157..1a831f0f 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -107,10 +107,10 @@ export const McpServersPanel = ({ // Load initial browse data useEffect(() => { - if (browseCatalog.length === 0 && !browseLoading) { + if (browseCatalog.length === 0 && !browseLoading && !browseError) { void mcpBrowse(); } - }, [browseCatalog.length, browseLoading, mcpBrowse]); + }, [browseCatalog.length, browseError, browseLoading, mcpBrowse]); useEffect(() => { void runMcpDiagnostics(); diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts new file mode 100644 index 00000000..7c5956f7 --- /dev/null +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -0,0 +1,211 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface StoreState { + mcpBrowseCatalog: Array<{ + id: string; + name: string; + description: string; + source: 'official' | 'glama'; + installSpec: null; + envVars: []; + tools: []; + requiresAuth: boolean; + }>; + mcpBrowseNextCursor?: string; + mcpBrowseLoading: boolean; + mcpBrowseError: string | null; + mcpBrowse: ReturnType; + mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>; + fetchMcpGitHubStars: ReturnType; + mcpDiagnostics: Record; + mcpDiagnosticsLoading: boolean; + mcpDiagnosticsError: string | null; + mcpDiagnosticsLastCheckedAt: number | null; + runMcpDiagnostics: ReturnType; +} + +const storeState = {} as StoreState; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + disabled, + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }>) => + React.createElement( + 'button', + { + type, + disabled, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + SelectTrigger: ({ children }: React.PropsWithChildren) => + React.createElement('button', { type: 'button' }, children), + SelectValue: () => React.createElement('span', null, 'select-value'), + SelectContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + SelectItem: ({ children }: React.PropsWithChildren<{ value: string }>) => + React.createElement('button', { type: 'button' }, children), +})); + +vi.mock('@renderer/components/extensions/common/SearchInput', () => ({ + SearchInput: ({ + value, + onChange, + }: { + value: string; + onChange: (value: string) => void; + }) => + React.createElement('input', { + value, + onChange: (event: React.ChangeEvent) => onChange(event.target.value), + }), +})); + +vi.mock('@renderer/components/extensions/mcp/McpServerCard', () => ({ + McpServerCard: ({ server }: { server: { id: string; name: string } }) => + React.createElement('div', { 'data-testid': 'mcp-card', 'data-server-id': server.id }, server.name), +})); + +vi.mock('@renderer/components/extensions/mcp/McpServerDetailDialog', () => ({ + McpServerDetailDialog: ({ open }: { open: boolean }) => + open ? React.createElement('div', { 'data-testid': 'mcp-detail' }) : null, +})); + +vi.mock('@renderer/utils/formatters', () => ({ + formatRelativeTime: () => 'just now', +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + AlertTriangle: Icon, + RefreshCw: Icon, + Search: Icon, + Server: Icon, + }; +}); + +import { McpServersPanel } from '@renderer/components/extensions/mcp/McpServersPanel'; + +describe('McpServersPanel initial browse loading', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.mcpBrowseCatalog = []; + storeState.mcpBrowseNextCursor = undefined; + storeState.mcpBrowseLoading = false; + storeState.mcpBrowseError = null; + storeState.mcpBrowse = vi.fn(); + storeState.mcpInstalledServers = []; + storeState.fetchMcpGitHubStars = vi.fn(); + storeState.mcpDiagnostics = {}; + storeState.mcpDiagnosticsLoading = false; + storeState.mcpDiagnosticsError = null; + storeState.mcpDiagnosticsLastCheckedAt = null; + storeState.runMcpDiagnostics = vi.fn(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('loads the catalog once on first mount when browse state is empty', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1); + expect(storeState.runMcpDiagnostics).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not auto-retry browse after an error with an empty catalog', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1); + + storeState.mcpBrowseError = 'Registry unavailable'; + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +});