import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; interface StoreState { installCustomMcpServer: ReturnType; cliStatus?: Record | null; cliStatusLoading?: boolean; } const storeState = {} as StoreState; const lookupMock = vi.fn(); vi.mock('@renderer/store', () => ({ useStore: (selector: (state: StoreState) => unknown) => selector(storeState), })); vi.mock('@renderer/api', () => ({ api: { apiKeys: { lookup: (...args: unknown[]) => lookupMock(...args), }, }, })); 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('lucide-react', () => { const Icon = (props: React.SVGProps) => React.createElement('svg', props); return { Plus: Icon, Server: Icon, Trash2: Icon, }; }); import { CustomMcpServerDialog } from '@renderer/components/extensions/mcp/CustomMcpServerDialog'; function setNativeValue( element: HTMLInputElement | HTMLSelectElement, value: string, eventName: 'input' | 'change' ): 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(eventName, { bubbles: true })); } describe('CustomMcpServerDialog project scope', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined); storeState.cliStatus = { flavor: 'claude', installed: true, authLoggedIn: true, binaryPath: '/usr/local/bin/claude', launchError: null, providers: [], }; storeState.cliStatusLoading = false; lookupMock.mockReset(); lookupMock.mockResolvedValue([]); }); afterEach(() => { document.body.innerHTML = ''; vi.unstubAllGlobals(); }); it('disables non-user scopes without an active project', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(CustomMcpServerDialog, { open: true, onClose: vi.fn(), projectPath: null, }) ); await Promise.resolve(); }); 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('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(CustomMcpServerDialog, { open: true, onClose: vi.fn(), projectPath: null, }) ); 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('preserves entered values when multimodel scope metadata loads after open', async () => { storeState.cliStatus = null; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(CustomMcpServerDialog, { open: true, onClose: vi.fn(), projectPath: null, }) ); await Promise.resolve(); }); const nameInput = host.querySelector('#custom-name') as HTMLInputElement; const packageInput = host.querySelector('#custom-npm') as HTMLInputElement; const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; await act(async () => { setNativeValue(nameInput, 'late-hydration-server', 'input'); setNativeValue(packageInput, '@example/late-hydration', 'input'); await Promise.resolve(); }); expect(scopeSelect.value).toBe('user'); storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; await act(async () => { root.render( React.createElement(CustomMcpServerDialog, { open: true, onClose: vi.fn(), projectPath: null, }) ); await Promise.resolve(); }); expect((host.querySelector('#custom-name') as HTMLInputElement).value).toBe( 'late-hydration-server' ); expect((host.querySelector('#custom-npm') as HTMLInputElement).value).toBe( '@example/late-hydration' ); expect((host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement).value).toBe( 'global' ); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('disables installation when the runtime declares MCP writes unavailable', async () => { storeState.cliStatus = { flavor: 'agent_teams_orchestrator', installed: true, authLoggedIn: true, binaryPath: '/usr/local/bin/claude-multimodel', launchError: null, providers: [ { providerId: 'anthropic', displayName: 'Anthropic', supported: true, authenticated: true, authMethod: 'oauth_token', verificationState: 'verified', models: [], canLoginFromUi: true, capabilities: { teamLaunch: true, oneShot: true, extensions: { plugins: { status: 'supported', ownership: 'shared', reason: null }, mcp: { status: 'read-only', ownership: 'shared', reason: 'MCP writes unavailable', }, skills: { status: 'supported', ownership: 'shared', reason: null }, apiKeys: { status: 'supported', ownership: 'shared', reason: null }, }, }, }, ], }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(CustomMcpServerDialog, { open: true, onClose: vi.fn(), projectPath: null, }) ); await Promise.resolve(); }); expect(host.textContent).toContain('MCP writes unavailable'); const installButton = Array.from(host.querySelectorAll('button')).find( (button) => button.textContent?.includes('Install') ) as HTMLButtonElement | undefined; expect(installButton).toBeDefined(); expect(installButton?.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); await act(async () => { root.render( React.createElement(CustomMcpServerDialog, { open: true, onClose: vi.fn(), projectPath: '/tmp/custom-mcp-project', }) ); await Promise.resolve(); }); const addEnvButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('Add') ) as HTMLButtonElement; await act(async () => { addEnvButton.click(); await Promise.resolve(); }); const envKeyInput = host.querySelector( 'input[placeholder="ENV_VAR_NAME"]' ) as HTMLInputElement; await act(async () => { setNativeValue(envKeyInput, 'CONTEXT7_API_KEY', 'input'); 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 () => { setNativeValue(scopeSelect, 'project', 'change'); await Promise.resolve(); await Promise.resolve(); }); expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], '/tmp/custom-mcp-project'); await act(async () => { setNativeValue(scopeSelect, 'user', 'change'); 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); lookupMock .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ envVarName: 'CONTEXT7_API_KEY', value: 'project-secret' }]) .mockResolvedValueOnce([]); await act(async () => { root.render( React.createElement(CustomMcpServerDialog, { open: true, onClose: vi.fn(), projectPath: '/tmp/custom-mcp-project', }) ); await Promise.resolve(); }); const addEnvButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('Add') ) as HTMLButtonElement; await act(async () => { addEnvButton.click(); await Promise.resolve(); }); const envKeyInput = host.querySelector( 'input[placeholder="ENV_VAR_NAME"]' ) as HTMLInputElement; const envValueInput = host.querySelector( 'input[placeholder="value"]' ) as HTMLInputElement; const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; await act(async () => { setNativeValue(envKeyInput, 'CONTEXT7_API_KEY', 'input'); await Promise.resolve(); await Promise.resolve(); }); await act(async () => { setNativeValue(scopeSelect, 'project', 'change'); await Promise.resolve(); await Promise.resolve(); }); expect(envValueInput.value).toBe('project-secret'); await act(async () => { setNativeValue(scopeSelect, 'user', 'change'); await Promise.resolve(); await Promise.resolve(); }); expect(envValueInput.value).toBe(''); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('passes projectPath for project-scoped custom installs', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onClose = vi.fn(); const projectPath = '/tmp/custom-mcp-project'; await act(async () => { root.render( React.createElement(CustomMcpServerDialog, { open: true, onClose, projectPath, }) ); await Promise.resolve(); }); const nameInput = host.querySelector('#custom-name') as HTMLInputElement; const packageInput = host.querySelector('#custom-npm') as HTMLInputElement; const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; await act(async () => { setNativeValue(nameInput, 'custom-context7', 'input'); setNativeValue(packageInput, '@upstash/context7-mcp', 'input'); setNativeValue(scopeSelect, 'project', 'change'); await Promise.resolve(); }); const installButton = Array.from(host.querySelectorAll('button')).find( (button) => button.textContent === 'Install' ) as HTMLButtonElement; expect(installButton.disabled).toBe(false); await act(async () => { installButton.click(); await Promise.resolve(); }); expect(storeState.installCustomMcpServer).toHaveBeenCalledWith( expect.objectContaining({ serverName: 'custom-context7', scope: 'project', projectPath, }) ); expect(onClose).toHaveBeenCalledTimes(1); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('passes projectPath for local-scoped custom installs', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const onClose = vi.fn(); const projectPath = '/tmp/custom-mcp-project'; await act(async () => { root.render( React.createElement(CustomMcpServerDialog, { open: true, onClose, projectPath, }) ); await Promise.resolve(); }); const nameInput = host.querySelector('#custom-name') as HTMLInputElement; const packageInput = host.querySelector('#custom-npm') as HTMLInputElement; const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; await act(async () => { setNativeValue(nameInput, 'local-context7', 'input'); setNativeValue(packageInput, '@upstash/context7-mcp', 'input'); setNativeValue(scopeSelect, 'local', 'change'); await Promise.resolve(); }); const installButton = Array.from(host.querySelectorAll('button')).find( (button) => button.textContent === 'Install' ) as HTMLButtonElement; expect(installButton.disabled).toBe(false); await act(async () => { installButton.click(); await Promise.resolve(); }); expect(storeState.installCustomMcpServer).toHaveBeenCalledWith( expect.objectContaining({ serverName: 'local-context7', scope: 'local', projectPath, }) ); expect(onClose).toHaveBeenCalledTimes(1); await act(async () => { root.unmount(); await Promise.resolve(); }); }); });