fix(extensions): honor installed MCP targets in manage actions
This commit is contained in:
parent
acabe52ae7
commit
0d6276ea0b
5 changed files with 453 additions and 10 deletions
|
|
@ -18,11 +18,16 @@ import { Github as GithubIcon } from 'lucide-react';
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { SourceBadge } from '../common/SourceBadge';
|
import { SourceBadge } from '../common/SourceBadge';
|
||||||
|
|
||||||
import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions';
|
import type {
|
||||||
|
InstalledMcpEntry,
|
||||||
|
McpCatalogItem,
|
||||||
|
McpServerDiagnostic,
|
||||||
|
} from '@shared/types/extensions';
|
||||||
|
|
||||||
interface McpServerCardProps {
|
interface McpServerCardProps {
|
||||||
server: McpCatalogItem;
|
server: McpCatalogItem;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
installedEntry?: InstalledMcpEntry | null;
|
||||||
diagnostic?: McpServerDiagnostic | null;
|
diagnostic?: McpServerDiagnostic | null;
|
||||||
diagnosticsLoading?: boolean;
|
diagnosticsLoading?: boolean;
|
||||||
onClick: (serverId: string) => void;
|
onClick: (serverId: string) => void;
|
||||||
|
|
@ -31,6 +36,7 @@ interface McpServerCardProps {
|
||||||
export const McpServerCard = ({
|
export const McpServerCard = ({
|
||||||
server,
|
server,
|
||||||
isInstalled,
|
isInstalled,
|
||||||
|
installedEntry,
|
||||||
diagnostic,
|
diagnostic,
|
||||||
diagnosticsLoading,
|
diagnosticsLoading,
|
||||||
onClick,
|
onClick,
|
||||||
|
|
@ -48,6 +54,14 @@ export const McpServerCard = ({
|
||||||
server.envVars.length > 0 ||
|
server.envVars.length > 0 ||
|
||||||
server.requiresAuth ||
|
server.requiresAuth ||
|
||||||
(server.authHeaders?.length ?? 0) > 0;
|
(server.authHeaders?.length ?? 0) > 0;
|
||||||
|
const defaultServerName = sanitizeMcpServerName(server.name);
|
||||||
|
const supportsDirectInstalledAction =
|
||||||
|
isInstalled &&
|
||||||
|
installedEntry?.scope === 'user' &&
|
||||||
|
installedEntry.name === defaultServerName &&
|
||||||
|
!requiresConfiguration;
|
||||||
|
const shouldShowDirectInstallButton =
|
||||||
|
canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction);
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const hasIcon = !!server.iconUrl && !imgError;
|
const hasIcon = !!server.iconUrl && !imgError;
|
||||||
const diagnosticBadgeClass =
|
const diagnosticBadgeClass =
|
||||||
|
|
@ -224,7 +238,7 @@ export const McpServerCard = ({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canAutoInstall && !requiresConfiguration && (
|
{shouldShowDirectInstallButton && (
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
|
|
@ -232,19 +246,21 @@ export const McpServerCard = ({
|
||||||
onInstall={() =>
|
onInstall={() =>
|
||||||
installMcpServer({
|
installMcpServer({
|
||||||
registryId: server.id,
|
registryId: server.id,
|
||||||
serverName: sanitizeMcpServerName(server.name),
|
serverName: defaultServerName,
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
envValues: {},
|
envValues: {},
|
||||||
headers: [],
|
headers: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onUninstall={() => uninstallMcpServer(server.id, sanitizeMcpServerName(server.name))}
|
onUninstall={() =>
|
||||||
|
uninstallMcpServer(server.id, installedEntry?.name ?? defaultServerName, 'user')
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
errorMessage={installError}
|
errorMessage={installError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canAutoInstall && requiresConfiguration && (
|
{canAutoInstall && (!shouldShowDirectInstallButton || requiresConfiguration) && (
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,17 @@ import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { SourceBadge } from '../common/SourceBadge';
|
import { SourceBadge } from '../common/SourceBadge';
|
||||||
|
|
||||||
import type { McpCatalogItem, McpHeaderDef, McpServerDiagnostic } from '@shared/types/extensions';
|
import type {
|
||||||
|
InstalledMcpEntry,
|
||||||
|
McpCatalogItem,
|
||||||
|
McpHeaderDef,
|
||||||
|
McpServerDiagnostic,
|
||||||
|
} from '@shared/types/extensions';
|
||||||
|
|
||||||
interface McpServerDetailDialogProps {
|
interface McpServerDetailDialogProps {
|
||||||
server: McpCatalogItem | null;
|
server: McpCatalogItem | null;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
installedEntry?: InstalledMcpEntry | null;
|
||||||
diagnostic?: McpServerDiagnostic | null;
|
diagnostic?: McpServerDiagnostic | null;
|
||||||
diagnosticsLoading?: boolean;
|
diagnosticsLoading?: boolean;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -52,6 +58,7 @@ const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||||
export const McpServerDetailDialog = ({
|
export const McpServerDetailDialog = ({
|
||||||
server,
|
server,
|
||||||
isInstalled,
|
isInstalled,
|
||||||
|
installedEntry,
|
||||||
diagnostic,
|
diagnostic,
|
||||||
diagnosticsLoading,
|
diagnosticsLoading,
|
||||||
open,
|
open,
|
||||||
|
|
@ -80,7 +87,6 @@ export const McpServerDetailDialog = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setServerName(sanitizeMcpServerName(server.name));
|
|
||||||
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
|
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
|
||||||
setHeaders(
|
setHeaders(
|
||||||
(server.authHeaders ?? []).map((header) => ({
|
(server.authHeaders ?? []).map((header) => ({
|
||||||
|
|
@ -93,10 +99,11 @@ export const McpServerDetailDialog = ({
|
||||||
locked: true,
|
locked: true,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
setScope('user');
|
setServerName(installedEntry?.name ?? sanitizeMcpServerName(server.name));
|
||||||
|
setScope(installedEntry?.scope === 'local' ? 'local' : 'user');
|
||||||
setImgError(false);
|
setImgError(false);
|
||||||
setAutoFilledFields(new Set());
|
setAutoFilledFields(new Set());
|
||||||
}, [server?.id, open]);
|
}, [installedEntry?.name, installedEntry?.scope, open, server?.id]);
|
||||||
|
|
||||||
// Auto-fill env values from saved API keys
|
// Auto-fill env values from saved API keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -170,6 +177,8 @@ export const McpServerDetailDialog = ({
|
||||||
(header) => header.isRequired && !header.value.trim()
|
(header) => header.isRequired && !header.value.trim()
|
||||||
);
|
);
|
||||||
const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders;
|
const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders;
|
||||||
|
const uninstallServerName = installedEntry?.name ?? serverName;
|
||||||
|
const uninstallScope = installedEntry?.scope ?? scope;
|
||||||
const diagnosticBadgeClass =
|
const diagnosticBadgeClass =
|
||||||
diagnostic?.status === 'connected'
|
diagnostic?.status === 'connected'
|
||||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
||||||
|
|
@ -190,7 +199,7 @@ export const McpServerDetailDialog = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUninstall = () => {
|
const handleUninstall = () => {
|
||||||
uninstallMcpServer(server.id, serverName, scope);
|
uninstallMcpServer(server.id, uninstallServerName, uninstallScope);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
|
|
@ -380,6 +389,7 @@ export const McpServerDetailDialog = ({
|
||||||
onChange={(e) => setServerName(e.target.value)}
|
onChange={(e) => setServerName(e.target.value)}
|
||||||
placeholder="my-server"
|
placeholder="my-server"
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
|
disabled={isInstalled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -374,6 +374,7 @@ export const McpServersPanel = ({
|
||||||
key={server.id}
|
key={server.id}
|
||||||
server={server}
|
server={server}
|
||||||
isInstalled={isServerInstalled(server)}
|
isInstalled={isServerInstalled(server)}
|
||||||
|
installedEntry={getInstalledEntry(server)}
|
||||||
diagnostic={getDiagnostic(server)}
|
diagnostic={getDiagnostic(server)}
|
||||||
diagnosticsLoading={mcpDiagnosticsLoading}
|
diagnosticsLoading={mcpDiagnosticsLoading}
|
||||||
onClick={setSelectedMcpServerId}
|
onClick={setSelectedMcpServerId}
|
||||||
|
|
@ -400,6 +401,7 @@ export const McpServersPanel = ({
|
||||||
<McpServerDetailDialog
|
<McpServerDetailDialog
|
||||||
server={selectedServer}
|
server={selectedServer}
|
||||||
isInstalled={selectedServer ? isServerInstalled(selectedServer) : false}
|
isInstalled={selectedServer ? isServerInstalled(selectedServer) : false}
|
||||||
|
installedEntry={selectedServer ? getInstalledEntry(selectedServer) : null}
|
||||||
diagnostic={selectedServer ? getDiagnostic(selectedServer) : null}
|
diagnostic={selectedServer ? getDiagnostic(selectedServer) : null}
|
||||||
diagnosticsLoading={mcpDiagnosticsLoading}
|
diagnosticsLoading={mcpDiagnosticsLoading}
|
||||||
open={selectedMcpServerId !== null}
|
open={selectedMcpServerId !== null}
|
||||||
|
|
|
||||||
193
test/renderer/components/extensions/mcp/McpServerCard.test.ts
Normal file
193
test/renderer/components/extensions/mcp/McpServerCard.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeState = {} as StoreState;
|
||||||
|
|
||||||
|
vi.mock('@renderer/store', () => ({
|
||||||
|
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/api', () => ({
|
||||||
|
api: {
|
||||||
|
openExternal: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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',
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
}>) =>
|
||||||
|
React.createElement(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
onClick,
|
||||||
|
},
|
||||||
|
children
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||||
|
Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
|
||||||
|
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
|
TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
||||||
|
InstallButton: () => React.createElement('button', { type: 'button', 'data-testid': 'install-button' }, 'Install'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
|
||||||
|
SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/utils/formatters', () => ({
|
||||||
|
formatCompactNumber: (value: number) => String(value),
|
||||||
|
formatRelativeTime: () => 'recently',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => {
|
||||||
|
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||||
|
return {
|
||||||
|
Clock: Icon,
|
||||||
|
Cloud: Icon,
|
||||||
|
Globe: Icon,
|
||||||
|
KeyRound: Icon,
|
||||||
|
Lock: Icon,
|
||||||
|
Monitor: Icon,
|
||||||
|
Star: Icon,
|
||||||
|
Tag: Icon,
|
||||||
|
Wrench: Icon,
|
||||||
|
Github: Icon,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { McpServerCard } from '@renderer/components/extensions/mcp/McpServerCard';
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('McpServerCard direct action safety', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
storeState.mcpInstallProgress = {};
|
||||||
|
storeState.installMcpServer = vi.fn();
|
||||||
|
storeState.uninstallMcpServer = vi.fn();
|
||||||
|
storeState.installErrors = {};
|
||||||
|
storeState.mcpGitHubStars = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to Manage for installed entries that cannot be safely uninstalled directly', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const installedEntry: InstalledMcpEntry = {
|
||||||
|
name: 'context7-local',
|
||||||
|
scope: 'local',
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(McpServerCard, {
|
||||||
|
server: makeServer(),
|
||||||
|
isInstalled: true,
|
||||||
|
installedEntry,
|
||||||
|
diagnostic: null,
|
||||||
|
diagnosticsLoading: false,
|
||||||
|
onClick,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.querySelector('[data-testid="install-button"]')).toBeNull();
|
||||||
|
const manageButton = Array.from(host.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent === 'Manage'
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
expect(manageButton).toBeDefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
manageButton?.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledWith('io.github.upstash/context7');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps direct actions for standard user-scope installs', 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(McpServerCard, {
|
||||||
|
server: makeServer(),
|
||||||
|
isInstalled: true,
|
||||||
|
installedEntry,
|
||||||
|
diagnostic: null,
|
||||||
|
diagnosticsLoading: false,
|
||||||
|
onClick: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.querySelector('[data-testid="install-button"]')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }: React.PropsWithChildren<{ value: string }>) =>
|
||||||
|
React.createElement('option', { value }, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
||||||
|
InstallButton: ({
|
||||||
|
isInstalled,
|
||||||
|
onInstall,
|
||||||
|
onUninstall,
|
||||||
|
}: {
|
||||||
|
isInstalled: boolean;
|
||||||
|
onInstall: () => void;
|
||||||
|
onUninstall: () => void;
|
||||||
|
}) =>
|
||||||
|
React.createElement(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
'data-testid': 'install-button',
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {};
|
||||||
|
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,
|
||||||
|
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'
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue