511 lines
15 KiB
TypeScript
511 lines
15 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
|
|
|
|
interface StoreState {
|
|
mcpBrowseCatalog: {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
source: 'official' | 'glama';
|
|
installSpec: null;
|
|
envVars: [];
|
|
tools: [];
|
|
requiresAuth: boolean;
|
|
}[];
|
|
mcpBrowseNextCursor?: string;
|
|
mcpBrowseLoading: boolean;
|
|
mcpBrowseError: string | null;
|
|
mcpBrowse: ReturnType<typeof vi.fn>;
|
|
mcpInstalledServers: { name: string; scope: 'local' | 'user' | 'project' }[];
|
|
mcpInstalledServersByProjectPath?: Record<
|
|
string,
|
|
{ name: string; scope: 'local' | 'user' | 'project' }[]
|
|
>;
|
|
fetchMcpGitHubStars: ReturnType<typeof vi.fn>;
|
|
mcpDiagnostics: Record<string, never>;
|
|
mcpDiagnosticsByProjectPath?: Record<string, Record<string, never>>;
|
|
mcpDiagnosticsLoading: boolean;
|
|
mcpDiagnosticsLoadingByProjectPath?: Record<string, boolean>;
|
|
mcpDiagnosticsError: string | null;
|
|
mcpDiagnosticsErrorByProjectPath?: Record<string, string | null>;
|
|
mcpDiagnosticsLastCheckedAt: number | null;
|
|
mcpDiagnosticsLastCheckedAtByProjectPath?: Record<string, number | null>;
|
|
runMcpDiagnostics: ReturnType<typeof vi.fn>;
|
|
cliStatusLoading: boolean;
|
|
cliStatus?: {
|
|
flavor?: 'claude' | 'agent_teams_orchestrator';
|
|
installed?: boolean;
|
|
binaryPath?: string | null;
|
|
launchError?: string | null;
|
|
} | null;
|
|
}
|
|
|
|
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<HTMLInputElement>) => 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<SVGSVGElement>) => 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.mcpInstalledServersByProjectPath = undefined;
|
|
storeState.fetchMcpGitHubStars = vi.fn();
|
|
storeState.mcpDiagnostics = {};
|
|
storeState.mcpDiagnosticsByProjectPath = undefined;
|
|
storeState.mcpDiagnosticsLoading = false;
|
|
storeState.mcpDiagnosticsLoadingByProjectPath = undefined;
|
|
storeState.mcpDiagnosticsError = null;
|
|
storeState.mcpDiagnosticsErrorByProjectPath = undefined;
|
|
storeState.mcpDiagnosticsLastCheckedAt = null;
|
|
storeState.mcpDiagnosticsLastCheckedAtByProjectPath = undefined;
|
|
storeState.runMcpDiagnostics = vi.fn();
|
|
storeState.cliStatusLoading = false;
|
|
storeState.cliStatus = {
|
|
flavor: 'claude',
|
|
installed: true,
|
|
binaryPath: '/usr/local/bin/claude',
|
|
launchError: null,
|
|
};
|
|
});
|
|
|
|
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, {
|
|
projectPath: null,
|
|
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, {
|
|
projectPath: null,
|
|
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, {
|
|
projectPath: null,
|
|
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();
|
|
});
|
|
});
|
|
|
|
it('uses truthful diagnostics copy instead of suggesting a hard-coded CLI command', async () => {
|
|
storeState.mcpBrowseCatalog = [
|
|
{
|
|
id: 'context7',
|
|
name: 'Context7',
|
|
description: 'Docs MCP',
|
|
source: 'official',
|
|
installSpec: null,
|
|
envVars: [],
|
|
tools: [],
|
|
requiresAuth: false,
|
|
},
|
|
];
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(McpServersPanel, {
|
|
projectPath: null,
|
|
mcpSearchQuery: '',
|
|
mcpSearch: vi.fn(),
|
|
mcpSearchResults: [],
|
|
mcpSearchLoading: false,
|
|
mcpSearchWarnings: [],
|
|
selectedMcpServerId: null,
|
|
setSelectedMcpServerId: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Run diagnostics from this page');
|
|
expect(host.textContent).not.toContain('claude-multimodel mcp diagnose');
|
|
expect(host.textContent).not.toContain('claude mcp list');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('uses runtime-aware missing-runtime copy for multimodel diagnostics failures', async () => {
|
|
storeState.mcpDiagnosticsError = `${CLI_NOT_FOUND_MARKER}: missing runtime`;
|
|
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(McpServersPanel, {
|
|
projectPath: null,
|
|
mcpSearchQuery: '',
|
|
mcpSearch: vi.fn(),
|
|
mcpSearchResults: [],
|
|
mcpSearchLoading: false,
|
|
mcpSearchWarnings: [],
|
|
selectedMcpServerId: null,
|
|
setSelectedMcpServerId: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Multimodel runtime not available');
|
|
expect(host.textContent).toContain('MCP health checks require Multimodel runtime');
|
|
expect(host.textContent).not.toContain('Claude CLI not installed');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('does not auto-run diagnostics when the configured runtime is unavailable', async () => {
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
installed: false,
|
|
binaryPath: null,
|
|
launchError: null,
|
|
};
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(McpServersPanel, {
|
|
projectPath: null,
|
|
mcpSearchQuery: '',
|
|
mcpSearch: vi.fn(),
|
|
mcpSearchResults: [],
|
|
mcpSearchLoading: false,
|
|
mcpSearchWarnings: [],
|
|
selectedMcpServerId: null,
|
|
setSelectedMcpServerId: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(storeState.runMcpDiagnostics).not.toHaveBeenCalled();
|
|
expect(host.textContent).toContain(
|
|
'The configured runtime is required. Install or repair it from the Dashboard.'
|
|
);
|
|
const checkStatusButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
|
button.textContent?.includes('Check Status')
|
|
);
|
|
expect(checkStatusButton).toBeDefined();
|
|
expect((checkStatusButton!).disabled).toBe(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('waits for runtime hydration before auto-running diagnostics', async () => {
|
|
storeState.cliStatus = null;
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(McpServersPanel, {
|
|
projectPath: null,
|
|
mcpSearchQuery: '',
|
|
mcpSearch: vi.fn(),
|
|
mcpSearchResults: [],
|
|
mcpSearchLoading: false,
|
|
mcpSearchWarnings: [],
|
|
selectedMcpServerId: null,
|
|
setSelectedMcpServerId: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(storeState.runMcpDiagnostics).not.toHaveBeenCalled();
|
|
expect(host.textContent).toContain('Checking runtime availability...');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('renders provider-neutral waiting copy while diagnostics are still running', async () => {
|
|
storeState.mcpDiagnosticsLoading = true;
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(McpServersPanel, {
|
|
projectPath: null,
|
|
mcpSearchQuery: '',
|
|
mcpSearch: vi.fn(),
|
|
mcpSearchResults: [],
|
|
mcpSearchLoading: false,
|
|
mcpSearchWarnings: [],
|
|
selectedMcpServerId: null,
|
|
setSelectedMcpServerId: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Waiting for diagnostics results...');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('uses the effective runtime status override for diagnostics gating during background refresh', async () => {
|
|
storeState.cliStatus = null;
|
|
storeState.cliStatusLoading = true;
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(McpServersPanel, {
|
|
projectPath: null,
|
|
mcpSearchQuery: '',
|
|
mcpSearch: vi.fn(),
|
|
mcpSearchResults: [],
|
|
mcpSearchLoading: false,
|
|
mcpSearchWarnings: [],
|
|
selectedMcpServerId: null,
|
|
setSelectedMcpServerId: vi.fn(),
|
|
cliStatus: {
|
|
flavor: 'agent_teams_orchestrator',
|
|
displayName: 'Multimodel runtime',
|
|
installed: true,
|
|
authLoggedIn: false,
|
|
binaryPath: '/usr/local/bin/agent-teams',
|
|
launchError: null,
|
|
providers: [],
|
|
},
|
|
cliStatusLoading: false,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(storeState.runMcpDiagnostics).toHaveBeenCalledTimes(1);
|
|
expect(host.textContent).not.toContain('Checking runtime availability...');
|
|
expect(host.textContent).not.toContain('The configured runtime is required.');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('does not block diagnostics when a usable runtime status already exists during background refresh', async () => {
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
installed: true,
|
|
binaryPath: '/usr/local/bin/agent-teams',
|
|
launchError: null,
|
|
};
|
|
storeState.cliStatusLoading = true;
|
|
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(McpServersPanel, {
|
|
projectPath: null,
|
|
mcpSearchQuery: '',
|
|
mcpSearch: vi.fn(),
|
|
mcpSearchResults: [],
|
|
mcpSearchLoading: false,
|
|
mcpSearchWarnings: [],
|
|
selectedMcpServerId: null,
|
|
setSelectedMcpServerId: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(storeState.runMcpDiagnostics).toHaveBeenCalledTimes(1);
|
|
expect(host.textContent).not.toContain('Checking runtime status...');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
});
|