Compare commits

...

4 Commits

Author SHA1 Message Date
CodingOnStar
7314345b7a suppression 2026-01-26 15:24:50 +08:00
Coding On Star
fb08ac1a86 Merge branch 'main' into refactor/tools-components 2026-01-26 14:41:39 +08:00
CodingOnStar
53a620b6ce test(mcp): enhance unit tests for MCP components and improve mock handling
- Added beforeEach hooks to reset mock states in  and  for better test isolation.
- Refactored mock data handling in  to streamline test setup.
- Improved error handling in  to gracefully manage JSON parsing errors.
- Updated  to utilize a pending status for optimistic updates, enhancing user experience during state changes.

These changes aim to improve the reliability and maintainability of tests across MCP components.
2026-01-26 14:41:19 +08:00
CodingOnStar
6489903c77 refactor(web): extract MCP components and add comprehensive tests
Extract logic and UI components from mcp-service-card and modal into reusable hooks and sub-components. Add comprehensive test coverage for all MCP components including new section components for authentication, configurations, and headers.

Changes:
- Extract useMCPServiceCardState and useMCPModalForm hooks
- Create section components: AuthenticationSection, ConfigurationsSection, HeadersSection
- Refactor mcp-service-card and modal for better maintainability
- Add 7000+ lines of test coverage with >95% component coverage

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-26 14:14:42 +08:00
25 changed files with 7082 additions and 573 deletions

View File

@@ -0,0 +1,221 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NewMCPCard from './create-card'
// Track the mock functions
const mockCreateMCP = vi.fn().mockResolvedValue({ id: 'new-mcp-id', name: 'New MCP' })
// Mock the service
vi.mock('@/service/use-tools', () => ({
useCreateMCP: () => ({
mutateAsync: mockCreateMCP,
}),
}))
// Mock the MCP Modal
type MockMCPModalProps = {
show: boolean
onConfirm: (info: { name: string, server_url: string }) => void
onHide: () => void
}
vi.mock('./modal', () => ({
default: ({ show, onConfirm, onHide }: MockMCPModalProps) => {
if (!show)
return null
return (
<div data-testid="mcp-modal">
<span>tools.mcp.modal.title</span>
<button data-testid="confirm-btn" onClick={() => onConfirm({ name: 'Test MCP', server_url: 'https://test.com' })}>
Confirm
</button>
<button data-testid="close-btn" onClick={onHide}>
Close
</button>
</div>
)
},
}))
// Mutable workspace manager state
let mockIsCurrentWorkspaceManager = true
// Mock the app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock the plugins service
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
// Mock common service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
describe('NewMCPCard', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const defaultProps = {
handleCreate: vi.fn(),
}
beforeEach(() => {
mockCreateMCP.mockClear()
mockIsCurrentWorkspaceManager = true
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument()
})
it('should render card title', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument()
})
it('should render documentation link', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.create.cardLink')).toBeInTheDocument()
})
it('should render add icon', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const svgElements = document.querySelectorAll('svg')
expect(svgElements.length).toBeGreaterThan(0)
})
})
describe('User Interactions', () => {
it('should open modal when card is clicked', async () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
const clickableArea = cardTitle.closest('.group')
if (clickableArea) {
fireEvent.click(clickableArea)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
})
}
})
it('should have documentation link with correct target', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const docLink = screen.getByText('tools.mcp.create.cardLink').closest('a')
expect(docLink).toHaveAttribute('target', '_blank')
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})
describe('Non-Manager User', () => {
it('should not render card when user is not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.create.cardTitle')).not.toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct card structure', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const card = document.querySelector('.rounded-xl')
expect(card).toBeInTheDocument()
})
it('should have clickable cursor style', () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
const card = document.querySelector('.cursor-pointer')
expect(card).toBeInTheDocument()
})
})
describe('Modal Interactions', () => {
it('should call create function when modal confirms', async () => {
const handleCreate = vi.fn()
render(<NewMCPCard handleCreate={handleCreate} />, { wrapper: createWrapper() })
// Open the modal
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
const clickableArea = cardTitle.closest('.group')
if (clickableArea) {
fireEvent.click(clickableArea)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Click confirm
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockCreateMCP).toHaveBeenCalledWith({
name: 'Test MCP',
server_url: 'https://test.com',
})
expect(handleCreate).toHaveBeenCalled()
})
}
})
it('should close modal when close button is clicked', async () => {
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Open the modal
const cardTitle = screen.getByText('tools.mcp.create.cardTitle')
const clickableArea = cardTitle.closest('.group')
if (clickableArea) {
fireEvent.click(clickableArea)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Click close
const closeBtn = screen.getByTestId('close-btn')
fireEvent.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('mcp-modal')).not.toBeInTheDocument()
})
}
})
})
})

View File

@@ -0,0 +1,855 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPDetailContent from './content'
// Mutable mock functions
const mockUpdateTools = vi.fn().mockResolvedValue({})
const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' })
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockInvalidateMCPTools = vi.fn()
const mockOpenOAuthPopup = vi.fn()
// Mutable mock state
type MockTool = {
id: string
name: string
description?: string
}
let mockToolsData: { tools: MockTool[] } = { tools: [] }
let mockIsFetching = false
let mockIsUpdating = false
let mockIsAuthorizing = false
// Mock the services
vi.mock('@/service/use-tools', () => ({
useMCPTools: () => ({
data: mockToolsData,
isFetching: mockIsFetching,
}),
useInvalidateMCPTools: () => mockInvalidateMCPTools,
useUpdateMCPTools: () => ({
mutateAsync: mockUpdateTools,
isPending: mockIsUpdating,
}),
useAuthorizeMCP: () => ({
mutateAsync: mockAuthorizeMcp,
isPending: mockIsAuthorizing,
}),
useUpdateMCP: () => ({
mutateAsync: mockUpdateMCP,
}),
useDeleteMCP: () => ({
mutateAsync: mockDeleteMCP,
}),
}))
// Mock OAuth hook
type OAuthArgs = readonly unknown[]
vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: (...args: OAuthArgs) => mockOpenOAuthPopup(...args),
}))
// Mock MCPModal
type MCPModalData = {
name: string
server_url: string
}
type MCPModalProps = {
show: boolean
onConfirm: (data: MCPModalData) => void
onHide: () => void
}
vi.mock('../modal', () => ({
default: ({ show, onConfirm, onHide }: MCPModalProps) => {
if (!show)
return null
return (
<div data-testid="mcp-update-modal">
<button data-testid="modal-confirm-btn" onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}>
Confirm
</button>
<button data-testid="modal-close-btn" onClick={onHide}>
Close
</button>
</div>
)
},
}))
// Mock Confirm dialog
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog" data-title={title}>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
</div>
)
},
}))
// Mock OperationDropdown
vi.mock('./operation-dropdown', () => ({
default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => (
<div data-testid="operation-dropdown">
<button data-testid="edit-btn" onClick={onEdit}>Edit</button>
<button data-testid="remove-btn" onClick={onRemove}>Remove</button>
</div>
),
}))
// Mock ToolItem
type ToolItemData = {
name: string
}
vi.mock('./tool-item', () => ({
default: ({ tool }: { tool: ToolItemData }) => (
<div data-testid="tool-item">{tool.name}</div>
),
}))
// Mutable workspace manager state
let mockIsCurrentWorkspaceManager = true
// Mock the app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock the plugins service
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
// Mock common service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
// Mock copy-to-clipboard
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
describe('MCPDetailContent', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockDetail = (overrides = {}): ToolWithProvider => ({
id: 'mcp-1',
name: 'Test MCP Server',
server_identifier: 'test-mcp',
server_url: 'https://example.com/mcp',
icon: { content: '🔧', background: '#FF0000' },
tools: [],
is_team_authorization: false,
...overrides,
} as unknown as ToolWithProvider)
const defaultProps = {
detail: createMockDetail(),
onUpdate: vi.fn(),
onHide: vi.fn(),
isTriggerAuthorize: false,
onFirstCreate: vi.fn(),
}
beforeEach(() => {
// Reset mocks
mockUpdateTools.mockClear()
mockAuthorizeMcp.mockClear()
mockUpdateMCP.mockClear()
mockDeleteMCP.mockClear()
mockInvalidateMCPTools.mockClear()
mockOpenOAuthPopup.mockClear()
// Reset mock return values
mockUpdateTools.mockResolvedValue({})
mockAuthorizeMcp.mockResolvedValue({ result: 'success' })
mockUpdateMCP.mockResolvedValue({ result: 'success' })
mockDeleteMCP.mockResolvedValue({ result: 'success' })
// Reset state
mockToolsData = { tools: [] }
mockIsFetching = false
mockIsUpdating = false
mockIsAuthorizing = false
mockIsCurrentWorkspaceManager = true
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display MCP name', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display server identifier', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-mcp')).toBeInTheDocument()
})
it('should display server URL', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('https://example.com/mcp')).toBeInTheDocument()
})
it('should render close button', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Close button should be present
const closeButtons = document.querySelectorAll('button')
expect(closeButtons.length).toBeGreaterThan(0)
})
it('should render operation dropdown', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Operation dropdown trigger should be present
expect(document.querySelector('button')).toBeInTheDocument()
})
})
describe('Authorization State', () => {
it('should show authorize button when not authorized', () => {
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.authorize')).toBeInTheDocument()
})
it('should show authorized button when authorized', () => {
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
})
it('should show authorization required message when not authorized', () => {
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.authorizingRequired')).toBeInTheDocument()
})
it('should show authorization tip', () => {
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.authorizeTip')).toBeInTheDocument()
})
})
describe('Empty Tools State', () => {
it('should show empty message when authorized but no tools', () => {
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.toolsEmpty')).toBeInTheDocument()
})
it('should show get tools button when empty', () => {
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.getTools')).toBeInTheDocument()
})
})
describe('Icon Display', () => {
it('should render MCP icon', () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Icon container should be present
const iconContainer = document.querySelector('[class*="rounded-xl"][class*="border"]')
expect(iconContainer).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty server URL', () => {
const detail = createMockDetail({ server_url: '' })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should handle long MCP name', () => {
const longName = 'A'.repeat(100)
const detail = createMockDetail({ name: longName })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText(longName)).toBeInTheDocument()
})
})
describe('Tools List', () => {
it('should show tools list when authorized and has tools', () => {
mockToolsData = {
tools: [
{ id: 'tool1', name: 'tool1', description: 'Tool 1' },
{ id: 'tool2', name: 'tool2', description: 'Tool 2' },
],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tool1')).toBeInTheDocument()
expect(screen.getByText('tool2')).toBeInTheDocument()
})
it('should show single tool label when only one tool', () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.onlyTool')).toBeInTheDocument()
})
it('should show tools count when multiple tools', () => {
mockToolsData = {
tools: [
{ id: 'tool1', name: 'tool1', description: 'Tool 1' },
{ id: 'tool2', name: 'tool2', description: 'Tool 2' },
],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText(/tools.mcp.toolsNum/)).toBeInTheDocument()
})
})
describe('Loading States', () => {
it('should show loading state when fetching tools', () => {
mockIsFetching = true
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.gettingTools')).toBeInTheDocument()
})
it('should show updating state when updating tools', () => {
mockIsUpdating = true
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.updateTools')).toBeInTheDocument()
})
it('should show authorizing button when authorizing', () => {
mockIsAuthorizing = true
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
// Multiple elements show authorizing text - use getAllByText
const authorizingElements = screen.getAllByText('tools.mcp.authorizing')
expect(authorizingElements.length).toBeGreaterThan(0)
})
})
describe('Authorize Flow', () => {
it('should call authorizeMcp when authorize button is clicked', async () => {
const onFirstCreate = vi.fn()
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />,
{ wrapper: createWrapper() },
)
const authorizeBtn = screen.getByText('tools.mcp.authorize')
fireEvent.click(authorizeBtn)
await waitFor(() => {
expect(onFirstCreate).toHaveBeenCalled()
expect(mockAuthorizeMcp).toHaveBeenCalledWith({ provider_id: 'mcp-1' })
})
})
it('should open OAuth popup when authorization_url is returned', async () => {
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const authorizeBtn = screen.getByText('tools.mcp.authorize')
fireEvent.click(authorizeBtn)
await waitFor(() => {
expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
'https://oauth.example.com',
expect.any(Function),
)
})
})
it('should trigger authorize on mount when isTriggerAuthorize is true', async () => {
const onFirstCreate = vi.fn()
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} isTriggerAuthorize={true} onFirstCreate={onFirstCreate} />,
{ wrapper: createWrapper() },
)
await waitFor(() => {
expect(onFirstCreate).toHaveBeenCalled()
expect(mockAuthorizeMcp).toHaveBeenCalled()
})
})
it('should disable authorize button when not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const authorizeBtn = screen.getByText('tools.mcp.authorize')
expect(authorizeBtn.closest('button')).toBeDisabled()
})
})
describe('Update Tools Flow', () => {
it('should show update confirm dialog when update button is clicked', async () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const updateBtn = screen.getByText('tools.mcp.update')
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
})
it('should call updateTools when update is confirmed', async () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const onUpdate = vi.fn()
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
// Open confirm dialog
const updateBtn = screen.getByText('tools.mcp.update')
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm the update
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1')
expect(onUpdate).toHaveBeenCalled()
})
})
it('should call handleUpdateTools when get tools button is clicked', async () => {
const onUpdate = vi.fn()
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
render(
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
const getToolsBtn = screen.getByText('tools.mcp.getTools')
fireEvent.click(getToolsBtn)
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
})
})
})
describe('Update MCP Modal', () => {
it('should open update modal when edit button is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
})
it('should close update modal when close button is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Open modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
// Close modal
const closeBtn = screen.getByTestId('modal-close-btn')
fireEvent.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('mcp-update-modal')).not.toBeInTheDocument()
})
})
it('should call updateMCP when form is confirmed', async () => {
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
// Confirm form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalledWith({
name: 'Updated MCP',
server_url: 'https://updated.com',
provider_id: 'mcp-1',
})
expect(onUpdate).toHaveBeenCalled()
})
})
it('should not call onUpdate when updateMCP fails', async () => {
mockUpdateMCP.mockResolvedValue({ result: 'error' })
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
})
// Confirm form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalled()
})
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('Delete MCP Flow', () => {
it('should open delete confirm when remove button is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
})
it('should close delete confirm when cancel is clicked', async () => {
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Open confirm
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Cancel
const cancelBtn = screen.getByTestId('cancel-btn')
fireEvent.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
it('should call deleteMCP when delete is confirmed', async () => {
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open confirm
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1')
expect(onUpdate).toHaveBeenCalledWith(true)
})
})
it('should not call onUpdate when deleteMCP fails', async () => {
mockDeleteMCP.mockResolvedValue({ result: 'error' })
const onUpdate = vi.fn()
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open confirm
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalled()
})
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('Close Button', () => {
it('should call onHide when close button is clicked', () => {
const onHide = vi.fn()
render(<MCPDetailContent {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
// Find the close button (ActionButton with RiCloseLine)
const buttons = screen.getAllByRole('button')
const closeButton = buttons.find(btn =>
btn.querySelector('svg.h-4.w-4'),
)
if (closeButton) {
fireEvent.click(closeButton)
expect(onHide).toHaveBeenCalled()
}
})
})
describe('Copy Server Identifier', () => {
it('should copy server identifier when clicked', async () => {
const { default: copy } = await import('copy-to-clipboard')
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
// Find the server identifier element
const serverIdentifier = screen.getByText('test-mcp')
fireEvent.click(serverIdentifier)
expect(copy).toHaveBeenCalledWith('test-mcp')
})
})
describe('OAuth Callback', () => {
it('should call handleUpdateTools on OAuth callback when authorized', async () => {
// Simulate OAuth flow with authorization_url
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
const onUpdate = vi.fn()
const detail = createMockDetail({ is_team_authorization: false })
render(
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
// Click authorize to trigger OAuth popup
const authorizeBtn = screen.getByText('tools.mcp.authorize')
fireEvent.click(authorizeBtn)
await waitFor(() => {
expect(mockOpenOAuthPopup).toHaveBeenCalled()
})
// Get the callback function and call it
const oauthCallback = mockOpenOAuthPopup.mock.calls[0][1]
oauthCallback()
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
})
})
it('should not call handleUpdateTools if not workspace manager', async () => {
mockIsCurrentWorkspaceManager = false
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
const detail = createMockDetail({ is_team_authorization: false })
// OAuth callback should not trigger update for non-manager
// The button is disabled, so we simulate a scenario where OAuth was already started
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
// Button should be disabled
const authorizeBtn = screen.getByText('tools.mcp.authorize')
expect(authorizeBtn.closest('button')).toBeDisabled()
})
})
describe('Authorized Button', () => {
it('should show authorized button when team is authorized', () => {
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
})
it('should call handleAuthorize when authorized button is clicked', async () => {
const onFirstCreate = vi.fn()
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />,
{ wrapper: createWrapper() },
)
const authorizedBtn = screen.getByText('tools.auth.authorized')
fireEvent.click(authorizedBtn)
await waitFor(() => {
expect(onFirstCreate).toHaveBeenCalled()
expect(mockAuthorizeMcp).toHaveBeenCalled()
})
})
it('should disable authorized button when not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
const authorizedBtn = screen.getByText('tools.auth.authorized')
expect(authorizedBtn.closest('button')).toBeDisabled()
})
})
describe('Cancel Update Confirm', () => {
it('should close update confirm when cancel is clicked', async () => {
mockToolsData = {
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
}
const detail = createMockDetail({ is_team_authorization: true })
render(
<MCPDetailContent {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
// Open confirm dialog
const updateBtn = screen.getByText('tools.mcp.update')
fireEvent.click(updateBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Cancel the update
const cancelBtn = screen.getByTestId('cancel-btn')
fireEvent.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
})
})

View File

@@ -0,0 +1,71 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ListLoading from './list-loading'
describe('ListLoading', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<ListLoading />)
expect(container).toBeInTheDocument()
})
it('should render 5 skeleton items', () => {
render(<ListLoading />)
const skeletonItems = document.querySelectorAll('[class*="bg-components-panel-on-panel-item-bg-hover"]')
expect(skeletonItems.length).toBe(5)
})
it('should have rounded-xl class on skeleton items', () => {
render(<ListLoading />)
const skeletonItems = document.querySelectorAll('.rounded-xl')
expect(skeletonItems.length).toBeGreaterThanOrEqual(5)
})
it('should have proper spacing', () => {
render(<ListLoading />)
const container = document.querySelector('.space-y-2')
expect(container).toBeInTheDocument()
})
it('should render placeholder bars with different widths', () => {
render(<ListLoading />)
const bar180 = document.querySelector('.w-\\[180px\\]')
const bar148 = document.querySelector('.w-\\[148px\\]')
const bar196 = document.querySelector('.w-\\[196px\\]')
expect(bar180).toBeInTheDocument()
expect(bar148).toBeInTheDocument()
expect(bar196).toBeInTheDocument()
})
it('should have opacity styling on skeleton bars', () => {
render(<ListLoading />)
const opacity20Bars = document.querySelectorAll('.opacity-20')
const opacity10Bars = document.querySelectorAll('.opacity-10')
expect(opacity20Bars.length).toBeGreaterThan(0)
expect(opacity10Bars.length).toBeGreaterThan(0)
})
})
describe('Structure', () => {
it('should have correct nested structure', () => {
render(<ListLoading />)
const items = document.querySelectorAll('.space-y-3')
expect(items.length).toBe(5)
})
it('should render padding on skeleton items', () => {
render(<ListLoading />)
const paddedItems = document.querySelectorAll('.p-4')
expect(paddedItems.length).toBe(5)
})
it('should render height-2 skeleton bars', () => {
render(<ListLoading />)
const h2Bars = document.querySelectorAll('.h-2')
// 3 bars per skeleton item * 5 items = 15
expect(h2Bars.length).toBe(15)
})
})
})

