@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
1,147 lines (952 loc) • 34.9 kB
text/typescript
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { PluginItem } from '@lobehub/market-sdk';
import { act, renderHook, waitFor } from '@testing-library/react';
import { TRPCClientError } from '@trpc/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { discoverService } from '@/services/discover';
import { mcpService } from '@/services/mcp';
import { pluginService } from '@/services/plugin';
import { globalHelpers } from '@/store/global/helpers';
import { CheckMcpInstallResult, MCPInstallStep } from '@/types/plugins';
import { useToolStore } from '../../store';
// Keep zustand mock as it's needed globally
vi.mock('zustand/traditional');
// Mock sleep to speed up tests
vi.mock('@/utils/sleep', () => ({
sleep: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(() => {
vi.clearAllMocks();
// Reset store state
act(() => {
useToolStore.setState(
{
mcpPluginItems: [],
mcpInstallProgress: {},
mcpInstallAbortControllers: {},
mcpTestAbortControllers: {},
mcpTestLoading: {},
mcpTestErrors: {},
currentPage: 1,
totalCount: 0,
categories: [],
refreshPlugins: vi.fn(),
updateInstallLoadingState: vi.fn(),
},
false,
);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('mcpStore actions', () => {
describe('updateMCPInstallProgress', () => {
it('should update install progress for an identifier', () => {
const { result } = renderHook(() => useToolStore());
act(() => {
result.current.updateMCPInstallProgress('test-plugin', {
progress: 50,
step: MCPInstallStep.GETTING_SERVER_MANIFEST,
});
});
expect(result.current.mcpInstallProgress['test-plugin']).toEqual({
progress: 50,
step: MCPInstallStep.GETTING_SERVER_MANIFEST,
});
});
it('should clear install progress when progress is undefined', () => {
const { result } = renderHook(() => useToolStore());
act(() => {
result.current.updateMCPInstallProgress('test-plugin', {
progress: 50,
step: MCPInstallStep.INSTALLING_PLUGIN,
});
});
act(() => {
result.current.updateMCPInstallProgress('test-plugin', undefined);
});
expect(result.current.mcpInstallProgress['test-plugin']).toBeUndefined();
});
});
describe('cancelInstallMCPPlugin', () => {
it('should abort the installation and clear progress', async () => {
const { result } = renderHook(() => useToolStore());
const abortController = new AbortController();
const abortSpy = vi.spyOn(abortController, 'abort');
act(() => {
useToolStore.setState({
mcpInstallAbortControllers: { 'test-plugin': abortController },
mcpInstallProgress: {
'test-plugin': { progress: 50, step: MCPInstallStep.CHECKING_INSTALLATION },
},
});
});
await act(async () => {
await result.current.cancelInstallMCPPlugin('test-plugin');
});
expect(abortSpy).toHaveBeenCalled();
expect(result.current.mcpInstallAbortControllers['test-plugin']).toBeUndefined();
expect(result.current.mcpInstallProgress['test-plugin']).toBeUndefined();
});
it('should handle cancel when no AbortController exists', async () => {
const { result } = renderHook(() => useToolStore());
await act(async () => {
await result.current.cancelInstallMCPPlugin('non-existent-plugin');
});
// Should not throw error
expect(result.current.mcpInstallAbortControllers['non-existent-plugin']).toBeUndefined();
});
});
describe('cancelMcpConnectionTest', () => {
it('should abort the connection test and clear state', () => {
const { result } = renderHook(() => useToolStore());
const abortController = new AbortController();
const abortSpy = vi.spyOn(abortController, 'abort');
act(() => {
useToolStore.setState({
mcpTestAbortControllers: { 'test-plugin': abortController },
mcpTestLoading: { 'test-plugin': true },
mcpTestErrors: { 'test-plugin': 'Some error' },
});
});
act(() => {
result.current.cancelMcpConnectionTest('test-plugin');
});
expect(abortSpy).toHaveBeenCalled();
expect(result.current.mcpTestLoading['test-plugin']).toBe(false);
expect(result.current.mcpTestAbortControllers['test-plugin']).toBeUndefined();
expect(result.current.mcpTestErrors['test-plugin']).toBeUndefined();
});
it('should handle cancel when no AbortController exists', () => {
const { result } = renderHook(() => useToolStore());
act(() => {
result.current.cancelMcpConnectionTest('non-existent-plugin');
});
// Should not throw error
expect(result.current.mcpTestAbortControllers['non-existent-plugin']).toBeUndefined();
});
});
describe('testMcpConnection', () => {
const mockManifest: LobeChatPluginManifest = {
api: [],
gateway: '',
identifier: 'test-plugin',
meta: {
avatar: 'https://example.com/avatar.png',
description: 'Test plugin',
title: 'Test Plugin',
},
type: 'standalone',
version: '1',
};
describe('HTTP connection', () => {
it('should successfully test HTTP connection', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'getStreamableMcpServerManifest').mockResolvedValue(mockManifest);
let testResult;
await act(async () => {
testResult = await result.current.testMcpConnection({
identifier: 'test-plugin',
connection: {
type: 'http',
url: 'https://example.com/mcp',
},
metadata: {
avatar: 'https://example.com/avatar.png',
description: 'Test plugin',
},
});
});
expect(testResult).toEqual({
success: true,
manifest: mockManifest,
});
expect(result.current.mcpTestLoading['test-plugin']).toBe(false);
expect(result.current.mcpTestErrors['test-plugin']).toBeUndefined();
});
it('should handle HTTP connection error', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'getStreamableMcpServerManifest').mockRejectedValue(
new Error('Connection failed'),
);
let testResult;
await act(async () => {
testResult = await result.current.testMcpConnection({
identifier: 'test-plugin',
connection: {
type: 'http',
url: 'https://example.com/mcp',
},
});
});
expect(testResult).toEqual({
success: false,
error: 'Connection failed',
});
expect(result.current.mcpTestLoading['test-plugin']).toBe(false);
expect(result.current.mcpTestErrors['test-plugin']).toBe('Connection failed');
});
it('should throw error when URL is missing for HTTP connection', async () => {
const { result } = renderHook(() => useToolStore());
let testResult;
await act(async () => {
testResult = await result.current.testMcpConnection({
identifier: 'test-plugin',
connection: {
type: 'http',
} as any,
});
});
expect(testResult).toEqual({
success: false,
error: 'URL is required for HTTP connection',
});
});
});
describe('STDIO connection', () => {
it('should successfully test STDIO connection', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'getStdioMcpServerManifest').mockResolvedValue(mockManifest);
let testResult;
await act(async () => {
testResult = await result.current.testMcpConnection({
identifier: 'test-plugin',
connection: {
type: 'stdio',
command: 'node',
args: ['server.js'],
},
});
});
expect(testResult).toEqual({
success: true,
manifest: mockManifest,
});
expect(result.current.mcpTestLoading['test-plugin']).toBe(false);
});
it('should handle STDIO connection error', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'getStdioMcpServerManifest').mockRejectedValue(
new Error('Command not found'),
);
let testResult;
await act(async () => {
testResult = await result.current.testMcpConnection({
identifier: 'test-plugin',
connection: {
type: 'stdio',
command: 'invalid-command',
},
});
});
expect(testResult).toEqual({
success: false,
error: 'Command not found',
});
expect(result.current.mcpTestErrors['test-plugin']).toBe('Command not found');
});
it('should throw error when command is missing for STDIO connection', async () => {
const { result } = renderHook(() => useToolStore());
let testResult;
await act(async () => {
testResult = await result.current.testMcpConnection({
identifier: 'test-plugin',
connection: {
type: 'stdio',
} as any,
});
});
expect(testResult).toEqual({
success: false,
error: 'Command is required for STDIO connection',
});
});
});
describe('cancellation', () => {
it('should handle cancellation during test', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'getStreamableMcpServerManifest').mockImplementation(
async (params, signal) => {
// Simulate cancellation
signal?.dispatchEvent(new Event('abort'));
throw new Error('Aborted');
},
);
let testResult;
await act(async () => {
testResult = await result.current.testMcpConnection({
identifier: 'test-plugin',
connection: {
type: 'http',
url: 'https://example.com/mcp',
},
});
});
expect(testResult).toEqual({
success: false,
error: 'Aborted',
});
});
});
it('should handle invalid connection type', async () => {
const { result } = renderHook(() => useToolStore());
let testResult;
await act(async () => {
testResult = await result.current.testMcpConnection({
identifier: 'test-plugin',
connection: {
type: 'invalid' as any,
},
});
});
expect(testResult).toEqual({
success: false,
error: 'Invalid MCP connection type',
});
});
});
describe('uninstallMCPPlugin', () => {
it('should uninstall plugin and refresh plugins', async () => {
const { result } = renderHook(() => useToolStore());
const uninstallSpy = vi.spyOn(pluginService, 'uninstallPlugin').mockResolvedValue(undefined);
await act(async () => {
await result.current.uninstallMCPPlugin('test-plugin');
});
expect(uninstallSpy).toHaveBeenCalledWith('test-plugin');
expect(result.current.refreshPlugins).toHaveBeenCalled();
});
});
describe('loadMoreMCPPlugins', () => {
it('should increment current page when more items available', () => {
const { result } = renderHook(() => useToolStore());
act(() => {
useToolStore.setState({
mcpPluginItems: Array.from({ length: 10 }, (_, i) => ({
identifier: `plugin-${i}`,
})) as PluginItem[],
totalCount: 50,
currentPage: 1,
});
});
act(() => {
result.current.loadMoreMCPPlugins();
});
expect(result.current.currentPage).toBe(2);
});
it('should not increment page when all items loaded', () => {
const { result } = renderHook(() => useToolStore());
act(() => {
useToolStore.setState({
mcpPluginItems: Array.from({ length: 50 }, (_, i) => ({
identifier: `plugin-${i}`,
})) as PluginItem[],
totalCount: 50,
currentPage: 5,
});
});
act(() => {
result.current.loadMoreMCPPlugins();
});
expect(result.current.currentPage).toBe(5);
});
});
describe('resetMCPPluginList', () => {
it('should reset plugin list and page', () => {
const { result } = renderHook(() => useToolStore());
act(() => {
useToolStore.setState({
mcpPluginItems: [{ identifier: 'plugin-1' }] as PluginItem[],
currentPage: 5,
mcpSearchKeywords: 'old-keyword',
});
});
act(() => {
result.current.resetMCPPluginList('new-keyword');
});
expect(result.current.mcpPluginItems).toEqual([]);
expect(result.current.currentPage).toBe(1);
expect(result.current.mcpSearchKeywords).toBe('new-keyword');
});
it('should reset without keywords', () => {
const { result } = renderHook(() => useToolStore());
act(() => {
useToolStore.setState({
mcpPluginItems: [{ identifier: 'plugin-1' }] as PluginItem[],
currentPage: 3,
});
});
act(() => {
result.current.resetMCPPluginList();
});
expect(result.current.mcpPluginItems).toEqual([]);
expect(result.current.currentPage).toBe(1);
expect(result.current.mcpSearchKeywords).toBeUndefined();
});
});
describe('useFetchMCPPluginList', () => {
it('should fetch MCP plugin list and update state', async () => {
const mockData = {
items: [
{ identifier: 'plugin-1', name: 'Plugin 1' },
{ identifier: 'plugin-2', name: 'Plugin 2' },
] as PluginItem[],
categories: ['category1', 'category2'],
totalCount: 2,
totalPages: 1,
currentPage: 1,
pageSize: 20,
};
vi.spyOn(discoverService, 'getMCPPluginList').mockResolvedValue(mockData);
vi.spyOn(globalHelpers, 'getCurrentLanguage').mockReturnValue('en-US');
const { result } = renderHook(() =>
useToolStore.getState().useFetchMCPPluginList({ page: 1, pageSize: 20 }),
);
await waitFor(() => {
expect(result.current.data).toEqual(mockData);
});
expect(discoverService.getMCPPluginList).toHaveBeenCalledWith({ page: 1, pageSize: 20 });
const state = useToolStore.getState();
expect(state.mcpPluginItems).toEqual(mockData.items);
expect(state.categories).toEqual(mockData.categories);
expect(state.totalCount).toBe(2);
expect(state.totalPages).toBe(1);
expect(state.searchLoading).toBe(false);
});
it('should set active identifier on first init', async () => {
const mockData = {
items: [{ identifier: 'first-plugin', name: 'First Plugin' }] as PluginItem[],
categories: [],
totalCount: 1,
totalPages: 1,
currentPage: 1,
pageSize: 20,
};
vi.spyOn(discoverService, 'getMCPPluginList').mockResolvedValue(mockData);
vi.spyOn(globalHelpers, 'getCurrentLanguage').mockReturnValue('en-US');
act(() => {
useToolStore.setState({ isMcpListInit: false });
});
const { result } = renderHook(() =>
useToolStore.getState().useFetchMCPPluginList({ page: 1 }),
);
await waitFor(() => {
expect(result.current.data).toEqual(mockData);
});
const state = useToolStore.getState();
expect(state.activeMCPIdentifier).toBe('first-plugin');
expect(state.isMcpListInit).toBe(true);
});
it('should convert page to number', async () => {
vi.spyOn(globalHelpers, 'getCurrentLanguage').mockReturnValue('en-US');
vi.spyOn(discoverService, 'getMCPPluginList').mockResolvedValue({
items: [],
categories: [],
totalCount: 0,
totalPages: 0,
currentPage: 1,
pageSize: 20,
});
const params = { page: 2, pageSize: 15 } as any;
renderHook(() => useToolStore.getState().useFetchMCPPluginList(params));
await waitFor(() => {
expect(discoverService.getMCPPluginList).toHaveBeenCalledWith(params);
});
});
it('should include locale and parameters in SWR key', async () => {
vi.spyOn(globalHelpers, 'getCurrentLanguage').mockReturnValue('zh-CN');
vi.spyOn(discoverService, 'getMCPPluginList').mockResolvedValue({
items: [],
categories: [],
totalCount: 0,
totalPages: 0,
currentPage: 1,
pageSize: 20,
});
const params = { page: 3, pageSize: 15, q: 'test' } as any;
renderHook(() => useToolStore.getState().useFetchMCPPluginList(params));
await waitFor(() => {
expect(discoverService.getMCPPluginList).toHaveBeenCalledWith(params);
});
});
});
describe('installMCPPlugin', () => {
const mockPlugin: PluginItem = {
identifier: 'test-plugin',
name: 'Test Plugin',
manifestUrl: 'https://example.com/manifest.json',
icon: 'https://example.com/icon.png',
description: 'Test description',
} as PluginItem;
const mockManifest = {
name: 'Test Plugin',
version: '1.0.0',
deploymentOptions: [
{
type: 'stdio',
command: 'node',
args: ['server.js'],
},
],
};
const mockCheckResult: CheckMcpInstallResult = {
success: true,
platform: 'darwin',
allDependenciesMet: true,
connection: {
type: 'stdio',
command: 'node',
args: ['server.js'],
},
};
const mockServerManifest: LobeChatPluginManifest = {
api: [],
gateway: '',
identifier: 'test-plugin',
meta: {
avatar: 'https://example.com/icon.png',
description: 'Test description',
title: 'Test Plugin',
},
type: 'standalone',
version: '1',
};
beforeEach(() => {
vi.spyOn(discoverService, 'getMCPPluginManifest').mockResolvedValue(mockManifest as any);
vi.spyOn(mcpService, 'checkInstallation').mockResolvedValue(mockCheckResult);
vi.spyOn(mcpService, 'getStdioMcpServerManifest').mockResolvedValue(mockServerManifest);
vi.spyOn(pluginService, 'installPlugin').mockResolvedValue(undefined);
vi.spyOn(discoverService, 'reportMcpInstallResult').mockResolvedValue(undefined as any);
vi.spyOn(discoverService, 'getMcpDetail').mockResolvedValue(mockPlugin as any);
});
describe('normal installation flow', () => {
it('should successfully install MCP plugin', async () => {
const { result } = renderHook(() => useToolStore());
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
let installResult;
await act(async () => {
installResult = await result.current.installMCPPlugin('test-plugin');
});
expect(installResult).toBe(true);
expect(discoverService.getMCPPluginManifest).toHaveBeenCalledWith('test-plugin', {
install: true,
});
expect(mcpService.checkInstallation).toHaveBeenCalled();
expect(mcpService.getStdioMcpServerManifest).toHaveBeenCalled();
expect(pluginService.installPlugin).toHaveBeenCalled();
expect(result.current.refreshPlugins).toHaveBeenCalled();
});
it('should update progress through installation steps', async () => {
const { result } = renderHook(() => useToolStore());
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
const progressUpdates: any[] = [];
const updateProgressSpy = vi
.spyOn(result.current, 'updateMCPInstallProgress')
.mockImplementation((identifier, progress) => {
progressUpdates.push({ identifier, progress });
});
await act(async () => {
await result.current.installMCPPlugin('test-plugin');
});
expect(progressUpdates.length).toBeGreaterThan(0);
expect(
progressUpdates.some((p) => p.progress?.step === MCPInstallStep.FETCHING_MANIFEST),
).toBe(true);
expect(
progressUpdates.some((p) => p.progress?.step === MCPInstallStep.CHECKING_INSTALLATION),
).toBe(true);
expect(
progressUpdates.some((p) => p.progress?.step === MCPInstallStep.GETTING_SERVER_MANIFEST),
).toBe(true);
expect(
progressUpdates.some((p) => p.progress?.step === MCPInstallStep.INSTALLING_PLUGIN),
).toBe(true);
expect(progressUpdates.some((p) => p.progress?.step === MCPInstallStep.COMPLETED)).toBe(
true,
);
updateProgressSpy.mockRestore();
});
it('should fetch plugin detail if not in store', async () => {
const { result } = renderHook(() => useToolStore());
act(() => {
useToolStore.setState({
mcpPluginItems: [],
});
});
await act(async () => {
await result.current.installMCPPlugin('test-plugin');
});
expect(discoverService.getMcpDetail).toHaveBeenCalledWith({ identifier: 'test-plugin' });
});
it('should return early if plugin not found', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(discoverService, 'getMcpDetail').mockResolvedValue(null as any);
act(() => {
useToolStore.setState({
mcpPluginItems: [],
});
});
let installResult;
await act(async () => {
installResult = await result.current.installMCPPlugin('non-existent-plugin');
});
expect(installResult).toBeUndefined();
expect(mcpService.checkInstallation).not.toHaveBeenCalled();
});
});
describe('dependencies check', () => {
it('should pause installation when dependencies not met', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'checkInstallation').mockResolvedValue({
...mockCheckResult,
allDependenciesMet: false,
systemDependencies: [
{
name: 'node',
installed: false,
meetRequirement: false,
},
],
});
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
let installResult;
await act(async () => {
installResult = await result.current.installMCPPlugin('test-plugin');
});
expect(installResult).toBe(false);
expect(pluginService.installPlugin).not.toHaveBeenCalled();
});
it('should skip dependencies check when skipDepsCheck is true', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'checkInstallation').mockResolvedValue({
...mockCheckResult,
allDependenciesMet: false,
});
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
let installResult;
await act(async () => {
installResult = await result.current.installMCPPlugin('test-plugin', {
skipDepsCheck: true,
});
});
expect(installResult).toBe(true);
expect(pluginService.installPlugin).toHaveBeenCalled();
});
});
describe('configuration requirement', () => {
it('should pause installation when configuration is needed', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'checkInstallation').mockResolvedValue({
...mockCheckResult,
needsConfig: true,
configSchema: {
type: 'object',
properties: {
apiKey: { type: 'string' },
},
},
});
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
let installResult;
await act(async () => {
installResult = await result.current.installMCPPlugin('test-plugin');
});
expect(installResult).toBe(false);
expect(pluginService.installPlugin).not.toHaveBeenCalled();
});
});
describe('resume mode', () => {
it('should resume installation with previous config info', async () => {
const { result } = renderHook(() => useToolStore());
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
mcpInstallProgress: {
'test-plugin': {
progress: 50,
step: MCPInstallStep.CONFIGURATION_REQUIRED,
manifest: mockManifest,
connection: mockCheckResult.connection,
checkResult: mockCheckResult,
},
},
});
});
const config = { apiKey: 'test-key' };
await act(async () => {
await result.current.installMCPPlugin('test-plugin', { resume: true, config });
});
expect(discoverService.getMCPPluginManifest).not.toHaveBeenCalled();
expect(mcpService.checkInstallation).not.toHaveBeenCalled();
expect(mcpService.getStdioMcpServerManifest).toHaveBeenCalledWith(
expect.objectContaining({
env: config,
}),
expect.any(Object),
expect.any(AbortSignal),
);
});
it('should return early if config info not found in resume mode', async () => {
const { result } = renderHook(() => useToolStore());
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
mcpInstallProgress: {},
});
});
let installResult;
await act(async () => {
installResult = await result.current.installMCPPlugin('test-plugin', { resume: true });
});
expect(installResult).toBeUndefined();
});
});
describe('HTTP connection', () => {
it('should install HTTP MCP plugin', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'checkInstallation').mockResolvedValue({
...mockCheckResult,
connection: {
type: 'http',
url: 'https://example.com/mcp',
},
});
vi.spyOn(mcpService, 'getStreamableMcpServerManifest').mockResolvedValue(
mockServerManifest,
);
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
await act(async () => {
await result.current.installMCPPlugin('test-plugin');
});
expect(mcpService.getStreamableMcpServerManifest).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://example.com/mcp',
identifier: 'test-plugin',
}),
expect.any(AbortSignal),
);
});
});
describe('version handling', () => {
it('should use larger version from manifest and data', async () => {
const { result } = renderHook(() => useToolStore());
const manifestWithVersion = {
...mockManifest,
version: '1.5.0',
};
const serverManifestWithVersion: LobeChatPluginManifest = {
api: [],
gateway: '',
identifier: 'test-plugin',
meta: {
avatar: 'https://example.com/icon.png',
description: 'Test description',
title: 'Test Plugin',
},
type: 'standalone',
version: '1',
};
vi.spyOn(discoverService, 'getMCPPluginManifest').mockResolvedValue(
manifestWithVersion as any,
);
vi.spyOn(mcpService, 'getStdioMcpServerManifest').mockResolvedValue(
serverManifestWithVersion,
);
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
const installPluginSpy = vi.spyOn(pluginService, 'installPlugin');
await act(async () => {
await result.current.installMCPPlugin('test-plugin');
});
expect(installPluginSpy).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({
version: '1.5.0',
}),
}),
);
});
});
describe('cancellation', () => {
it('should handle cancellation during installation', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'checkInstallation').mockImplementation(async (manifest, signal) => {
// Cancel after check
setTimeout(() => {
result.current.cancelInstallMCPPlugin('test-plugin');
}, 10);
await new Promise((resolve) => setTimeout(resolve, 20));
return mockCheckResult;
});
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
await act(async () => {
await result.current.installMCPPlugin('test-plugin');
});
// Should not install if cancelled
expect(pluginService.installPlugin).not.toHaveBeenCalled();
});
});
describe('error handling', () => {
it('should handle structured MCP error', async () => {
const { result } = renderHook(() => useToolStore());
// Create proper TRPC error with data property
const mcpError: any = new Error('MCP Error');
mcpError.data = {
errorData: {
type: 'CONNECTION_ERROR',
message: 'Failed to connect to MCP server',
metadata: {
step: 'connection',
timestamp: Date.now(),
},
},
};
vi.spyOn(mcpService, 'getStdioMcpServerManifest').mockRejectedValue(mcpError);
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
await act(async () => {
await result.current.installMCPPlugin('test-plugin');
});
const progress = result.current.mcpInstallProgress['test-plugin'];
expect(progress?.step).toBe(MCPInstallStep.ERROR);
expect(progress?.errorInfo).toMatchObject({
type: 'CONNECTION_ERROR',
message: 'Failed to connect to MCP server',
metadata: expect.objectContaining({
step: 'connection',
}),
});
});
it('should handle generic error', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'getStdioMcpServerManifest').mockRejectedValue(
new Error('Generic error'),
);
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
await act(async () => {
await result.current.installMCPPlugin('test-plugin');
});
expect(result.current.mcpInstallProgress['test-plugin']).toMatchObject({
step: MCPInstallStep.ERROR,
errorInfo: {
type: 'UNKNOWN_ERROR',
message: 'Generic error',
},
});
});
it('should return undefined if manifest not retrieved', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'getStdioMcpServerManifest').mockResolvedValue(undefined as any);
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
let installResult;
await act(async () => {
installResult = await result.current.installMCPPlugin('test-plugin');
});
expect(installResult).toBeUndefined();
expect(pluginService.installPlugin).not.toHaveBeenCalled();
});
it('should return undefined if installation check fails', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'checkInstallation').mockResolvedValue({
...mockCheckResult,
success: false,
});
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
let installResult;
await act(async () => {
installResult = await result.current.installMCPPlugin('test-plugin');
});
expect(installResult).toBeUndefined();
expect(mcpService.getStdioMcpServerManifest).not.toHaveBeenCalled();
});
it('should report installation failure', async () => {
const { result } = renderHook(() => useToolStore());
vi.spyOn(mcpService, 'getStdioMcpServerManifest').mockRejectedValue(
new Error('Installation failed'),
);
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
await act(async () => {
await result.current.installMCPPlugin('test-plugin');
});
expect(discoverService.reportMcpInstallResult).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
errorMessage: 'Installation failed',
identifier: 'test-plugin',
}),
);
});
});
describe('installation reporting', () => {
it('should report successful installation', async () => {
const { result } = renderHook(() => useToolStore());
act(() => {
useToolStore.setState({
mcpPluginItems: [mockPlugin],
});
});
await act(async () => {
await result.current.installMCPPlugin('test-plugin');
});
expect(discoverService.reportMcpInstallResult).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
identifier: 'test-plugin',
platform: 'darwin',
version: '1.0.0',
}),
);
});
});
});
});