mirror of
https://github.com/langgenius/dify.git
synced 2026-01-27 02:24:19 +00:00
Compare commits
4 Commits
yanli/api-
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7314345b7a | ||
|
|
fb08ac1a86 | ||
|
|
53a620b6ce | ||
|
|
6489903c77 |
221
web/app/components/tools/mcp/create-card.spec.tsx
Normal file
221
web/app/components/tools/mcp/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()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
855
web/app/components/tools/mcp/detail/content.spec.tsx
Normal file
855
web/app/components/tools/mcp/detail/content.spec.tsx
Normal file
@@ -0,0 +1,855 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MCPDetailContent from './content'
|
||||
|
||||
// Mutable mock functions
|
||||
const mockUpdateTools = vi.fn().mockResolvedValue({})
|
||||
const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' })
|
||||
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
|
||||
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
|
||||
const mockInvalidateMCPTools = vi.fn()
|
||||
const mockOpenOAuthPopup = vi.fn()
|
||||
|
||||
// Mutable mock state
|
||||
type MockTool = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
let mockToolsData: { tools: MockTool[] } = { tools: [] }
|
||||
let mockIsFetching = false
|
||||
let mockIsUpdating = false
|
||||
let mockIsAuthorizing = false
|
||||
|
||||
// Mock the services
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useMCPTools: () => ({
|
||||
data: mockToolsData,
|
||||
isFetching: mockIsFetching,
|
||||
}),
|
||||
useInvalidateMCPTools: () => mockInvalidateMCPTools,
|
||||
useUpdateMCPTools: () => ({
|
||||
mutateAsync: mockUpdateTools,
|
||||
isPending: mockIsUpdating,
|
||||
}),
|
||||
useAuthorizeMCP: () => ({
|
||||
mutateAsync: mockAuthorizeMcp,
|
||||
isPending: mockIsAuthorizing,
|
||||
}),
|
||||
useUpdateMCP: () => ({
|
||||
mutateAsync: mockUpdateMCP,
|
||||
}),
|
||||
useDeleteMCP: () => ({
|
||||
mutateAsync: mockDeleteMCP,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock OAuth hook
|
||||
type OAuthArgs = readonly unknown[]
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: (...args: OAuthArgs) => mockOpenOAuthPopup(...args),
|
||||
}))
|
||||
|
||||
// Mock MCPModal
|
||||
type MCPModalData = {
|
||||
name: string
|
||||
server_url: string
|
||||
}
|
||||
|
||||
type MCPModalProps = {
|
||||
show: boolean
|
||||
onConfirm: (data: MCPModalData) => void
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
vi.mock('../modal', () => ({
|
||||
default: ({ show, onConfirm, onHide }: MCPModalProps) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="mcp-update-modal">
|
||||
<button data-testid="modal-confirm-btn" onClick={() => onConfirm({ name: 'Updated MCP', server_url: 'https://updated.com' })}>
|
||||
Confirm
|
||||
</button>
|
||||
<button data-testid="modal-close-btn" onClick={onHide}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Confirm dialog
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) => {
|
||||
if (!isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="confirm-dialog" data-title={title}>
|
||||
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
|
||||
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock OperationDropdown
|
||||
vi.mock('./operation-dropdown', () => ({
|
||||
default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => (
|
||||
<div data-testid="operation-dropdown">
|
||||
<button data-testid="edit-btn" onClick={onEdit}>Edit</button>
|
||||
<button data-testid="remove-btn" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock ToolItem
|
||||
type ToolItemData = {
|
||||
name: string
|
||||
}
|
||||
|
||||
vi.mock('./tool-item', () => ({
|
||||
default: ({ tool }: { tool: ToolItemData }) => (
|
||||
<div data-testid="tool-item">{tool.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mutable workspace manager state
|
||||
let mockIsCurrentWorkspaceManager = true
|
||||
|
||||
// Mock the app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the plugins service
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: () => ({
|
||||
data: { pages: [] },
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock common service
|
||||
vi.mock('@/service/common', () => ({
|
||||
uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }),
|
||||
}))
|
||||
|
||||
// Mock copy-to-clipboard
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('MCPDetailContent', () => {
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
const createMockDetail = (overrides = {}): ToolWithProvider => ({
|
||||
id: 'mcp-1',
|
||||
name: 'Test MCP Server',
|
||||
server_identifier: 'test-mcp',
|
||||
server_url: 'https://example.com/mcp',
|
||||
icon: { content: '🔧', background: '#FF0000' },
|
||||
tools: [],
|
||||
is_team_authorization: false,
|
||||
...overrides,
|
||||
} as unknown as ToolWithProvider)
|
||||
|
||||
const defaultProps = {
|
||||
detail: createMockDetail(),
|
||||
onUpdate: vi.fn(),
|
||||
onHide: vi.fn(),
|
||||
isTriggerAuthorize: false,
|
||||
onFirstCreate: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
mockUpdateTools.mockClear()
|
||||
mockAuthorizeMcp.mockClear()
|
||||
mockUpdateMCP.mockClear()
|
||||
mockDeleteMCP.mockClear()
|
||||
mockInvalidateMCPTools.mockClear()
|
||||
mockOpenOAuthPopup.mockClear()
|
||||
|
||||
// Reset mock return values
|
||||
mockUpdateTools.mockResolvedValue({})
|
||||
mockAuthorizeMcp.mockResolvedValue({ result: 'success' })
|
||||
mockUpdateMCP.mockResolvedValue({ result: 'success' })
|
||||
mockDeleteMCP.mockResolvedValue({ result: 'success' })
|
||||
|
||||
// Reset state
|
||||
mockToolsData = { tools: [] }
|
||||
mockIsFetching = false
|
||||
mockIsUpdating = false
|
||||
mockIsAuthorizing = false
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display MCP name', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display server identifier', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('test-mcp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display server URL', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
expect(screen.getByText('https://example.com/mcp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
// Close button should be present
|
||||
const closeButtons = document.querySelectorAll('button')
|
||||
expect(closeButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render operation dropdown', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
// Operation dropdown trigger should be present
|
||||
expect(document.querySelector('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorization State', () => {
|
||||
it('should show authorize button when not authorized', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.authorize')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show authorized button when authorized', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show authorization required message when not authorized', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.authorizingRequired')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show authorization tip', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.authorizeTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty Tools State', () => {
|
||||
it('should show empty message when authorized but no tools', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.toolsEmpty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show get tools button when empty', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.getTools')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon Display', () => {
|
||||
it('should render MCP icon', () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
// Icon container should be present
|
||||
const iconContainer = document.querySelector('[class*="rounded-xl"][class*="border"]')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty server URL', () => {
|
||||
const detail = createMockDetail({ server_url: '' })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('Test MCP Server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long MCP name', () => {
|
||||
const longName = 'A'.repeat(100)
|
||||
const detail = createMockDetail({ name: longName })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText(longName)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tools List', () => {
|
||||
it('should show tools list when authorized and has tools', () => {
|
||||
mockToolsData = {
|
||||
tools: [
|
||||
{ id: 'tool1', name: 'tool1', description: 'Tool 1' },
|
||||
{ id: 'tool2', name: 'tool2', description: 'Tool 2' },
|
||||
],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tool1')).toBeInTheDocument()
|
||||
expect(screen.getByText('tool2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show single tool label when only one tool', () => {
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.onlyTool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tools count when multiple tools', () => {
|
||||
mockToolsData = {
|
||||
tools: [
|
||||
{ id: 'tool1', name: 'tool1', description: 'Tool 1' },
|
||||
{ id: 'tool2', name: 'tool2', description: 'Tool 2' },
|
||||
],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText(/tools.mcp.toolsNum/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show loading state when fetching tools', () => {
|
||||
mockIsFetching = true
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.gettingTools')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show updating state when updating tools', () => {
|
||||
mockIsUpdating = true
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.updateTools')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show authorizing button when authorizing', () => {
|
||||
mockIsAuthorizing = true
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
// Multiple elements show authorizing text - use getAllByText
|
||||
const authorizingElements = screen.getAllByText('tools.mcp.authorizing')
|
||||
expect(authorizingElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorize Flow', () => {
|
||||
it('should call authorizeMcp when authorize button is clicked', async () => {
|
||||
const onFirstCreate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const authorizeBtn = screen.getByText('tools.mcp.authorize')
|
||||
fireEvent.click(authorizeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFirstCreate).toHaveBeenCalled()
|
||||
expect(mockAuthorizeMcp).toHaveBeenCalledWith({ provider_id: 'mcp-1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should open OAuth popup when authorization_url is returned', async () => {
|
||||
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const authorizeBtn = screen.getByText('tools.mcp.authorize')
|
||||
fireEvent.click(authorizeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
|
||||
'https://oauth.example.com',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should trigger authorize on mount when isTriggerAuthorize is true', async () => {
|
||||
const onFirstCreate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} isTriggerAuthorize={true} onFirstCreate={onFirstCreate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFirstCreate).toHaveBeenCalled()
|
||||
expect(mockAuthorizeMcp).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable authorize button when not workspace manager', () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const authorizeBtn = screen.getByText('tools.mcp.authorize')
|
||||
expect(authorizeBtn.closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update Tools Flow', () => {
|
||||
it('should show update confirm dialog when update button is clicked', async () => {
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const updateBtn = screen.getByText('tools.mcp.update')
|
||||
fireEvent.click(updateBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateTools when update is confirmed', async () => {
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const onUpdate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Open confirm dialog
|
||||
const updateBtn = screen.getByText('tools.mcp.update')
|
||||
fireEvent.click(updateBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm the update
|
||||
const confirmBtn = screen.getByTestId('confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
|
||||
expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1')
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleUpdateTools when get tools button is clicked', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: true, tools: [] })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const getToolsBtn = screen.getByText('tools.mcp.getTools')
|
||||
fireEvent.click(getToolsBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update MCP Modal', () => {
|
||||
it('should open update modal when edit button is clicked', async () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close update modal when close button is clicked', async () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open modal
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close modal
|
||||
const closeBtn = screen.getByTestId('modal-close-btn')
|
||||
fireEvent.click(closeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('mcp-update-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateMCP when form is confirmed', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open modal
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm form
|
||||
const confirmBtn = screen.getByTestId('modal-confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMCP).toHaveBeenCalledWith({
|
||||
name: 'Updated MCP',
|
||||
server_url: 'https://updated.com',
|
||||
provider_id: 'mcp-1',
|
||||
})
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onUpdate when updateMCP fails', async () => {
|
||||
mockUpdateMCP.mockResolvedValue({ result: 'error' })
|
||||
const onUpdate = vi.fn()
|
||||
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open modal
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
fireEvent.click(editBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm form
|
||||
const confirmBtn = screen.getByTestId('modal-confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMCP).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete MCP Flow', () => {
|
||||
it('should open delete confirm when remove button is clicked', async () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close delete confirm when cancel is clicked', async () => {
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open confirm
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cancel
|
||||
const cancelBtn = screen.getByTestId('cancel-btn')
|
||||
fireEvent.click(cancelBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call deleteMCP when delete is confirmed', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open confirm
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm delete
|
||||
const confirmBtn = screen.getByTestId('confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1')
|
||||
expect(onUpdate).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onUpdate when deleteMCP fails', async () => {
|
||||
mockDeleteMCP.mockResolvedValue({ result: 'error' })
|
||||
const onUpdate = vi.fn()
|
||||
render(<MCPDetailContent {...defaultProps} onUpdate={onUpdate} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open confirm
|
||||
const removeBtn = screen.getByTestId('remove-btn')
|
||||
fireEvent.click(removeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm delete
|
||||
const confirmBtn = screen.getByTestId('confirm-btn')
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMCP).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Close Button', () => {
|
||||
it('should call onHide when close button is clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<MCPDetailContent {...defaultProps} onHide={onHide} />, { wrapper: createWrapper() })
|
||||
|
||||
// Find the close button (ActionButton with RiCloseLine)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const closeButton = buttons.find(btn =>
|
||||
btn.querySelector('svg.h-4.w-4'),
|
||||
)
|
||||
|
||||
if (closeButton) {
|
||||
fireEvent.click(closeButton)
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy Server Identifier', () => {
|
||||
it('should copy server identifier when clicked', async () => {
|
||||
const { default: copy } = await import('copy-to-clipboard')
|
||||
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Find the server identifier element
|
||||
const serverIdentifier = screen.getByText('test-mcp')
|
||||
fireEvent.click(serverIdentifier)
|
||||
|
||||
expect(copy).toHaveBeenCalledWith('test-mcp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuth Callback', () => {
|
||||
it('should call handleUpdateTools on OAuth callback when authorized', async () => {
|
||||
// Simulate OAuth flow with authorization_url
|
||||
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
|
||||
const onUpdate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} onUpdate={onUpdate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Click authorize to trigger OAuth popup
|
||||
const authorizeBtn = screen.getByText('tools.mcp.authorize')
|
||||
fireEvent.click(authorizeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenOAuthPopup).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Get the callback function and call it
|
||||
const oauthCallback = mockOpenOAuthPopup.mock.calls[0][1]
|
||||
oauthCallback()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call handleUpdateTools if not workspace manager', async () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
|
||||
const detail = createMockDetail({ is_team_authorization: false })
|
||||
|
||||
// OAuth callback should not trigger update for non-manager
|
||||
// The button is disabled, so we simulate a scenario where OAuth was already started
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Button should be disabled
|
||||
const authorizeBtn = screen.getByText('tools.mcp.authorize')
|
||||
expect(authorizeBtn.closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorized Button', () => {
|
||||
it('should show authorized button when team is authorized', () => {
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleAuthorize when authorized button is clicked', async () => {
|
||||
const onFirstCreate = vi.fn()
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} onFirstCreate={onFirstCreate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const authorizedBtn = screen.getByText('tools.auth.authorized')
|
||||
fireEvent.click(authorizedBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFirstCreate).toHaveBeenCalled()
|
||||
expect(mockAuthorizeMcp).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable authorized button when not workspace manager', () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const authorizedBtn = screen.getByText('tools.auth.authorized')
|
||||
expect(authorizedBtn.closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel Update Confirm', () => {
|
||||
it('should close update confirm when cancel is clicked', async () => {
|
||||
mockToolsData = {
|
||||
tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }],
|
||||
}
|
||||
const detail = createMockDetail({ is_team_authorization: true })
|
||||
render(
|
||||
<MCPDetailContent {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Open confirm dialog
|
||||
const updateBtn = screen.getByText('tools.mcp.update')
|
||||
fireEvent.click(updateBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cancel the update
|
||||
const cancelBtn = screen.getByTestId('cancel-btn')
|
||||
fireEvent.click(cancelBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
71
web/app/components/tools/mcp/detail/list-loading.spec.tsx
Normal file
71
web/app/components/tools/mcp/detail/list-loading.spec.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ListLoading from './list-loading'
|
||||
|
||||
describe('ListLoading', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<ListLoading />)
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render 5 skeleton items', () => {
|
||||
render(<ListLoading />)
|
||||
const skeletonItems = document.querySelectorAll('[class*="bg-components-panel-on-panel-item-bg-hover"]')
|
||||
expect(skeletonItems.length).toBe(5)
|
||||
})
|
||||
|
||||
it('should have rounded-xl class on skeleton items', () => {
|
||||
render(<ListLoading />)
|
||||
const skeletonItems = document.querySelectorAll('.rounded-xl')
|
||||
expect(skeletonItems.length).toBeGreaterThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('should have proper spacing', () => {
|
||||
render(<ListLoading />)
|
||||
const container = document.querySelector('.space-y-2')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder bars with different widths', () => {
|
||||
render(<ListLoading />)
|
||||
const bar180 = document.querySelector('.w-\\[180px\\]')
|
||||
const bar148 = document.querySelector('.w-\\[148px\\]')
|
||||
const bar196 = document.querySelector('.w-\\[196px\\]')
|
||||
|
||||
expect(bar180).toBeInTheDocument()
|
||||
expect(bar148).toBeInTheDocument()
|
||||
expect(bar196).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have opacity styling on skeleton bars', () => {
|
||||
render(<ListLoading />)
|
||||
const opacity20Bars = document.querySelectorAll('.opacity-20')
|
||||
const opacity10Bars = document.querySelectorAll('.opacity-10')
|
||||
|
||||
expect(opacity20Bars.length).toBeGreaterThan(0)
|
||||
expect(opacity10Bars.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Structure', () => {
|
||||
it('should have correct nested structure', () => {
|
||||
render(<ListLoading />)
|
||||
const items = document.querySelectorAll('.space-y-3')
|
||||
expect(items.length).toBe(5)
|
||||
})
|
||||
|
||||
it('should render padding on skeleton items', () => {
|
||||
render(<ListLoading />)
|
||||
const paddedItems = document.querySelectorAll('.p-4')
|
||||
expect(paddedItems.length).toBe(5)
|
||||
})
|
||||
|
||||
it('should render height-2 skeleton bars', () => {
|
||||
render(<ListLoading />)
|
||||
const h2Bars = document.querySelectorAll('.h-2')
|
||||
// 3 bars per skeleton item * 5 items = 15
|
||||
expect(h2Bars.length).toBe(15)
|
||||
})
|
||||
})
|
||||
})
|
||||
193
web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx
Normal file
193
web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import OperationDropdown from './operation-dropdown'
|
||||
|
||||
describe('OperationDropdown', () => {
|
||||
const defaultProps = {
|
||||
onEdit: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
expect(document.querySelector('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger button with more icon', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
const button = document.querySelector('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
const svg = button?.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render medium size by default', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
const icon = document.querySelector('.h-4.w-4')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render large size when inCard is true', () => {
|
||||
render(<OperationDropdown {...defaultProps} inCard={true} />)
|
||||
const icon = document.querySelector('.h-5.w-5')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dropdown Behavior', () => {
|
||||
it('should open dropdown when trigger is clicked', async () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Dropdown content should be rendered
|
||||
expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onOpenChange when opened', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('should close dropdown when trigger is clicked again', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Menu Actions', () => {
|
||||
it('should call onEdit when edit option is clicked', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onEdit={onEdit} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const editOption = screen.getByText('tools.mcp.operation.edit')
|
||||
fireEvent.click(editOption)
|
||||
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onRemove when remove option is clicked', () => {
|
||||
const onRemove = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onRemove={onRemove} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const removeOption = screen.getByText('tools.mcp.operation.remove')
|
||||
fireEvent.click(removeOption)
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('should close dropdown after edit is clicked', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
onOpenChange.mockClear()
|
||||
|
||||
const editOption = screen.getByText('tools.mcp.operation.edit')
|
||||
fireEvent.click(editOption)
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('should close dropdown after remove is clicked', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
onOpenChange.mockClear()
|
||||
|
||||
const removeOption = screen.getByText('tools.mcp.operation.remove')
|
||||
fireEvent.click(removeOption)
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct dropdown width', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dropdown = document.querySelector('.w-\\[160px\\]')
|
||||
expect(dropdown).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('should have rounded-xl on dropdown', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dropdown = document.querySelector('[class*="rounded-xl"][class*="border"]')
|
||||
expect(dropdown).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('should show destructive hover style on remove option', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// The text is in a div, and the hover style is on the parent div with group class
|
||||
const removeOptionText = screen.getByText('tools.mcp.operation.remove')
|
||||
const removeOptionContainer = removeOptionText.closest('.group')
|
||||
expect(removeOptionContainer).toHaveClass('hover:bg-state-destructive-hover')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('inCard prop', () => {
|
||||
it('should adjust offset when inCard is false', () => {
|
||||
render(<OperationDropdown {...defaultProps} inCard={false} />)
|
||||
// Component renders with different offset values
|
||||
expect(document.querySelector('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should adjust offset when inCard is true', () => {
|
||||
render(<OperationDropdown {...defaultProps} inCard={true} />)
|
||||
// Component renders with different offset values
|
||||
expect(document.querySelector('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
153
web/app/components/tools/mcp/detail/provider-detail.spec.tsx
Normal file
153
web/app/components/tools/mcp/detail/provider-detail.spec.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import MCPDetailPanel from './provider-detail'
|
||||
|
||||
// Mock the drawer component
|
||||
vi.mock('@/app/components/base/drawer', () => ({
|
||||
default: ({ children, isOpen }: { children: ReactNode, isOpen: boolean }) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
return <div data-testid="drawer">{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the content component to expose onUpdate callback
|
||||
vi.mock('./content', () => ({
|
||||
default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => (
|
||||
<div data-testid="mcp-detail-content">
|
||||
{detail.name}
|
||||
<button data-testid="update-btn" onClick={() => onUpdate()}>Update</button>
|
||||
<button data-testid="delete-btn" onClick={() => onUpdate(true)}>Delete</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('MCPDetailPanel', () => {
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
const createMockDetail = (): ToolWithProvider => ({
|
||||
id: 'mcp-1',
|
||||
name: 'Test MCP',
|
||||
server_identifier: 'test-mcp',
|
||||
server_url: 'https://example.com/mcp',
|
||||
icon: { content: '🔧', background: '#FF0000' },
|
||||
tools: [],
|
||||
is_team_authorization: true,
|
||||
} as unknown as ToolWithProvider)
|
||||
|
||||
const defaultProps = {
|
||||
onUpdate: vi.fn(),
|
||||
onHide: vi.fn(),
|
||||
isTriggerAuthorize: false,
|
||||
onFirstCreate: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render nothing when detail is undefined', () => {
|
||||
const { container } = render(
|
||||
<MCPDetailPanel {...defaultProps} detail={undefined} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should render drawer when detail is provided', () => {
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content when detail is provided', () => {
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass detail to content component', () => {
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('Test MCP')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Callbacks', () => {
|
||||
it('should call onUpdate when update is triggered', () => {
|
||||
const onUpdate = vi.fn()
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
// The update callback is passed to content component
|
||||
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept isTriggerAuthorize prop', () => {
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} isTriggerAuthorize={true} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUpdate', () => {
|
||||
it('should call onUpdate but not onHide when isDelete is false (default)', () => {
|
||||
const onUpdate = vi.fn()
|
||||
const onHide = vi.fn()
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} onHide={onHide} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Click update button which calls onUpdate() without isDelete parameter
|
||||
const updateBtn = screen.getByTestId('update-btn')
|
||||
fireEvent.click(updateBtn)
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call both onHide and onUpdate when isDelete is true', () => {
|
||||
const onUpdate = vi.fn()
|
||||
const onHide = vi.fn()
|
||||
const detail = createMockDetail()
|
||||
render(
|
||||
<MCPDetailPanel {...defaultProps} detail={detail} onUpdate={onUpdate} onHide={onHide} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Click delete button which calls onUpdate(true)
|
||||
const deleteBtn = screen.getByTestId('delete-btn')
|
||||
fireEvent.click(deleteBtn)
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
126
web/app/components/tools/mcp/detail/tool-item.spec.tsx
Normal file
126
web/app/components/tools/mcp/detail/tool-item.spec.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import MCPToolItem from './tool-item'
|
||||
|
||||
describe('MCPToolItem', () => {
|
||||
const createMockTool = (overrides = {}): Tool => ({
|
||||
name: 'test-tool',
|
||||
label: {
|
||||
en_US: 'Test Tool',
|
||||
zh_Hans: '测试工具',
|
||||
},
|
||||
description: {
|
||||
en_US: 'A test tool description',
|
||||
zh_Hans: '测试工具描述',
|
||||
},
|
||||
parameters: [],
|
||||
...overrides,
|
||||
} as unknown as Tool)
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.getByText('Test Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display tool label', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.getByText('Test Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display tool description', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.getByText('A test tool description')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('With Parameters', () => {
|
||||
it('should not show parameters section when no parameters', () => {
|
||||
const tool = createMockTool({ parameters: [] })
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.queryByText('tools.mcp.toolItem.parameters')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with parameters', () => {
|
||||
const tool = createMockTool({
|
||||
parameters: [
|
||||
{
|
||||
name: 'param1',
|
||||
type: 'string',
|
||||
human_description: {
|
||||
en_US: 'A parameter description',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
// Tooltip content is rendered in portal, may not be visible immediately
|
||||
expect(screen.getByText('Test Tool')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have cursor-pointer class', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
const toolElement = document.querySelector('.cursor-pointer')
|
||||
expect(toolElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have rounded-xl class', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
const toolElement = document.querySelector('.rounded-xl')
|
||||
expect(toolElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have hover styles', () => {
|
||||
const tool = createMockTool()
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
const toolElement = document.querySelector('[class*="hover:bg-components-panel-on-panel-item-bg-hover"]')
|
||||
expect(toolElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty label', () => {
|
||||
const tool = createMockTool({
|
||||
label: { en_US: '', zh_Hans: '' },
|
||||
})
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
// Should render without crashing
|
||||
expect(document.querySelector('.cursor-pointer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty description', () => {
|
||||
const tool = createMockTool({
|
||||
description: { en_US: '', zh_Hans: '' },
|
||||
})
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.getByText('Test Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long description with line clamp', () => {
|
||||
const longDescription = 'This is a very long description '.repeat(20)
|
||||
const tool = createMockTool({
|
||||
description: { en_US: longDescription, zh_Hans: longDescription },
|
||||
})
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
const descElement = document.querySelector('.line-clamp-2')
|
||||
expect(descElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in tool name', () => {
|
||||
const tool = createMockTool({
|
||||
name: 'special-tool_v2.0',
|
||||
label: { en_US: 'Special Tool <v2.0>', zh_Hans: '特殊工具' },
|
||||
})
|
||||
render(<MCPToolItem tool={tool} />)
|
||||
expect(screen.getByText('Special Tool <v2.0>')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
245
web/app/components/tools/mcp/headers-input.spec.tsx
Normal file
245
web/app/components/tools/mcp/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()
|
||||
})
|
||||
})
|
||||
})
|
||||
500
web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts
Normal file
500
web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import type { AppIconEmojiSelection, AppIconImageSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MCPAuthMethod } from '@/app/components/tools/types'
|
||||
import { isValidServerID, isValidUrl, useMCPModalForm } from './use-mcp-modal-form'
|
||||
|
||||
// Mock the API service
|
||||
vi.mock('@/service/common', () => ({
|
||||
uploadRemoteFileInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useMCPModalForm', () => {
|
||||
describe('Utility Functions', () => {
|
||||
describe('isValidUrl', () => {
|
||||
it('should return true for valid http URL', () => {
|
||||
expect(isValidUrl('http://example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for valid https URL', () => {
|
||||
expect(isValidUrl('https://example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for URL with path', () => {
|
||||
expect(isValidUrl('https://example.com/path/to/resource')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for URL with query params', () => {
|
||||
expect(isValidUrl('https://example.com?foo=bar')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for invalid URL', () => {
|
||||
expect(isValidUrl('not-a-url')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for ftp URL', () => {
|
||||
expect(isValidUrl('ftp://example.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isValidUrl('')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for file URL', () => {
|
||||
expect(isValidUrl('file:///path/to/file')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidServerID', () => {
|
||||
it('should return true for lowercase letters', () => {
|
||||
expect(isValidServerID('myserver')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for numbers', () => {
|
||||
expect(isValidServerID('123')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for alphanumeric with hyphens', () => {
|
||||
expect(isValidServerID('my-server-123')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for alphanumeric with underscores', () => {
|
||||
expect(isValidServerID('my_server_123')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for max length (24 chars)', () => {
|
||||
expect(isValidServerID('abcdefghijklmnopqrstuvwx')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for uppercase letters', () => {
|
||||
expect(isValidServerID('MyServer')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for spaces', () => {
|
||||
expect(isValidServerID('my server')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for special characters', () => {
|
||||
expect(isValidServerID('my@server')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isValidServerID('')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for string longer than 24 chars', () => {
|
||||
expect(isValidServerID('abcdefghijklmnopqrstuvwxy')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hook Initialization', () => {
|
||||
describe('Create Mode (no data)', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
expect(result.current.isCreate).toBe(true)
|
||||
expect(result.current.formKey).toBe('create')
|
||||
expect(result.current.state.url).toBe('')
|
||||
expect(result.current.state.name).toBe('')
|
||||
expect(result.current.state.serverIdentifier).toBe('')
|
||||
expect(result.current.state.timeout).toBe(30)
|
||||
expect(result.current.state.sseReadTimeout).toBe(300)
|
||||
expect(result.current.state.headers).toEqual([])
|
||||
expect(result.current.state.authMethod).toBe(MCPAuthMethod.authentication)
|
||||
expect(result.current.state.isDynamicRegistration).toBe(true)
|
||||
expect(result.current.state.clientID).toBe('')
|
||||
expect(result.current.state.credentials).toBe('')
|
||||
})
|
||||
|
||||
it('should initialize with default emoji icon', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
expect(result.current.state.appIcon).toEqual({
|
||||
type: 'emoji',
|
||||
icon: '🔗',
|
||||
background: '#6366F1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode (with data)', () => {
|
||||
const mockData: ToolWithProvider = {
|
||||
id: 'test-id-123',
|
||||
name: 'Test MCP Server',
|
||||
server_url: 'https://example.com/mcp',
|
||||
server_identifier: 'test-server',
|
||||
icon: { content: '🚀', background: '#FF0000' },
|
||||
configuration: {
|
||||
timeout: 60,
|
||||
sse_read_timeout: 600,
|
||||
},
|
||||
masked_headers: {
|
||||
'Authorization': '***',
|
||||
'X-Custom': 'value',
|
||||
},
|
||||
is_dynamic_registration: false,
|
||||
authentication: {
|
||||
client_id: 'client-123',
|
||||
client_secret: 'secret-456',
|
||||
},
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
it('should initialize with data values', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.isCreate).toBe(false)
|
||||
expect(result.current.formKey).toBe('test-id-123')
|
||||
expect(result.current.state.url).toBe('https://example.com/mcp')
|
||||
expect(result.current.state.name).toBe('Test MCP Server')
|
||||
expect(result.current.state.serverIdentifier).toBe('test-server')
|
||||
expect(result.current.state.timeout).toBe(60)
|
||||
expect(result.current.state.sseReadTimeout).toBe(600)
|
||||
expect(result.current.state.isDynamicRegistration).toBe(false)
|
||||
expect(result.current.state.clientID).toBe('client-123')
|
||||
expect(result.current.state.credentials).toBe('secret-456')
|
||||
})
|
||||
|
||||
it('should initialize headers from masked_headers', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.headers).toHaveLength(2)
|
||||
expect(result.current.state.headers[0].key).toBe('Authorization')
|
||||
expect(result.current.state.headers[0].value).toBe('***')
|
||||
expect(result.current.state.headers[1].key).toBe('X-Custom')
|
||||
expect(result.current.state.headers[1].value).toBe('value')
|
||||
})
|
||||
|
||||
it('should initialize emoji icon from data', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.appIcon.type).toBe('emoji')
|
||||
expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🚀')
|
||||
expect(((result.current.state.appIcon) as AppIconEmojiSelection).background).toBe('#FF0000')
|
||||
})
|
||||
|
||||
it('should store original server URL and ID', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.originalServerUrl).toBe('https://example.com/mcp')
|
||||
expect(result.current.originalServerID).toBe('test-server')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode with string icon', () => {
|
||||
const mockDataWithImageIcon: ToolWithProvider = {
|
||||
id: 'test-id',
|
||||
name: 'Test',
|
||||
icon: 'https://example.com/files/abc123/file-preview/icon.png',
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
it('should initialize image icon from string URL', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm(mockDataWithImageIcon))
|
||||
|
||||
expect(result.current.state.appIcon.type).toBe('image')
|
||||
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/abc123/file-preview/icon.png')
|
||||
expect(((result.current.state.appIcon) as AppIconImageSelection).fileId).toBe('abc123')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Actions', () => {
|
||||
it('should update url', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setUrl('https://new-url.com')
|
||||
})
|
||||
|
||||
expect(result.current.state.url).toBe('https://new-url.com')
|
||||
})
|
||||
|
||||
it('should update name', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setName('New Server Name')
|
||||
})
|
||||
|
||||
expect(result.current.state.name).toBe('New Server Name')
|
||||
})
|
||||
|
||||
it('should update serverIdentifier', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setServerIdentifier('new-server-id')
|
||||
})
|
||||
|
||||
expect(result.current.state.serverIdentifier).toBe('new-server-id')
|
||||
})
|
||||
|
||||
it('should update timeout', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setTimeout(120)
|
||||
})
|
||||
|
||||
expect(result.current.state.timeout).toBe(120)
|
||||
})
|
||||
|
||||
it('should update sseReadTimeout', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setSseReadTimeout(900)
|
||||
})
|
||||
|
||||
expect(result.current.state.sseReadTimeout).toBe(900)
|
||||
})
|
||||
|
||||
it('should update headers', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
const newHeaders = [{ id: '1', key: 'X-New', value: 'new-value' }]
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setHeaders(newHeaders)
|
||||
})
|
||||
|
||||
expect(result.current.state.headers).toEqual(newHeaders)
|
||||
})
|
||||
|
||||
it('should update authMethod', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setAuthMethod(MCPAuthMethod.headers)
|
||||
})
|
||||
|
||||
expect(result.current.state.authMethod).toBe(MCPAuthMethod.headers)
|
||||
})
|
||||
|
||||
it('should update isDynamicRegistration', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setIsDynamicRegistration(false)
|
||||
})
|
||||
|
||||
expect(result.current.state.isDynamicRegistration).toBe(false)
|
||||
})
|
||||
|
||||
it('should update clientID', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setClientID('new-client-id')
|
||||
})
|
||||
|
||||
expect(result.current.state.clientID).toBe('new-client-id')
|
||||
})
|
||||
|
||||
it('should update credentials', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setCredentials('new-secret')
|
||||
})
|
||||
|
||||
expect(result.current.state.credentials).toBe('new-secret')
|
||||
})
|
||||
|
||||
it('should update appIcon', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
const newIcon = { type: 'emoji' as const, icon: '🎉', background: '#00FF00' }
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setAppIcon(newIcon)
|
||||
})
|
||||
|
||||
expect(result.current.state.appIcon).toEqual(newIcon)
|
||||
})
|
||||
|
||||
it('should toggle showAppIconPicker', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
expect(result.current.state.showAppIconPicker).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.actions.setShowAppIconPicker(true)
|
||||
})
|
||||
|
||||
expect(result.current.state.showAppIconPicker).toBe(true)
|
||||
})
|
||||
|
||||
it('should reset icon to default', () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
// Change icon first
|
||||
act(() => {
|
||||
result.current.actions.setAppIcon({ type: 'emoji', icon: '🎉', background: '#00FF00' })
|
||||
})
|
||||
|
||||
expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🎉')
|
||||
|
||||
// Reset icon
|
||||
act(() => {
|
||||
result.current.actions.resetIcon()
|
||||
})
|
||||
|
||||
expect(result.current.state.appIcon).toEqual({
|
||||
type: 'emoji',
|
||||
icon: '🔗',
|
||||
background: '#6366F1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUrlBlur', () => {
|
||||
it('should not fetch icon in edit mode (when data is provided)', async () => {
|
||||
const mockData = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
icon: { content: '🔗', background: '#6366F1' },
|
||||
} as unknown as ToolWithProvider
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.handleUrlBlur('https://example.com')
|
||||
})
|
||||
|
||||
// In edit mode, handleUrlBlur should return early
|
||||
expect(result.current.state.isFetchingIcon).toBe(false)
|
||||
})
|
||||
|
||||
it('should not fetch icon for invalid URL', async () => {
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.handleUrlBlur('not-a-valid-url')
|
||||
})
|
||||
|
||||
expect(result.current.state.isFetchingIcon).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle error when icon fetch fails with error code', async () => {
|
||||
const { uploadRemoteFileInfo } = await import('@/service/common')
|
||||
const mockError = {
|
||||
json: vi.fn().mockResolvedValue({ code: 'UPLOAD_ERROR' }),
|
||||
}
|
||||
vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError)
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.handleUrlBlur('https://example.com/mcp')
|
||||
})
|
||||
|
||||
// Should have called console.error
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
// isFetchingIcon should be reset to false after error
|
||||
expect(result.current.state.isFetchingIcon).toBe(false)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle error when icon fetch fails without error code', async () => {
|
||||
const { uploadRemoteFileInfo } = await import('@/service/common')
|
||||
const mockError = {
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError)
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.handleUrlBlur('https://example.com/mcp')
|
||||
})
|
||||
|
||||
// Should have called console.error
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
// isFetchingIcon should be reset to false after error
|
||||
expect(result.current.state.isFetchingIcon).toBe(false)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should fetch icon successfully for valid URL in create mode', async () => {
|
||||
vi.mocked(await import('@/service/common').then(m => m.uploadRemoteFileInfo)).mockResolvedValueOnce({
|
||||
id: 'file123',
|
||||
name: 'icon.png',
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
url: 'https://example.com/files/file123/file-preview/icon.png',
|
||||
} as unknown as { id: string, name: string, size: number, mime_type: string, url: string })
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.handleUrlBlur('https://example.com/mcp')
|
||||
})
|
||||
|
||||
// Icon should be set to image type
|
||||
expect(result.current.state.appIcon.type).toBe('image')
|
||||
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/file123/file-preview/icon.png')
|
||||
expect(result.current.state.isFetchingIcon).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
// Base mock data with required icon field
|
||||
const baseMockData = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
icon: { content: '🔗', background: '#6366F1' },
|
||||
}
|
||||
|
||||
it('should handle undefined configuration', () => {
|
||||
const mockData = { ...baseMockData } as unknown as ToolWithProvider
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.timeout).toBe(30)
|
||||
expect(result.current.state.sseReadTimeout).toBe(300)
|
||||
})
|
||||
|
||||
it('should handle undefined authentication', () => {
|
||||
const mockData = { ...baseMockData } as unknown as ToolWithProvider
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.clientID).toBe('')
|
||||
expect(result.current.state.credentials).toBe('')
|
||||
})
|
||||
|
||||
it('should handle undefined masked_headers', () => {
|
||||
const mockData = { ...baseMockData } as unknown as ToolWithProvider
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.headers).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle undefined is_dynamic_registration (defaults to true)', () => {
|
||||
const mockData = { ...baseMockData } as unknown as ToolWithProvider
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.isDynamicRegistration).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle string icon URL', () => {
|
||||
const mockData = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
icon: 'https://example.com/icon.png',
|
||||
} as unknown as ToolWithProvider
|
||||
|
||||
const { result } = renderHook(() => useMCPModalForm(mockData))
|
||||
|
||||
expect(result.current.state.appIcon.type).toBe('image')
|
||||
expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/icon.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
203
web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts
Normal file
203
web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
import type { HeaderItem } from '../headers-input'
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { getDomain } from 'tldts'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { MCPAuthMethod } from '@/app/components/tools/types'
|
||||
import { uploadRemoteFileInfo } from '@/service/common'
|
||||
|
||||
const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' }
|
||||
|
||||
const extractFileId = (url: string) => {
|
||||
const match = url.match(/files\/(.+?)\/file-preview/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
const getIcon = (data?: ToolWithProvider): AppIconSelection => {
|
||||
if (!data)
|
||||
return DEFAULT_ICON as AppIconSelection
|
||||
if (typeof data.icon === 'string')
|
||||
return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
|
||||
return {
|
||||
...data.icon,
|
||||
icon: data.icon.content,
|
||||
type: 'emoji',
|
||||
} as unknown as AppIconSelection
|
||||
}
|
||||
|
||||
const getInitialHeaders = (data?: ToolWithProvider): HeaderItem[] => {
|
||||
return Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value }))
|
||||
}
|
||||
|
||||
export const isValidUrl = (string: string) => {
|
||||
try {
|
||||
const url = new URL(string)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const isValidServerID = (str: string) => {
|
||||
return /^[a-z0-9_-]{1,24}$/.test(str)
|
||||
}
|
||||
|
||||
export type MCPModalFormState = {
|
||||
url: string
|
||||
name: string
|
||||
appIcon: AppIconSelection
|
||||
showAppIconPicker: boolean
|
||||
serverIdentifier: string
|
||||
timeout: number
|
||||
sseReadTimeout: number
|
||||
headers: HeaderItem[]
|
||||
isFetchingIcon: boolean
|
||||
authMethod: MCPAuthMethod
|
||||
isDynamicRegistration: boolean
|
||||
clientID: string
|
||||
credentials: string
|
||||
}
|
||||
|
||||
export type MCPModalFormActions = {
|
||||
setUrl: (url: string) => void
|
||||
setName: (name: string) => void
|
||||
setAppIcon: (icon: AppIconSelection) => void
|
||||
setShowAppIconPicker: (show: boolean) => void
|
||||
setServerIdentifier: (id: string) => void
|
||||
setTimeout: (timeout: number) => void
|
||||
setSseReadTimeout: (timeout: number) => void
|
||||
setHeaders: (headers: HeaderItem[]) => void
|
||||
setAuthMethod: (method: string) => void
|
||||
setIsDynamicRegistration: (value: boolean) => void
|
||||
setClientID: (id: string) => void
|
||||
setCredentials: (credentials: string) => void
|
||||
handleUrlBlur: (url: string) => Promise<void>
|
||||
resetIcon: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for MCP Modal form state management.
|
||||
*
|
||||
* Note: This hook uses a `formKey` (data ID or 'create') to reset form state when
|
||||
* switching between edit and create modes. All useState initializers read from `data`
|
||||
* directly, and the key change triggers a remount of the consumer component.
|
||||
*/
|
||||
export const useMCPModalForm = (data?: ToolWithProvider) => {
|
||||
const isCreate = !data
|
||||
const originalServerUrl = data?.server_url
|
||||
const originalServerID = data?.server_identifier
|
||||
|
||||
// Form key for resetting state - changes when data changes
|
||||
const formKey = useMemo(() => data?.id ?? 'create', [data?.id])
|
||||
|
||||
// Form state - initialized from data
|
||||
const [url, setUrl] = useState(() => data?.server_url || '')
|
||||
const [name, setName] = useState(() => data?.name || '')
|
||||
const [appIcon, setAppIcon] = useState<AppIconSelection>(() => getIcon(data))
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
const [serverIdentifier, setServerIdentifier] = useState(() => data?.server_identifier || '')
|
||||
const [timeout, setMcpTimeout] = useState(() => data?.configuration?.timeout || 30)
|
||||
const [sseReadTimeout, setSseReadTimeout] = useState(() => data?.configuration?.sse_read_timeout || 300)
|
||||
const [headers, setHeaders] = useState<HeaderItem[]>(() => getInitialHeaders(data))
|
||||
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
|
||||
const appIconRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auth state
|
||||
const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication)
|
||||
const [isDynamicRegistration, setIsDynamicRegistration] = useState(() => isCreate ? true : (data?.is_dynamic_registration ?? true))
|
||||
const [clientID, setClientID] = useState(() => data?.authentication?.client_id || '')
|
||||
const [credentials, setCredentials] = useState(() => data?.authentication?.client_secret || '')
|
||||
|
||||
const handleUrlBlur = useCallback(async (urlValue: string) => {
|
||||
if (data)
|
||||
return
|
||||
if (!isValidUrl(urlValue))
|
||||
return
|
||||
const domain = getDomain(urlValue)
|
||||
const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
|
||||
setIsFetchingIcon(true)
|
||||
try {
|
||||
const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
|
||||
setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
|
||||
}
|
||||
catch (e) {
|
||||
let errorMessage = 'Failed to fetch remote icon'
|
||||
if (e instanceof Response) {
|
||||
try {
|
||||
const errorData = await e.json()
|
||||
if (errorData?.code)
|
||||
errorMessage = `Upload failed: ${errorData.code}`
|
||||
}
|
||||
catch {
|
||||
// Ignore JSON parsing errors
|
||||
}
|
||||
}
|
||||
else if (e instanceof Error) {
|
||||
errorMessage = e.message
|
||||
}
|
||||
console.error('Failed to fetch remote icon:', e)
|
||||
Toast.notify({ type: 'warning', message: errorMessage })
|
||||
}
|
||||
finally {
|
||||
setIsFetchingIcon(false)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const resetIcon = useCallback(() => {
|
||||
setAppIcon(getIcon(data))
|
||||
}, [data])
|
||||
|
||||
const handleAuthMethodChange = useCallback((value: string) => {
|
||||
setAuthMethod(value as MCPAuthMethod)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Key for form reset (use as React key on parent)
|
||||
formKey,
|
||||
|
||||
// Metadata
|
||||
isCreate,
|
||||
originalServerUrl,
|
||||
originalServerID,
|
||||
appIconRef,
|
||||
|
||||
// State
|
||||
state: {
|
||||
url,
|
||||
name,
|
||||
appIcon,
|
||||
showAppIconPicker,
|
||||
serverIdentifier,
|
||||
timeout,
|
||||
sseReadTimeout,
|
||||
headers,
|
||||
isFetchingIcon,
|
||||
authMethod,
|
||||
isDynamicRegistration,
|
||||
clientID,
|
||||
credentials,
|
||||
} satisfies MCPModalFormState,
|
||||
|
||||
// Actions
|
||||
actions: {
|
||||
setUrl,
|
||||
setName,
|
||||
setAppIcon,
|
||||
setShowAppIconPicker,
|
||||
setServerIdentifier,
|
||||
setTimeout: setMcpTimeout,
|
||||
setSseReadTimeout,
|
||||
setHeaders,
|
||||
setAuthMethod: handleAuthMethodChange,
|
||||
setIsDynamicRegistration,
|
||||
setClientID,
|
||||
setCredentials,
|
||||
handleUrlBlur,
|
||||
resetIcon,
|
||||
} satisfies MCPModalFormActions,
|
||||
}
|
||||
}
|
||||
451
web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts
Normal file
451
web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { useMCPServiceCardState } from './use-mcp-service-card'
|
||||
|
||||
// Mutable mock data for MCP server detail
|
||||
let mockMCPServerDetailData: {
|
||||
id: string
|
||||
status: string
|
||||
server_code: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
} | undefined = {
|
||||
id: 'server-123',
|
||||
status: 'active',
|
||||
server_code: 'abc123',
|
||||
description: 'Test server',
|
||||
parameters: {},
|
||||
}
|
||||
|
||||
// Mock service hooks
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useUpdateMCPServer: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
useRefreshMCPServerCode: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
}),
|
||||
useMCPServerDetail: () => ({
|
||||
data: mockMCPServerDetailData,
|
||||
}),
|
||||
useInvalidateMCPServerDetail: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock workflow hook
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useAppWorkflow: (appId: string) => ({
|
||||
data: appId
|
||||
? {
|
||||
graph: {
|
||||
nodes: [
|
||||
{ data: { type: 'start', variables: [{ variable: 'input', label: 'Input' }] } },
|
||||
],
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock apps service
|
||||
vi.mock('@/service/apps', () => ({
|
||||
fetchAppDetail: vi.fn().mockResolvedValue({
|
||||
model_config: {
|
||||
updated_at: '2024-01-01',
|
||||
user_input_form: [],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useMCPServiceCardState', () => {
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
const createMockAppInfo = (mode: AppModeEnum = AppModeEnum.CHAT): AppDetailResponse & Partial<AppSSO> => ({
|
||||
id: 'app-123',
|
||||
name: 'Test App',
|
||||
mode,
|
||||
api_base_url: 'https://api.example.com/v1',
|
||||
} as AppDetailResponse & Partial<AppSSO>)
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock data to default (published server)
|
||||
mockMCPServerDetailData = {
|
||||
id: 'server-123',
|
||||
status: 'active',
|
||||
server_code: 'abc123',
|
||||
description: 'Test server',
|
||||
parameters: {},
|
||||
}
|
||||
})
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with correct default values for basic app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.serverPublished).toBe(true)
|
||||
expect(result.current.serverActivated).toBe(true)
|
||||
expect(result.current.showConfirmDelete).toBe(false)
|
||||
expect(result.current.showMCPServerModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should initialize with correct values for workflow app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should initialize with correct values for advanced chat app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.ADVANCED_CHAT)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Server URL Generation', () => {
|
||||
it('should generate correct server URL when published', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.serverURL).toBe('https://api.example.com/mcp/server/abc123/mcp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission Flags', () => {
|
||||
it('should have isCurrentWorkspaceManager as true', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.isCurrentWorkspaceManager).toBe(true)
|
||||
})
|
||||
|
||||
it('should have toggleDisabled false when editor has permissions', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Toggle is not disabled when user has permissions and app is published
|
||||
expect(typeof result.current.toggleDisabled).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should have toggleDisabled true when triggerModeDisabled is true', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, true),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.toggleDisabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI State Actions', () => {
|
||||
it('should open confirm delete modal', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.showConfirmDelete).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirmDelete()
|
||||
})
|
||||
|
||||
expect(result.current.showConfirmDelete).toBe(true)
|
||||
})
|
||||
|
||||
it('should close confirm delete modal', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirmDelete()
|
||||
})
|
||||
expect(result.current.showConfirmDelete).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.closeConfirmDelete()
|
||||
})
|
||||
expect(result.current.showConfirmDelete).toBe(false)
|
||||
})
|
||||
|
||||
it('should open server modal', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.showMCPServerModal).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.openServerModal()
|
||||
})
|
||||
|
||||
expect(result.current.showMCPServerModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle server modal hide', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.openServerModal()
|
||||
})
|
||||
expect(result.current.showMCPServerModal).toBe(true)
|
||||
|
||||
let hideResult: { shouldDeactivate: boolean } | undefined
|
||||
act(() => {
|
||||
hideResult = result.current.handleServerModalHide(false)
|
||||
})
|
||||
|
||||
expect(result.current.showMCPServerModal).toBe(false)
|
||||
expect(hideResult?.shouldDeactivate).toBe(true)
|
||||
})
|
||||
|
||||
it('should not deactivate when wasActivated is true', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
let hideResult: { shouldDeactivate: boolean } | undefined
|
||||
act(() => {
|
||||
hideResult = result.current.handleServerModalHide(true)
|
||||
})
|
||||
|
||||
expect(hideResult?.shouldDeactivate).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Handler Functions', () => {
|
||||
it('should have handleGenCode function', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(typeof result.current.handleGenCode).toBe('function')
|
||||
})
|
||||
|
||||
it('should call handleGenCode and invalidate server detail', async () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleGenCode()
|
||||
})
|
||||
|
||||
// handleGenCode should complete without error
|
||||
expect(result.current.genLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should have handleStatusChange function', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(typeof result.current.handleStatusChange).toBe('function')
|
||||
})
|
||||
|
||||
it('should have invalidateBasicAppConfig function', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(typeof result.current.invalidateBasicAppConfig).toBe('function')
|
||||
})
|
||||
|
||||
it('should call invalidateBasicAppConfig', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Call the function - should not throw
|
||||
act(() => {
|
||||
result.current.invalidateBasicAppConfig()
|
||||
})
|
||||
|
||||
// Function should exist and be callable
|
||||
expect(typeof result.current.invalidateBasicAppConfig).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status Change', () => {
|
||||
it('should return activated state when status change succeeds', async () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
let statusResult: { activated: boolean } | undefined
|
||||
await act(async () => {
|
||||
statusResult = await result.current.handleStatusChange(true)
|
||||
})
|
||||
|
||||
expect(statusResult?.activated).toBe(true)
|
||||
})
|
||||
|
||||
it('should return deactivated state when disabling', async () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
let statusResult: { activated: boolean } | undefined
|
||||
await act(async () => {
|
||||
statusResult = await result.current.handleStatusChange(false)
|
||||
})
|
||||
|
||||
expect(statusResult?.activated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unpublished Server', () => {
|
||||
it('should open modal and return not activated when enabling unpublished server', async () => {
|
||||
// Set mock to return undefined (unpublished server)
|
||||
mockMCPServerDetailData = undefined
|
||||
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Verify server is not published
|
||||
expect(result.current.serverPublished).toBe(false)
|
||||
|
||||
let statusResult: { activated: boolean } | undefined
|
||||
await act(async () => {
|
||||
statusResult = await result.current.handleStatusChange(true)
|
||||
})
|
||||
|
||||
// Should open modal and return not activated
|
||||
expect(result.current.showMCPServerModal).toBe(true)
|
||||
expect(statusResult?.activated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should have genLoading state', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(typeof result.current.genLoading).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should have isLoading state for basic app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Basic app doesn't need workflow, so isLoading should be false
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Detail Data', () => {
|
||||
it('should return detail data when available', () => {
|
||||
const appInfo = createMockAppInfo()
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(result.current.detail).toBeDefined()
|
||||
expect(result.current.detail?.id).toBe('server-123')
|
||||
expect(result.current.detail?.status).toBe('active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Latest Params', () => {
|
||||
it('should return latestParams for workflow app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(Array.isArray(result.current.latestParams)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return latestParams for basic app', () => {
|
||||
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
|
||||
const { result } = renderHook(
|
||||
() => useMCPServiceCardState(appInfo, false),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(Array.isArray(result.current.latestParams)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
179
web/app/components/tools/mcp/hooks/use-mcp-service-card.ts
Normal file
179
web/app/components/tools/mcp/hooks/use-mcp-service-card.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { fetchAppDetail } from '@/service/apps'
|
||||
import {
|
||||
useInvalidateMCPServerDetail,
|
||||
useMCPServerDetail,
|
||||
useRefreshMCPServerCode,
|
||||
useUpdateMCPServer,
|
||||
} from '@/service/use-tools'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const BASIC_APP_CONFIG_KEY = 'basicAppConfig'
|
||||
|
||||
type AppInfo = AppDetailResponse & Partial<AppSSO>
|
||||
|
||||
type BasicAppConfig = {
|
||||
updated_at?: string
|
||||
user_input_form?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export const useMCPServiceCardState = (
|
||||
appInfo: AppInfo,
|
||||
triggerModeDisabled: boolean,
|
||||
) => {
|
||||
const appId = appInfo.id
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// API hooks
|
||||
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
|
||||
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
|
||||
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
|
||||
|
||||
// Context
|
||||
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
|
||||
|
||||
// UI state
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
|
||||
|
||||
// Derived app type values
|
||||
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
|
||||
const isBasicApp = !isAdvancedApp
|
||||
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
|
||||
|
||||
// Workflow data for advanced apps
|
||||
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
|
||||
|
||||
// Basic app config fetch using React Query
|
||||
const { data: basicAppConfig = {} } = useQuery<BasicAppConfig>({
|
||||
queryKey: [BASIC_APP_CONFIG_KEY, appId],
|
||||
queryFn: async () => {
|
||||
const res = await fetchAppDetail({ url: '/apps', id: appId })
|
||||
return (res?.model_config as BasicAppConfig) || {}
|
||||
},
|
||||
enabled: isBasicApp && !!appId,
|
||||
})
|
||||
|
||||
// MCP server detail
|
||||
const { data: detail } = useMCPServerDetail(appId)
|
||||
const { id, status, server_code } = detail ?? {}
|
||||
|
||||
// Server state
|
||||
const serverPublished = !!id
|
||||
const serverActivated = status === 'active'
|
||||
const serverURL = serverPublished
|
||||
? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp`
|
||||
: '***********'
|
||||
|
||||
// App state checks
|
||||
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
|
||||
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
|
||||
const missingStartNode = isWorkflowApp && !hasStartNode
|
||||
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
|
||||
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
|
||||
const isMinimalState = appUnpublished || missingStartNode
|
||||
|
||||
// Basic app input form
|
||||
const basicAppInputForm = useMemo(() => {
|
||||
if (!isBasicApp || !basicAppConfig?.user_input_form)
|
||||
return []
|
||||
return (basicAppConfig.user_input_form as Array<Record<string, unknown>>).map((item) => {
|
||||
const type = Object.keys(item)[0]
|
||||
return {
|
||||
...(item[type] as object),
|
||||
type: type || 'text-input',
|
||||
}
|
||||
})
|
||||
}, [basicAppConfig?.user_input_form, isBasicApp])
|
||||
|
||||
// Latest params for modal
|
||||
const latestParams = useMemo(() => {
|
||||
if (isAdvancedApp) {
|
||||
if (!currentWorkflow?.graph)
|
||||
return []
|
||||
type StartNodeData = { type: string, variables?: Array<{ variable: string, label: string }> }
|
||||
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as { data: StartNodeData } | undefined
|
||||
return startNode?.data.variables || []
|
||||
}
|
||||
return basicAppInputForm
|
||||
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
|
||||
|
||||
// Handlers
|
||||
const handleGenCode = useCallback(async () => {
|
||||
await refreshMCPServerCode(detail?.id || '')
|
||||
invalidateMCPServerDetail(appId)
|
||||
}, [refreshMCPServerCode, detail?.id, invalidateMCPServerDetail, appId])
|
||||
|
||||
const handleStatusChange = useCallback(async (state: boolean) => {
|
||||
if (state && !serverPublished) {
|
||||
setShowMCPServerModal(true)
|
||||
return { activated: false }
|
||||
}
|
||||
|
||||
await updateMCPServer({
|
||||
appID: appId,
|
||||
id: id || '',
|
||||
description: detail?.description || '',
|
||||
parameters: detail?.parameters || {},
|
||||
status: state ? 'active' : 'inactive',
|
||||
})
|
||||
invalidateMCPServerDetail(appId)
|
||||
return { activated: state }
|
||||
}, [serverPublished, updateMCPServer, appId, id, detail, invalidateMCPServerDetail])
|
||||
|
||||
const handleServerModalHide = useCallback((wasActivated: boolean) => {
|
||||
setShowMCPServerModal(false)
|
||||
// If server wasn't activated before opening modal, keep it deactivated
|
||||
return { shouldDeactivate: !wasActivated }
|
||||
}, [])
|
||||
|
||||
const openConfirmDelete = useCallback(() => setShowConfirmDelete(true), [])
|
||||
const closeConfirmDelete = useCallback(() => setShowConfirmDelete(false), [])
|
||||
const openServerModal = useCallback(() => setShowMCPServerModal(true), [])
|
||||
|
||||
const invalidateBasicAppConfig = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: [BASIC_APP_CONFIG_KEY, appId] })
|
||||
}, [queryClient, appId])
|
||||
|
||||
return {
|
||||
// Loading states
|
||||
genLoading,
|
||||
isLoading: isAdvancedApp ? !currentWorkflow : false,
|
||||
|
||||
// Server state
|
||||
serverPublished,
|
||||
serverActivated,
|
||||
serverURL,
|
||||
detail,
|
||||
|
||||
// Permission & validation flags
|
||||
isCurrentWorkspaceManager,
|
||||
toggleDisabled,
|
||||
isMinimalState,
|
||||
appUnpublished,
|
||||
missingStartNode,
|
||||
|
||||
// UI state
|
||||
showConfirmDelete,
|
||||
showMCPServerModal,
|
||||
|
||||
// Data
|
||||
latestParams,
|
||||
|
||||
// Handlers
|
||||
handleGenCode,
|
||||
handleStatusChange,
|
||||
handleServerModalHide,
|
||||
openConfirmDelete,
|
||||
closeConfirmDelete,
|
||||
openServerModal,
|
||||
invalidateBasicAppConfig,
|
||||
}
|
||||
}
|
||||
361
web/app/components/tools/mcp/mcp-server-modal.spec.tsx
Normal file
361
web/app/components/tools/mcp/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()
|
||||
})
|
||||
})
|
||||
})
|
||||
165
web/app/components/tools/mcp/mcp-server-param-item.spec.tsx
Normal file
165
web/app/components/tools/mcp/mcp-server-param-item.spec.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import MCPServerParamItem from './mcp-server-param-item'
|
||||
|
||||
describe('MCPServerParamItem', () => {
|
||||
const defaultProps = {
|
||||
data: {
|
||||
label: 'Test Label',
|
||||
variable: 'test_variable',
|
||||
type: 'string',
|
||||
},
|
||||
value: '',
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display label', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display variable name', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
expect(screen.getByText('test_variable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display type', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
expect(screen.getByText('string')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display separator dot', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render textarea with placeholder', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value Display', () => {
|
||||
it('should display empty value by default', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} />)
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should display provided value', () => {
|
||||
render(<MCPServerParamItem {...defaultProps} value="test value" />)
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
expect(textarea).toHaveValue('test value')
|
||||
})
|
||||
|
||||
it('should display long text value', () => {
|
||||
const longValue = 'This is a very long text value that might span multiple lines'
|
||||
render(<MCPServerParamItem {...defaultProps} value={longValue} />)
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
expect(textarea).toHaveValue(longValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when text is entered', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: 'new value' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('new value')
|
||||
})
|
||||
|
||||
it('should call onChange with empty string when cleared', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MCPServerParamItem {...defaultProps} value="existing" onChange={onChange} />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: '' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should handle multiple changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'first' } })
|
||||
fireEvent.change(textarea, { target: { value: 'second' } })
|
||||
fireEvent.change(textarea, { target: { value: 'third' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(3)
|
||||
expect(onChange).toHaveBeenLastCalledWith('third')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Different Data Types', () => {
|
||||
it('should display number type', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: { label: 'Count', variable: 'count', type: 'number' },
|
||||
}
|
||||
render(<MCPServerParamItem {...props} />)
|
||||
expect(screen.getByText('number')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display boolean type', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: { label: 'Enabled', variable: 'enabled', type: 'boolean' },
|
||||
}
|
||||
render(<MCPServerParamItem {...props} />)
|
||||
expect(screen.getByText('boolean')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display array type', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: { label: 'Items', variable: 'items', type: 'array' },
|
||||
}
|
||||
render(<MCPServerParamItem {...props} />)
|
||||
expect(screen.getByText('array')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle special characters in label', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: { label: 'Test <Label> & "Special"', variable: 'test', type: 'string' },
|
||||
}
|
||||
render(<MCPServerParamItem {...props} />)
|
||||
expect(screen.getByText('Test <Label> & "Special"')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty data object properties', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
data: { label: '', variable: '', type: '' },
|
||||
}
|
||||
render(<MCPServerParamItem {...props} />)
|
||||
// Should render without crashing
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle unicode characters in value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<MCPServerParamItem {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: '你好世界 🌍' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('你好世界 🌍')
|
||||
})
|
||||
})
|
||||
})
|
||||
1041
web/app/components/tools/mcp/mcp-service-card.spec.tsx
Normal file
1041
web/app/components/tools/mcp/mcp-service-card.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,168 +1,234 @@
|
||||
'use client'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import CopyFeedback from '@/app/components/base/copy-feedback'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import {
|
||||
Mcp,
|
||||
} from '@/app/components/base/icons/src/vender/other'
|
||||
import { Mcp } from '@/app/components/base/icons/src/vender/other'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { fetchAppDetail } from '@/service/apps'
|
||||
import {
|
||||
useInvalidateMCPServerDetail,
|
||||
useMCPServerDetail,
|
||||
useRefreshMCPServerCode,
|
||||
useUpdateMCPServer,
|
||||
} from '@/service/use-tools'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMCPServiceCardState } from './hooks/use-mcp-service-card'
|
||||
|
||||
export type IAppCardProps = {
|
||||
appInfo: AppDetailResponse & Partial<AppSSO>
|
||||
triggerModeDisabled?: boolean // align with Trigger Node vs User Input exclusivity
|
||||
triggerModeMessage?: React.ReactNode // display-only message explaining the trigger restriction
|
||||
// Sub-components
|
||||
type StatusIndicatorProps = {
|
||||
serverActivated: boolean
|
||||
}
|
||||
|
||||
function MCPServiceCard({
|
||||
const StatusIndicator: FC<StatusIndicatorProps> = ({ serverActivated }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Indicator color={serverActivated ? 'green' : 'yellow'} />
|
||||
<div className={cn('system-xs-semibold-uppercase', serverActivated ? 'text-text-success' : 'text-text-warning')}>
|
||||
{serverActivated
|
||||
? t('overview.status.running', { ns: 'appOverview' })
|
||||
: t('overview.status.disable', { ns: 'appOverview' })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ServerURLSectionProps = {
|
||||
serverURL: string
|
||||
serverPublished: boolean
|
||||
isCurrentWorkspaceManager: boolean
|
||||
genLoading: boolean
|
||||
onRegenerate: () => void
|
||||
}
|
||||
|
||||
const ServerURLSection: FC<ServerURLSectionProps> = ({
|
||||
serverURL,
|
||||
serverPublished,
|
||||
isCurrentWorkspaceManager,
|
||||
genLoading,
|
||||
onRegenerate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-center self-stretch">
|
||||
<div className="system-xs-medium pb-1 text-text-tertiary">
|
||||
{t('mcp.server.url', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
|
||||
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
|
||||
{serverURL}
|
||||
</div>
|
||||
</div>
|
||||
{serverPublished && (
|
||||
<>
|
||||
<CopyFeedback content={serverURL} className="!size-6" />
|
||||
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
|
||||
{isCurrentWorkspaceManager && (
|
||||
<Tooltip popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}>
|
||||
<div
|
||||
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TriggerModeOverlayProps = {
|
||||
triggerModeMessage: ReactNode
|
||||
}
|
||||
|
||||
const TriggerModeOverlay: FC<TriggerModeOverlayProps> = ({ triggerModeMessage }) => {
|
||||
if (triggerModeMessage) {
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={triggerModeMessage}
|
||||
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
|
||||
position="right"
|
||||
>
|
||||
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
|
||||
}
|
||||
|
||||
// Helper function for tooltip content
|
||||
type TooltipContentParams = {
|
||||
toggleDisabled: boolean
|
||||
appUnpublished: boolean
|
||||
missingStartNode: boolean
|
||||
triggerModeMessage: ReactNode
|
||||
t: TFunction
|
||||
docLink: ReturnType<typeof useDocLink>
|
||||
}
|
||||
|
||||
function getTooltipContent({
|
||||
toggleDisabled,
|
||||
appUnpublished,
|
||||
missingStartNode,
|
||||
triggerModeMessage,
|
||||
t,
|
||||
docLink,
|
||||
}: TooltipContentParams): ReactNode {
|
||||
if (!toggleDisabled)
|
||||
return ''
|
||||
|
||||
if (appUnpublished)
|
||||
return t('mcp.server.publishTip', { ns: 'tools' })
|
||||
|
||||
if (missingStartNode) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 text-xs font-normal text-text-secondary">
|
||||
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
|
||||
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
|
||||
>
|
||||
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return triggerModeMessage || ''
|
||||
}
|
||||
|
||||
// Main component
|
||||
export type IAppCardProps = {
|
||||
appInfo: AppDetailResponse & Partial<AppSSO>
|
||||
triggerModeDisabled?: boolean
|
||||
triggerModeMessage?: ReactNode
|
||||
}
|
||||
|
||||
const MCPServiceCard: FC<IAppCardProps> = ({
|
||||
appInfo,
|
||||
triggerModeDisabled = false,
|
||||
triggerModeMessage = '',
|
||||
}: IAppCardProps) {
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const appId = appInfo.id
|
||||
const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
|
||||
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
|
||||
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
|
||||
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showMCPServerModal, setShowMCPServerModal] = useState(false)
|
||||
|
||||
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
|
||||
const isBasicApp = !isAdvancedApp
|
||||
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
|
||||
const [basicAppConfig, setBasicAppConfig] = useState<any>({})
|
||||
const basicAppInputForm = useMemo(() => {
|
||||
if (!isBasicApp || !basicAppConfig?.user_input_form)
|
||||
return []
|
||||
return basicAppConfig.user_input_form.map((item: any) => {
|
||||
const type = Object.keys(item)[0]
|
||||
return {
|
||||
...item[type],
|
||||
type: type || 'text-input',
|
||||
}
|
||||
})
|
||||
}, [basicAppConfig.user_input_form, isBasicApp])
|
||||
useEffect(() => {
|
||||
if (isBasicApp && appId) {
|
||||
(async () => {
|
||||
const res = await fetchAppDetail({ url: '/apps', id: appId })
|
||||
setBasicAppConfig(res?.model_config || {})
|
||||
})()
|
||||
}
|
||||
}, [appId, isBasicApp])
|
||||
const { data: detail } = useMCPServerDetail(appId)
|
||||
const { id, status, server_code } = detail ?? {}
|
||||
const {
|
||||
genLoading,
|
||||
isLoading,
|
||||
serverPublished,
|
||||
serverActivated,
|
||||
serverURL,
|
||||
detail,
|
||||
isCurrentWorkspaceManager,
|
||||
toggleDisabled,
|
||||
isMinimalState,
|
||||
appUnpublished,
|
||||
missingStartNode,
|
||||
showConfirmDelete,
|
||||
showMCPServerModal,
|
||||
latestParams,
|
||||
handleGenCode,
|
||||
handleStatusChange,
|
||||
handleServerModalHide,
|
||||
openConfirmDelete,
|
||||
closeConfirmDelete,
|
||||
openServerModal,
|
||||
} = useMCPServiceCardState(appInfo, triggerModeDisabled)
|
||||
|
||||
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
|
||||
const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at
|
||||
const serverPublished = !!id
|
||||
const serverActivated = status === 'active'
|
||||
const serverURL = serverPublished ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` : '***********'
|
||||
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
|
||||
const missingStartNode = isWorkflowApp && !hasStartNode
|
||||
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
|
||||
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
|
||||
const isMinimalState = appUnpublished || missingStartNode
|
||||
|
||||
const [activated, setActivated] = useState(serverActivated)
|
||||
|
||||
const latestParams = useMemo(() => {
|
||||
if (isAdvancedApp) {
|
||||
if (!currentWorkflow?.graph)
|
||||
return []
|
||||
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any
|
||||
return startNode?.data.variables as any[] || []
|
||||
}
|
||||
return basicAppInputForm
|
||||
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
|
||||
|
||||
const onGenCode = async () => {
|
||||
await refreshMCPServerCode(detail?.id || '')
|
||||
invalidateMCPServerDetail(appId)
|
||||
}
|
||||
// Pending status for optimistic updates (null means use server state)
|
||||
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
|
||||
const activated = pendingStatus ?? serverActivated
|
||||
|
||||
const onChangeStatus = async (state: boolean) => {
|
||||
setActivated(state)
|
||||
if (state) {
|
||||
if (!serverPublished) {
|
||||
setShowMCPServerModal(true)
|
||||
return
|
||||
}
|
||||
|
||||
await updateMCPServer({
|
||||
appID: appId,
|
||||
id: id || '',
|
||||
description: detail?.description || '',
|
||||
parameters: detail?.parameters || {},
|
||||
status: 'active',
|
||||
})
|
||||
invalidateMCPServerDetail(appId)
|
||||
}
|
||||
else {
|
||||
await updateMCPServer({
|
||||
appID: appId,
|
||||
id: id || '',
|
||||
description: detail?.description || '',
|
||||
parameters: detail?.parameters || {},
|
||||
status: 'inactive',
|
||||
})
|
||||
invalidateMCPServerDetail(appId)
|
||||
setPendingStatus(state)
|
||||
const result = await handleStatusChange(state)
|
||||
if (!result.activated && state) {
|
||||
// Server modal was opened instead, clear pending status
|
||||
setPendingStatus(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleServerModalHide = () => {
|
||||
setShowMCPServerModal(false)
|
||||
if (!serverActivated)
|
||||
setActivated(false)
|
||||
const onServerModalHide = () => {
|
||||
handleServerModalHide(serverActivated)
|
||||
// Clear pending status when modal closes to sync with server state
|
||||
setPendingStatus(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setActivated(serverActivated)
|
||||
}, [serverActivated])
|
||||
const onConfirmRegenerate = () => {
|
||||
handleGenCode()
|
||||
closeConfirmDelete()
|
||||
}
|
||||
|
||||
if (!currentWorkflow && isAdvancedApp)
|
||||
if (isLoading)
|
||||
return null
|
||||
|
||||
const tooltipContent = getTooltipContent({
|
||||
toggleDisabled,
|
||||
appUnpublished,
|
||||
missingStartNode,
|
||||
triggerModeMessage,
|
||||
t,
|
||||
docLink,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}>
|
||||
<div className={cn('relative rounded-xl bg-background-default', triggerModeDisabled && 'opacity-60')}>
|
||||
{triggerModeDisabled && (
|
||||
triggerModeMessage
|
||||
? (
|
||||
<Tooltip
|
||||
popupContent={triggerModeMessage}
|
||||
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
|
||||
position="right"
|
||||
>
|
||||
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
|
||||
</Tooltip>
|
||||
)
|
||||
: <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
|
||||
<TriggerModeOverlay triggerModeMessage={triggerModeMessage} />
|
||||
)}
|
||||
<div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}>
|
||||
<div className="flex w-full items-center gap-3 self-stretch">
|
||||
@@ -176,40 +242,9 @@ function MCPServiceCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Indicator color={serverActivated ? 'green' : 'yellow'} />
|
||||
<div className={`${serverActivated ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`}>
|
||||
{serverActivated
|
||||
? t('overview.status.running', { ns: 'appOverview' })
|
||||
: t('overview.status.disable', { ns: 'appOverview' })}
|
||||
</div>
|
||||
</div>
|
||||
<StatusIndicator serverActivated={serverActivated} />
|
||||
<Tooltip
|
||||
popupContent={
|
||||
toggleDisabled
|
||||
? (
|
||||
appUnpublished
|
||||
? (
|
||||
t('mcp.server.publishTip', { ns: 'tools' })
|
||||
)
|
||||
: missingStartNode
|
||||
? (
|
||||
<>
|
||||
<div className="mb-1 text-xs font-normal text-text-secondary">
|
||||
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
|
||||
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
|
||||
>
|
||||
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
: triggerModeMessage || ''
|
||||
)
|
||||
: ''
|
||||
}
|
||||
popupContent={tooltipContent}
|
||||
position="right"
|
||||
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
|
||||
offset={24}
|
||||
@@ -220,39 +255,13 @@ function MCPServiceCard({
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!isMinimalState && (
|
||||
<div className="flex flex-col items-start justify-center self-stretch">
|
||||
<div className="system-xs-medium pb-1 text-text-tertiary">
|
||||
{t('mcp.server.url', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
|
||||
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
|
||||
{serverURL}
|
||||
</div>
|
||||
</div>
|
||||
{serverPublished && (
|
||||
<>
|
||||
<CopyFeedback
|
||||
content={serverURL}
|
||||
className="!size-6"
|
||||
/>
|
||||
<Divider type="vertical" className="!mx-0.5 !h-3.5 shrink-0" />
|
||||
{isCurrentWorkspaceManager && (
|
||||
<Tooltip
|
||||
popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}
|
||||
>
|
||||
<div
|
||||
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
|
||||
onClick={() => setShowConfirmDelete(true)}
|
||||
>
|
||||
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ServerURLSection
|
||||
serverURL={serverURL}
|
||||
serverPublished={serverPublished}
|
||||
isCurrentWorkspaceManager={isCurrentWorkspaceManager}
|
||||
genLoading={genLoading}
|
||||
onRegenerate={openConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isMinimalState && (
|
||||
@@ -261,40 +270,39 @@ function MCPServiceCard({
|
||||
disabled={toggleDisabled}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => setShowMCPServerModal(true)}
|
||||
onClick={openServerModal}
|
||||
>
|
||||
|
||||
<div className="flex items-center justify-center gap-[1px]">
|
||||
<RiEditLine className="h-3.5 w-3.5" />
|
||||
<div className="system-xs-medium px-[3px] text-text-tertiary">{serverPublished ? t('mcp.server.edit', { ns: 'tools' }) : t('mcp.server.addDescription', { ns: 'tools' })}</div>
|
||||
<div className="system-xs-medium px-[3px] text-text-tertiary">
|
||||
{serverPublished ? t('mcp.server.edit', { ns: 'tools' }) : t('mcp.server.addDescription', { ns: 'tools' })}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMCPServerModal && (
|
||||
<MCPServerModal
|
||||
show={showMCPServerModal}
|
||||
appID={appId}
|
||||
data={serverPublished ? detail : undefined}
|
||||
latestParams={latestParams}
|
||||
onHide={handleServerModalHide}
|
||||
onHide={onServerModalHide}
|
||||
appInfo={appInfo}
|
||||
/>
|
||||
)}
|
||||
{/* button copy link/ button regenerate */}
|
||||
|
||||
{showConfirmDelete && (
|
||||
<Confirm
|
||||
type="warning"
|
||||
title={t('overview.appInfo.regenerate', { ns: 'appOverview' })}
|
||||
content={t('mcp.server.reGen', { ns: 'tools' })}
|
||||
isShow={showConfirmDelete}
|
||||
onConfirm={() => {
|
||||
onGenCode()
|
||||
setShowConfirmDelete(false)
|
||||
}}
|
||||
onCancel={() => setShowConfirmDelete(false)}
|
||||
onConfirm={onConfirmRegenerate}
|
||||
onCancel={closeConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
745
web/app/components/tools/mcp/modal.spec.tsx
Normal file
745
web/app/components/tools/mcp/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()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,429 +1,298 @@
|
||||
'use client'
|
||||
import type { HeaderItem } from './headers-input'
|
||||
import type { FC } from 'react'
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { RiCloseLine, RiEditLine } from '@remixicon/react'
|
||||
import { useHover } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getDomain } from 'tldts'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Mcp } from '@/app/components/base/icons/src/vender/other'
|
||||
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import TabSlider from '@/app/components/base/tab-slider'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { MCPAuthMethod } from '@/app/components/tools/types'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { uploadRemoteFileInfo } from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
|
||||
import HeadersInput from './headers-input'
|
||||
import { isValidServerID, isValidUrl, useMCPModalForm } from './hooks/use-mcp-modal-form'
|
||||
import AuthenticationSection from './sections/authentication-section'
|
||||
import ConfigurationsSection from './sections/configurations-section'
|
||||
import HeadersSection from './sections/headers-section'
|
||||
|
||||
export type MCPModalConfirmPayload = {
|
||||
name: string
|
||||
server_url: string
|
||||
icon_type: AppIconType
|
||||
icon: string
|
||||
icon_background?: string | null
|
||||
server_identifier: string
|
||||
headers?: Record<string, string>
|
||||
is_dynamic_registration?: boolean
|
||||
authentication?: {
|
||||
client_id?: string
|
||||
client_secret?: string
|
||||
grant_type?: string
|
||||
}
|
||||
configuration: {
|
||||
timeout: number
|
||||
sse_read_timeout: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DuplicateAppModalProps = {
|
||||
data?: ToolWithProvider
|
||||
show: boolean
|
||||
onConfirm: (info: {
|
||||
name: string
|
||||
server_url: string
|
||||
icon_type: AppIconType
|
||||
icon: string
|
||||
icon_background?: string | null
|
||||
server_identifier: string
|
||||
headers?: Record<string, string>
|
||||
is_dynamic_registration?: boolean
|
||||
authentication?: {
|
||||
client_id?: string
|
||||
client_secret?: string
|
||||
grant_type?: string
|
||||
}
|
||||
configuration: {
|
||||
timeout: number
|
||||
sse_read_timeout: number
|
||||
}
|
||||
}) => void
|
||||
onConfirm: (info: MCPModalConfirmPayload) => void
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' }
|
||||
const extractFileId = (url: string) => {
|
||||
const match = url.match(/files\/(.+?)\/file-preview/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
const getIcon = (data?: ToolWithProvider) => {
|
||||
if (!data)
|
||||
return DEFAULT_ICON as AppIconSelection
|
||||
if (typeof data.icon === 'string')
|
||||
return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection
|
||||
return {
|
||||
...data.icon,
|
||||
icon: data.icon.content,
|
||||
type: 'emoji',
|
||||
} as unknown as AppIconSelection
|
||||
type MCPModalContentProps = {
|
||||
data?: ToolWithProvider
|
||||
onConfirm: (info: MCPModalConfirmPayload) => void
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
const MCPModal = ({
|
||||
const MCPModalContent: FC<MCPModalContentProps> = ({
|
||||
data,
|
||||
show,
|
||||
onConfirm,
|
||||
onHide,
|
||||
}: DuplicateAppModalProps) => {
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isCreate = !data
|
||||
|
||||
const {
|
||||
isCreate,
|
||||
originalServerUrl,
|
||||
originalServerID,
|
||||
appIconRef,
|
||||
state,
|
||||
actions,
|
||||
} = useMCPModalForm(data)
|
||||
|
||||
const isHovering = useHover(appIconRef)
|
||||
|
||||
const authMethods = [
|
||||
{
|
||||
text: t('mcp.modal.authentication', { ns: 'tools' }),
|
||||
value: MCPAuthMethod.authentication,
|
||||
},
|
||||
{
|
||||
text: t('mcp.modal.headers', { ns: 'tools' }),
|
||||
value: MCPAuthMethod.headers,
|
||||
},
|
||||
{
|
||||
text: t('mcp.modal.configurations', { ns: 'tools' }),
|
||||
value: MCPAuthMethod.configurations,
|
||||
},
|
||||
{ text: t('mcp.modal.authentication', { ns: 'tools' }), value: MCPAuthMethod.authentication },
|
||||
{ text: t('mcp.modal.headers', { ns: 'tools' }), value: MCPAuthMethod.headers },
|
||||
{ text: t('mcp.modal.configurations', { ns: 'tools' }), value: MCPAuthMethod.configurations },
|
||||
]
|
||||
const originalServerUrl = data?.server_url
|
||||
const originalServerID = data?.server_identifier
|
||||
const [url, setUrl] = React.useState(data?.server_url || '')
|
||||
const [name, setName] = React.useState(data?.name || '')
|
||||
const [appIcon, setAppIcon] = useState<AppIconSelection>(() => getIcon(data))
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '')
|
||||
const [timeout, setMcpTimeout] = React.useState(data?.configuration?.timeout || 30)
|
||||
const [sseReadTimeout, setSseReadTimeout] = React.useState(data?.configuration?.sse_read_timeout || 300)
|
||||
const [headers, setHeaders] = React.useState<HeaderItem[]>(
|
||||
Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })),
|
||||
)
|
||||
const [isFetchingIcon, setIsFetchingIcon] = useState(false)
|
||||
const appIconRef = useRef<HTMLDivElement>(null)
|
||||
const isHovering = useHover(appIconRef)
|
||||
const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication)
|
||||
const [isDynamicRegistration, setIsDynamicRegistration] = useState(isCreate ? true : data?.is_dynamic_registration)
|
||||
const [clientID, setClientID] = useState(data?.authentication?.client_id || '')
|
||||
const [credentials, setCredentials] = useState(data?.authentication?.client_secret || '')
|
||||
|
||||
// Update states when data changes (for edit mode)
|
||||
React.useEffect(() => {
|
||||
if (data) {
|
||||
setUrl(data.server_url || '')
|
||||
setName(data.name || '')
|
||||
setServerIdentifier(data.server_identifier || '')
|
||||
setMcpTimeout(data.configuration?.timeout || 30)
|
||||
setSseReadTimeout(data.configuration?.sse_read_timeout || 300)
|
||||
setHeaders(Object.entries(data.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })))
|
||||
setAppIcon(getIcon(data))
|
||||
setIsDynamicRegistration(data.is_dynamic_registration)
|
||||
setClientID(data.authentication?.client_id || '')
|
||||
setCredentials(data.authentication?.client_secret || '')
|
||||
}
|
||||
else {
|
||||
// Reset for create mode
|
||||
setUrl('')
|
||||
setName('')
|
||||
setServerIdentifier('')
|
||||
setMcpTimeout(30)
|
||||
setSseReadTimeout(300)
|
||||
setHeaders([])
|
||||
setAppIcon(DEFAULT_ICON as AppIconSelection)
|
||||
setIsDynamicRegistration(true)
|
||||
setClientID('')
|
||||
setCredentials('')
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const isValidUrl = (string: string) => {
|
||||
try {
|
||||
const url = new URL(string)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isValidServerID = (str: string) => {
|
||||
return /^[a-z0-9_-]{1,24}$/.test(str)
|
||||
}
|
||||
|
||||
const handleBlur = async (url: string) => {
|
||||
if (data)
|
||||
return
|
||||
if (!isValidUrl(url))
|
||||
return
|
||||
const domain = getDomain(url)
|
||||
const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
|
||||
setIsFetchingIcon(true)
|
||||
try {
|
||||
const res = await uploadRemoteFileInfo(remoteIcon, undefined, true)
|
||||
setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' })
|
||||
}
|
||||
catch (e) {
|
||||
let errorMessage = 'Failed to fetch remote icon'
|
||||
const errorData = await (e as Response).json()
|
||||
if (errorData?.code)
|
||||
errorMessage = `Upload failed: ${errorData.code}`
|
||||
console.error('Failed to fetch remote icon:', e)
|
||||
Toast.notify({ type: 'warning', message: errorMessage })
|
||||
}
|
||||
finally {
|
||||
setIsFetchingIcon(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!isValidUrl(url)) {
|
||||
if (!isValidUrl(state.url)) {
|
||||
Toast.notify({ type: 'error', message: 'invalid server url' })
|
||||
return
|
||||
}
|
||||
if (!isValidServerID(serverIdentifier.trim())) {
|
||||
if (!isValidServerID(state.serverIdentifier.trim())) {
|
||||
Toast.notify({ type: 'error', message: 'invalid server identifier' })
|
||||
return
|
||||
}
|
||||
const formattedHeaders = headers.reduce((acc, item) => {
|
||||
const formattedHeaders = state.headers.reduce((acc, item) => {
|
||||
if (item.key.trim())
|
||||
acc[item.key.trim()] = item.value
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
await onConfirm({
|
||||
server_url: originalServerUrl === url ? '[__HIDDEN__]' : url.trim(),
|
||||
name,
|
||||
icon_type: appIcon.type,
|
||||
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
|
||||
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
|
||||
server_identifier: serverIdentifier.trim(),
|
||||
server_url: originalServerUrl === state.url ? '[__HIDDEN__]' : state.url.trim(),
|
||||
name: state.name,
|
||||
icon_type: state.appIcon.type,
|
||||
icon: state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId,
|
||||
icon_background: state.appIcon.type === 'emoji' ? state.appIcon.background : undefined,
|
||||
server_identifier: state.serverIdentifier.trim(),
|
||||
headers: Object.keys(formattedHeaders).length > 0 ? formattedHeaders : undefined,
|
||||
is_dynamic_registration: isDynamicRegistration,
|
||||
is_dynamic_registration: state.isDynamicRegistration,
|
||||
authentication: {
|
||||
client_id: clientID,
|
||||
client_secret: credentials,
|
||||
client_id: state.clientID,
|
||||
client_secret: state.credentials,
|
||||
},
|
||||
configuration: {
|
||||
timeout: timeout || 30,
|
||||
sse_read_timeout: sseReadTimeout || 300,
|
||||
timeout: state.timeout || 30,
|
||||
sse_read_timeout: state.sseReadTimeout || 300,
|
||||
},
|
||||
})
|
||||
if (isCreate)
|
||||
onHide()
|
||||
}
|
||||
|
||||
const handleAuthMethodChange = useCallback((value: string) => {
|
||||
setAuthMethod(value as MCPAuthMethod)
|
||||
}, [])
|
||||
const handleIconSelect = (payload: AppIconSelection) => {
|
||||
actions.setAppIcon(payload)
|
||||
actions.setShowAppIconPicker(false)
|
||||
}
|
||||
|
||||
const handleIconClose = () => {
|
||||
actions.resetIcon()
|
||||
actions.setShowAppIconPicker(false)
|
||||
}
|
||||
|
||||
const isSubmitDisabled = !state.name || !state.url || !state.serverIdentifier || state.isFetchingIcon
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
className={cn('relative !max-w-[520px]', 'p-6')}
|
||||
>
|
||||
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
|
||||
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="title-2xl-semi-bold relative pb-3 text-xl text-text-primary">{!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}</div>
|
||||
<div className="space-y-5 py-3">
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverUrl', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
onBlur={e => handleBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
{originalServerUrl && originalServerUrl !== url && (
|
||||
<div className="mt-1 flex h-5 items-center">
|
||||
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
|
||||
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="title-2xl-semi-bold relative pb-3 text-xl text-text-primary">
|
||||
{!isCreate ? t('mcp.modal.editTitle', { ns: 'tools' }) : t('mcp.modal.title', { ns: 'tools' })}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 py-3">
|
||||
{/* Server URL */}
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverUrl', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<div className="grow pb-1">
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.name', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2" ref={appIconRef}>
|
||||
<AppIcon
|
||||
iconType={appIcon.type}
|
||||
icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
|
||||
background={appIcon.type === 'emoji' ? appIcon.background : undefined}
|
||||
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
|
||||
innerIcon={shouldUseMcpIconForAppIcon(appIcon.type, appIcon.type === 'emoji' ? appIcon.icon : '') ? <Mcp className="h-8 w-8 text-text-primary-on-surface" /> : undefined}
|
||||
size="xxl"
|
||||
className="relative cursor-pointer rounded-2xl"
|
||||
coverElement={
|
||||
isHovering
|
||||
? (
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt">
|
||||
<RiEditLine className="size-6 text-text-primary-on-surface" />
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
onClick={() => { setShowAppIconPicker(true) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverIdentifier', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<div className="body-xs-regular mb-1 text-text-tertiary">{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}</div>
|
||||
<Input
|
||||
value={serverIdentifier}
|
||||
onChange={e => setServerIdentifier(e.target.value)}
|
||||
placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
{originalServerID && originalServerID !== serverIdentifier && (
|
||||
<div className="mt-1 flex h-5 items-center">
|
||||
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TabSlider
|
||||
className="w-full"
|
||||
itemClassName={(isActive) => {
|
||||
return `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`
|
||||
}}
|
||||
value={authMethod}
|
||||
onChange={handleAuthMethodChange}
|
||||
options={authMethods}
|
||||
<Input
|
||||
value={state.url}
|
||||
onChange={e => actions.setUrl(e.target.value)}
|
||||
onBlur={e => actions.handleUrlBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.serverUrlPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
{
|
||||
authMethod === MCPAuthMethod.authentication && (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<Switch
|
||||
className="mr-2"
|
||||
defaultValue={isDynamicRegistration}
|
||||
onChange={setIsDynamicRegistration}
|
||||
/>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.useDynamicClientRegistration', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
{!isDynamicRegistration && (
|
||||
<div className="mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
<div className="mb-1">{t('mcp.modal.redirectUrlWarning', { ns: 'tools' })}</div>
|
||||
<code className="system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary">
|
||||
{`${API_PREFIX}/mcp/oauth/callback`}
|
||||
</code>
|
||||
{originalServerUrl && originalServerUrl !== state.url && (
|
||||
<div className="mt-1 flex h-5 items-center">
|
||||
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverUrlWarning', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name and Icon */}
|
||||
<div className="flex space-x-3">
|
||||
<div className="grow pb-1">
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.name', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={state.name}
|
||||
onChange={e => actions.setName(e.target.value)}
|
||||
placeholder={t('mcp.modal.namePlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2" ref={appIconRef}>
|
||||
<AppIcon
|
||||
iconType={state.appIcon.type}
|
||||
icon={state.appIcon.type === 'emoji' ? state.appIcon.icon : state.appIcon.fileId}
|
||||
background={state.appIcon.type === 'emoji' ? state.appIcon.background : undefined}
|
||||
imageUrl={state.appIcon.type === 'image' ? state.appIcon.url : undefined}
|
||||
innerIcon={shouldUseMcpIconForAppIcon(state.appIcon.type, state.appIcon.type === 'emoji' ? state.appIcon.icon : '') ? <Mcp className="h-8 w-8 text-text-primary-on-surface" /> : undefined}
|
||||
size="xxl"
|
||||
className="relative cursor-pointer rounded-2xl"
|
||||
coverElement={
|
||||
isHovering
|
||||
? (
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden rounded-2xl bg-background-overlay-alt">
|
||||
<RiEditLine className="size-6 text-text-primary-on-surface" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientID', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={clientID}
|
||||
onChange={e => setClientID(e.target.value)}
|
||||
onBlur={e => handleBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.clientID', { ns: 'tools' })}
|
||||
disabled={isDynamicRegistration}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientSecret', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={credentials}
|
||||
onChange={e => setCredentials(e.target.value)}
|
||||
onBlur={e => handleBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.clientSecretPlaceholder', { ns: 'tools' })}
|
||||
disabled={isDynamicRegistration}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
authMethod === MCPAuthMethod.headers && (
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.headers', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<div className="body-xs-regular mb-2 text-text-tertiary">{t('mcp.modal.headersTip', { ns: 'tools' })}</div>
|
||||
<HeadersInput
|
||||
headersItems={headers}
|
||||
onChange={setHeaders}
|
||||
readonly={false}
|
||||
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
authMethod === MCPAuthMethod.configurations && (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.timeout', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={e => setMcpTimeout(Number(e.target.value))}
|
||||
onBlur={e => handleBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.sseReadTimeout', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={sseReadTimeout}
|
||||
onChange={e => setSseReadTimeout(Number(e.target.value))}
|
||||
onBlur={e => handleBlur(e.target.value.trim())}
|
||||
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
: null
|
||||
}
|
||||
onClick={() => actions.setShowAppIconPicker(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse pt-5">
|
||||
<Button disabled={!name || !url || !serverIdentifier || isFetchingIcon} className="ml-2" variant="primary" onClick={submit}>{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.modal.confirm', { ns: 'tools' })}</Button>
|
||||
<Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button>
|
||||
|
||||
{/* Server Identifier */}
|
||||
<div>
|
||||
<div className="flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.serverIdentifier', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<div className="body-xs-regular mb-1 text-text-tertiary">{t('mcp.modal.serverIdentifierTip', { ns: 'tools' })}</div>
|
||||
<Input
|
||||
value={state.serverIdentifier}
|
||||
onChange={e => actions.setServerIdentifier(e.target.value)}
|
||||
placeholder={t('mcp.modal.serverIdentifierPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
{originalServerID && originalServerID !== state.serverIdentifier && (
|
||||
<div className="mt-1 flex h-5 items-center">
|
||||
<span className="body-xs-regular text-text-warning">{t('mcp.modal.serverIdentifierWarning', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
{showAppIconPicker && (
|
||||
|
||||
{/* Auth Method Tabs */}
|
||||
<TabSlider
|
||||
className="w-full"
|
||||
itemClassName={isActive => `flex-1 ${isActive && 'text-text-accent-light-mode-only'}`}
|
||||
value={state.authMethod}
|
||||
onChange={actions.setAuthMethod}
|
||||
options={authMethods}
|
||||
/>
|
||||
|
||||
{/* Tab Content */}
|
||||
{state.authMethod === MCPAuthMethod.authentication && (
|
||||
<AuthenticationSection
|
||||
isDynamicRegistration={state.isDynamicRegistration}
|
||||
onDynamicRegistrationChange={actions.setIsDynamicRegistration}
|
||||
clientID={state.clientID}
|
||||
onClientIDChange={actions.setClientID}
|
||||
credentials={state.credentials}
|
||||
onCredentialsChange={actions.setCredentials}
|
||||
/>
|
||||
)}
|
||||
{state.authMethod === MCPAuthMethod.headers && (
|
||||
<HeadersSection
|
||||
headers={state.headers}
|
||||
onHeadersChange={actions.setHeaders}
|
||||
isCreate={isCreate}
|
||||
/>
|
||||
)}
|
||||
{state.authMethod === MCPAuthMethod.configurations && (
|
||||
<ConfigurationsSection
|
||||
timeout={state.timeout}
|
||||
onTimeoutChange={actions.setTimeout}
|
||||
sseReadTimeout={state.sseReadTimeout}
|
||||
onSseReadTimeoutChange={actions.setSseReadTimeout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-row-reverse pt-5">
|
||||
<Button disabled={isSubmitDisabled} className="ml-2" variant="primary" onClick={submit}>
|
||||
{data ? t('mcp.modal.save', { ns: 'tools' }) : t('mcp.modal.confirm', { ns: 'tools' })}
|
||||
</Button>
|
||||
<Button onClick={onHide}>{t('mcp.modal.cancel', { ns: 'tools' })}</Button>
|
||||
</div>
|
||||
|
||||
{state.showAppIconPicker && (
|
||||
<AppIconPicker
|
||||
onSelect={(payload) => {
|
||||
setAppIcon(payload)
|
||||
setShowAppIconPicker(false)
|
||||
}}
|
||||
onClose={() => {
|
||||
setAppIcon(getIcon(data))
|
||||
setShowAppIconPicker(false)
|
||||
}}
|
||||
onSelect={handleIconSelect}
|
||||
onClose={handleIconClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Modal component for creating and editing MCP server configurations.
|
||||
*
|
||||
* Uses a keyed inner component to ensure form state resets when switching
|
||||
* between create mode and edit mode with different data.
|
||||
*/
|
||||
const MCPModal: FC<DuplicateAppModalProps> = ({
|
||||
data,
|
||||
show,
|
||||
onConfirm,
|
||||
onHide,
|
||||
}) => {
|
||||
// Use data ID as key to reset form state when switching between items
|
||||
const formKey = data?.id ?? 'create'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
className={cn('relative !max-w-[520px]', 'p-6')}
|
||||
>
|
||||
<MCPModalContent
|
||||
key={formKey}
|
||||
data={data}
|
||||
onConfirm={onConfirm}
|
||||
onHide={onHide}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
524
web/app/components/tools/mcp/provider-card.spec.tsx
Normal file
524
web/app/components/tools/mcp/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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import AuthenticationSection from './authentication-section'
|
||||
|
||||
describe('AuthenticationSection', () => {
|
||||
const defaultProps = {
|
||||
isDynamicRegistration: true,
|
||||
onDynamicRegistrationChange: vi.fn(),
|
||||
clientID: '',
|
||||
onClientIDChange: vi.fn(),
|
||||
credentials: '',
|
||||
onCredentialsChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<AuthenticationSection {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render switch for dynamic registration', () => {
|
||||
render(<AuthenticationSection {...defaultProps} />)
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render client ID input', () => {
|
||||
render(<AuthenticationSection {...defaultProps} clientID="test-client-id" />)
|
||||
expect(screen.getByDisplayValue('test-client-id')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render credentials input', () => {
|
||||
render(<AuthenticationSection {...defaultProps} credentials="test-secret" />)
|
||||
expect(screen.getByDisplayValue('test-secret')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render labels for all fields', () => {
|
||||
render(<AuthenticationSection {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.useDynamicClientRegistration')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.clientID')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.clientSecret')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dynamic Registration Toggle', () => {
|
||||
it('should not show warning when isDynamicRegistration is true', () => {
|
||||
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={true} />)
|
||||
expect(screen.queryByText('tools.mcp.modal.redirectUrlWarning')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show warning when isDynamicRegistration is false', () => {
|
||||
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
|
||||
expect(screen.getByText('tools.mcp.modal.redirectUrlWarning')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show OAuth callback URL in warning', () => {
|
||||
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
|
||||
expect(screen.getByText(/\/mcp\/oauth\/callback/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable inputs when isDynamicRegistration is true', () => {
|
||||
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={true} />)
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
inputs.forEach((input) => {
|
||||
expect(input).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should enable inputs when isDynamicRegistration is false', () => {
|
||||
render(<AuthenticationSection {...defaultProps} isDynamicRegistration={false} />)
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
inputs.forEach((input) => {
|
||||
expect(input).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onDynamicRegistrationChange when switch is toggled', () => {
|
||||
const onDynamicRegistrationChange = vi.fn()
|
||||
render(
|
||||
<AuthenticationSection
|
||||
{...defaultProps}
|
||||
onDynamicRegistrationChange={onDynamicRegistrationChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const switchElement = screen.getByRole('switch')
|
||||
fireEvent.click(switchElement)
|
||||
|
||||
expect(onDynamicRegistrationChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClientIDChange when client ID input changes', () => {
|
||||
const onClientIDChange = vi.fn()
|
||||
render(
|
||||
<AuthenticationSection
|
||||
{...defaultProps}
|
||||
isDynamicRegistration={false}
|
||||
onClientIDChange={onClientIDChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const clientIDInput = inputs[0]
|
||||
fireEvent.change(clientIDInput, { target: { value: 'new-client-id' } })
|
||||
|
||||
expect(onClientIDChange).toHaveBeenCalledWith('new-client-id')
|
||||
})
|
||||
|
||||
it('should call onCredentialsChange when credentials input changes', () => {
|
||||
const onCredentialsChange = vi.fn()
|
||||
render(
|
||||
<AuthenticationSection
|
||||
{...defaultProps}
|
||||
isDynamicRegistration={false}
|
||||
onCredentialsChange={onCredentialsChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const credentialsInput = inputs[1]
|
||||
fireEvent.change(credentialsInput, { target: { value: 'new-secret' } })
|
||||
|
||||
expect(onCredentialsChange).toHaveBeenCalledWith('new-secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display provided clientID value', () => {
|
||||
render(<AuthenticationSection {...defaultProps} clientID="my-client-123" />)
|
||||
expect(screen.getByDisplayValue('my-client-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display provided credentials value', () => {
|
||||
render(<AuthenticationSection {...defaultProps} credentials="secret-456" />)
|
||||
expect(screen.getByDisplayValue('secret-456')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string values', () => {
|
||||
render(<AuthenticationSection {...defaultProps} clientID="" credentials="" />)
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs).toHaveLength(2)
|
||||
inputs.forEach((input) => {
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle special characters in values', () => {
|
||||
render(
|
||||
<AuthenticationSection
|
||||
{...defaultProps}
|
||||
clientID="client@123!#$"
|
||||
credentials="secret&*()_+"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByDisplayValue('client@123!#$')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('secret&*()_+')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type AuthenticationSectionProps = {
|
||||
isDynamicRegistration: boolean
|
||||
onDynamicRegistrationChange: (value: boolean) => void
|
||||
clientID: string
|
||||
onClientIDChange: (value: string) => void
|
||||
credentials: string
|
||||
onCredentialsChange: (value: string) => void
|
||||
}
|
||||
|
||||
const AuthenticationSection: FC<AuthenticationSectionProps> = ({
|
||||
isDynamicRegistration,
|
||||
onDynamicRegistrationChange,
|
||||
clientID,
|
||||
onClientIDChange,
|
||||
credentials,
|
||||
onCredentialsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<Switch
|
||||
className="mr-2"
|
||||
defaultValue={isDynamicRegistration}
|
||||
onChange={onDynamicRegistrationChange}
|
||||
/>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.useDynamicClientRegistration', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
{!isDynamicRegistration && (
|
||||
<div className="mt-2 flex gap-2 rounded-lg bg-state-warning-hover p-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
<div className="mb-1">{t('mcp.modal.redirectUrlWarning', { ns: 'tools' })}</div>
|
||||
<code className="system-xs-medium block break-all rounded bg-state-warning-active px-2 py-1 text-text-secondary">
|
||||
{`${API_PREFIX}/mcp/oauth/callback`}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientID', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={clientID}
|
||||
onChange={e => onClientIDChange(e.target.value)}
|
||||
placeholder={t('mcp.modal.clientID', { ns: 'tools' })}
|
||||
disabled={isDynamicRegistration}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('mb-1 flex h-6 items-center', isDynamicRegistration && 'opacity-50')}>
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.clientSecret', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
value={credentials}
|
||||
onChange={e => onCredentialsChange(e.target.value)}
|
||||
placeholder={t('mcp.modal.clientSecretPlaceholder', { ns: 'tools' })}
|
||||
disabled={isDynamicRegistration}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthenticationSection
|
||||
@@ -0,0 +1,100 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ConfigurationsSection from './configurations-section'
|
||||
|
||||
describe('ConfigurationsSection', () => {
|
||||
const defaultProps = {
|
||||
timeout: 30,
|
||||
onTimeoutChange: vi.fn(),
|
||||
sseReadTimeout: 300,
|
||||
onSseReadTimeoutChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} />)
|
||||
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('300')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render timeout input with correct value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} />)
|
||||
const timeoutInput = screen.getByDisplayValue('30')
|
||||
expect(timeoutInput).toHaveAttribute('type', 'number')
|
||||
})
|
||||
|
||||
it('should render SSE read timeout input with correct value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} />)
|
||||
const sseInput = screen.getByDisplayValue('300')
|
||||
expect(sseInput).toHaveAttribute('type', 'number')
|
||||
})
|
||||
|
||||
it('should render labels for both inputs', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} />)
|
||||
// i18n keys are rendered as-is in test environment
|
||||
expect(screen.getByText('tools.mcp.modal.timeout')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.sseReadTimeout')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display custom timeout value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} timeout={60} />)
|
||||
expect(screen.getByDisplayValue('60')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display custom SSE read timeout value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} sseReadTimeout={600} />)
|
||||
expect(screen.getByDisplayValue('600')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onTimeoutChange when timeout input changes', () => {
|
||||
const onTimeoutChange = vi.fn()
|
||||
render(<ConfigurationsSection {...defaultProps} onTimeoutChange={onTimeoutChange} />)
|
||||
|
||||
const timeoutInput = screen.getByDisplayValue('30')
|
||||
fireEvent.change(timeoutInput, { target: { value: '45' } })
|
||||
|
||||
expect(onTimeoutChange).toHaveBeenCalledWith(45)
|
||||
})
|
||||
|
||||
it('should call onSseReadTimeoutChange when SSE timeout input changes', () => {
|
||||
const onSseReadTimeoutChange = vi.fn()
|
||||
render(<ConfigurationsSection {...defaultProps} onSseReadTimeoutChange={onSseReadTimeoutChange} />)
|
||||
|
||||
const sseInput = screen.getByDisplayValue('300')
|
||||
fireEvent.change(sseInput, { target: { value: '500' } })
|
||||
|
||||
expect(onSseReadTimeoutChange).toHaveBeenCalledWith(500)
|
||||
})
|
||||
|
||||
it('should handle numeric conversion correctly', () => {
|
||||
const onTimeoutChange = vi.fn()
|
||||
render(<ConfigurationsSection {...defaultProps} onTimeoutChange={onTimeoutChange} />)
|
||||
|
||||
const timeoutInput = screen.getByDisplayValue('30')
|
||||
fireEvent.change(timeoutInput, { target: { value: '0' } })
|
||||
|
||||
expect(onTimeoutChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle zero timeout value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} timeout={0} />)
|
||||
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero SSE read timeout value', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} sseReadTimeout={0} />)
|
||||
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large timeout values', () => {
|
||||
render(<ConfigurationsSection {...defaultProps} timeout={9999} sseReadTimeout={9999} />)
|
||||
expect(screen.getAllByDisplayValue('9999')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
||||
type ConfigurationsSectionProps = {
|
||||
timeout: number
|
||||
onTimeoutChange: (timeout: number) => void
|
||||
sseReadTimeout: number
|
||||
onSseReadTimeoutChange: (timeout: number) => void
|
||||
}
|
||||
|
||||
const ConfigurationsSection: FC<ConfigurationsSectionProps> = ({
|
||||
timeout,
|
||||
onTimeoutChange,
|
||||
sseReadTimeout,
|
||||
onSseReadTimeoutChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.timeout', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={e => onTimeoutChange(Number(e.target.value))}
|
||||
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.sseReadTimeout', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={sseReadTimeout}
|
||||
onChange={e => onSseReadTimeoutChange(Number(e.target.value))}
|
||||
placeholder={t('mcp.modal.timeoutPlaceholder', { ns: 'tools' })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfigurationsSection
|
||||
192
web/app/components/tools/mcp/sections/headers-section.spec.tsx
Normal file
192
web/app/components/tools/mcp/sections/headers-section.spec.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import HeadersSection from './headers-section'
|
||||
|
||||
describe('HeadersSection', () => {
|
||||
const defaultProps = {
|
||||
headers: [],
|
||||
onHeadersChange: vi.fn(),
|
||||
isCreate: true,
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<HeadersSection {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render headers label', () => {
|
||||
render(<HeadersSection {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.headers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render headers tip', () => {
|
||||
render(<HeadersSection {...defaultProps} />)
|
||||
expect(screen.getByText('tools.mcp.modal.headersTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty state when no headers', () => {
|
||||
render(<HeadersSection {...defaultProps} headers={[]} />)
|
||||
expect(screen.getByText('tools.mcp.modal.noHeaders')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add header button when empty', () => {
|
||||
render(<HeadersSection {...defaultProps} headers={[]} />)
|
||||
expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('With Headers', () => {
|
||||
const headersWithItems = [
|
||||
{ id: '1', key: 'Authorization', value: 'Bearer token123' },
|
||||
{ id: '2', key: 'Content-Type', value: 'application/json' },
|
||||
]
|
||||
|
||||
it('should render header items', () => {
|
||||
render(<HeadersSection {...defaultProps} headers={headersWithItems} />)
|
||||
expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Bearer token123')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Content-Type')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('application/json')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render table headers', () => {
|
||||
render(<HeadersSection {...defaultProps} headers={headersWithItems} />)
|
||||
expect(screen.getByText('tools.mcp.modal.headerKey')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.modal.headerValue')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show masked tip when not isCreate and has headers with content', () => {
|
||||
render(
|
||||
<HeadersSection
|
||||
{...defaultProps}
|
||||
isCreate={false}
|
||||
headers={headersWithItems}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show masked tip when isCreate is true', () => {
|
||||
render(
|
||||
<HeadersSection
|
||||
{...defaultProps}
|
||||
isCreate={true}
|
||||
headers={headersWithItems}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onHeadersChange when adding a header', () => {
|
||||
const onHeadersChange = vi.fn()
|
||||
render(<HeadersSection {...defaultProps} onHeadersChange={onHeadersChange} />)
|
||||
|
||||
const addButton = screen.getByText('tools.mcp.modal.addHeader')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalled()
|
||||
const calledWithHeaders = onHeadersChange.mock.calls[0][0]
|
||||
expect(calledWithHeaders).toHaveLength(1)
|
||||
expect(calledWithHeaders[0]).toHaveProperty('id')
|
||||
expect(calledWithHeaders[0]).toHaveProperty('key', '')
|
||||
expect(calledWithHeaders[0]).toHaveProperty('value', '')
|
||||
})
|
||||
|
||||
it('should call onHeadersChange when editing header key', () => {
|
||||
const onHeadersChange = vi.fn()
|
||||
const headers = [{ id: '1', key: '', value: '' }]
|
||||
render(
|
||||
<HeadersSection
|
||||
{...defaultProps}
|
||||
headers={headers}
|
||||
onHeadersChange={onHeadersChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const keyInput = inputs[0]
|
||||
fireEvent.change(keyInput, { target: { value: 'X-Custom-Header' } })
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onHeadersChange when editing header value', () => {
|
||||
const onHeadersChange = vi.fn()
|
||||
const headers = [{ id: '1', key: 'X-Custom-Header', value: '' }]
|
||||
render(
|
||||
<HeadersSection
|
||||
{...defaultProps}
|
||||
headers={headers}
|
||||
onHeadersChange={onHeadersChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
const valueInput = inputs[1]
|
||||
fireEvent.change(valueInput, { target: { value: 'custom-value' } })
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onHeadersChange when removing a header', () => {
|
||||
const onHeadersChange = vi.fn()
|
||||
const headers = [{ id: '1', key: 'X-Header', value: 'value' }]
|
||||
render(
|
||||
<HeadersSection
|
||||
{...defaultProps}
|
||||
headers={headers}
|
||||
onHeadersChange={onHeadersChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the delete button
|
||||
const deleteButton = screen.getByRole('button', { name: '' })
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onHeadersChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass isCreate=true correctly (no masking)', () => {
|
||||
const headers = [{ id: '1', key: 'Header', value: 'Value' }]
|
||||
render(<HeadersSection {...defaultProps} isCreate={true} headers={headers} />)
|
||||
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass isCreate=false correctly (with masking)', () => {
|
||||
const headers = [{ id: '1', key: 'Header', value: 'Value' }]
|
||||
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
|
||||
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle headers with empty keys (no masking even when not isCreate)', () => {
|
||||
const headers = [{ id: '1', key: '', value: 'Value' }]
|
||||
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
|
||||
// Empty key headers don't trigger masking
|
||||
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle headers with whitespace-only keys', () => {
|
||||
const headers = [{ id: '1', key: ' ', value: 'Value' }]
|
||||
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
|
||||
// Whitespace-only key doesn't count as having content
|
||||
expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple headers where some have empty keys', () => {
|
||||
const headers = [
|
||||
{ id: '1', key: '', value: 'Value1' },
|
||||
{ id: '2', key: 'ValidKey', value: 'Value2' },
|
||||
]
|
||||
render(<HeadersSection {...defaultProps} isCreate={false} headers={headers} />)
|
||||
// At least one header has a non-empty key, so masking should apply
|
||||
expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
36
web/app/components/tools/mcp/sections/headers-section.tsx
Normal file
36
web/app/components/tools/mcp/sections/headers-section.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { HeaderItem } from '../headers-input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import HeadersInput from '../headers-input'
|
||||
|
||||
type HeadersSectionProps = {
|
||||
headers: HeaderItem[]
|
||||
onHeadersChange: (headers: HeaderItem[]) => void
|
||||
isCreate: boolean
|
||||
}
|
||||
|
||||
const HeadersSection: FC<HeadersSectionProps> = ({
|
||||
headers,
|
||||
onHeadersChange,
|
||||
isCreate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<span className="system-sm-medium text-text-secondary">{t('mcp.modal.headers', { ns: 'tools' })}</span>
|
||||
</div>
|
||||
<div className="body-xs-regular mb-2 text-text-tertiary">{t('mcp.modal.headersTip', { ns: 'tools' })}</div>
|
||||
<HeadersInput
|
||||
headersItems={headers}
|
||||
onChange={onHeadersChange}
|
||||
readonly={false}
|
||||
isMasked={!isCreate && headers.filter(item => item.key.trim()).length > 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeadersSection
|
||||
@@ -2650,19 +2650,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/tools/mcp/mcp-service-card.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"app/components/tools/mcp/modal.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 20
|
||||
}
|
||||
},
|
||||
"app/components/tools/mcp/provider-card.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
@@ -2688,11 +2675,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"app/components/tools/utils/to-form-schema.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 15
|
||||
}
|
||||
},
|
||||
"app/components/workflow-app/components/workflow-children.tsx": {
|
||||
"no-console": {
|
||||
"count": 1
|
||||
|
||||
Reference in New Issue
Block a user