View File

@@ -0,0 +1,193 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OperationDropdown from './operation-dropdown'
describe('OperationDropdown', () => {
const defaultProps = {
onEdit: vi.fn(),
onRemove: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<OperationDropdown {...defaultProps} />)
expect(document.querySelector('button')).toBeInTheDocument()
})
it('should render trigger button with more icon', () => {
render(<OperationDropdown {...defaultProps} />)
const button = document.querySelector('button')
expect(button).toBeInTheDocument()
const svg = button?.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render medium size by default', () => {
render(<OperationDropdown {...defaultProps} />)
const icon = document.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
it('should render large size when inCard is true', () => {
render(<OperationDropdown {...defaultProps} inCard={true} />)
const icon = document.querySelector('.h-5.w-5')
expect(icon).toBeInTheDocument()
})
})
describe('Dropdown Behavior', () => {
it('should open dropdown when trigger is clicked', async () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
// Dropdown content should be rendered
expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument()
}
})
it('should call onOpenChange when opened', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
expect(onOpenChange).toHaveBeenCalledWith(true)
}
})
it('should close dropdown when trigger is clicked again', async () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
fireEvent.click(trigger)
expect(onOpenChange).toHaveBeenLastCalledWith(false)
}
})
})
describe('Menu Actions', () => {
it('should call onEdit when edit option is clicked', () => {
const onEdit = vi.fn()
render(<OperationDropdown {...defaultProps} onEdit={onEdit} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const editOption = screen.getByText('tools.mcp.operation.edit')
fireEvent.click(editOption)
expect(onEdit).toHaveBeenCalledTimes(1)
}
})
it('should call onRemove when remove option is clicked', () => {
const onRemove = vi.fn()
render(<OperationDropdown {...defaultProps} onRemove={onRemove} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const removeOption = screen.getByText('tools.mcp.operation.remove')
fireEvent.click(removeOption)
expect(onRemove).toHaveBeenCalledTimes(1)
}
})
it('should close dropdown after edit is clicked', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
onOpenChange.mockClear()
const editOption = screen.getByText('tools.mcp.operation.edit')
fireEvent.click(editOption)
expect(onOpenChange).toHaveBeenCalledWith(false)
}
})
it('should close dropdown after remove is clicked', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
onOpenChange.mockClear()
const removeOption = screen.getByText('tools.mcp.operation.remove')
fireEvent.click(removeOption)
expect(onOpenChange).toHaveBeenCalledWith(false)
}
})
})
describe('Styling', () => {
it('should have correct dropdown width', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const dropdown = document.querySelector('.w-\\[160px\\]')
expect(dropdown).toBeInTheDocument()
}
})
it('should have rounded-xl on dropdown', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
const dropdown = document.querySelector('[class*="rounded-xl"][class*="border"]')
expect(dropdown).toBeInTheDocument()
}
})
it('should show destructive hover style on remove option', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = document.querySelector('button')
if (trigger) {
fireEvent.click(trigger)
// The text is in a div, and the hover style is on the parent div with group class
const removeOptionText = screen.getByText('tools.mcp.operation.remove')
const removeOptionContainer = removeOptionText.closest('.group')
expect(removeOptionContainer).toHaveClass('hover:bg-state-destructive-hover')
}
})
})
describe('inCard prop', () => {
it('should adjust offset when inCard is false', () => {
render(<OperationDropdown {...defaultProps} inCard={false} />)
// Component renders with different offset values
expect(document.querySelector('button')).toBeInTheDocument()
})
it('should adjust offset when inCard is true', () => {
render(<OperationDropdown {...defaultProps} inCard={true} />)
// Component renders with different offset values
expect(document.querySelector('button')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,153 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPDetailPanel from './provider-detail'
// Mock the drawer component
vi.mock('@/app/components/base/drawer', () => ({
default: ({ children, isOpen }: { children: ReactNode, isOpen: boolean }) => {
if (!isOpen)
return null
return <div data-testid="drawer">{children}</div>
},
}))
// Mock the content component to expose onUpdate callback
vi.mock('./content', () => ({
default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => (
<div data-testid="mcp-detail-content">
{detail.name}
<button data-testid="update-btn" onClick={() => onUpdate()}>Update</button>
<button data-testid="delete-btn" onClick={() => onUpdate(true)}>Delete</button>
</div>
),
}))
describe('MCPDetailPanel', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockDetail = (): ToolWithProvider => ({
id: 'mcp-1',
name: 'Test MCP',
server_identifier: 'test-mcp',
server_url: 'https://example.com/mcp',
icon: { content: '🔧', background: '#FF0000' },
tools: [],
is_team_authorization: true,
} as unknown as ToolWithProvider)
const defaultProps = {
onUpdate: vi.fn(),
onHide: vi.fn(),
isTriggerAuthorize: false,
onFirstCreate: vi.fn(),
}
describe('Rendering', () => {
it('should render nothing when detail is undefined', () => {
const { container } = render(
<MCPDetailPanel {...defaultProps} detail={undefined} />,
{ wrapper: createWrapper() },
)
expect(container.innerHTML).toBe('')
})
it('should render drawer when detail is provided', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('drawer')).toBeInTheDocument()
})
it('should render content when detail is provided', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
})
it('should pass detail to content component', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test MCP')).toBeInTheDocument()
})
})
describe('Callbacks', () => {
it('should call onUpdate when update is triggered', () => {
const onUpdate = vi.fn()
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} />,
{ wrapper: createWrapper() },
)
// The update callback is passed to content component
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
})
it('should accept isTriggerAuthorize prop', () => {
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} isTriggerAuthorize={true} />,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
})
})
describe('handleUpdate', () => {
it('should call onUpdate but not onHide when isDelete is false (default)', () => {
const onUpdate = vi.fn()
const onHide = vi.fn()
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Click update button which calls onUpdate() without isDelete parameter
const updateBtn = screen.getByTestId('update-btn')
fireEvent.click(updateBtn)
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onHide).not.toHaveBeenCalled()
})
it('should call both onHide and onUpdate when isDelete is true', () => {
const onUpdate = vi.fn()
const onHide = vi.fn()
const detail = createMockDetail()
render(
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Click delete button which calls onUpdate(true)
const deleteBtn = screen.getByTestId('delete-btn')
fireEvent.click(deleteBtn)
expect(onHide).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,126 @@
import type { Tool } from '@/app/components/tools/types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import MCPToolItem from './tool-item'
describe('MCPToolItem', () => {
const createMockTool = (overrides = {}): Tool => ({
name: 'test-tool',
label: {
en_US: 'Test Tool',
zh_Hans: '测试工具',
},
description: {
en_US: 'A test tool description',
zh_Hans: '测试工具描述',
},
parameters: [],
...overrides,
} as unknown as Tool)
describe('Rendering', () => {
it('should render without crashing', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
it('should display tool label', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
it('should display tool description', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('A test tool description')).toBeInTheDocument()
})
})
describe('With Parameters', () => {
it('should not show parameters section when no parameters', () => {
const tool = createMockTool({ parameters: [] })
render(<MCPToolItem tool={tool} />)
expect(screen.queryByText('tools.mcp.toolItem.parameters')).not.toBeInTheDocument()
})
it('should render with parameters', () => {
const tool = createMockTool({
parameters: [
{
name: 'param1',
type: 'string',
human_description: {
en_US: 'A parameter description',
},
},
],
})
render(<MCPToolItem tool={tool} />)
// Tooltip content is rendered in portal, may not be visible immediately
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have cursor-pointer class', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
const toolElement = document.querySelector('.cursor-pointer')
expect(toolElement).toBeInTheDocument()
})
it('should have rounded-xl class', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
const toolElement = document.querySelector('.rounded-xl')
expect(toolElement).toBeInTheDocument()
})
it('should have hover styles', () => {
const tool = createMockTool()
render(<MCPToolItem tool={tool} />)
const toolElement = document.querySelector('[class*="hover:bg-components-panel-on-panel-item-bg-hover"]')
expect(toolElement).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty label', () => {
const tool = createMockTool({
label: { en_US: '', zh_Hans: '' },
})
render(<MCPToolItem tool={tool} />)
// Should render without crashing
expect(document.querySelector('.cursor-pointer')).toBeInTheDocument()
})
it('should handle empty description', () => {
const tool = createMockTool({
description: { en_US: '', zh_Hans: '' },
})
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
it('should handle long description with line clamp', () => {
const longDescription = 'This is a very long description '.repeat(20)
const tool = createMockTool({
description: { en_US: longDescription, zh_Hans: longDescription },
})
render(<MCPToolItem tool={tool} />)
const descElement = document.querySelector('.line-clamp-2')
expect(descElement).toBeInTheDocument()
})
it('should handle special characters in tool name', () => {
const tool = createMockTool({
name: 'special-tool_v2.0',
label: { en_US: 'Special Tool <v2.0>', zh_Hans: '特殊工具' },
})
render(<MCPToolItem tool={tool} />)
expect(screen.getByText('Special Tool <v2.0>')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,245 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import HeadersInput from './headers-input'
describe('HeadersInput', () => {
const defaultProps = {
headersItems: [],
onChange: vi.fn(),
}
describe('Empty State', () => {
it('should render no headers message when empty', () => {
render(<HeadersInput {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.noHeaders')).toBeInTheDocument()
})
it('should render add header button when empty and not readonly', () => {
render(<HeadersInput {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
})
it('should not render add header button when empty and readonly', () => {
render(<HeadersInput {...defaultProps} readonly={true} />)
expect(screen.queryByText('tools.mcp.modal.addHeader')).not.toBeInTheDocument()
})
it('should call onChange with new item when add button is clicked', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} onChange={onChange} />)
const addButton = screen.getByText('tools.mcp.modal.addHeader')
fireEvent.click(addButton)
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({
key: '',
value: '',
}),
])
})
})
describe('With Headers', () => {
const headersItems = [
{ id: '1', key: 'Authorization', value: 'Bearer token123' },
{ id: '2', key: 'Content-Type', value: 'application/json' },
]
it('should render header items', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument()
expect(screen.getByDisplayValue('Bearer token123')).toBeInTheDocument()
expect(screen.getByDisplayValue('Content-Type')).toBeInTheDocument()
expect(screen.getByDisplayValue('application/json')).toBeInTheDocument()
})
it('should render table headers', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByText('tools.mcp.modal.headerKey')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.headerValue')).toBeInTheDocument()
})
it('should render delete buttons for each item when not readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
// Should have delete buttons for each header
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
expect(deleteButtons.length).toBe(headersItems.length)
})
it('should not render delete buttons when readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
expect(deleteButtons.length).toBe(0)
})
it('should render add button at bottom when not readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
})
it('should not render add button when readonly', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
expect(screen.queryByText('tools.mcp.modal.addHeader')).not.toBeInTheDocument()
})
})
describe('Masked Headers', () => {
const headersItems = [{ id: '1', key: 'Secret', value: '***' }]
it('should show masked headers tip when isMasked is true', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} isMasked={true} />)
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
it('should not show masked headers tip when isMasked is false', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} isMasked={false} />)
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
})
describe('Item Interactions', () => {
const headersItems = [
{ id: '1', key: 'Header1', value: 'Value1' },
]
it('should call onChange when key is changed', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const keyInput = screen.getByDisplayValue('Header1')
fireEvent.change(keyInput, { target: { value: 'NewHeader' } })
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'NewHeader', value: 'Value1' },
])
})
it('should call onChange when value is changed', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const valueInput = screen.getByDisplayValue('Value1')
fireEvent.change(valueInput, { target: { value: 'NewValue' } })
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'NewValue' },
])
})
it('should remove item when delete button is clicked', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const deleteButton = document.querySelector('[class*="text-text-destructive"]')?.closest('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onChange).toHaveBeenCalledWith([])
}
})
it('should add new item when add button is clicked', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const addButton = screen.getByText('tools.mcp.modal.addHeader')
fireEvent.click(addButton)
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'Value1' },
expect.objectContaining({ key: '', value: '' }),
])
})
})
describe('Multiple Headers', () => {
const headersItems = [
{ id: '1', key: 'Header1', value: 'Value1' },
{ id: '2', key: 'Header2', value: 'Value2' },
{ id: '3', key: 'Header3', value: 'Value3' },
]
it('should render all headers', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('Header1')).toBeInTheDocument()
expect(screen.getByDisplayValue('Header2')).toBeInTheDocument()
expect(screen.getByDisplayValue('Header3')).toBeInTheDocument()
})
it('should update correct item when changed', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
const header2Input = screen.getByDisplayValue('Header2')
fireEvent.change(header2Input, { target: { value: 'UpdatedHeader2' } })
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'Value1' },
{ id: '2', key: 'UpdatedHeader2', value: 'Value2' },
{ id: '3', key: 'Header3', value: 'Value3' },
])
})
it('should remove correct item when deleted', () => {
const onChange = vi.fn()
render(<HeadersInput {...defaultProps} headersItems={headersItems} onChange={onChange} />)
// Find all delete buttons and click the second one
const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]')
const secondDeleteButton = deleteButtons[1]?.closest('button')
if (secondDeleteButton) {
fireEvent.click(secondDeleteButton)
expect(onChange).toHaveBeenCalledWith([
{ id: '1', key: 'Header1', value: 'Value1' },
{ id: '3', key: 'Header3', value: 'Value3' },
])
}
})
})
describe('Readonly Mode', () => {
const headersItems = [{ id: '1', key: 'ReadOnly', value: 'Value' }]
it('should make inputs readonly when readonly is true', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={true} />)
const keyInput = screen.getByDisplayValue('ReadOnly')
const valueInput = screen.getByDisplayValue('Value')
expect(keyInput).toHaveAttribute('readonly')
expect(valueInput).toHaveAttribute('readonly')
})
it('should not make inputs readonly when readonly is false', () => {
render(<HeadersInput {...defaultProps} headersItems={headersItems} readonly={false} />)
const keyInput = screen.getByDisplayValue('ReadOnly')
const valueInput = screen.getByDisplayValue('Value')
expect(keyInput).not.toHaveAttribute('readonly')
expect(valueInput).not.toHaveAttribute('readonly')
})
})
describe('Edge Cases', () => {
it('should handle empty key and value', () => {
const headersItems = [{ id: '1', key: '', value: '' }]
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
const inputs = screen.getAllByRole('textbox')
expect(inputs.length).toBe(2)
})
it('should handle special characters in header key', () => {
const headersItems = [{ id: '1', key: 'X-Custom-Header', value: 'value' }]
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('X-Custom-Header')).toBeInTheDocument()
})
it('should handle JSON value', () => {
const headersItems = [{ id: '1', key: 'Data', value: '{"key":"value"}' }]
render(<HeadersInput {...defaultProps} headersItems={headersItems} />)
expect(screen.getByDisplayValue('{"key":"value"}')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,500 @@
import type { AppIconEmojiSelection, AppIconImageSelection } from '@/app/components/base/app-icon-picker'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { isValidServerID, isValidUrl, useMCPModalForm } from './use-mcp-modal-form'
// Mock the API service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn(),
}))
describe('useMCPModalForm', () => {
describe('Utility Functions', () => {
describe('isValidUrl', () => {
it('should return true for valid http URL', () => {
expect(isValidUrl('http://example.com')).toBe(true)
})
it('should return true for valid https URL', () => {
expect(isValidUrl('https://example.com')).toBe(true)
})
it('should return true for URL with path', () => {
expect(isValidUrl('https://example.com/path/to/resource')).toBe(true)
})
it('should return true for URL with query params', () => {
expect(isValidUrl('https://example.com?foo=bar')).toBe(true)
})
it('should return false for invalid URL', () => {
expect(isValidUrl('not-a-url')).toBe(false)
})
it('should return false for ftp URL', () => {
expect(isValidUrl('ftp://example.com')).toBe(false)
})
it('should return false for empty string', () => {
expect(isValidUrl('')).toBe(false)
})
it('should return false for file URL', () => {
expect(isValidUrl('file:///path/to/file')).toBe(false)
})
})
describe('isValidServerID', () => {
it('should return true for lowercase letters', () => {
expect(isValidServerID('myserver')).toBe(true)
})
it('should return true for numbers', () => {
expect(isValidServerID('123')).toBe(true)
})
it('should return true for alphanumeric with hyphens', () => {
expect(isValidServerID('my-server-123')).toBe(true)
})
it('should return true for alphanumeric with underscores', () => {
expect(isValidServerID('my_server_123')).toBe(true)
})
it('should return true for max length (24 chars)', () => {
expect(isValidServerID('abcdefghijklmnopqrstuvwx')).toBe(true)
})
it('should return false for uppercase letters', () => {
expect(isValidServerID('MyServer')).toBe(false)
})
it('should return false for spaces', () => {
expect(isValidServerID('my server')).toBe(false)
})
it('should return false for special characters', () => {
expect(isValidServerID('my@server')).toBe(false)
})
it('should return false for empty string', () => {
expect(isValidServerID('')).toBe(false)
})
it('should return false for string longer than 24 chars', () => {
expect(isValidServerID('abcdefghijklmnopqrstuvwxy')).toBe(false)
})
})
})
describe('Hook Initialization', () => {
describe('Create Mode (no data)', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useMCPModalForm())
expect(result.current.isCreate).toBe(true)
expect(result.current.formKey).toBe('create')
expect(result.current.state.url).toBe('')
expect(result.current.state.name).toBe('')
expect(result.current.state.serverIdentifier).toBe('')
expect(result.current.state.timeout).toBe(30)
expect(result.current.state.sseReadTimeout).toBe(300)
expect(result.current.state.headers).toEqual([])
expect(result.current.state.authMethod).toBe(MCPAuthMethod.authentication)
expect(result.current.state.isDynamicRegistration).toBe(true)
expect(result.current.state.clientID).toBe('')
expect(result.current.state.credentials).toBe('')
})
it('should initialize with default emoji icon', () => {
const { result } = renderHook(() => useMCPModalForm())
expect(result.current.state.appIcon).toEqual({
type: 'emoji',
icon: '🔗',
background: '#6366F1',
})
})
})
describe('Edit Mode (with data)', () => {
const mockData: ToolWithProvider = {
id: 'test-id-123',
name: 'Test MCP Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🚀', background: '#FF0000' },
configuration: {
timeout: 60,
sse_read_timeout: 600,
},
masked_headers: {
'Authorization': '***',
'X-Custom': 'value',
},
is_dynamic_registration: false,
authentication: {
client_id: 'client-123',
client_secret: 'secret-456',
},
} as unknown as ToolWithProvider
it('should initialize with data values', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.isCreate).toBe(false)
expect(result.current.formKey).toBe('test-id-123')
expect(result.current.state.url).toBe('https://example.com/mcp')
expect(result.current.state.name).toBe('Test MCP Server')
expect(result.current.state.serverIdentifier).toBe('test-server')
expect(result.current.state.timeout).toBe(60)
expect(result.current.state.sseReadTimeout).toBe(600)
expect(result.current.state.isDynamicRegistration).toBe(false)
expect(result.current.state.clientID).toBe('client-123')
expect(result.current.state.credentials).toBe('secret-456')
})
it('should initialize headers from masked_headers', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.headers).toHaveLength(2)
expect(result.current.state.headers[0].key).toBe('Authorization')
expect(result.current.state.headers[0].value).toBe('***')
expect(result.current.state.headers[1].key).toBe('X-Custom')
expect(result.current.state.headers[1].value).toBe('value')
})
it('should initialize emoji icon from data', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.appIcon.type).toBe('emoji')
expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🚀')
expect(((result.current.state.appIcon) as AppIconEmojiSelection).background).toBe('#FF0000')
})
it('should store original server URL and ID', () => {
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.originalServerUrl).toBe('https://example.com/mcp')
expect(result.current.originalServerID).toBe('test-server')
})
})
describe('Edit Mode with string icon', () => {
const mockDataWithImageIcon: ToolWithProvider = {
id: 'test-id',
name: 'Test',
icon: 'https://example.com/files/abc123/file-preview/icon.png',
} as unknown as ToolWithProvider
it('should initialize image icon from string URL', () => {
const { result } = renderHook(() => useMCPModalForm(mockDataWithImageIcon))
expect(result.current.state.appIcon.type).toBe('image')
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/abc123/file-preview/icon.png')
expect(((result.current.state.appIcon) as AppIconImageSelection).fileId).toBe('abc123')
})
})
})
describe('Actions', () => {
it('should update url', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setUrl('https://new-url.com')
})
expect(result.current.state.url).toBe('https://new-url.com')
})
it('should update name', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setName('New Server Name')
})
expect(result.current.state.name).toBe('New Server Name')
})
it('should update serverIdentifier', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setServerIdentifier('new-server-id')
})
expect(result.current.state.serverIdentifier).toBe('new-server-id')
})
it('should update timeout', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setTimeout(120)
})
expect(result.current.state.timeout).toBe(120)
})
it('should update sseReadTimeout', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setSseReadTimeout(900)
})
expect(result.current.state.sseReadTimeout).toBe(900)
})
it('should update headers', () => {
const { result } = renderHook(() => useMCPModalForm())
const newHeaders = [{ id: '1', key: 'X-New', value: 'new-value' }]
act(() => {
result.current.actions.setHeaders(newHeaders)
})
expect(result.current.state.headers).toEqual(newHeaders)
})
it('should update authMethod', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setAuthMethod(MCPAuthMethod.headers)
})
expect(result.current.state.authMethod).toBe(MCPAuthMethod.headers)
})
it('should update isDynamicRegistration', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setIsDynamicRegistration(false)
})
expect(result.current.state.isDynamicRegistration).toBe(false)
})
it('should update clientID', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setClientID('new-client-id')
})
expect(result.current.state.clientID).toBe('new-client-id')
})
it('should update credentials', () => {
const { result } = renderHook(() => useMCPModalForm())
act(() => {
result.current.actions.setCredentials('new-secret')
})
expect(result.current.state.credentials).toBe('new-secret')
})
it('should update appIcon', () => {
const { result } = renderHook(() => useMCPModalForm())
const newIcon = { type: 'emoji' as const, icon: '🎉', background: '#00FF00' }
act(() => {
result.current.actions.setAppIcon(newIcon)
})
expect(result.current.state.appIcon).toEqual(newIcon)
})
it('should toggle showAppIconPicker', () => {
const { result } = renderHook(() => useMCPModalForm())
expect(result.current.state.showAppIconPicker).toBe(false)
act(() => {
result.current.actions.setShowAppIconPicker(true)
})
expect(result.current.state.showAppIconPicker).toBe(true)
})
it('should reset icon to default', () => {
const { result } = renderHook(() => useMCPModalForm())
// Change icon first
act(() => {
result.current.actions.setAppIcon({ type: 'emoji', icon: '🎉', background: '#00FF00' })
})
expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🎉')
// Reset icon
act(() => {
result.current.actions.resetIcon()
})
expect(result.current.state.appIcon).toEqual({
type: 'emoji',
icon: '🔗',
background: '#6366F1',
})
})
})
describe('handleUrlBlur', () => {
it('should not fetch icon in edit mode (when data is provided)', async () => {
const mockData = {
id: 'test',
name: 'Test',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com')
})
// In edit mode, handleUrlBlur should return early
expect(result.current.state.isFetchingIcon).toBe(false)
})
it('should not fetch icon for invalid URL', async () => {
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('not-a-valid-url')
})
expect(result.current.state.isFetchingIcon).toBe(false)
})
it('should handle error when icon fetch fails with error code', async () => {
const { uploadRemoteFileInfo } = await import('@/service/common')
const mockError = {
json: vi.fn().mockResolvedValue({ code: 'UPLOAD_ERROR' }),
}
vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com/mcp')
})
// Should have called console.error
expect(consoleErrorSpy).toHaveBeenCalled()
// isFetchingIcon should be reset to false after error
expect(result.current.state.isFetchingIcon).toBe(false)
consoleErrorSpy.mockRestore()
})
it('should handle error when icon fetch fails without error code', async () => {
const { uploadRemoteFileInfo } = await import('@/service/common')
const mockError = {
json: vi.fn().mockResolvedValue({}),
}
vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com/mcp')
})
// Should have called console.error
expect(consoleErrorSpy).toHaveBeenCalled()
// isFetchingIcon should be reset to false after error
expect(result.current.state.isFetchingIcon).toBe(false)
consoleErrorSpy.mockRestore()
})
it('should fetch icon successfully for valid URL in create mode', async () => {
vi.mocked(await import('@/service/common').then(m => m.uploadRemoteFileInfo)).mockResolvedValueOnce({
id: 'file123',
name: 'icon.png',
size: 1024,
mime_type: 'image/png',
url: 'https://example.com/files/file123/file-preview/icon.png',
} as unknown as { id: string, name: string, size: number, mime_type: string, url: string })
const { result } = renderHook(() => useMCPModalForm())
await act(async () => {
await result.current.actions.handleUrlBlur('https://example.com/mcp')
})
// Icon should be set to image type
expect(result.current.state.appIcon.type).toBe('image')
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/file123/file-preview/icon.png')
expect(result.current.state.isFetchingIcon).toBe(false)
})
})
describe('Edge Cases', () => {
// Base mock data with required icon field
const baseMockData = {
id: 'test',
name: 'Test',
icon: { content: '🔗', background: '#6366F1' },
}
it('should handle undefined configuration', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.timeout).toBe(30)
expect(result.current.state.sseReadTimeout).toBe(300)
})
it('should handle undefined authentication', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.clientID).toBe('')
expect(result.current.state.credentials).toBe('')
})
it('should handle undefined masked_headers', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.headers).toEqual([])
})
it('should handle undefined is_dynamic_registration (defaults to true)', () => {
const mockData = { ...baseMockData } as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.isDynamicRegistration).toBe(true)
})
it('should handle string icon URL', () => {
const mockData = {
id: 'test',
name: 'Test',
icon: 'https://example.com/icon.png',
} as unknown as ToolWithProvider
const { result } = renderHook(() => useMCPModalForm(mockData))
expect(result.current.state.appIcon.type).toBe('image')
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/icon.png')
})
})
})

