mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
test(web): add comprehensive unit and integration tests for plugins and tools modules (#32220)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
221
web/app/components/tools/mcp/__tests__/create-card.spec.tsx
Normal file
221
web/app/components/tools/mcp/__tests__/create-card.spec.tsx
Normal 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()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
245
web/app/components/tools/mcp/__tests__/headers-input.spec.tsx
Normal file
245
web/app/components/tools/mcp/__tests__/headers-input.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
344
web/app/components/tools/mcp/__tests__/index.spec.tsx
Normal file
344
web/app/components/tools/mcp/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,344 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MCPList from '../index'
|
||||
|
||||
type MockProvider = {
|
||||
id: string
|
||||
name: string | Record<string, string>
|
||||
type: string
|
||||
}
|
||||
|
||||
type MockDetail = MockProvider | undefined
|
||||
|
||||
// Mock dependencies
|
||||
const mockRefetch = vi.fn()
|
||||
let mockProviders: MockProvider[] = []
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: () => ({
|
||||
data: mockProviders,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../create-card', () => ({
|
||||
default: ({ handleCreate }: { handleCreate: (provider: { id: string, name: string }) => void }) => (
|
||||
<div data-testid="create-card" onClick={() => handleCreate({ id: 'new-id', name: 'New Provider' })}>
|
||||
Create Card
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../provider-card', () => ({
|
||||
default: ({ data, handleSelect, onUpdate, onDeleted }: { data: MockProvider, handleSelect: (id: string) => void, onUpdate: (id: string) => void, onDeleted: () => void }) => {
|
||||
const displayName = typeof data.name === 'string' ? data.name : Object.values(data.name)[0]
|
||||
return (
|
||||
<div data-testid={`provider-card-${data.id}`}>
|
||||
<span onClick={() => handleSelect(data.id)}>{displayName}</span>
|
||||
<button data-testid={`update-btn-${data.id}`} onClick={() => onUpdate(data.id)}>Update</button>
|
||||
<button data-testid={`delete-btn-${data.id}`} onClick={onDeleted}>Delete</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../detail/provider-detail', () => ({
|
||||
default: ({ detail, onHide, onUpdate, isTriggerAuthorize, onFirstCreate }: { detail: MockDetail, onHide: () => void, onUpdate: () => void, isTriggerAuthorize: boolean, onFirstCreate: () => void }) => {
|
||||
const displayName = detail?.name
|
||||
? (typeof detail.name === 'string' ? detail.name : Object.values(detail.name)[0])
|
||||
: ''
|
||||
return (
|
||||
<div data-testid="detail-panel">
|
||||
<div data-testid="detail-name">{displayName}</div>
|
||||
<div data-testid="trigger-authorize">{isTriggerAuthorize ? 'true' : 'false'}</div>
|
||||
<button data-testid="close-detail" onClick={onHide}>Close</button>
|
||||
<button data-testid="update-detail" onClick={onUpdate}>Update List</button>
|
||||
<button data-testid="first-create-done" onClick={onFirstCreate}>First Create Done</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('MCPList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
mockProviders = []
|
||||
mockRefetch.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render create card', () => {
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render default skeleton cards when list is empty', () => {
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
// Should render skeleton cards when no providers
|
||||
const container = document.querySelector('.grid')
|
||||
expect(container).toBeInTheDocument()
|
||||
// Check for skeleton cards (36 of them)
|
||||
const skeletonCards = document.querySelectorAll('.h-\\[111px\\]')
|
||||
expect(skeletonCards.length).toBe(36)
|
||||
})
|
||||
|
||||
it('should not render skeleton cards when providers exist', () => {
|
||||
mockProviders = [
|
||||
{ id: '1', name: 'Provider 1', type: 'mcp' },
|
||||
]
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.h-\\[111px\\]')
|
||||
expect(skeletonCards.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('With Providers', () => {
|
||||
beforeEach(() => {
|
||||
mockProviders = [
|
||||
{ id: '1', name: 'Provider 1', type: 'mcp' },
|
||||
{ id: '2', name: 'Provider 2', type: 'mcp' },
|
||||
{ id: '3', name: 'API Tool', type: 'api' },
|
||||
]
|
||||
})
|
||||
|
||||
it('should render provider cards for MCP type providers', () => {
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
expect(screen.getByTestId('provider-card-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('provider-card-2')).toBeInTheDocument()
|
||||
// API type should not be rendered (only MCP type)
|
||||
expect(screen.queryByTestId('provider-card-3')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show detail panel when provider is selected', async () => {
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const providerName = screen.getByText('Provider 1')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(providerName)
|
||||
vi.advanceTimersByTime(10)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('detail-name')).toHaveTextContent('Provider 1')
|
||||
})
|
||||
|
||||
it('should hide detail panel when close is clicked', async () => {
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const providerName = screen.getByText('Provider 1')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(providerName)
|
||||
vi.advanceTimersByTime(10)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
|
||||
|
||||
const closeBtn = screen.getByTestId('close-detail')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(closeBtn)
|
||||
vi.advanceTimersByTime(10)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Filtering', () => {
|
||||
beforeEach(() => {
|
||||
mockProviders = [
|
||||
{ id: '1', name: { 'en-US': 'Search Tool' }, type: 'mcp' },
|
||||
{ id: '2', name: { 'en-US': 'Another Provider' }, type: 'mcp' },
|
||||
]
|
||||
})
|
||||
|
||||
it('should filter providers based on search text', () => {
|
||||
render(<MCPList searchText="search" />)
|
||||
|
||||
expect(screen.getByTestId('provider-card-1')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('provider-card-2')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter case-insensitively', () => {
|
||||
render(<MCPList searchText="SEARCH" />)
|
||||
|
||||
expect(screen.getByTestId('provider-card-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show all MCP type providers when search is empty', () => {
|
||||
mockProviders = [
|
||||
{ id: '1', name: 'Provider 1', type: 'mcp' },
|
||||
{ id: '2', name: 'Provider 2', type: 'mcp' },
|
||||
]
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
expect(screen.getByTestId('provider-card-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('provider-card-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create Provider', () => {
|
||||
beforeEach(() => {
|
||||
mockProviders = []
|
||||
})
|
||||
|
||||
it('should call refetch and set provider after create', async () => {
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const createCard = screen.getByTestId('create-card')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(createCard)
|
||||
vi.advanceTimersByTime(10)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show detail panel with trigger authorize after create', async () => {
|
||||
mockProviders = [{ id: 'new-id', name: 'New Provider', type: 'mcp' }]
|
||||
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const createCard = screen.getByTestId('create-card')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(createCard)
|
||||
vi.advanceTimersByTime(10)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('trigger-authorize')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should reset trigger authorize when onFirstCreate is called', async () => {
|
||||
mockProviders = [{ id: 'new-id', name: 'New Provider', type: 'mcp' }]
|
||||
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const createCard = screen.getByTestId('create-card')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(createCard)
|
||||
vi.advanceTimersByTime(10)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('trigger-authorize')).toHaveTextContent('true')
|
||||
|
||||
const firstCreateDone = screen.getByTestId('first-create-done')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(firstCreateDone)
|
||||
vi.advanceTimersByTime(10)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('trigger-authorize')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update Provider', () => {
|
||||
beforeEach(() => {
|
||||
mockProviders = [
|
||||
{ id: '1', name: 'Provider 1', type: 'mcp' },
|
||||
]
|
||||
})
|
||||
|
||||
it('should call refetch and set provider after update', async () => {
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const updateBtn = screen.getByTestId('update-btn-1')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateBtn)
|
||||
vi.advanceTimersByTime(10)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show detail panel with trigger authorize after update', async () => {
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const updateBtn = screen.getByTestId('update-btn-1')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateBtn)
|
||||
vi.advanceTimersByTime(10)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('detail-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('trigger-authorize')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Provider', () => {
|
||||
beforeEach(() => {
|
||||
mockProviders = [
|
||||
{ id: '1', name: 'Provider 1', type: 'mcp' },
|
||||
]
|
||||
})
|
||||
|
||||
it('should call refetch after delete', async () => {
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const deleteBtn = screen.getByTestId('delete-btn-1')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteBtn)
|
||||
vi.advanceTimersByTime(10)
|
||||
})
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Grid Layout', () => {
|
||||
it('should have responsive grid layout', () => {
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const grid = document.querySelector('.grid')
|
||||
expect(grid).toHaveClass('grid-cols-1')
|
||||
expect(grid).toHaveClass('md:grid-cols-2')
|
||||
expect(grid).toHaveClass('xl:grid-cols-4')
|
||||
})
|
||||
|
||||
it('should have overflow hidden when list is empty', () => {
|
||||
mockProviders = []
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const grid = document.querySelector('.grid')
|
||||
expect(grid).toHaveClass('overflow-hidden')
|
||||
})
|
||||
|
||||
it('should not have overflow hidden when list has providers', () => {
|
||||
mockProviders = [{ id: '1', name: 'Provider 1', type: 'mcp' }]
|
||||
render(<MCPList searchText="" />)
|
||||
|
||||
const grid = document.querySelector('.grid')
|
||||
expect(grid).not.toHaveClass('overflow-hidden')
|
||||
})
|
||||
})
|
||||
})
|
||||
361
web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx
Normal file
361
web/app/components/tools/mcp/__tests__/mcp-server-modal.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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('你好世界 🌍')
|
||||
})
|
||||
})
|
||||
})
|
||||
1041
web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx
Normal file
1041
web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
745
web/app/components/tools/mcp/__tests__/modal.spec.tsx
Normal file
745
web/app/components/tools/mcp/__tests__/modal.spec.tsx
Normal 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()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
524
web/app/components/tools/mcp/__tests__/provider-card.spec.tsx
Normal file
524
web/app/components/tools/mcp/__tests__/provider-card.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user