import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getMcpOperationKey } from '@shared/utils/extensionNormalizers'; import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions'; interface StoreState { mcpInstallProgress: Record; installMcpServer: ReturnType; uninstallMcpServer: ReturnType; installErrors: Record; mcpGitHubStars: Record; cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null; } const storeState = {} as StoreState; const lookupMock = vi.fn(); vi.mock('@renderer/store', () => ({ useStore: (selector: (state: StoreState) => unknown) => selector(storeState), })); vi.mock('@renderer/api', () => ({ api: { openExternal: vi.fn(), apiKeys: { lookup: (...args: unknown[]) => lookupMock(...args), }, }, })); 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/dialog', () => ({ Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => open ? React.createElement('div', null, children) : null, DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), DialogDescription: ({ children }: React.PropsWithChildren) => React.createElement('p', null, children), })); vi.mock('@renderer/components/ui/input', () => ({ Input: (props: React.InputHTMLAttributes) => React.createElement('input', props), })); vi.mock('@renderer/components/ui/label', () => ({ Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children), })); vi.mock('@renderer/components/ui/select', () => ({ Select: ({ children, value, onValueChange, }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => React.createElement( 'select', { 'data-testid': 'scope-select', value, onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), }, children ), SelectTrigger: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), SelectValue: () => null, SelectContent: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), SelectItem: ({ children, value, disabled, }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => React.createElement('option', { value, disabled }, children), })); vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ InstallButton: ({ isInstalled, state, errorMessage, onInstall, onUninstall, }: { isInstalled: boolean; state?: string; errorMessage?: string; onInstall: () => void; onUninstall: () => void; }) => React.createElement( 'button', { type: 'button', 'data-testid': 'install-button', 'data-state': state, 'data-error': errorMessage, onClick: () => (isInstalled ? onUninstall() : onInstall()), }, isInstalled ? 'Uninstall' : 'Install' ), })); vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({ SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source), })); vi.mock('lucide-react', () => { const Icon = (props: React.SVGProps) => React.createElement('svg', props); return { ExternalLink: Icon, Lock: Icon, Plus: Icon, Star: Icon, Trash2: Icon, Wrench: Icon, }; }); import { McpServerDetailDialog } from '@renderer/components/extensions/mcp/McpServerDetailDialog'; function makeServer(): McpCatalogItem { return { id: 'io.github.upstash/context7', name: 'Context7', description: 'Docs server', source: 'official', installSpec: { type: 'stdio', npmPackage: '@upstash/context7-mcp', }, envVars: [], tools: [], requiresAuth: false, authHeaders: [], }; } function setInputValue(element: HTMLInputElement | HTMLSelectElement, value: string): void { const prototype = element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype; const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); descriptor?.set?.call(element, value); element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); } describe('McpServerDetailDialog installed entry handling', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.mcpInstallProgress = {}; storeState.installMcpServer = vi.fn(); storeState.uninstallMcpServer = vi.fn(); storeState.installErrors = {}; storeState.mcpGitHubStars = {}; storeState.cliStatus = null; lookupMock.mockReset(); lookupMock.mockResolvedValue([]); }); afterEach(() => { document.body.innerHTML = ''; vi.unstubAllGlobals(); }); it('uninstalls using the real installed server name and scope', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const installedEntry: InstalledMcpEntry = { name: 'context7-local', scope: 'local', }; await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server: makeServer(), isInstalled: true, installedEntry, diagnostic: null, diagnosticsLoading: false, projectPath: '/tmp/project', open: true, onClose: vi.fn(), }) ); await Promise.resolve(); }); const serverNameInput = host.querySelector('#server-name') as HTMLInputElement; expect(serverNameInput).not.toBeNull(); expect(serverNameInput.value).toBe('context7-local'); expect(serverNameInput.disabled).toBe(true); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; expect(scopeSelect.value).toBe('local'); const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; await act(async () => { uninstallButton.click(); await Promise.resolve(); }); expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( 'io.github.upstash/context7', 'context7-local', 'local', '/tmp/project' ); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('looks up saved API keys only once per dialog open', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const server = makeServer(); server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; lookupMock.mockResolvedValue([ { envVarName: 'CONTEXT7_API_KEY', value: 'secret', }, ]); await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server, isInstalled: false, installedEntry: null, diagnostic: null, diagnosticsLoading: false, projectPath: null, open: true, onClose: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); expect(lookupMock).toHaveBeenCalledTimes(1); expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined); const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement; const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement; expect(projectOption.disabled).toBe(true); expect(localOption.disabled).toBe(true); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('looks up project-scoped API keys only when project scope is selected', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const server = makeServer(); server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server, isInstalled: false, installedEntry: null, diagnostic: null, diagnosticsLoading: false, projectPath: '/tmp/project-context7', open: true, onClose: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; await act(async () => { scopeSelect.value = 'project'; scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); await Promise.resolve(); await Promise.resolve(); }); expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], '/tmp/project-context7'); await act(async () => { scopeSelect.value = 'user'; scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); await Promise.resolve(); await Promise.resolve(); }); expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], undefined); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('clears stale project auto-filled values when switching back to user scope', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const server = makeServer(); server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; lookupMock .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ envVarName: 'CONTEXT7_API_KEY', value: 'project-secret' }]) .mockResolvedValueOnce([]); await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server, isInstalled: false, installedEntry: null, diagnostic: null, diagnosticsLoading: false, projectPath: '/tmp/project-context7', open: true, onClose: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; const envValueInput = host.querySelector('input[type="password"]') as HTMLInputElement; await act(async () => { scopeSelect.value = 'project'; scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); await Promise.resolve(); await Promise.resolve(); }); expect(envValueInput.value).toBe('project-secret'); await act(async () => { scopeSelect.value = 'user'; scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); await Promise.resolve(); await Promise.resolve(); }); expect(envValueInput.value).toBe(''); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('defaults to global scope in multimodel mode', async () => { storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server: makeServer(), isInstalled: false, installedEntry: null, installedEntries: [], diagnostic: null, diagnosticsLoading: false, projectPath: null, open: true, onClose: vi.fn(), }) ); await Promise.resolve(); }); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; expect(scopeSelect.value).toBe('global'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('uses a runtime-aware status label in multimodel mode', async () => { storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const installedEntry: InstalledMcpEntry = { name: 'context7-global', scope: 'global', }; await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server: makeServer(), isInstalled: true, installedEntry, installedEntries: [installedEntry], diagnostic: null, diagnosticsLoading: false, projectPath: null, open: true, onClose: vi.fn(), }) ); await Promise.resolve(); }); expect(host.textContent).toContain('Runtime Status'); expect(host.textContent).not.toContain('Claude Status'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('preserves edited fields when multimodel scope metadata loads after open', async () => { storeState.cliStatus = null; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const server = makeServer(); server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server, isInstalled: false, installedEntry: null, installedEntries: [], diagnostic: null, diagnosticsLoading: false, projectPath: null, open: true, onClose: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); const serverNameInput = host.querySelector('#server-name') as HTMLInputElement; const envValueInput = host.querySelector('input[type="password"]') as HTMLInputElement; const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; await act(async () => { setInputValue(serverNameInput, 'late-hydration-context7'); setInputValue(envValueInput, 'secret'); await Promise.resolve(); }); expect(scopeSelect.value).toBe('user'); storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server, isInstalled: false, installedEntry: null, installedEntries: [], diagnostic: null, diagnosticsLoading: false, projectPath: null, open: true, onClose: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); expect((host.querySelector('#server-name') as HTMLInputElement).value).toBe( 'late-hydration-context7' ); expect((host.querySelector('input[type="password"]') as HTMLInputElement).value).toBe( 'secret' ); expect((host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement).value).toBe( 'global' ); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('passes project path for project-scoped installs and uninstalls', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const projectPath = '/tmp/project-context7'; const installedEntry: InstalledMcpEntry = { name: 'context7-project', scope: 'project', }; await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server: makeServer(), isInstalled: true, installedEntry, installedEntries: [installedEntry], diagnostic: null, diagnosticsLoading: false, projectPath, open: true, onClose: vi.fn(), }) ); await Promise.resolve(); }); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; expect(scopeSelect.value).toBe('project'); const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; await act(async () => { uninstallButton.click(); await Promise.resolve(); }); expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( 'io.github.upstash/context7', 'context7-project', 'project', projectPath ); await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server: makeServer(), isInstalled: false, installedEntry: null, installedEntries: [], diagnostic: null, diagnosticsLoading: false, projectPath, open: true, onClose: vi.fn(), }) ); await Promise.resolve(); }); const installScopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; await act(async () => { installScopeSelect.value = 'project'; installScopeSelect.dispatchEvent(new Event('change', { bubbles: true })); await Promise.resolve(); }); const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; await act(async () => { installButton.click(); await Promise.resolve(); }); expect(storeState.installMcpServer).toHaveBeenCalledWith( expect.objectContaining({ registryId: 'io.github.upstash/context7', scope: 'project', projectPath, }) ); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('passes project path for local-scoped installs and uninstalls', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const projectPath = '/tmp/local-context7'; const installedEntry: InstalledMcpEntry = { name: 'context7-local', scope: 'local', }; await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server: makeServer(), isInstalled: true, installedEntry, installedEntries: [installedEntry], diagnostic: null, diagnosticsLoading: false, projectPath, open: true, onClose: vi.fn(), }) ); await Promise.resolve(); }); const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; await act(async () => { uninstallButton.click(); await Promise.resolve(); }); expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( 'io.github.upstash/context7', 'context7-local', 'local', projectPath ); await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server: makeServer(), isInstalled: false, installedEntry: null, installedEntries: [], diagnostic: null, diagnosticsLoading: false, projectPath, open: true, onClose: vi.fn(), }) ); await Promise.resolve(); }); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; await act(async () => { scopeSelect.value = 'local'; scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); await Promise.resolve(); }); const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; await act(async () => { installButton.click(); await Promise.resolve(); }); expect(storeState.installMcpServer).toHaveBeenCalledWith( expect.objectContaining({ registryId: 'io.github.upstash/context7', scope: 'local', projectPath, }) ); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('uses selected scope instead of aggregated installed state', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const installedEntry: InstalledMcpEntry = { name: 'context7', scope: 'user', }; await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server: makeServer(), isInstalled: true, installedEntry, installedEntries: [installedEntry], diagnostic: null, diagnosticsLoading: false, projectPath: '/tmp/project', open: true, onClose: vi.fn(), }) ); await Promise.resolve(); }); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; await act(async () => { scopeSelect.value = 'project'; scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); await Promise.resolve(); }); const actionButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; expect(actionButton.textContent).toBe('Install'); await act(async () => { actionButton.click(); await Promise.resolve(); }); expect(storeState.installMcpServer).toHaveBeenCalledWith( expect.objectContaining({ registryId: 'io.github.upstash/context7', scope: 'project', projectPath: '/tmp/project', }) ); expect(storeState.uninstallMcpServer).not.toHaveBeenCalled(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('defaults to the highest-precedence installed scope', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const installedEntries: InstalledMcpEntry[] = [ { name: 'context7', scope: 'user' }, { name: 'context7-shared', scope: 'project' }, ]; await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server: makeServer(), isInstalled: true, installedEntry: installedEntries[0], installedEntries, diagnostic: null, diagnosticsLoading: false, projectPath: '/tmp/project', open: true, onClose: vi.fn(), }) ); await Promise.resolve(); }); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; const serverNameInput = host.querySelector('#server-name') as HTMLInputElement; expect(scopeSelect.value).toBe('project'); expect(serverNameInput.value).toBe('context7-shared'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('reads install state from the selected scope operation key', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); storeState.mcpInstallProgress = { [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'success', [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: 'error', }; storeState.installErrors = { [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: 'Project failed', }; await act(async () => { root.render( React.createElement(McpServerDetailDialog, { server: makeServer(), isInstalled: false, installedEntry: null, installedEntries: [], diagnostic: null, diagnosticsLoading: false, projectPath: '/tmp/project', open: true, onClose: vi.fn(), }) ); await Promise.resolve(); }); const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; expect(installButton.dataset.state).toBe('success'); expect(installButton.dataset.error ?? '').toBe(''); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; await act(async () => { scopeSelect.value = 'project'; scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); await Promise.resolve(); }); expect(installButton.dataset.state).toBe('error'); expect(installButton.dataset.error).toBe('Project failed'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); });