import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { McpSettings } from '../pages/McpSettings'
import { useMcpStore } from '../stores/mcpStore'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
describe('McpSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useSessionStore.setState({
sessions: [
{
id: 'session-1',
title: 'Test Session',
createdAt: '',
modifiedAt: '',
messageCount: 0,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
},
],
activeSessionId: 'session-1',
isLoading: false,
error: null,
fetchSessions: vi.fn(),
createSession: vi.fn(),
deleteSession: vi.fn(),
renameSession: vi.fn(),
updateSessionTitle: vi.fn(),
setActiveSession: vi.fn(),
})
useMcpStore.setState({
servers: [],
selectedServer: null,
isLoading: false,
error: null,
fetchServers: vi.fn(),
createServer: vi.fn(),
updateServer: vi.fn(),
deleteServer: vi.fn(),
toggleServer: vi.fn(),
reconnectServer: vi.fn(),
refreshServerStatus: vi.fn(),
selectServer: vi.fn(),
})
})
it('loads only global MCP servers on mount', () => {
const fetchServers = vi.fn()
useMcpStore.setState({ fetchServers })
render()
expect(fetchServers).toHaveBeenCalledWith(undefined, '/workspace/project')
})
it('renders the empty state and add button', () => {
render()
expect(screen.getByText('MCP servers')).toBeInTheDocument()
expect(screen.getByText('No MCP servers configured yet')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /add server/i })).toBeInTheDocument()
})
it('shows plugin and user MCP servers in grouped sections', () => {
useMcpStore.setState({
servers: [
{
name: 'plugin:telegram:telegram',
scope: 'dynamic',
transport: 'stdio',
enabled: true,
status: 'connected',
statusLabel: 'Connected',
configLocation: '/tmp/config',
summary: 'npx @telegram/mcp',
canEdit: false,
canRemove: false,
canReconnect: true,
canToggle: true,
config: { type: 'stdio', command: 'npx', args: ['@telegram/mcp'], env: {} },
},
{
name: 'global-user',
scope: 'user',
transport: 'http',
enabled: true,
status: 'connected',
statusLabel: 'Connected',
configLocation: '/tmp/config',
summary: 'https://example.com/mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
config: { type: 'http', url: 'https://example.com/mcp', headers: {} },
},
],
})
render()
expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0)
expect(screen.getAllByText('User').length).toBeGreaterThan(0)
expect(screen.getByText('plugin:telegram:telegram')).toBeInTheDocument()
expect(screen.getByText('global-user')).toBeInTheDocument()
})
it('starts background status refresh after the fast list render', async () => {
const server = {
name: 'deepwiki',
scope: 'user',
transport: 'http',
enabled: true,
status: 'checking' as const,
statusLabel: 'Checking',
configLocation: '/tmp/config',
summary: 'https://example.com/mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
config: { type: 'http' as const, url: 'https://example.com/mcp', headers: {} },
}
const refreshServerStatus = vi.fn().mockResolvedValue({
...server,
status: 'connected' as const,
statusLabel: 'Connected',
})
useMcpStore.setState({
servers: [server],
refreshServerStatus,
})
render()
expect(screen.getByText('Checking')).toBeInTheDocument()
await waitFor(() => {
expect(refreshServerStatus).toHaveBeenCalledWith(server, '/workspace/project')
})
})
it('opens the delete confirmation modal from the edit view and deletes with the active cwd', async () => {
const deleteServer = vi.fn().mockResolvedValue(undefined)
const server = {
name: 'global-user',
scope: 'user',
transport: 'http',
enabled: true,
status: 'connected',
statusLabel: 'Connected',
configLocation: '/tmp/config',
summary: 'https://example.com/mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
config: { type: 'http', url: 'https://example.com/mcp', headers: {} },
} as const
useMcpStore.setState({
servers: [server],
deleteServer,
})
render()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open global-user' }))
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /uninstall/i }))
})
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Delete MCP server')).toBeInTheDocument()
expect(screen.getByText('Delete MCP server "global-user"? This action cannot be undone.')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
})
expect(deleteServer).toHaveBeenCalledWith(server, '/workspace/project')
})
it('uses the active cwd when toggling a server', async () => {
const toggleServer = vi.fn().mockResolvedValue(undefined)
const server = {
name: 'global-user',
scope: 'user',
transport: 'http',
enabled: true,
status: 'connected',
statusLabel: 'Connected',
configLocation: '/tmp/config',
summary: 'https://example.com/mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
config: { type: 'http', url: 'https://example.com/mcp', headers: {} },
} as const
useMcpStore.setState({
servers: [server],
toggleServer,
})
render()
await act(async () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project')
})
it('shows reconnecting status immediately in the detail view', async () => {
let resolveReconnect: ((value: typeof server) => void) | null = null
const server = {
name: 'plugin:telegram:telegram',
scope: 'dynamic',
transport: 'stdio',
enabled: true,
status: 'failed' as 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking',
statusLabel: 'Unavailable',
statusDetail: 'Timed out' as string | undefined,
configLocation: '/tmp/config',
summary: 'bun run start',
canEdit: false,
canRemove: false,
canReconnect: true,
canToggle: true,
config: { type: 'stdio' as const, command: 'bun', args: ['run', 'start'], env: {} },
}
const reconnectServer = vi.fn().mockImplementation(() => new Promise((resolve) => {
resolveReconnect = resolve
}))
useMcpStore.setState({
servers: [server],
reconnectServer,
})
render()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open plugin:telegram:telegram' }))
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /reconnect/i }))
})
expect(screen.getAllByText('Reconnecting...').length).toBeGreaterThan(0)
expect(reconnectServer).toHaveBeenCalledWith(server, '/workspace/project')
await act(async () => {
resolveReconnect?.({
...server,
status: 'connected',
statusLabel: 'Connected',
statusDetail: undefined,
})
})
})
})