View File

@@ -0,0 +1,203 @@
'use client'
import type { HeaderItem } from '../headers-input'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { useCallback, useMemo, useRef, useState } from 'react'
import { getDomain } from 'tldts'
import { v4 as uuid } from 'uuid'
import Toast from '@/app/components/base/toast'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { uploadRemoteFileInfo } from '@/service/common'
const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' }
const extractFileId = (url: string) => {
const match = url.match(/files\/(.+?)\/file-preview/)
return match ? match[1] : null
}
const getIcon = (data?: ToolWithProvider): AppIconSelection => {
if (!data)
return DEFAULT_ICON as AppIconSelection
if (typeof data.icon === 'string')
return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
return {
...data.icon,
icon: data.icon.content,
type: 'emoji',
} as unknown as AppIconSelection
}
const getInitialHeaders = (data?: ToolWithProvider): HeaderItem[] => {
return Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value }))
}
export const isValidUrl = (string: string) => {
try {
const url = new URL(string)
return url.protocol === 'http:' || url.protocol === 'https:'
}
catch {
return false
}
}
export const isValidServerID = (str: string) => {
return /^[a-z0-9_-]{1,24}$/.test(str)
}
export type MCPModalFormState = {
url: string
name: string
appIcon: AppIconSelection
showAppIconPicker: boolean
serverIdentifier: string
timeout: number
sseReadTimeout: number
headers: HeaderItem[]
isFetchingIcon: boolean
authMethod: MCPAuthMethod
isDynamicRegistration: boolean
clientID: string
credentials: string
}
export type MCPModalFormActions = {
setUrl: (url: string) => void
setName: (name: string) => void
setAppIcon: (icon: AppIconSelection) => void
setShowAppIconPicker: (show: boolean) => void
setServerIdentifier: (id: string) => void
setTimeout: (timeout: number) => void
setSseReadTimeout: (timeout: number) => void
setHeaders: (headers: HeaderItem[]) => void
setAuthMethod: (method: string) => void
setIsDynamicRegistration: (value: boolean) => void
setClientID: (id: string) => void
setCredentials: (credentials: string) => void
handleUrlBlur: (url: string) => Promise<void>
resetIcon: () => void
}
/**
* Custom hook for MCP Modal form state management.
*
* Note: This hook uses a `formKey` (data ID or 'create') to reset form state when
* switching between edit and create modes. All useState initializers read from `data`
* directly, and the key change triggers a remount of the consumer component.
*/
export const useMCPModalForm = (data?: ToolWithProvider) => {
const isCreate = !data
const originalServerUrl = data?.server_url
const originalServerID = data?.server_identifier
// Form key for resetting state - changes when data changes
const formKey = useMemo(() => data?.id ?? 'create', [data?.id])
// Form state - initialized from data
const [url, setUrl] = useState(() => data?.server_url || '')
const [name, setName] = useState(() => data?.name || '')
const [appIcon, setAppIcon] = useState<AppIconSelection>(() => getIcon(data))
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [serverIdentifier, setServerIdentifier] = useState(() => data?.server_identifier || '')
const [timeout, setMcpTimeout] = useState(() => data?.configuration?.timeout || 30)
const [sseReadTimeout, setSseReadTimeout] = useState(() => data?.configuration?.sse_read_timeout || 300)
const [headers, setHeaders] = useState<HeaderItem[]>(() => getInitialHeaders(data))
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
const appIconRef = useRef<HTMLDivElement>(null)
// Auth state
const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication)
const [isDynamicRegistration, setIsDynamicRegistration] = useState(() => isCreate ? true : (data?.is_dynamic_registration ?? true))
const [clientID, setClientID] = useState(() => data?.authentication?.client_id || '')
const [credentials, setCredentials] = useState(() => data?.authentication?.client_secret || '')
const handleUrlBlur = useCallback(async (urlValue: string) => {
if (data)
return
if (!isValidUrl(urlValue))
return
const domain = getDomain(urlValue)
const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
setIsFetchingIcon(true)
try {
const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
}
catch (e) {
let errorMessage = 'Failed to fetch remote icon'
if (e instanceof Response) {
try {
const errorData = await e.json()
if (errorData?.code)
errorMessage = `Upload failed: ${errorData.code}`
}
catch {
// Ignore JSON parsing errors
}
}
else if (e instanceof Error) {
errorMessage = e.message
}
console.error('Failed to fetch remote icon:', e)
Toast.notify({ type: 'warning', message: errorMessage })
}
finally {
setIsFetchingIcon(false)
}
}, [data])
const resetIcon = useCallback(() => {
setAppIcon(getIcon(data))
}, [data])
const handleAuthMethodChange = useCallback((value: string) => {
setAuthMethod(value as MCPAuthMethod)
}, [])
return {
// Key for form reset (use as React key on parent)
formKey,
// Metadata
isCreate,
originalServerUrl,
originalServerID,
appIconRef,
// State
state: {
url,
name,
appIcon,
showAppIconPicker,
serverIdentifier,
timeout,
sseReadTimeout,
headers,
isFetchingIcon,
authMethod,
isDynamicRegistration,
clientID,
credentials,
} satisfies MCPModalFormState,
// Actions
actions: {
setUrl,
setName,
setAppIcon,
setShowAppIconPicker,
setServerIdentifier,
setTimeout: setMcpTimeout,
setSseReadTimeout,
setHeaders,
setAuthMethod: handleAuthMethodChange,
setIsDynamicRegistration,
setClientID,
setCredentials,
handleUrlBlur,
resetIcon,
} satisfies MCPModalFormActions,
}
}

