agent-ecosystem/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts

851 lines
25 KiB
TypeScript

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<string, string>;
installMcpServer: ReturnType<typeof vi.fn>;
uninstallMcpServer: ReturnType<typeof vi.fn>;
installErrors: Record<string, string>;
mcpGitHubStars: Record<string, number>;
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<HTMLInputElement>) =>
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<HTMLSelectElement>) => 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<SVGSVGElement>) => 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();
});
});
});