View File

@@ -0,0 +1,451 @@
import type { ReactNode } from 'react'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum } from '@/types/app'
import { useMCPServiceCardState } from './use-mcp-service-card'
// Mutable mock data for MCP server detail
let mockMCPServerDetailData: {
id: string
status: string
server_code: string
description: string
parameters: Record<string, unknown>
} | undefined = {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
}
// Mock service hooks
vi.mock('@/service/use-tools', () => ({
useUpdateMCPServer: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
}),
useRefreshMCPServerCode: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
}),
useMCPServerDetail: () => ({
data: mockMCPServerDetailData,
}),
useInvalidateMCPServerDetail: () => vi.fn(),
}))
// Mock workflow hook
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: (appId: string) => ({
data: appId
? {
graph: {
nodes: [
{ data: { type: 'start', variables: [{ variable: 'input', label: 'Input' }] } },
],
},
}
: undefined,
}),
}))
// Mock app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock apps service
vi.mock('@/service/apps', () => ({
fetchAppDetail: vi.fn().mockResolvedValue({
model_config: {
updated_at: '2024-01-01',
user_input_form: [],
},
}),
}))
describe('useMCPServiceCardState', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockAppInfo = (mode: AppModeEnum = AppModeEnum.CHAT): AppDetailResponse & Partial<AppSSO> => ({
id: 'app-123',
name: 'Test App',
mode,
api_base_url: 'https://api.example.com/v1',
} as AppDetailResponse & Partial<AppSSO>)
beforeEach(() => {
// Reset mock data to default (published server)
mockMCPServerDetailData = {
id: 'server-123',
status: 'active',
server_code: 'abc123',
description: 'Test server',
parameters: {},
}
})
describe('Initialization', () => {
it('should initialize with correct default values for basic app', () => {
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.serverPublished).toBe(true)
expect(result.current.serverActivated).toBe(true)
expect(result.current.showConfirmDelete).toBe(false)
expect(result.current.showMCPServerModal).toBe(false)
})
it('should initialize with correct values for workflow app', () => {
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.isLoading).toBe(false)
})
it('should initialize with correct values for advanced chat app', () => {
const appInfo = createMockAppInfo(AppModeEnum.ADVANCED_CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.isLoading).toBe(false)
})
})
describe('Server URL Generation', () => {
it('should generate correct server URL when published', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.serverURL).toBe('https://api.example.com/mcp/server/abc123/mcp')
})
})
describe('Permission Flags', () => {
it('should have isCurrentWorkspaceManager as true', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.isCurrentWorkspaceManager).toBe(true)
})
it('should have toggleDisabled false when editor has permissions', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Toggle is not disabled when user has permissions and app is published
expect(typeof result.current.toggleDisabled).toBe('boolean')
})
it('should have toggleDisabled true when triggerModeDisabled is true', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, true),
{ wrapper: createWrapper() },
)
expect(result.current.toggleDisabled).toBe(true)
})
})
describe('UI State Actions', () => {
it('should open confirm delete modal', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.showConfirmDelete).toBe(false)
act(() => {
result.current.openConfirmDelete()
})
expect(result.current.showConfirmDelete).toBe(true)
})
it('should close confirm delete modal', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
act(() => {
result.current.openConfirmDelete()
})
expect(result.current.showConfirmDelete).toBe(true)
act(() => {
result.current.closeConfirmDelete()
})
expect(result.current.showConfirmDelete).toBe(false)
})
it('should open server modal', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.showMCPServerModal).toBe(false)
act(() => {
result.current.openServerModal()
})
expect(result.current.showMCPServerModal).toBe(true)
})
it('should handle server modal hide', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
act(() => {
result.current.openServerModal()
})
expect(result.current.showMCPServerModal).toBe(true)
let hideResult: { shouldDeactivate: boolean } | undefined
act(() => {
hideResult = result.current.handleServerModalHide(false)
})
expect(result.current.showMCPServerModal).toBe(false)
expect(hideResult?.shouldDeactivate).toBe(true)
})
it('should not deactivate when wasActivated is true', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
let hideResult: { shouldDeactivate: boolean } | undefined
act(() => {
hideResult = result.current.handleServerModalHide(true)
})
expect(hideResult?.shouldDeactivate).toBe(false)
})
})
describe('Handler Functions', () => {
it('should have handleGenCode function', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.handleGenCode).toBe('function')
})
it('should call handleGenCode and invalidate server detail', async () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
await act(async () => {
await result.current.handleGenCode()
})
// handleGenCode should complete without error
expect(result.current.genLoading).toBe(false)
})
it('should have handleStatusChange function', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.handleStatusChange).toBe('function')
})
it('should have invalidateBasicAppConfig function', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.invalidateBasicAppConfig).toBe('function')
})
it('should call invalidateBasicAppConfig', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Call the function - should not throw
act(() => {
result.current.invalidateBasicAppConfig()
})
// Function should exist and be callable
expect(typeof result.current.invalidateBasicAppConfig).toBe('function')
})
})
describe('Status Change', () => {
it('should return activated state when status change succeeds', async () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
let statusResult: { activated: boolean } | undefined
await act(async () => {
statusResult = await result.current.handleStatusChange(true)
})
expect(statusResult?.activated).toBe(true)
})
it('should return deactivated state when disabling', async () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
let statusResult: { activated: boolean } | undefined
await act(async () => {
statusResult = await result.current.handleStatusChange(false)
})
expect(statusResult?.activated).toBe(false)
})
})
describe('Unpublished Server', () => {
it('should open modal and return not activated when enabling unpublished server', async () => {
// Set mock to return undefined (unpublished server)
mockMCPServerDetailData = undefined
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Verify server is not published
expect(result.current.serverPublished).toBe(false)
let statusResult: { activated: boolean } | undefined
await act(async () => {
statusResult = await result.current.handleStatusChange(true)
})
// Should open modal and return not activated
expect(result.current.showMCPServerModal).toBe(true)
expect(statusResult?.activated).toBe(false)
})
})
describe('Loading States', () => {
it('should have genLoading state', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(typeof result.current.genLoading).toBe('boolean')
})
it('should have isLoading state for basic app', () => {
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
// Basic app doesn't need workflow, so isLoading should be false
expect(result.current.isLoading).toBe(false)
})
})
describe('Detail Data', () => {
it('should return detail data when available', () => {
const appInfo = createMockAppInfo()
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(result.current.detail).toBeDefined()
expect(result.current.detail?.id).toBe('server-123')
expect(result.current.detail?.status).toBe('active')
})
})
describe('Latest Params', () => {
it('should return latestParams for workflow app', () => {
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(Array.isArray(result.current.latestParams)).toBe(true)
})
it('should return latestParams for basic app', () => {
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
const { result } = renderHook(
() => useMCPServiceCardState(appInfo, false),
{ wrapper: createWrapper() },
)
expect(Array.isArray(result.current.latestParams)).toBe(true)
})
})
})

View File

@@ -0,0 +1,179 @@
'use client'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { fetchAppDetail } from '@/service/apps'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
const BASIC_APP_CONFIG_KEY = 'basicAppConfig'
type AppInfo = AppDetailResponse & Partial<AppSSO>
type BasicAppConfig = {
updated_at?: string
user_input_form?: Array<Record<string, unknown>>
}
export const useMCPServiceCardState = (
appInfo: AppInfo,
triggerModeDisabled: boolean,
) => {
const appId = appInfo.id
const queryClient = useQueryClient()
// API hooks
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
// Context
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
// UI state
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
// Derived app type values
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
const isBasicApp = !isAdvancedApp
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
// Workflow data for advanced apps
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
// Basic app config fetch using React Query
const { data: basicAppConfig = {} } = useQuery<BasicAppConfig>({
queryKey: [BASIC_APP_CONFIG_KEY, appId],
queryFn: async () => {
const res = await fetchAppDetail({ url: '/apps', id: appId })
return (res?.model_config as BasicAppConfig) || {}
},
enabled: isBasicApp && !!appId,
})
// MCP server detail
const { data: detail } = useMCPServerDetail(appId)
const { id, status, server_code } = detail ?? {}
// Server state
const serverPublished = !!id
const serverActivated = status === 'active'
const serverURL = serverPublished
? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp`
: '***********'
// App state checks
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const isMinimalState = appUnpublished || missingStartNode
// Basic app input form
const basicAppInputForm = useMemo(() => {
if (!isBasicApp || !basicAppConfig?.user_input_form)
return []
return (basicAppConfig.user_input_form as Array<Record<string, unknown>>).map((item) => {
const type = Object.keys(item)[0]
return {
...(item[type] as object),
type: type || 'text-input',
}
})
}, [basicAppConfig?.user_input_form, isBasicApp])
// Latest params for modal
const latestParams = useMemo(() => {
if (isAdvancedApp) {
if (!currentWorkflow?.graph)
return []
type StartNodeData = { type: string, variables?: Array<{ variable: string, label: string }> }
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as { data: StartNodeData } | undefined
return startNode?.data.variables || []
}
return basicAppInputForm
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
// Handlers
const handleGenCode = useCallback(async () => {
await refreshMCPServerCode(detail?.id || '')
invalidateMCPServerDetail(appId)
}, [refreshMCPServerCode, detail?.id, invalidateMCPServerDetail, appId])
const handleStatusChange = useCallback(async (state: boolean) => {
if (state && !serverPublished) {
setShowMCPServerModal(true)
return { activated: false }
}
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: state ? 'active' : 'inactive',
})
invalidateMCPServerDetail(appId)
return { activated: state }
}, [serverPublished, updateMCPServer, appId, id, detail, invalidateMCPServerDetail])
const handleServerModalHide = useCallback((wasActivated: boolean) => {
setShowMCPServerModal(false)
// If server wasn't activated before opening modal, keep it deactivated
return { shouldDeactivate: !wasActivated }
}, [])
const openConfirmDelete = useCallback(() => setShowConfirmDelete(true), [])
const closeConfirmDelete = useCallback(() => setShowConfirmDelete(false), [])
const openServerModal = useCallback(() => setShowMCPServerModal(true), [])
const invalidateBasicAppConfig = useCallback(() => {
queryClient.invalidateQueries({ queryKey: [BASIC_APP_CONFIG_KEY, appId] })
}, [queryClient, appId])
return {
// Loading states
genLoading,
isLoading: isAdvancedApp ? !currentWorkflow : false,
// Server state
serverPublished,
serverActivated,
serverURL,
detail,
// Permission & validation flags
isCurrentWorkspaceManager,
toggleDisabled,
isMinimalState,
appUnpublished,
missingStartNode,
// UI state
showConfirmDelete,
showMCPServerModal,
// Data
latestParams,
// Handlers
handleGenCode,
handleStatusChange,
handleServerModalHide,
openConfirmDelete,
closeConfirmDelete,
openServerModal,
invalidateBasicAppConfig,
}
}

View File

@@ -0,0 +1,361 @@
import type { ReactNode } from 'react'
import type { MCPServerDetail } from '@/app/components/tools/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPServerModal from './mcp-server-modal'
// Mock the services
vi.mock('@/service/use-tools', () => ({
useCreateMCPServer: () => ({
mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }),
isPending: false,
}),
useUpdateMCPServer: () => ({
mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }),
isPending: false,
}),
useInvalidateMCPServerDetail: () => vi.fn(),
}))
describe('MCPServerModal', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const defaultProps = {
appID: 'app-123',
show: true,
onHide: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.addTitle')).toBeInTheDocument()
})
it('should render add title when no data is provided', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.addTitle')).toBeInTheDocument()
})
it('should render edit title when data is provided', () => {
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: {},
} as unknown as MCPServerDetail
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.editTitle')).toBeInTheDocument()
})
it('should render description label', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.description')).toBeInTheDocument()
})
it('should render required indicator', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('*')).toBeInTheDocument()
})
it('should render description textarea', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toBeInTheDocument()
})
it('should render cancel button', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.cancel')).toBeInTheDocument()
})
it('should render confirm button in add mode', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.confirm')).toBeInTheDocument()
})
it('should render save button in edit mode', () => {
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: {},
} as unknown as MCPServerDetail
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.save')).toBeInTheDocument()
})
it('should render close icon', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const closeButton = document.querySelector('.cursor-pointer svg')
expect(closeButton).toBeInTheDocument()
})
})
describe('Parameters Section', () => {
it('should not render parameters section when no latestParams', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.server.modal.parameters')).not.toBeInTheDocument()
})
it('should render parameters section when latestParams is provided', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.parameters')).toBeInTheDocument()
})
it('should render parameters tip', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.server.modal.parametersTip')).toBeInTheDocument()
})
it('should render parameter items', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
{ variable: 'param2', label: 'Parameter 2', type: 'number' },
]
render(<MCPServerModal {...defaultProps} latestParams={latestParams} />, { wrapper: createWrapper() })
expect(screen.getByText('Parameter 1')).toBeInTheDocument()
expect(screen.getByText('Parameter 2')).toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update description when typing', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'New description' } })
expect(textarea).toHaveValue('New description')
})
it('should call onHide when cancel button is clicked', () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const cancelButton = screen.getByText('tools.mcp.modal.cancel')
fireEvent.click(cancelButton)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should call onHide when close icon is clicked', () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const closeButton = document.querySelector('.cursor-pointer')
if (closeButton) {
fireEvent.click(closeButton)
expect(onHide).toHaveBeenCalled()
}
})
it('should disable confirm button when description is empty', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
expect(confirmButton).toBeDisabled()
})
it('should enable confirm button when description is filled', () => {
render(<MCPServerModal {...defaultProps} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Valid description' } })
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
expect(confirmButton).not.toBeDisabled()
})
})
describe('Edit Mode', () => {
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: { param1: 'existing value' },
} as unknown as MCPServerDetail
it('should populate description with existing value', () => {
render(<MCPServerModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toHaveValue('Existing description')
})
it('should populate parameters with existing values', () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(
<MCPServerModal {...defaultProps} data={mockData} latestParams={latestParams} />,
{ wrapper: createWrapper() },
)
const paramInput = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(paramInput).toHaveValue('existing value')
})
})
describe('Form Submission', () => {
it('should submit form with description', async () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Test description' } })
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
})
})
describe('With App Info', () => {
it('should use appInfo description as default when no data', () => {
const appInfo = { description: 'App default description' }
render(<MCPServerModal {...defaultProps} appInfo={appInfo} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toHaveValue('App default description')
})
it('should prefer data description over appInfo description', () => {
const appInfo = { description: 'App default description' }
const mockData = {
id: 'server-1',
description: 'Data description',
parameters: {},
} as unknown as MCPServerDetail
render(
<MCPServerModal {...defaultProps} data={mockData} appInfo={appInfo} />,
{ wrapper: createWrapper() },
)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
expect(textarea).toHaveValue('Data description')
})
})
describe('Not Shown State', () => {
it('should not render modal content when show is false', () => {
render(<MCPServerModal {...defaultProps} show={false} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.server.modal.addTitle')).not.toBeInTheDocument()
})
})
describe('Update Mode Submission', () => {
it('should submit update when data is provided', async () => {
const onHide = vi.fn()
const mockData = {
id: 'server-1',
description: 'Existing description',
parameters: { param1: 'value1' },
} as unknown as MCPServerDetail
render(
<MCPServerModal {...defaultProps} data={mockData} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Change description
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Updated description' } })
// Click save button
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
})
})
describe('Parameter Handling', () => {
it('should update parameter value when changed', async () => {
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
{ variable: 'param2', label: 'Parameter 2', type: 'string' },
]
render(
<MCPServerModal {...defaultProps} latestParams={latestParams} />,
{ wrapper: createWrapper() },
)
// Fill description first
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Test description' } })
// Get all parameter inputs
const paramInputs = screen.getAllByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
// Change the first parameter value
fireEvent.change(paramInputs[0], { target: { value: 'new param value' } })
expect(paramInputs[0]).toHaveValue('new param value')
})
it('should submit with parameter values', async () => {
const onHide = vi.fn()
const latestParams = [
{ variable: 'param1', label: 'Parameter 1', type: 'string' },
]
render(
<MCPServerModal {...defaultProps} latestParams={latestParams} onHide={onHide} />,
{ wrapper: createWrapper() },
)
// Fill description
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: 'Test description' } })
// Fill parameter
const paramInput = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(paramInput, { target: { value: 'param value' } })
// Submit
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
})
it('should handle empty description submission', async () => {
const onHide = vi.fn()
render(<MCPServerModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder')
fireEvent.change(textarea, { target: { value: '' } })
// Button should be disabled
const confirmButton = screen.getByText('tools.mcp.server.modal.confirm')
expect(confirmButton).toBeDisabled()
})
})
})

View File

@@ -0,0 +1,165 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import MCPServerParamItem from './mcp-server-param-item'
describe('MCPServerParamItem', () => {
const defaultProps = {
data: {
label: 'Test Label',
variable: 'test_variable',
type: 'string',
},
value: '',
onChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should display label', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should display variable name', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('test_variable')).toBeInTheDocument()
})
it('should display type', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('string')).toBeInTheDocument()
})
it('should display separator dot', () => {
render(<MCPServerParamItem {...defaultProps} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should render textarea with placeholder', () => {
render(<MCPServerParamItem {...defaultProps} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toBeInTheDocument()
})
})
describe('Value Display', () => {
it('should display empty value by default', () => {
render(<MCPServerParamItem {...defaultProps} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toHaveValue('')
})
it('should display provided value', () => {
render(<MCPServerParamItem {...defaultProps} value="test value" />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toHaveValue('test value')
})
it('should display long text value', () => {
const longValue = 'This is a very long text value that might span multiple lines'
render(<MCPServerParamItem {...defaultProps} value={longValue} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
expect(textarea).toHaveValue(longValue)
})
})
describe('User Interactions', () => {
it('should call onChange when text is entered', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: 'new value' } })
expect(onChange).toHaveBeenCalledWith('new value')
})
it('should call onChange with empty string when cleared', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} value="existing" onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith('')
})
it('should handle multiple changes', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: 'first' } })
fireEvent.change(textarea, { target: { value: 'second' } })
fireEvent.change(textarea, { target: { value: 'third' } })
expect(onChange).toHaveBeenCalledTimes(3)
expect(onChange).toHaveBeenLastCalledWith('third')
})
})
describe('Different Data Types', () => {
it('should display number type', () => {
const props = {
...defaultProps,
data: { label: 'Count', variable: 'count', type: 'number' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('number')).toBeInTheDocument()
})
it('should display boolean type', () => {
const props = {
...defaultProps,
data: { label: 'Enabled', variable: 'enabled', type: 'boolean' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('boolean')).toBeInTheDocument()
})
it('should display array type', () => {
const props = {
...defaultProps,
data: { label: 'Items', variable: 'items', type: 'array' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('array')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle special characters in label', () => {
const props = {
...defaultProps,
data: { label: 'Test <Label> & "Special"', variable: 'test', type: 'string' },
}
render(<MCPServerParamItem {...props} />)
expect(screen.getByText('Test <Label> & "Special"')).toBeInTheDocument()
})
it('should handle empty data object properties', () => {
const props = {
...defaultProps,
data: { label: '', variable: '', type: '' },
}
render(<MCPServerParamItem {...props} />)
// Should render without crashing
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should handle unicode characters in value', () => {
const onChange = vi.fn()
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
fireEvent.change(textarea, { target: { value: '你好世界 🌍' } })
expect(onChange).toHaveBeenCalledWith('你好世界 🌍')
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,168 +1,234 @@
'use client'
import type { TFunction } from 'i18next'
import type { FC, ReactNode } from 'react'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import {
Mcp,
} from '@/app/components/base/icons/src/vender/other'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { fetchAppDetail } from '@/service/apps'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { useMCPServiceCardState } from './hooks/use-mcp-service-card'
export type IAppCardProps = {
appInfo: AppDetailResponse & Partial<AppSSO>
triggerModeDisabled?: boolean // align with Trigger Node vs User Input exclusivity
triggerModeMessage?: React.ReactNode // display-only message explaining the trigger restriction
// Sub-components
type StatusIndicatorProps = {
serverActivated: boolean
}
function MCPServiceCard({
const StatusIndicator: FC<StatusIndicatorProps> = ({ serverActivated }) => {
const { t } = useTranslation()
return (
<div className="flex items-center gap-1">
<Indicator color={serverActivated ? 'green' : 'yellow'} />
<div className={cn('system-xs-semibold-uppercase', serverActivated ? 'text-text-success' : 'text-text-warning')}>
{serverActivated
? t('overview.status.running', { ns: 'appOverview' })
: t('overview.status.disable', { ns: 'appOverview' })}
</div>
</div>
)
}
type ServerURLSectionProps = {
serverURL: string
serverPublished: boolean
isCurrentWorkspaceManager: boolean
genLoading: boolean
onRegenerate: () => void
}
const ServerURLSection: FC<ServerURLSectionProps> = ({
serverURL,
serverPublished,
isCurrentWorkspaceManager,
genLoading,
onRegenerate,
}) => {
const { t } = useTranslation()
return (
<div className="flex flex-col items-start justify-center self-stretch">
<div className="system-xs-medium pb-1 text-text-tertiary">
{t('mcp.server.url', { ns: 'tools' })}
</div>
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
{serverURL}
</div>
</div>
{serverPublished && (
<>
<CopyFeedback content={serverURL} className="!size-6" />
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
{isCurrentWorkspaceManager && (
<Tooltip popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={onRegenerate}
>
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
</div>
</Tooltip>
)}
</>
)}
</div>
</div>
)
}
type TriggerModeOverlayProps = {
triggerModeMessage: ReactNode
}
const TriggerModeOverlay: FC<TriggerModeOverlayProps> = ({ triggerModeMessage }) => {
if (triggerModeMessage) {
return (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
</Tooltip>
)
}
return <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
}
// Helper function for tooltip content
type TooltipContentParams = {
toggleDisabled: boolean
appUnpublished: boolean
missingStartNode: boolean
triggerModeMessage: ReactNode
t: TFunction
docLink: ReturnType<typeof useDocLink>
}
function getTooltipContent({
toggleDisabled,
appUnpublished,
missingStartNode,
triggerModeMessage,
t,
docLink,
}: TooltipContentParams): ReactNode {
if (!toggleDisabled)
return ''
if (appUnpublished)
return t('mcp.server.publishTip', { ns: 'tools' })
if (missingStartNode) {
return (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</>
)
}
return triggerModeMessage || ''
}
// Main component
export type IAppCardProps = {
appInfo: AppDetailResponse & Partial<AppSSO>
triggerModeDisabled?: boolean
triggerModeMessage?: ReactNode
}
const MCPServiceCard: FC<IAppCardProps> = ({
appInfo,
triggerModeDisabled = false,
triggerModeMessage = '',
}: IAppCardProps) {
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
const appId = appInfo.id
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
const isBasicApp = !isAdvancedApp
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
const [basicAppConfig, setBasicAppConfig] = useState<any>({})
const basicAppInputForm = useMemo(() => {
if (!isBasicApp || !basicAppConfig?.user_input_form)
return []
return basicAppConfig.user_input_form.map((item: any) => {
const type = Object.keys(item)[0]
return {
...item[type],
type: type || 'text-input',
}
})
}, [basicAppConfig.user_input_form, isBasicApp])
useEffect(() => {
if (isBasicApp && appId) {
(async () => {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setBasicAppConfig(res?.model_config || {})
})()
}
}, [appId, isBasicApp])
const { data: detail } = useMCPServerDetail(appId)
const { id, status, server_code } = detail ?? {}
const {
genLoading,
isLoading,
serverPublished,
serverActivated,
serverURL,
detail,
isCurrentWorkspaceManager,
toggleDisabled,
isMinimalState,
appUnpublished,
missingStartNode,
showConfirmDelete,
showMCPServerModal,
latestParams,
handleGenCode,
handleStatusChange,
handleServerModalHide,
openConfirmDelete,
closeConfirmDelete,
openServerModal,
} = useMCPServiceCardState(appInfo, triggerModeDisabled)
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
const serverPublished = !!id
const serverActivated = status === 'active'
const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********'
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const isMinimalState = appUnpublished || missingStartNode
const [activated, setActivated] = useState(serverActivated)
const latestParams = useMemo(() => {
if (isAdvancedApp) {
if (!currentWorkflow?.graph)
return []
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any
return startNode?.data.variables as any[] || []
}
return basicAppInputForm
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
const onGenCode = async () => {
await refreshMCPServerCode(detail?.id || '')
invalidateMCPServerDetail(appId)
}
// Pending status for optimistic updates (null means use server state)
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
const activated = pendingStatus ?? serverActivated
const onChangeStatus = async (state: boolean) => {
setActivated(state)
if (state) {
if (!serverPublished) {
setShowMCPServerModal(true)
return
}
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: 'active',
})
invalidateMCPServerDetail(appId)
}
else {
await updateMCPServer({
appID: appId,
id: id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: 'inactive',
})
invalidateMCPServerDetail(appId)
setPendingStatus(state)
const result = await handleStatusChange(state)
if (!result.activated && state) {
// Server modal was opened instead, clear pending status
setPendingStatus(null)
}
}
const handleServerModalHide = () => {
setShowMCPServerModal(false)
if (!serverActivated)
setActivated(false)
const onServerModalHide = () => {
handleServerModalHide(serverActivated)
// Clear pending status when modal closes to sync with server state
setPendingStatus(null)
}
useEffect(() => {
setActivated(serverActivated)
}, [serverActivated])
const onConfirmRegenerate = () => {
handleGenCode()
closeConfirmDelete()
}
if (!currentWorkflow && isAdvancedApp)
if (isLoading)
return null
const tooltipContent = getTooltipContent({
toggleDisabled,
appUnpublished,
missingStartNode,
triggerModeMessage,
t,
docLink,
})
return (
<>
<div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}>
<div className={cn('relative rounded-xl bg-background-default', triggerModeDisabled && 'opacity-60')}>
{triggerModeDisabled && (
triggerModeMessage
? (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
</Tooltip>
)
: <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
<TriggerModeOverlay triggerModeMessage={triggerModeMessage} />
)}
<div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}>
<div className="flex w-full items-center gap-3 self-stretch">
@@ -176,40 +242,9 @@ function MCPServiceCard({
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Indicator color={serverActivated ? 'green' : 'yellow'} />
<div className={`${serverActivated ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`}>
{serverActivated
? t('overview.status.running', { ns: 'appOverview' })
: t('overview.status.disable', { ns: 'appOverview' })}
</div>
</div>
<StatusIndicator serverActivated={serverActivated} />
<Tooltip
popupContent={
toggleDisabled
? (
appUnpublished
? (
t('mcp.server.publishTip', { ns: 'tools' })
)
: missingStartNode
? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</>
)
: triggerModeMessage || ''
)
: ''
}
popupContent={tooltipContent}
position="right"
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
offset={24}
@@ -220,39 +255,13 @@ function MCPServiceCard({
</Tooltip>
</div>
{!isMinimalState && (
<div className="flex flex-col items-start justify-center self-stretch">
<div className="system-xs-medium pb-1 text-text-tertiary">
{t('mcp.server.url', { ns: 'tools' })}
</div>
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
{serverURL}
</div>
</div>
{serverPublished && (
<>
<CopyFeedback
content={serverURL}
className="!size-6"
/>
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
{isCurrentWorkspaceManager && (
<Tooltip
popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}
>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={() => setShowConfirmDelete(true)}
>
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
</div>
</Tooltip>
)}
</>
)}
</div>
</div>
<ServerURLSection
serverURL={serverURL}
serverPublished={serverPublished}
isCurrentWorkspaceManager={isCurrentWorkspaceManager}
genLoading={genLoading}
onRegenerate={openConfirmDelete}
/>
)}
</div>
{!isMinimalState && (
@@ -261,40 +270,39 @@ function MCPServiceCard({
disabled={toggleDisabled}
size="small"
variant="ghost"
onClick={() => setShowMCPServerModal(true)}
onClick={openServerModal}
>
<div className="flex items-center justify-center gap-[1px]">
<RiEditLine className="h-3.5 w-3.5" />
<div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('mcp.server.edit', { ns: 'tools' }) : t('mcp.server.addDescription', { ns: 'tools' })}</div>
<div className="system-xs-medium px-[3px] text-text-tertiary">
{serverPublished ? t('mcp.server.edit', { ns: 'tools' }) : t('mcp.server.addDescription', { ns: 'tools' })}
</div>
</div>
</Button>
</div>
)}
</div>
</div>
{showMCPServerModal && (
<MCPServerModal
show={showMCPServerModal}
appID={appId}
data={serverPublished ? detail : undefined}
latestParams={latestParams}
onHide={handleServerModalHide}
onHide={onServerModalHide}
appInfo={appInfo}
/>
)}
{/* button copy link/ button regenerate */}
{showConfirmDelete && (
<Confirm
type="warning"
title={t('overview.appInfo.regenerate', { ns: 'appOverview' })}
content={t('mcp.server.reGen', { ns: 'tools' })}
isShow={showConfirmDelete}
onConfirm={() => {
onGenCode()
setShowConfirmDelete(false)
}}
onCancel={() => setShowConfirmDelete(false)}
onConfirm={onConfirmRegenerate}
onCancel={closeConfirmDelete}
/>
)}
</>

View File

@@ -0,0 +1,745 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import MCPModal from './modal'
// Mock the service API
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
// Mock the AppIconPicker component
type IconPayload = {
type: string
icon: string
background: string
}
type AppIconPickerProps = {
onSelect: (payload: IconPayload) => void
onClose: () => void
}
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: AppIconPickerProps) => (
<div data-testid="app-icon-picker">
<button data-testid="select-emoji-btn" onClick={() => onSelect({ type: 'emoji', icon: '🎉', background: '#FF0000' })}>
Select Emoji
</button>
<button data-testid="close-picker-btn" onClick={onClose}>
Close Picker
</button>
</div>
),
}))
// Mock the plugins service to avoid React Query issues from TabSlider
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
describe('MCPModal', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const defaultProps = {
show: true,
onConfirm: vi.fn(),
onHide: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
})
it('should not render when show is false', () => {
render(<MCPModal {...defaultProps} show={false} />, { wrapper: createWrapper() })
expect(screen.queryByText('tools.mcp.modal.title')).not.toBeInTheDocument()
})
it('should render create title when no data is provided', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument()
})
it('should render edit title when data is provided', () => {
const mockData = {
id: 'test-id',
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.editTitle')).toBeInTheDocument()
})
})
describe('Form Fields', () => {
it('should render server URL input', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.serverUrl')).toBeInTheDocument()
})
it('should render name input', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.name')).toBeInTheDocument()
})
it('should render server identifier input', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.serverIdentifier')).toBeInTheDocument()
})
it('should render auth method tabs', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.authentication')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.configurations')).toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update URL input value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://test.com/mcp' } })
expect(urlInput).toHaveValue('https://test.com/mcp')
})
it('should update name input value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
fireEvent.change(nameInput, { target: { value: 'My Server' } })
expect(nameInput).toHaveValue('My Server')
})
it('should update server identifier input value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(identifierInput, { target: { value: 'my-server' } })
expect(identifierInput).toHaveValue('my-server')
})
})
describe('Tab Navigation', () => {
it('should show authentication section by default', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
})
it('should switch to headers section when clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const headersTab = screen.getByText('tools.mcp.modal.headers')
fireEvent.click(headersTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
})
it('should switch to configurations section when clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const configTab = screen.getByText('tools.mcp.modal.configurations')
fireEvent.click(configTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.sseReadTimeout')).toBeInTheDocument()
})
})
})
describe('Action Buttons', () => {
it('should render confirm button', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.confirm')).toBeInTheDocument()
})
it('should render save button in edit mode', () => {
const mockData = {
id: 'test-id',
name: 'Test',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.save')).toBeInTheDocument()
})
it('should render cancel button', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('tools.mcp.modal.cancel')).toBeInTheDocument()
})
it('should call onHide when cancel is clicked', () => {
const onHide = vi.fn()
render(<MCPModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
const cancelButton = screen.getByText('tools.mcp.modal.cancel')
fireEvent.click(cancelButton)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should call onHide when close icon is clicked', () => {
const onHide = vi.fn()
render(<MCPModal {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
// Find the close button by its parent div with cursor-pointer class
const closeButtons = document.querySelectorAll('.cursor-pointer')
const closeButton = Array.from(closeButtons).find(el =>
el.querySelector('svg'),
)
if (closeButton) {
fireEvent.click(closeButton)
expect(onHide).toHaveBeenCalled()
}
})
it('should have confirm button disabled when form is empty', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
expect(confirmButton).toBeDisabled()
})
it('should enable confirm button when required fields are filled', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
expect(confirmButton).not.toBeDisabled()
})
})
describe('Form Submission', () => {
it('should call onConfirm with correct data when form is submitted', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
}),
)
})
})
it('should not call onConfirm with invalid URL', async () => {
const onConfirm = vi.fn()
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill fields with invalid URL
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'not-a-valid-url' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
})
it('should not call onConfirm with invalid server identifier', async () => {
const onConfirm = vi.fn()
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill fields with invalid server identifier
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'Invalid Server ID!' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
})
})
describe('Edit Mode', () => {
const mockData = {
id: 'test-id',
name: 'Existing Server',
server_url: 'https://existing.com/mcp',
server_identifier: 'existing-server',
icon: { content: '🚀', background: '#FF0000' },
configuration: {
timeout: 60,
sse_read_timeout: 600,
},
masked_headers: {
Authorization: '***',
},
is_dynamic_registration: false,
authentication: {
client_id: 'client-123',
client_secret: 'secret-456',
},
} as unknown as ToolWithProvider
it('should populate form with existing data', () => {
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
expect(screen.getByDisplayValue('https://existing.com/mcp')).toBeInTheDocument()
expect(screen.getByDisplayValue('Existing Server')).toBeInTheDocument()
expect(screen.getByDisplayValue('existing-server')).toBeInTheDocument()
})
it('should show warning when URL is changed', () => {
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
const urlInput = screen.getByDisplayValue('https://existing.com/mcp')
fireEvent.change(urlInput, { target: { value: 'https://new.com/mcp' } })
expect(screen.getByText('tools.mcp.modal.serverUrlWarning')).toBeInTheDocument()
})
it('should show warning when server identifier is changed', () => {
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
const identifierInput = screen.getByDisplayValue('existing-server')
fireEvent.change(identifierInput, { target: { value: 'new-server' } })
expect(screen.getByText('tools.mcp.modal.serverIdentifierWarning')).toBeInTheDocument()
})
})
describe('Form Key Reset', () => {
it('should reset form when switching from create to edit mode', () => {
const { rerender } = render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Fill some data in create mode
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
fireEvent.change(nameInput, { target: { value: 'New Server' } })
// Switch to edit mode with different data
const mockData = {
id: 'edit-id',
name: 'Edit Server',
icon: { content: '🔗', background: '#6366F1' },
} as unknown as ToolWithProvider
rerender(<MCPModal {...defaultProps} data={mockData} />)
// Should show edit mode data
expect(screen.getByDisplayValue('Edit Server')).toBeInTheDocument()
})
})
describe('URL Blur Handler', () => {
it('should trigger URL blur handler when URL input loses focus', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
fireEvent.change(urlInput, { target: { value: ' https://test.com/mcp ' } })
fireEvent.blur(urlInput)
// The blur handler trims the value
expect(urlInput).toHaveValue(' https://test.com/mcp ')
})
it('should handle URL blur with empty value', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
fireEvent.change(urlInput, { target: { value: '' } })
fireEvent.blur(urlInput)
expect(urlInput).toHaveValue('')
})
})
describe('App Icon', () => {
it('should render app icon with default emoji', () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// The app icon should be rendered
const appIcons = document.querySelectorAll('[class*="rounded-2xl"]')
expect(appIcons.length).toBeGreaterThan(0)
})
it('should render app icon in edit mode with custom icon', () => {
const mockData = {
id: 'test-id',
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🚀', background: '#FF0000' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} />, { wrapper: createWrapper() })
// The app icon should be rendered
const appIcons = document.querySelectorAll('[class*="rounded-2xl"]')
expect(appIcons.length).toBeGreaterThan(0)
})
})
describe('Form Submission with Headers', () => {
it('should submit form with headers data', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
// Switch to headers tab and add a header
const headersTab = screen.getByText('tools.mcp.modal.headers')
fireEvent.click(headersTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
}),
)
})
})
it('should submit with authentication data', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
// Submit form
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
authentication: expect.objectContaining({
client_id: '',
client_secret: '',
}),
}),
)
})
})
it('should format headers correctly when submitting with header keys', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
const mockData = {
id: 'test-id',
name: 'Test Server',
server_url: 'https://example.com/mcp',
server_identifier: 'test-server',
icon: { content: '🔗', background: '#6366F1' },
masked_headers: {
'Authorization': 'Bearer token',
'X-Custom': 'value',
},
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Switch to headers tab
const headersTab = screen.getByText('tools.mcp.modal.headers')
fireEvent.click(headersTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
// Submit form
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: expect.any(String),
}),
}),
)
})
})
})
describe('Edit Mode Submission', () => {
it('should send hidden URL when URL is unchanged in edit mode', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
const mockData = {
id: 'test-id',
name: 'Existing Server',
server_url: 'https://existing.com/mcp',
server_identifier: 'existing-server',
icon: { content: '🚀', background: '#FF0000' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Don't change the URL, just submit
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
server_url: '[__HIDDEN__]',
}),
)
})
})
it('should send new URL when URL is changed in edit mode', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
const mockData = {
id: 'test-id',
name: 'Existing Server',
server_url: 'https://existing.com/mcp',
server_identifier: 'existing-server',
icon: { content: '🚀', background: '#FF0000' },
} as unknown as ToolWithProvider
render(<MCPModal {...defaultProps} data={mockData} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Change the URL
const urlInput = screen.getByDisplayValue('https://existing.com/mcp')
fireEvent.change(urlInput, { target: { value: 'https://new.com/mcp' } })
const saveButton = screen.getByText('tools.mcp.modal.save')
fireEvent.click(saveButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
server_url: 'https://new.com/mcp',
}),
)
})
})
})
describe('Configuration Section', () => {
it('should submit with default timeout values', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
configuration: expect.objectContaining({
timeout: 30,
sse_read_timeout: 300,
}),
}),
)
})
})
it('should submit with custom timeout values', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
render(<MCPModal {...defaultProps} onConfirm={onConfirm} />, { wrapper: createWrapper() })
// Fill required fields
const urlInput = screen.getByPlaceholderText('tools.mcp.modal.serverUrlPlaceholder')
const nameInput = screen.getByPlaceholderText('tools.mcp.modal.namePlaceholder')
const identifierInput = screen.getByPlaceholderText('tools.mcp.modal.serverIdentifierPlaceholder')
fireEvent.change(urlInput, { target: { value: 'https://example.com/mcp' } })
fireEvent.change(nameInput, { target: { value: 'Test Server' } })
fireEvent.change(identifierInput, { target: { value: 'test-server' } })
// Switch to configurations tab
const configTab = screen.getByText('tools.mcp.modal.configurations')
fireEvent.click(configTab)
await waitFor(() => {
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
})
const confirmButton = screen.getByText('tools.mcp.modal.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(onConfirm).toHaveBeenCalled()
})
})
})
describe('Dynamic Registration', () => {
it('should toggle dynamic registration', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Find the switch for dynamic registration
const switchElements = screen.getAllByRole('switch')
expect(switchElements.length).toBeGreaterThan(0)
// Click the first switch (dynamic registration)
fireEvent.click(switchElements[0])
// The switch should toggle
expect(switchElements[0]).toBeInTheDocument()
})
})
describe('App Icon Picker Interactions', () => {
it('should open app icon picker when app icon is clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Find the app icon container with cursor-pointer and rounded-2xl classes
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
if (appIconContainer) {
fireEvent.click(appIconContainer)
// The mocked AppIconPicker should now be visible
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
}
})
it('should close app icon picker and update icon when selecting an icon', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Open the icon picker
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
if (appIconContainer) {
fireEvent.click(appIconContainer)
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
// Click the select emoji button
const selectBtn = screen.getByTestId('select-emoji-btn')
fireEvent.click(selectBtn)
// The picker should be closed
await waitFor(() => {
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
}
})
it('should close app icon picker and reset icon when close button is clicked', async () => {
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
// Open the icon picker
const appIconContainer = document.querySelector('[class*="rounded-2xl"][class*="cursor-pointer"]')
if (appIconContainer) {
fireEvent.click(appIconContainer)
await waitFor(() => {
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
// Click the close button
const closeBtn = screen.getByTestId('close-picker-btn')
fireEvent.click(closeBtn)
// The picker should be closed
await waitFor(() => {
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
}
})
})
})

View File

@@ -1,429 +1,298 @@
'use client'
import type { HeaderItem } from './headers-input'
import type { FC } from 'react'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { AppIconType } from '@/types/app'
import { RiCloseLine, RiEditLine } from '@remixicon/react'
import { useHover } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getDomain } from 'tldts'
import { v4 as uuid } from 'uuid'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Button from '@/app/components/base/button'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import Switch from '@/app/components/base/switch'
import TabSlider from '@/app/components/base/tab-slider'
import Toast from '@/app/components/base/toast'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { API_PREFIX } from '@/config'
import { uploadRemoteFileInfo } from '@/service/common'
import { cn } from '@/utils/classnames'
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
import HeadersInput from './headers-input'
import { isValidServerID, isValidUrl, useMCPModalForm } from './hooks/use-mcp-modal-form'
import AuthenticationSection from './sections/authentication-section'
import ConfigurationsSection from './sections/configurations-section'
import HeadersSection from './sections/headers-section'
export type MCPModalConfirmPayload = {
name: string
server_url: string
icon_type: AppIconType
icon: string
icon_background?: string | null
server_identifier: string
headers?: Record<string, string>
is_dynamic_registration?: boolean
authentication?: {
client_id?: string
client_secret?: string
grant_type?: string
}
configuration: {
timeout: number
sse_read_timeout: number
}
}
export type DuplicateAppModalProps = {
data?: ToolWithProvider
show: boolean
onConfirm: (info: {
name: string
server_url: string
icon_type: AppIconType
icon: string
icon_background?: string | null
server_identifier: string
headers?: Record<string, string>
is_dynamic_registration?: boolean
authentication?: {
client_id?: string
client_secret?: string
grant_type?: string
}
configuration: {
timeout: number
sse_read_timeout: number
}
}) => void
onConfirm: (info: MCPModalConfirmPayload) => void
onHide: () => void
}
const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' }
const extractFileId = (url: string) => {
const match = url.match(/files\/(.+?)\/file-preview/)
return match ? match[1] : null
}
const getIcon = (data?: ToolWithProvider) => {
if (!data)
return DEFAULT_ICON as AppIconSelection
if (typeof data.icon === 'string')
return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
return {
...data.icon,
icon: data.icon.content,
type: 'emoji',
} as unknown as AppIconSelection
type MCPModalContentProps = {
data?: ToolWithProvider
onConfirm: (info: MCPModalConfirmPayload) => void
onHide: () => void
}
const MCPModal = ({
const MCPModalContent: FC<MCPModalContentProps> = ({
data,
show,
onConfirm,
onHide,
}: DuplicateAppModalProps) => {
}) => {
const { t } = useTranslation()
const isCreate = !data
const {
isCreate,
originalServerUrl,
originalServerID,
appIconRef,
state,
actions,
} = useMCPModalForm(data)
const isHovering = useHover(appIconRef)
const authMethods = [
{
text: t('mcp.modal.authentication', { ns: 'tools' }),
value: MCPAuthMethod.authentication,
},
{
text: t('mcp.modal.headers', { ns: 'tools' }),
value: MCPAuthMethod.headers,
},
{
text: t('mcp.modal.configurations', { ns: 'tools' }),
value: MCPAuthMethod.configurations,
},
{ text: t('mcp.modal.authentication', { ns: 'tools' }), value: MCPAuthMethod.authentication },
{ text: t('mcp.modal.headers', { ns: 'tools' }), value: MCPAuthMethod.headers },
{ text: t('mcp.modal.configurations', { ns: 'tools' }), value: MCPAuthMethod.configurations },
]
const originalServerUrl = data?.server_url
const originalServerID = data?.server_identifier
const [url, setUrl] = React.useState(data?.server_url || '')
const [name, setName] = React.useState(data?.name || '')
const [appIcon, setAppIcon] = useState<AppIconSelection>(() => getIcon(data))
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '')
const [timeout, setMcpTimeout] = React.useState(data?.configuration?.timeout || 30)
const [sseReadTimeout, setSseReadTimeout] = React.useState(data?.configuration?.sse_read_timeout || 300)
const [headers, setHeaders] = React.useState<HeaderItem[]>(
Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })),
)
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
const appIconRef = useRef<HTMLDivElement>(null)
const isHovering = useHover(appIconRef)
const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication)
const [isDynamicRegistration, setIsDynamicRegistration] = useState(isCreate ? true : data?.is_dynamic_registration)
const [clientID, setClientID] = useState(data?.authentication?.client_id || '')
const [credentials, setCredentials] = useState(data?.authentication?.client_secret || '')
// Update states when data changes (for edit mode)
React.useEffect(() => {
if (data) {
setUrl(data.server_url || '')
setName(data.name || '')
setServerIdentifier(data.server_identifier || '')
setMcpTimeout(data.configuration?.timeout || 30)
setSseReadTimeout(data.configuration?.sse_read_timeout || 300)
setHeaders(Object.entries(data.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })))
setAppIcon(getIcon(data))
setIsDynamicRegistration(data.is_dynamic_registration)
setClientID(data.authentication?.client_id || '')
setCredentials(data.authentication?.client_secret || '')
}
else {
// Reset for create mode
setUrl('')
setName('')
setServerIdentifier('')
setMcpTimeout(30)
setSseReadTimeout(300)
setHeaders([])
setAppIcon(DEFAULT_ICON as AppIconSelection)
setIsDynamicRegistration(true)
setClientID('')
setCredentials('')
}
}, [data])
const isValidUrl = (string: string) => {
try {
const url = new URL(string)
return url.protocol === 'http:' || url.protocol === 'https:'
}
catch {
return false
}
}
const isValidServerID = (str: string) => {
return /^[a-z0-9_-]{1,24}$/.test(str)
}
const handleBlur = async (url: string) => {
if (data)
return
if (!isValidUrl(url))
return
const domain = getDomain(url)
const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
setIsFetchingIcon(true)
try {
const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
}
catch (e) {
let errorMessage = 'Failed to fetch remote icon'
const errorData = await (e as Response).json()
if (errorData?.code)
errorMessage = `Upload failed: ${errorData.code}`
console.error('Failed to fetch remote icon:', e)
Toast.notify({ type: 'warning', message: errorMessage })
}
finally {
setIsFetchingIcon(false)
}
}
const submit = async () => {
if (!isValidUrl(url)) {
if (!isValidUrl(state.url)) {
Toast.notify({ type: 'error', message: 'invalid server url' })
return
}
if (!isValidServerID(serverIdentifier.trim())) {
if (!isValidServerID(state.serverIdentifier.trim())) {
Toast.notify({ type: 'error', message: 'invalid server identifier' })
return
}
const formattedHeaders = headers.reduce((acc, item) => {
const formattedHeaders = state.headers.reduce((acc, item) => {
if (item.key.trim())
acc[item.key.trim()] = item.value
return acc
}, {} as Record<string, string>)
await onConfirm({
server_url: originalServerUrl === url ? '[__HIDDEN__]' : url.trim(),
name,
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
server_identifier: serverIdentifier.trim(),
server_url: originalServerUrl === state.url ? '[__HIDDEN__]' : state.url.trim(),
name: state.name,
icon_type: state.appIcon.type,
icon: state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId,
icon_background: state.appIcon.type === 'emoji' ? state.appIcon.background : undefined,
server_identifier: state.serverIdentifier.trim(),
headers: Object.keys(formattedHeaders).length > 0 ? formattedHeaders : undefined,
is_dynamic_registration: isDynamicRegistration,
is_dynamic_registration: state.isDynamicRegistration,
authentication: {
client_id: clientID,
client_secret: credentials,
client_id: state.clientID,
client_secret: state.credentials,
},
configuration: {
timeout: timeout || 30,
sse_read_timeout: sseReadTimeout || 300,
timeout: state.timeout || 30,
sse_read_timeout: state.sseReadTimeout || 300,
},
})
if (isCreate)
onHide()
}
const handleAuthMethodChange = useCallback((value: string) => {
setAuthMethod(value as MCPAuthMethod)
}, [])
const handleIconSelect = (payload: AppIconSelection) => {
actions.setAppIcon(payload)
actions.setShowAppIconPicker(false)
}
const handleIconClose = () => {
actions.resetIcon()
actions.setShowAppIconPicker(false)
}
const isSubmitDisabled = !state.name || !state.url || !state.serverIdentifier || state.isFetchingIcon
return (
<>
<Modal
isShow={show}
onClose={noop}
className={cn('relative !max-w-[520px]', 'p-6')}
>
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
<div className="title-2xl-semi-bold relative pb-3 text-xl text-text-primary">{!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}</div>
<div className="space-y-5 py-3">
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverUrl', { ns: 'tools' })}</span>
</div>
<Input
value={url}
onChange={e => setUrl(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })}
/>
{originalServerUrl && originalServerUrl !== url && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}</span>
</div>
)}
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
<div className="title-2xl-semi-bold relative pb-3 text-xl text-text-primary">
{!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}
</div>
<div className="space-y-5 py-3">
{/* Server URL */}
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverUrl', { ns: 'tools' })}</span>
</div>
<div className="flex space-x-3">
<div className="grow pb-1">
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.name', { ns: 'tools' })}</span>
</div>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })}
/>
</div>
<div className="pt-2" ref={appIconRef}>
<AppIcon
iconType={appIcon.type}
icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
background={appIcon.type === 'emoji' ? appIcon.background : undefined}
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
innerIcon={shouldUseMcpIconForAppIcon(appIcon.type, appIcon.type === 'emoji' ? appIcon.icon : '') ? <Mcp className="h-8 w-8 text-text-primary-on-surface" /> : undefined}
size="xxl"
className="relative cursor-pointer rounded-2xl"
coverElement={
isHovering
? (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt">
<RiEditLine className="size-6 text-text-primary-on-surface" />
</div>
)
: null
}
onClick={() => { setShowAppIconPicker(true) }}
/>
</div>
</div>
<div>
<div className="flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverIdentifier', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-1 text-text-tertiary">{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}</div>
<Input
value={serverIdentifier}
onChange={e => setServerIdentifier(e.target.value)}
placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })}
/>
{originalServerID && originalServerID !== serverIdentifier && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}</span>
</div>
)}
</div>
<TabSlider
className="w-full"
itemClassName={(isActive) => {
return `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`
}}
value={authMethod}
onChange={handleAuthMethodChange}
options={authMethods}
<Input
value={state.url}
onChange={e => actions.setUrl(e.target.value)}
onBlur={e => actions.handleUrlBlur(e.target.value.trim())}
placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })}
/>
{
authMethod === MCPAuthMethod.authentication && (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<Switch
className="mr-2"
defaultValue={isDynamicRegistration}
onChange={setIsDynamicRegistration}
/>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.useDynamicClientRegistration', { ns: 'tools' })}</span>
</div>
{!isDynamicRegistration && (
<div className="mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
<div className="system-xs-regular text-text-secondary">
<div className="mb-1">{t('mcp.modal.redirectUrlWarning', { ns: 'tools' })}</div>
<code className="system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary">
{`${API_PREFIX}/mcp/oauth/callback`}
</code>
{originalServerUrl && originalServerUrl !== state.url && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}</span>
</div>
)}
</div>
{/* Name and Icon */}
<div className="flex space-x-3">
<div className="grow pb-1">
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.name', { ns: 'tools' })}</span>
</div>
<Input
value={state.name}
onChange={e => actions.setName(e.target.value)}
placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })}
/>
</div>
<div className="pt-2" ref={appIconRef}>
<AppIcon
iconType={state.appIcon.type}
icon={state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId}
background={state.appIcon.type === 'emoji' ? state.appIcon.background : undefined}
imageUrl={state.appIcon.type === 'image' ? state.appIcon.url : undefined}
innerIcon={shouldUseMcpIconForAppIcon(state.appIcon.type, state.appIcon.type === 'emoji' ? state.appIcon.icon : '') ? <Mcp className="h-8 w-8 text-text-primary-on-surface" /> : undefined}
size="xxl"
className="relative cursor-pointer rounded-2xl"
coverElement={
isHovering
? (
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt">
<RiEditLine className="size-6 text-text-primary-on-surface" />
</div>
</div>
)}
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientID', { ns: 'tools' })}</span>
</div>
<Input
value={clientID}
onChange={e => setClientID(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.clientID', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientSecret', { ns: 'tools' })}</span>
</div>
<Input
value={credentials}
onChange={e => setCredentials(e.target.value)}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.clientSecretPlaceholder', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
</>
)
}
{
authMethod === MCPAuthMethod.headers && (
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.headers', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-2 text-text-tertiary">{t('mcp.modal.headersTip', { ns: 'tools' })}</div>
<HeadersInput
headersItems={headers}
onChange={setHeaders}
readonly={false}
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
/>
</div>
)
}
{
authMethod === MCPAuthMethod.configurations && (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.timeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={timeout}
onChange={e => setMcpTimeout(Number(e.target.value))}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.sseReadTimeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={sseReadTimeout}
onChange={e => setSseReadTimeout(Number(e.target.value))}
onBlur={e => handleBlur(e.target.value.trim())}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
</>
)
}
)
: null
}
onClick={() => actions.setShowAppIconPicker(true)}
/>
</div>
</div>
<div className="flex flex-row-reverse pt-5">
<Button disabled={!name || !url || !serverIdentifier || isFetchingIcon} className="ml-2" variant="primary" onClick={submit}>{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.modal.confirm', { ns: 'tools' })}</Button>
<Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button>
{/* Server Identifier */}
<div>
<div className="flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverIdentifier', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-1 text-text-tertiary">{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}</div>
<Input
value={state.serverIdentifier}
onChange={e => actions.setServerIdentifier(e.target.value)}
placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })}
/>
{originalServerID && originalServerID !== state.serverIdentifier && (
<div className="mt-1 flex h-5 items-center">
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}</span>
</div>
)}
</div>
</Modal>
{showAppIconPicker && (
{/* Auth Method Tabs */}
<TabSlider
className="w-full"
itemClassName={isActive => `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`}
value={state.authMethod}
onChange={actions.setAuthMethod}
options={authMethods}
/>
{/* Tab Content */}
{state.authMethod === MCPAuthMethod.authentication && (
<AuthenticationSection
isDynamicRegistration={state.isDynamicRegistration}
onDynamicRegistrationChange={actions.setIsDynamicRegistration}
clientID={state.clientID}
onClientIDChange={actions.setClientID}
credentials={state.credentials}
onCredentialsChange={actions.setCredentials}
/>
)}
{state.authMethod === MCPAuthMethod.headers && (
<HeadersSection
headers={state.headers}
onHeadersChange={actions.setHeaders}
isCreate={isCreate}
/>
)}
{state.authMethod === MCPAuthMethod.configurations && (
<ConfigurationsSection
timeout={state.timeout}
onTimeoutChange={actions.setTimeout}
sseReadTimeout={state.sseReadTimeout}
onSseReadTimeoutChange={actions.setSseReadTimeout}
/>
)}
</div>
{/* Actions */}
<div className="flex flex-row-reverse pt-5">
<Button disabled={isSubmitDisabled} className="ml-2" variant="primary" onClick={submit}>
{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.modal.confirm', { ns: 'tools' })}
</Button>
<Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button>
</div>
{state.showAppIconPicker && (
<AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setAppIcon(getIcon(data))
setShowAppIconPicker(false)
}}
onSelect={handleIconSelect}
onClose={handleIconClose}
/>
)}
</>
)
}
/**
* MCP Modal component for creating and editing MCP server configurations.
*
* Uses a keyed inner component to ensure form state resets when switching
* between create mode and edit mode with different data.
*/
const MCPModal: FC<DuplicateAppModalProps> = ({
data,
show,
onConfirm,
onHide,
}) => {
// Use data ID as key to reset form state when switching between items
const formKey = data?.id ?? 'create'
return (
<Modal
isShow={show}
onClose={noop}
className={cn('relative !max-w-[520px]', 'p-6')}
>
<MCPModalContent
key={formKey}
data={data}
onConfirm={onConfirm}
onHide={onHide}
/>
</Modal>
)
}

View File

@@ -0,0 +1,524 @@
import type { ReactNode } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPCard from './provider-card'
// Mutable mock functions
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
// Mock the services
vi.mock('@/service/use-tools', () => ({
useUpdateMCP: () => ({
mutateAsync: mockUpdateMCP,
}),
useDeleteMCP: () => ({
mutateAsync: mockDeleteMCP,
}),
}))
// Mock the MCPModal
type MCPModalForm = {
name: string
server_url: string
}
type MCPModalProps = {
show: boolean
onConfirm: (form: MCPModalForm) => void
onHide: () => void
}
vi.mock('./modal', () => ({
default: ({ show, onConfirm, onHide }: MCPModalProps) => {
if (!show)
return null
return (
<div data-testid="mcp-modal">
<button data-testid="modal-confirm-btn" onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}>
Confirm
</button>
<button data-testid="modal-close-btn" onClick={onHide}>
Close
</button>
</div>
)
},
}))
// Mock the Confirm dialog
type ConfirmDialogProps = {
isShow: boolean
onConfirm: () => void
onCancel: () => void
isLoading: boolean
}
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, isLoading }: ConfirmDialogProps) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog">
<button data-testid="confirm-delete-btn" onClick={onConfirm} disabled={isLoading}>
{isLoading ? 'Deleting...' : 'Confirm Delete'}
</button>
<button data-testid="cancel-delete-btn" onClick={onCancel}>
Cancel
</button>
</div>
)
},
}))
// Mock the OperationDropdown
type OperationDropdownProps = {
onEdit: () => void
onRemove: () => void
onOpenChange: (open: boolean) => void
}
vi.mock('./detail/operation-dropdown', () => ({
default: ({ onEdit, onRemove, onOpenChange }: OperationDropdownProps) => (
<div data-testid="operation-dropdown">
<button
data-testid="edit-btn"
onClick={() => {
onOpenChange(true)
onEdit()
}}
>
Edit
</button>
<button
data-testid="remove-btn"
onClick={() => {
onOpenChange(true)
onRemove()
}}
>
Remove
</button>
</div>
),
}))
// Mock the app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceEditor: true,
}),
}))
// Mock the format time hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (_timestamp: number) => '2 hours ago',
}),
}))
// Mock the plugins service
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({
data: { pages: [] },
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
isLoading: false,
isSuccess: true,
}),
}))
// Mock common service
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
}))
describe('MCPCard', () => {
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children)
}
const createMockData = (overrides = {}): ToolWithProvider => ({
id: 'mcp-1',
name: 'Test MCP Server',
server_identifier: 'test-server',
icon: { content: '🔧', background: '#FF0000' },
tools: [
{ name: 'tool1', description: 'Tool 1' },
{ name: 'tool2', description: 'Tool 2' },
],
is_team_authorization: true,
updated_at: Date.now() / 1000,
...overrides,
} as unknown as ToolWithProvider)
const defaultProps = {
data: createMockData(),
handleSelect: vi.fn(),
onUpdate: vi.fn(),
onDeleted: vi.fn(),
}
beforeEach(() => {
mockUpdateMCP.mockClear()
mockDeleteMCP.mockClear()
mockUpdateMCP.mockResolvedValue({ result: 'success' })
mockDeleteMCP.mockResolvedValue({ result: 'success' })
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display MCP name', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
it('should display server identifier', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText('test-server')).toBeInTheDocument()
})
it('should display tools count', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// The tools count uses i18n with count parameter
expect(screen.getByText(/tools.mcp.toolsCount/)).toBeInTheDocument()
})
it('should display update time', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByText(/tools.mcp.updateTime/)).toBeInTheDocument()
})
})
describe('No Tools State', () => {
it('should show no tools message when tools array is empty', () => {
const dataWithNoTools = createMockData({ tools: [] })
render(
<MCPCard {...defaultProps} data={dataWithNoTools} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noTools')).toBeInTheDocument()
})
it('should show not configured badge when not authorized', () => {
const dataNotAuthorized = createMockData({ is_team_authorization: false })
render(
<MCPCard {...defaultProps} data={dataNotAuthorized} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
})
it('should show not configured badge when no tools', () => {
const dataWithNoTools = createMockData({ tools: [], is_team_authorization: true })
render(
<MCPCard {...defaultProps} data={dataWithNoTools} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
})
})
describe('Selected State', () => {
it('should apply selected styles when current provider matches', () => {
render(
<MCPCard {...defaultProps} currentProvider={defaultProps.data} />,
{ wrapper: createWrapper() },
)
const card = document.querySelector('[class*="border-components-option-card-option-selected-border"]')
expect(card).toBeInTheDocument()
})
it('should not apply selected styles when different provider', () => {
const differentProvider = createMockData({ id: 'different-id' })
render(
<MCPCard {...defaultProps} currentProvider={differentProvider} />,
{ wrapper: createWrapper() },
)
const card = document.querySelector('[class*="border-components-option-card-option-selected-border"]')
expect(card).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call handleSelect when card is clicked', () => {
const handleSelect = vi.fn()
render(
<MCPCard {...defaultProps} handleSelect={handleSelect} />,
{ wrapper: createWrapper() },
)
const card = screen.getByText('Test MCP Server').closest('[class*="cursor-pointer"]')
if (card) {
fireEvent.click(card)
expect(handleSelect).toHaveBeenCalledWith('mcp-1')
}
})
})
describe('Card Icon', () => {
it('should render card icon', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Icon component is rendered
const iconContainer = document.querySelector('[class*="rounded-xl"][class*="border"]')
expect(iconContainer).toBeInTheDocument()
})
})
describe('Status Indicator', () => {
it('should show green indicator when authorized and has tools', () => {
const data = createMockData({ is_team_authorization: true, tools: [{ name: 'tool1' }] })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
// Should have green indicator (not showing red badge)
expect(screen.queryByText('tools.mcp.noConfigured')).not.toBeInTheDocument()
})
it('should show red indicator when not configured', () => {
const data = createMockData({ is_team_authorization: false })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('tools.mcp.noConfigured')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle long MCP name', () => {
const longName = 'A'.repeat(100)
const data = createMockData({ name: longName })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle special characters in name', () => {
const data = createMockData({ name: 'Test <Script> & "Quotes"' })
render(
<MCPCard {...defaultProps} data={data} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test <Script> & "Quotes"')).toBeInTheDocument()
})
it('should handle undefined currentProvider', () => {
render(
<MCPCard {...defaultProps} currentProvider={undefined} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
})
})
describe('Operation Dropdown', () => {
it('should render operation dropdown for workspace managers', () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('operation-dropdown')).toBeInTheDocument()
})
it('should stop propagation when clicking on dropdown container', () => {
const handleSelect = vi.fn()
render(<MCPCard {...defaultProps} handleSelect={handleSelect} />, { wrapper: createWrapper() })
// Click on the dropdown area (which should stop propagation)
const dropdown = screen.getByTestId('operation-dropdown')
const dropdownContainer = dropdown.closest('[class*="absolute"]')
if (dropdownContainer) {
fireEvent.click(dropdownContainer)
// handleSelect should NOT be called because stopPropagation
expect(handleSelect).not.toHaveBeenCalled()
}
})
})
describe('Update Modal', () => {
it('should open update modal when edit button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Click the edit button
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
// Modal should be shown
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
})
it('should close update modal when close button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Open the modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Close the modal
const closeBtn = screen.getByTestId('modal-close-btn')
fireEvent.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('mcp-modal')).not.toBeInTheDocument()
})
})
it('should call updateMCP and onUpdate when form is confirmed', async () => {
const onUpdate = vi.fn()
render(<MCPCard {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open the modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Confirm the form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalledWith({
name: 'Updated MCP',
server_url: 'https://updated.com',
provider_id: 'mcp-1',
})
expect(onUpdate).toHaveBeenCalledWith('mcp-1')
})
})
it('should not call onUpdate when updateMCP fails', async () => {
mockUpdateMCP.mockResolvedValue({ result: 'error' })
const onUpdate = vi.fn()
render(<MCPCard {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
// Open the modal
const editBtn = screen.getByTestId('edit-btn')
fireEvent.click(editBtn)
await waitFor(() => {
expect(screen.getByTestId('mcp-modal')).toBeInTheDocument()
})
// Confirm the form
const confirmBtn = screen.getByTestId('modal-confirm-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockUpdateMCP).toHaveBeenCalled()
})
// onUpdate should not be called because result is not 'success'
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('Delete Confirm', () => {
it('should open delete confirm when remove button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Click the remove button
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
// Confirm dialog should be shown
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
})
it('should close delete confirm when cancel button is clicked', async () => {
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })
// Open the confirm dialog
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Cancel
const cancelBtn = screen.getByTestId('cancel-delete-btn')
fireEvent.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
it('should call deleteMCP and onDeleted when delete is confirmed', async () => {
const onDeleted = vi.fn()
render(<MCPCard {...defaultProps} onDeleted={onDeleted} />, { wrapper: createWrapper() })
// Open the confirm dialog
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-delete-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1')
expect(onDeleted).toHaveBeenCalled()
})
})
it('should not call onDeleted when deleteMCP fails', async () => {
mockDeleteMCP.mockResolvedValue({ result: 'error' })
const onDeleted = vi.fn()
render(<MCPCard {...defaultProps} onDeleted={onDeleted} />, { wrapper: createWrapper() })
// Open the confirm dialog
const removeBtn = screen.getByTestId('remove-btn')
fireEvent.click(removeBtn)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
// Confirm delete
const confirmBtn = screen.getByTestId('confirm-delete-btn')
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(mockDeleteMCP).toHaveBeenCalled()
})
// onDeleted should not be called because result is not 'success'
expect(onDeleted).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,162 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import AuthenticationSection from './authentication-section'
describe('AuthenticationSection', () => {
const defaultProps = {
isDynamicRegistration: true,
onDynamicRegistrationChange: vi.fn(),
clientID: '',
onClientIDChange: vi.fn(),
credentials: '',
onCredentialsChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AuthenticationSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
})
it('should render switch for dynamic registration', () => {
render(<AuthenticationSection {...defaultProps} />)
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should render client ID input', () => {
render(<AuthenticationSection {...defaultProps} clientID="test-client-id" />)
expect(screen.getByDisplayValue('test-client-id')).toBeInTheDocument()
})
it('should render credentials input', () => {
render(<AuthenticationSection {...defaultProps} credentials="test-secret" />)
expect(screen.getByDisplayValue('test-secret')).toBeInTheDocument()
})
it('should render labels for all fields', () => {
render(<AuthenticationSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.clientID')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.clientSecret')).toBeInTheDocument()
})
})
describe('Dynamic Registration Toggle', () => {
it('should not show warning when isDynamicRegistration is true', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={true} />)
expect(screen.queryByText('tools.mcp.modal.redirectUrlWarning')).not.toBeInTheDocument()
})
it('should show warning when isDynamicRegistration is false', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
expect(screen.getByText('tools.mcp.modal.redirectUrlWarning')).toBeInTheDocument()
})
it('should show OAuth callback URL in warning', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
expect(screen.getByText(/\/mcp\/oauth\/callback/)).toBeInTheDocument()
})
it('should disable inputs when isDynamicRegistration is true', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={true} />)
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input) => {
expect(input).toBeDisabled()
})
})
it('should enable inputs when isDynamicRegistration is false', () => {
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input) => {
expect(input).not.toBeDisabled()
})
})
})
describe('User Interactions', () => {
it('should call onDynamicRegistrationChange when switch is toggled', () => {
const onDynamicRegistrationChange = vi.fn()
render(
<AuthenticationSection
{...defaultProps}
onDynamicRegistrationChange={onDynamicRegistrationChange}
/>,
)
const switchElement = screen.getByRole('switch')
fireEvent.click(switchElement)
expect(onDynamicRegistrationChange).toHaveBeenCalled()
})
it('should call onClientIDChange when client ID input changes', () => {
const onClientIDChange = vi.fn()
render(
<AuthenticationSection
{...defaultProps}
isDynamicRegistration={false}
onClientIDChange={onClientIDChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const clientIDInput = inputs[0]
fireEvent.change(clientIDInput, { target: { value: 'new-client-id' } })
expect(onClientIDChange).toHaveBeenCalledWith('new-client-id')
})
it('should call onCredentialsChange when credentials input changes', () => {
const onCredentialsChange = vi.fn()
render(
<AuthenticationSection
{...defaultProps}
isDynamicRegistration={false}
onCredentialsChange={onCredentialsChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const credentialsInput = inputs[1]
fireEvent.change(credentialsInput, { target: { value: 'new-secret' } })
expect(onCredentialsChange).toHaveBeenCalledWith('new-secret')
})
})
describe('Props', () => {
it('should display provided clientID value', () => {
render(<AuthenticationSection {...defaultProps} clientID="my-client-123" />)
expect(screen.getByDisplayValue('my-client-123')).toBeInTheDocument()
})
it('should display provided credentials value', () => {
render(<AuthenticationSection {...defaultProps} credentials="secret-456" />)
expect(screen.getByDisplayValue('secret-456')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty string values', () => {
render(<AuthenticationSection {...defaultProps} clientID="" credentials="" />)
const inputs = screen.getAllByRole('textbox')
expect(inputs).toHaveLength(2)
inputs.forEach((input) => {
expect(input).toHaveValue('')
})
})
it('should handle special characters in values', () => {
render(
<AuthenticationSection
{...defaultProps}
clientID="client@123!#$"
credentials="secret&*()_+"
/>,
)
expect(screen.getByDisplayValue('client@123!#$')).toBeInTheDocument()
expect(screen.getByDisplayValue('secret&*()_+')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,78 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
import Input from '@/app/components/base/input'
import Switch from '@/app/components/base/switch'
import { API_PREFIX } from '@/config'
import { cn } from '@/utils/classnames'
type AuthenticationSectionProps = {
isDynamicRegistration: boolean
onDynamicRegistrationChange: (value: boolean) => void
clientID: string
onClientIDChange: (value: string) => void
credentials: string
onCredentialsChange: (value: string) => void
}
const AuthenticationSection: FC<AuthenticationSectionProps> = ({
isDynamicRegistration,
onDynamicRegistrationChange,
clientID,
onClientIDChange,
credentials,
onCredentialsChange,
}) => {
const { t } = useTranslation()
return (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<Switch
className="mr-2"
defaultValue={isDynamicRegistration}
onChange={onDynamicRegistrationChange}
/>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.useDynamicClientRegistration', { ns: 'tools' })}</span>
</div>
{!isDynamicRegistration && (
<div className="mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
<div className="system-xs-regular text-text-secondary">
<div className="mb-1">{t('mcp.modal.redirectUrlWarning', { ns: 'tools' })}</div>
<code className="system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary">
{`${API_PREFIX}/mcp/oauth/callback`}
</code>
</div>
</div>
)}
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientID', { ns: 'tools' })}</span>
</div>
<Input
value={clientID}
onChange={e => onClientIDChange(e.target.value)}
placeholder={t('mcp.modal.clientID', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
<div>
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientSecret', { ns: 'tools' })}</span>
</div>
<Input
value={credentials}
onChange={e => onCredentialsChange(e.target.value)}
placeholder={t('mcp.modal.clientSecretPlaceholder', { ns: 'tools' })}
disabled={isDynamicRegistration}
/>
</div>
</>
)
}
export default AuthenticationSection

View File

@@ -0,0 +1,100 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ConfigurationsSection from './configurations-section'
describe('ConfigurationsSection', () => {
const defaultProps = {
timeout: 30,
onTimeoutChange: vi.fn(),
sseReadTimeout: 300,
onSseReadTimeoutChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ConfigurationsSection {...defaultProps} />)
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
expect(screen.getByDisplayValue('300')).toBeInTheDocument()
})
it('should render timeout input with correct value', () => {
render(<ConfigurationsSection {...defaultProps} />)
const timeoutInput = screen.getByDisplayValue('30')
expect(timeoutInput).toHaveAttribute('type', 'number')
})
it('should render SSE read timeout input with correct value', () => {
render(<ConfigurationsSection {...defaultProps} />)
const sseInput = screen.getByDisplayValue('300')
expect(sseInput).toHaveAttribute('type', 'number')
})
it('should render labels for both inputs', () => {
render(<ConfigurationsSection {...defaultProps} />)
// i18n keys are rendered as-is in test environment
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.sseReadTimeout')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display custom timeout value', () => {
render(<ConfigurationsSection {...defaultProps} timeout={60} />)
expect(screen.getByDisplayValue('60')).toBeInTheDocument()
})
it('should display custom SSE read timeout value', () => {
render(<ConfigurationsSection {...defaultProps} sseReadTimeout={600} />)
expect(screen.getByDisplayValue('600')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onTimeoutChange when timeout input changes', () => {
const onTimeoutChange = vi.fn()
render(<ConfigurationsSection {...defaultProps} onTimeoutChange={onTimeoutChange} />)
const timeoutInput = screen.getByDisplayValue('30')
fireEvent.change(timeoutInput, { target: { value: '45' } })
expect(onTimeoutChange).toHaveBeenCalledWith(45)
})
it('should call onSseReadTimeoutChange when SSE timeout input changes', () => {
const onSseReadTimeoutChange = vi.fn()
render(<ConfigurationsSection {...defaultProps} onSseReadTimeoutChange={onSseReadTimeoutChange} />)
const sseInput = screen.getByDisplayValue('300')
fireEvent.change(sseInput, { target: { value: '500' } })
expect(onSseReadTimeoutChange).toHaveBeenCalledWith(500)
})
it('should handle numeric conversion correctly', () => {
const onTimeoutChange = vi.fn()
render(<ConfigurationsSection {...defaultProps} onTimeoutChange={onTimeoutChange} />)
const timeoutInput = screen.getByDisplayValue('30')
fireEvent.change(timeoutInput, { target: { value: '0' } })
expect(onTimeoutChange).toHaveBeenCalledWith(0)
})
})
describe('Edge Cases', () => {
it('should handle zero timeout value', () => {
render(<ConfigurationsSection {...defaultProps} timeout={0} />)
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
})
it('should handle zero SSE read timeout value', () => {
render(<ConfigurationsSection {...defaultProps} sseReadTimeout={0} />)
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
})
it('should handle large timeout values', () => {
render(<ConfigurationsSection {...defaultProps} timeout={9999} sseReadTimeout={9999} />)
expect(screen.getAllByDisplayValue('9999')).toHaveLength(2)
})
})
})

View File

@@ -0,0 +1,49 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
type ConfigurationsSectionProps = {
timeout: number
onTimeoutChange: (timeout: number) => void
sseReadTimeout: number
onSseReadTimeoutChange: (timeout: number) => void
}
const ConfigurationsSection: FC<ConfigurationsSectionProps> = ({
timeout,
onTimeoutChange,
sseReadTimeout,
onSseReadTimeoutChange,
}) => {
const { t } = useTranslation()
return (
<>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.timeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={timeout}
onChange={e => onTimeoutChange(Number(e.target.value))}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.sseReadTimeout', { ns: 'tools' })}</span>
</div>
<Input
type="number"
value={sseReadTimeout}
onChange={e => onSseReadTimeoutChange(Number(e.target.value))}
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
/>
</div>
</>
)
}
export default ConfigurationsSection

View File

@@ -0,0 +1,192 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import HeadersSection from './headers-section'
describe('HeadersSection', () => {
const defaultProps = {
headers: [],
onHeadersChange: vi.fn(),
isCreate: true,
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<HeadersSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
})
it('should render headers label', () => {
render(<HeadersSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
})
it('should render headers tip', () => {
render(<HeadersSection {...defaultProps} />)
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
})
it('should render empty state when no headers', () => {
render(<HeadersSection {...defaultProps} headers={[]} />)
expect(screen.getByText('tools.mcp.modal.noHeaders')).toBeInTheDocument()
})
it('should render add header button when empty', () => {
render(<HeadersSection {...defaultProps} headers={[]} />)
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
})
})
describe('With Headers', () => {
const headersWithItems = [
{ id: '1', key: 'Authorization', value: 'Bearer token123' },
{ id: '2', key: 'Content-Type', value: 'application/json' },
]
it('should render header items', () => {
render(<HeadersSection {...defaultProps} headers={headersWithItems} />)
expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument()
expect(screen.getByDisplayValue('Bearer token123')).toBeInTheDocument()
expect(screen.getByDisplayValue('Content-Type')).toBeInTheDocument()
expect(screen.getByDisplayValue('application/json')).toBeInTheDocument()
})
it('should render table headers', () => {
render(<HeadersSection {...defaultProps} headers={headersWithItems} />)
expect(screen.getByText('tools.mcp.modal.headerKey')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.modal.headerValue')).toBeInTheDocument()
})
it('should show masked tip when not isCreate and has headers with content', () => {
render(
<HeadersSection
{...defaultProps}
isCreate={false}
headers={headersWithItems}
/>,
)
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
it('should not show masked tip when isCreate is true', () => {
render(
<HeadersSection
{...defaultProps}
isCreate={true}
headers={headersWithItems}
/>,
)
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onHeadersChange when adding a header', () => {
const onHeadersChange = vi.fn()
render(<HeadersSection {...defaultProps} onHeadersChange={onHeadersChange} />)
const addButton = screen.getByText('tools.mcp.modal.addHeader')
fireEvent.click(addButton)
expect(onHeadersChange).toHaveBeenCalled()
const calledWithHeaders = onHeadersChange.mock.calls[0][0]
expect(calledWithHeaders).toHaveLength(1)
expect(calledWithHeaders[0]).toHaveProperty('id')
expect(calledWithHeaders[0]).toHaveProperty('key', '')
expect(calledWithHeaders[0]).toHaveProperty('value', '')
})
it('should call onHeadersChange when editing header key', () => {
const onHeadersChange = vi.fn()
const headers = [{ id: '1', key: '', value: '' }]
render(
<HeadersSection
{...defaultProps}
headers={headers}
onHeadersChange={onHeadersChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const keyInput = inputs[0]
fireEvent.change(keyInput, { target: { value: 'X-Custom-Header' } })
expect(onHeadersChange).toHaveBeenCalled()
})
it('should call onHeadersChange when editing header value', () => {
const onHeadersChange = vi.fn()
const headers = [{ id: '1', key: 'X-Custom-Header', value: '' }]
render(
<HeadersSection
{...defaultProps}
headers={headers}
onHeadersChange={onHeadersChange}
/>,
)
const inputs = screen.getAllByRole('textbox')
const valueInput = inputs[1]
fireEvent.change(valueInput, { target: { value: 'custom-value' } })
expect(onHeadersChange).toHaveBeenCalled()
})
it('should call onHeadersChange when removing a header', () => {
const onHeadersChange = vi.fn()
const headers = [{ id: '1', key: 'X-Header', value: 'value' }]
render(
<HeadersSection
{...defaultProps}
headers={headers}
onHeadersChange={onHeadersChange}
/>,
)
// Find and click the delete button
const deleteButton = screen.getByRole('button', { name: '' })
fireEvent.click(deleteButton)
expect(onHeadersChange).toHaveBeenCalledWith([])
})
})
describe('Props', () => {
it('should pass isCreate=true correctly (no masking)', () => {
const headers = [{ id: '1', key: 'Header', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={true} headers={headers} />)
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
it('should pass isCreate=false correctly (with masking)', () => {
const headers = [{ id: '1', key: 'Header', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle headers with empty keys (no masking even when not isCreate)', () => {
const headers = [{ id: '1', key: '', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
// Empty key headers don't trigger masking
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
it('should handle headers with whitespace-only keys', () => {
const headers = [{ id: '1', key: ' ', value: 'Value' }]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
// Whitespace-only key doesn't count as having content
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
})
it('should handle multiple headers where some have empty keys', () => {
const headers = [
{ id: '1', key: '', value: 'Value1' },
{ id: '2', key: 'ValidKey', value: 'Value2' },
]
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
// At least one header has a non-empty key, so masking should apply
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,36 @@
'use client'
import type { FC } from 'react'
import type { HeaderItem } from '../headers-input'
import { useTranslation } from 'react-i18next'
import HeadersInput from '../headers-input'
type HeadersSectionProps = {
headers: HeaderItem[]
onHeadersChange: (headers: HeaderItem[]) => void
isCreate: boolean
}
const HeadersSection: FC<HeadersSectionProps> = ({
headers,
onHeadersChange,
isCreate,
}) => {
const { t } = useTranslation()
return (
<div>
<div className="mb-1 flex h-6 items-center">
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.headers', { ns: 'tools' })}</span>
</div>
<div className="body-xs-regular mb-2 text-text-tertiary">{t('mcp.modal.headersTip', { ns: 'tools' })}</div>
<HeadersInput
headersItems={headers}
onChange={onHeadersChange}
readonly={false}
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
/>
</div>
)
}
export default HeadersSection

View File

@@ -2650,19 +2650,6 @@
"count": 1
}
},
"app/components/tools/mcp/mcp-service-card.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
},
"app/components/tools/mcp/modal.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 20
}
},
"app/components/tools/mcp/provider-card.tsx": {
"ts/no-explicit-any": {
"count": 3
@@ -2688,11 +2675,6 @@
"count": 4
}
},
"app/components/tools/utils/to-form-schema.ts": {
"ts/no-explicit-any": {
"count": 15
}
},
"app/components/workflow-app/components/workflow-children.tsx": {
"no-console": {
"count": 1