mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 08:58:09 +08:00
Co-authored-by: CodingOnStar <hanxujiang@dify.ai> Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com> Co-authored-by: CodingOnStar <hanxujiang@dify.com>
1042 lines
34 KiB
TypeScript
1042 lines
34 KiB
TypeScript
/* eslint-disable react/no-unnecessary-use-prefix */
|
|
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 { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import * as React from 'react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { AppModeEnum } from '@/types/app'
|
|
import MCPServiceCard from './mcp-service-card'
|
|
|
|
// Mock MCPServerModal
|
|
vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({
|
|
default: ({ show, onHide }: { show: boolean, onHide: () => void }) => {
|
|
if (!show)
|
|
return null
|
|
return (
|
|
<div data-testid="mcp-server-modal">
|
|
<button data-testid="close-modal-btn" onClick={onHide}>Close</button>
|
|
</div>
|
|
)
|
|
},
|
|
}))
|
|
|
|
// Mock Confirm
|
|
vi.mock('@/app/components/base/confirm', () => ({
|
|
default: ({ isShow, onConfirm, onCancel }: { isShow: boolean, onConfirm: () => void, onCancel: () => void }) => {
|
|
if (!isShow)
|
|
return null
|
|
return (
|
|
<div data-testid="confirm-dialog">
|
|
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
|
|
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
|
|
</div>
|
|
)
|
|
},
|
|
}))
|
|
|
|
// Mutable mock handlers for hook
|
|
const mockHandleStatusChange = vi.fn().mockResolvedValue({ activated: true })
|
|
const mockHandleServerModalHide = vi.fn().mockReturnValue({ shouldDeactivate: false })
|
|
const mockHandleGenCode = vi.fn()
|
|
const mockOpenConfirmDelete = vi.fn()
|
|
const mockCloseConfirmDelete = vi.fn()
|
|
const mockOpenServerModal = vi.fn()
|
|
|
|
// Type for mock hook state
|
|
type MockHookState = {
|
|
genLoading: boolean
|
|
isLoading: boolean
|
|
serverPublished: boolean
|
|
serverActivated: boolean
|
|
serverURL: string
|
|
detail: {
|
|
id: string
|
|
status: string
|
|
server_code: string
|
|
description: string
|
|
parameters: Record<string, unknown>
|
|
} | undefined
|
|
isCurrentWorkspaceManager: boolean
|
|
toggleDisabled: boolean
|
|
isMinimalState: boolean
|
|
appUnpublished: boolean
|
|
missingStartNode: boolean
|
|
showConfirmDelete: boolean
|
|
showMCPServerModal: boolean
|
|
latestParams: Array<unknown>
|
|
}
|
|
|
|
// Default hook state factory - creates fresh state for each test
|
|
const createDefaultHookState = (): MockHookState => ({
|
|
genLoading: false,
|
|
isLoading: false,
|
|
serverPublished: true,
|
|
serverActivated: true,
|
|
serverURL: 'https://api.example.com/mcp/server/abc123/mcp',
|
|
detail: {
|
|
id: 'server-123',
|
|
status: 'active',
|
|
server_code: 'abc123',
|
|
description: 'Test server',
|
|
parameters: {},
|
|
},
|
|
isCurrentWorkspaceManager: true,
|
|
toggleDisabled: false,
|
|
isMinimalState: false,
|
|
appUnpublished: false,
|
|
missingStartNode: false,
|
|
showConfirmDelete: false,
|
|
showMCPServerModal: false,
|
|
latestParams: [],
|
|
})
|
|
|
|
// Mutable hook state - modify this in tests to change component behavior
|
|
let mockHookState = createDefaultHookState()
|
|
|
|
// Mock the hook - uses mockHookState which can be modified per test
|
|
vi.mock('./hooks/use-mcp-service-card', () => ({
|
|
useMCPServiceCardState: () => ({
|
|
...mockHookState,
|
|
handleStatusChange: mockHandleStatusChange,
|
|
handleServerModalHide: mockHandleServerModalHide,
|
|
handleGenCode: mockHandleGenCode,
|
|
openConfirmDelete: mockOpenConfirmDelete,
|
|
closeConfirmDelete: mockCloseConfirmDelete,
|
|
openServerModal: mockOpenServerModal,
|
|
}),
|
|
}))
|
|
|
|
describe('MCPServiceCard', () => {
|
|
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 hook state to defaults before each test
|
|
mockHookState = createDefaultHookState()
|
|
|
|
// Reset all mock function call history
|
|
mockHandleStatusChange.mockClear().mockResolvedValue({ activated: true })
|
|
mockHandleServerModalHide.mockClear().mockReturnValue({ shouldDeactivate: false })
|
|
mockHandleGenCode.mockClear()
|
|
mockOpenConfirmDelete.mockClear()
|
|
mockCloseConfirmDelete.mockClear()
|
|
mockOpenServerModal.mockClear()
|
|
})
|
|
|
|
describe('Rendering', () => {
|
|
it('should render without crashing', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render the MCP icon', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// The Mcp icon should be present in the component
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render status indicator', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Status indicator shows running or disable
|
|
expect(screen.getByText(/appOverview.overview.status/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render switch toggle', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByRole('switch')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render in minimal or full state based on server status', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Component renders either in minimal or full state
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render edit button', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Edit or add description button
|
|
const editOrAddButton = screen.queryByText('tools.mcp.server.edit') || screen.queryByText('tools.mcp.server.addDescription')
|
|
expect(editOrAddButton).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Status Indicator', () => {
|
|
it('should show running status when server is activated', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// The status text should be present
|
|
expect(screen.getByText(/appOverview.overview.status/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Server URL Display', () => {
|
|
it('should display title in both minimal and full state', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Title should always be displayed
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Trigger Mode Disabled', () => {
|
|
it('should apply opacity when triggerModeDisabled is true', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(
|
|
<MCPServiceCard appInfo={appInfo} triggerModeDisabled={true} />,
|
|
{ wrapper: createWrapper() },
|
|
)
|
|
|
|
// Component should have reduced opacity class
|
|
const container = document.querySelector('.opacity-60')
|
|
expect(container).toBeInTheDocument()
|
|
})
|
|
|
|
it('should not apply opacity when triggerModeDisabled is false', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(
|
|
<MCPServiceCard appInfo={appInfo} triggerModeDisabled={false} />,
|
|
{ wrapper: createWrapper() },
|
|
)
|
|
|
|
// Component should not have reduced opacity class on the main content
|
|
const opacityElements = document.querySelectorAll('.opacity-60')
|
|
// The opacity-60 should not be present when not disabled
|
|
expect(opacityElements.length).toBe(0)
|
|
})
|
|
|
|
it('should render overlay when triggerModeDisabled is true', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(
|
|
<MCPServiceCard appInfo={appInfo} triggerModeDisabled={true} />,
|
|
{ wrapper: createWrapper() },
|
|
)
|
|
|
|
// Overlay should have cursor-not-allowed
|
|
const overlay = document.querySelector('.cursor-not-allowed')
|
|
expect(overlay).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Different App Modes', () => {
|
|
it('should render for chat app', () => {
|
|
const appInfo = createMockAppInfo(AppModeEnum.CHAT)
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render for workflow app', () => {
|
|
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render for advanced chat app', () => {
|
|
const appInfo = createMockAppInfo(AppModeEnum.ADVANCED_CHAT)
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render for completion app', () => {
|
|
const appInfo = createMockAppInfo(AppModeEnum.COMPLETION)
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render for agent chat app', () => {
|
|
const appInfo = createMockAppInfo(AppModeEnum.AGENT_CHAT)
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should toggle switch', async () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
const switchElement = screen.getByRole('switch')
|
|
fireEvent.click(switchElement)
|
|
|
|
// Switch should be interactive
|
|
await waitFor(() => {
|
|
expect(switchElement).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should have switch button available', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// The switch is a button role element
|
|
const switchElement = screen.getByRole('switch')
|
|
expect(switchElement).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Props', () => {
|
|
it('should accept triggerModeMessage prop', () => {
|
|
const appInfo = createMockAppInfo()
|
|
const message = 'Custom trigger mode message'
|
|
render(
|
|
<MCPServiceCard
|
|
appInfo={appInfo}
|
|
triggerModeDisabled={true}
|
|
triggerModeMessage={message}
|
|
/>,
|
|
{ wrapper: createWrapper() },
|
|
)
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should handle empty triggerModeMessage', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(
|
|
<MCPServiceCard
|
|
appInfo={appInfo}
|
|
triggerModeDisabled={true}
|
|
triggerModeMessage=""
|
|
/>,
|
|
{ wrapper: createWrapper() },
|
|
)
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should handle ReactNode as triggerModeMessage', () => {
|
|
const appInfo = createMockAppInfo()
|
|
const message = <span data-testid="custom-message">Custom message</span>
|
|
render(
|
|
<MCPServiceCard
|
|
appInfo={appInfo}
|
|
triggerModeDisabled={true}
|
|
triggerModeMessage={message}
|
|
/>,
|
|
{ wrapper: createWrapper() },
|
|
)
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle minimal app info', () => {
|
|
const minimalAppInfo = {
|
|
id: 'minimal-app',
|
|
name: 'Minimal',
|
|
mode: AppModeEnum.CHAT,
|
|
api_base_url: 'https://api.example.com/v1',
|
|
} as AppDetailResponse & Partial<AppSSO>
|
|
|
|
render(<MCPServiceCard appInfo={minimalAppInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should handle app info with special characters in name', () => {
|
|
const appInfo = {
|
|
id: 'app-special',
|
|
name: 'Test App <script>alert("xss")</script>',
|
|
mode: AppModeEnum.CHAT,
|
|
api_base_url: 'https://api.example.com/v1',
|
|
} as AppDetailResponse & Partial<AppSSO>
|
|
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Server Not Published', () => {
|
|
beforeEach(() => {
|
|
// Modify hookState to simulate unpublished server
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
serverPublished: false,
|
|
serverActivated: false,
|
|
serverURL: '***********',
|
|
detail: undefined,
|
|
isMinimalState: true,
|
|
}
|
|
})
|
|
|
|
it('should show add description button when server is not published', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
const buttons = screen.queryAllByRole('button')
|
|
const addDescButton = buttons.find(btn =>
|
|
btn.textContent?.includes('tools.mcp.server.addDescription'),
|
|
)
|
|
expect(addDescButton || screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show masked URL when server is not published', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// In minimal/unpublished state, the URL should be masked or not shown
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should open modal when enabling unpublished server', async () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
const switchElement = screen.getByRole('switch')
|
|
fireEvent.click(switchElement)
|
|
|
|
await waitFor(() => {
|
|
const modal = screen.queryByTestId('mcp-server-modal')
|
|
if (modal)
|
|
expect(modal).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Inactive Server', () => {
|
|
beforeEach(() => {
|
|
// Modify hookState to simulate inactive server
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
serverActivated: false,
|
|
detail: {
|
|
id: 'server-123',
|
|
status: 'inactive',
|
|
server_code: 'abc123',
|
|
description: 'Test server',
|
|
parameters: {},
|
|
},
|
|
}
|
|
})
|
|
|
|
it('should show disabled status when server is inactive', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText(/appOverview.overview.status/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('should toggle switch when server is inactive', async () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
const switchElement = screen.getByRole('switch')
|
|
expect(switchElement).toBeInTheDocument()
|
|
|
|
fireEvent.click(switchElement)
|
|
|
|
// Switch should be interactive when server is inactive but published
|
|
await waitFor(() => {
|
|
expect(switchElement).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Non-Manager User', () => {
|
|
beforeEach(() => {
|
|
// Modify hookState to simulate non-manager user
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
isCurrentWorkspaceManager: false,
|
|
}
|
|
})
|
|
|
|
it('should not show regenerate button for non-manager', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Regenerate button should not be visible
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Non-Editor User', () => {
|
|
it('should show disabled styling for non-editor switch', () => {
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
toggleDisabled: true,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
const switchElement = screen.getByRole('switch')
|
|
// Switch uses CSS classes for disabled state, not disabled attribute
|
|
expect(switchElement.className).toContain('!cursor-not-allowed')
|
|
expect(switchElement.className).toContain('!opacity-50')
|
|
})
|
|
})
|
|
|
|
describe('Confirm Regenerate Dialog', () => {
|
|
it('should open confirm dialog and regenerate on confirm', async () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Find and click regenerate button
|
|
const regenerateButtons = document.querySelectorAll('.cursor-pointer')
|
|
const regenerateBtn = Array.from(regenerateButtons).find(btn =>
|
|
btn.querySelector('svg.h-4.w-4'),
|
|
)
|
|
|
|
if (regenerateBtn) {
|
|
fireEvent.click(regenerateBtn)
|
|
|
|
await waitFor(() => {
|
|
const confirmDialog = screen.queryByTestId('confirm-dialog')
|
|
if (confirmDialog) {
|
|
expect(confirmDialog).toBeInTheDocument()
|
|
const confirmBtn = screen.getByTestId('confirm-btn')
|
|
fireEvent.click(confirmBtn)
|
|
}
|
|
})
|
|
}
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should close confirm dialog on cancel', async () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Find and click regenerate button
|
|
const regenerateButtons = document.querySelectorAll('.cursor-pointer')
|
|
const regenerateBtn = Array.from(regenerateButtons).find(btn =>
|
|
btn.querySelector('svg.h-4.w-4'),
|
|
)
|
|
|
|
if (regenerateBtn) {
|
|
fireEvent.click(regenerateBtn)
|
|
|
|
await waitFor(() => {
|
|
const confirmDialog = screen.queryByTestId('confirm-dialog')
|
|
if (confirmDialog) {
|
|
expect(confirmDialog).toBeInTheDocument()
|
|
const cancelBtn = screen.getByTestId('cancel-btn')
|
|
fireEvent.click(cancelBtn)
|
|
}
|
|
})
|
|
}
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('MCP Server Modal', () => {
|
|
it('should open and close server modal', async () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Find edit button
|
|
const buttons = screen.queryAllByRole('button')
|
|
const editButton = buttons.find(btn =>
|
|
btn.textContent?.includes('tools.mcp.server.edit')
|
|
|| btn.textContent?.includes('tools.mcp.server.addDescription'),
|
|
)
|
|
|
|
if (editButton) {
|
|
fireEvent.click(editButton)
|
|
|
|
await waitFor(() => {
|
|
const modal = screen.queryByTestId('mcp-server-modal')
|
|
if (modal) {
|
|
expect(modal).toBeInTheDocument()
|
|
const closeBtn = screen.getByTestId('close-modal-btn')
|
|
fireEvent.click(closeBtn)
|
|
}
|
|
})
|
|
}
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should deactivate switch when modal closes without previous activation', async () => {
|
|
// Simulate unpublished server state
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
serverPublished: false,
|
|
serverActivated: false,
|
|
detail: undefined,
|
|
showMCPServerModal: true,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Modal should be visible
|
|
const modal = screen.getByTestId('mcp-server-modal')
|
|
expect(modal).toBeInTheDocument()
|
|
|
|
const closeBtn = screen.getByTestId('close-modal-btn')
|
|
fireEvent.click(closeBtn)
|
|
|
|
await waitFor(() => {
|
|
expect(mockHandleServerModalHide).toHaveBeenCalled()
|
|
})
|
|
|
|
// Switch should be off after closing modal without activation
|
|
const switchElement = screen.getByRole('switch')
|
|
expect(switchElement).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Unpublished App', () => {
|
|
it('should show minimal state for unpublished app', () => {
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
appUnpublished: true,
|
|
isMinimalState: true,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show disabled styling for unpublished app switch', () => {
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
appUnpublished: true,
|
|
toggleDisabled: true,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
const switchElement = screen.getByRole('switch')
|
|
// Switch uses CSS classes for disabled state
|
|
expect(switchElement.className).toContain('!cursor-not-allowed')
|
|
expect(switchElement.className).toContain('!opacity-50')
|
|
})
|
|
})
|
|
|
|
describe('Workflow App Without Start Node', () => {
|
|
it('should show minimal state for workflow without start node', () => {
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
missingStartNode: true,
|
|
isMinimalState: true,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show disabled styling for workflow without start node', () => {
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
missingStartNode: true,
|
|
toggleDisabled: true,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
const switchElement = screen.getByRole('switch')
|
|
// Switch uses CSS classes for disabled state
|
|
expect(switchElement.className).toContain('!cursor-not-allowed')
|
|
expect(switchElement.className).toContain('!opacity-50')
|
|
})
|
|
})
|
|
|
|
describe('Loading State', () => {
|
|
it('should return null when isLoading is true', () => {
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
isLoading: true,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo()
|
|
const { container } = render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Component returns null when loading
|
|
expect(container.firstChild).toBeNull()
|
|
})
|
|
|
|
it('should render content when isLoading is false', () => {
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
isLoading: false,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('TriggerModeOverlay', () => {
|
|
it('should show overlay without tooltip when triggerModeMessage is empty', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(
|
|
<MCPServiceCard appInfo={appInfo} triggerModeDisabled={true} triggerModeMessage="" />,
|
|
{ wrapper: createWrapper() },
|
|
)
|
|
|
|
const overlay = document.querySelector('.cursor-not-allowed')
|
|
expect(overlay).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show overlay with tooltip when triggerModeMessage is provided', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(
|
|
<MCPServiceCard appInfo={appInfo} triggerModeDisabled={true} triggerModeMessage="Custom message" />,
|
|
{ wrapper: createWrapper() },
|
|
)
|
|
|
|
const overlay = document.querySelector('.cursor-not-allowed')
|
|
expect(overlay).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('onChangeStatus Handler', () => {
|
|
it('should call handleStatusChange with false when turning off', async () => {
|
|
// Start with server activated
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
serverActivated: true,
|
|
}
|
|
mockHandleStatusChange.mockResolvedValue({ activated: false })
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
const switchElement = screen.getByRole('switch')
|
|
|
|
// Click to turn off - this will trigger onChangeStatus(false)
|
|
fireEvent.click(switchElement)
|
|
|
|
await waitFor(() => {
|
|
expect(mockHandleStatusChange).toHaveBeenCalledWith(false)
|
|
})
|
|
})
|
|
|
|
it('should call handleStatusChange with true when turning on', async () => {
|
|
// Start with server deactivated
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
serverActivated: false,
|
|
}
|
|
mockHandleStatusChange.mockResolvedValue({ activated: true })
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
const switchElement = screen.getByRole('switch')
|
|
|
|
// Click to turn on - this will trigger onChangeStatus(true)
|
|
fireEvent.click(switchElement)
|
|
|
|
await waitFor(() => {
|
|
expect(mockHandleStatusChange).toHaveBeenCalledWith(true)
|
|
})
|
|
})
|
|
|
|
it('should set local activated to false when handleStatusChange returns activated: false and state is true', async () => {
|
|
// Simulate unpublished server scenario where enabling opens modal
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
serverActivated: false,
|
|
serverPublished: false,
|
|
}
|
|
// Handler returns activated: false (modal opened instead)
|
|
mockHandleStatusChange.mockResolvedValue({ activated: false })
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
const switchElement = screen.getByRole('switch')
|
|
|
|
// Click to turn on
|
|
fireEvent.click(switchElement)
|
|
|
|
await waitFor(() => {
|
|
expect(mockHandleStatusChange).toHaveBeenCalledWith(true)
|
|
})
|
|
|
|
// The local state should be set to false because result.activated is false
|
|
expect(switchElement).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('onServerModalHide Handler', () => {
|
|
it('should deactivate when handleServerModalHide returns shouldDeactivate: true', async () => {
|
|
// Set up to show modal
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
showMCPServerModal: true,
|
|
serverActivated: false, // Server was not activated
|
|
}
|
|
mockHandleServerModalHide.mockReturnValue({ shouldDeactivate: true })
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Close the modal
|
|
const closeBtn = screen.getByTestId('close-modal-btn')
|
|
fireEvent.click(closeBtn)
|
|
|
|
await waitFor(() => {
|
|
expect(mockHandleServerModalHide).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should not deactivate when handleServerModalHide returns shouldDeactivate: false', async () => {
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
showMCPServerModal: true,
|
|
serverActivated: true, // Server was already activated
|
|
}
|
|
mockHandleServerModalHide.mockReturnValue({ shouldDeactivate: false })
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Close the modal
|
|
const closeBtn = screen.getByTestId('close-modal-btn')
|
|
fireEvent.click(closeBtn)
|
|
|
|
await waitFor(() => {
|
|
expect(mockHandleServerModalHide).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('onConfirmRegenerate Handler', () => {
|
|
it('should call handleGenCode and closeConfirmDelete when confirm is clicked', async () => {
|
|
// Set up to show confirm dialog
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
showConfirmDelete: true,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Confirm dialog should be visible
|
|
const confirmDialog = screen.getByTestId('confirm-dialog')
|
|
expect(confirmDialog).toBeInTheDocument()
|
|
|
|
// Click confirm button
|
|
const confirmBtn = screen.getByTestId('confirm-btn')
|
|
fireEvent.click(confirmBtn)
|
|
|
|
await waitFor(() => {
|
|
expect(mockHandleGenCode).toHaveBeenCalled()
|
|
expect(mockCloseConfirmDelete).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('should call closeConfirmDelete when cancel is clicked', async () => {
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
showConfirmDelete: true,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Click cancel button
|
|
const cancelBtn = screen.getByTestId('cancel-btn')
|
|
fireEvent.click(cancelBtn)
|
|
|
|
await waitFor(() => {
|
|
expect(mockCloseConfirmDelete).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('getTooltipContent Function', () => {
|
|
it('should show publish tip when app is unpublished', () => {
|
|
// Modify hookState to simulate unpublished app
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
appUnpublished: true,
|
|
toggleDisabled: true,
|
|
isMinimalState: true,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Tooltip should contain publish tip
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should show missing start node tooltip for workflow without start node', () => {
|
|
// Modify hookState to simulate missing start node
|
|
mockHookState = {
|
|
...createDefaultHookState(),
|
|
missingStartNode: true,
|
|
toggleDisabled: true,
|
|
isMinimalState: true,
|
|
}
|
|
|
|
const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW)
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// The tooltip with learn more link should be available
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should return triggerModeMessage when trigger mode is disabled', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(
|
|
<MCPServiceCard
|
|
appInfo={appInfo}
|
|
triggerModeDisabled={true}
|
|
triggerModeMessage="Test trigger message"
|
|
/>,
|
|
{ wrapper: createWrapper() },
|
|
)
|
|
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('State Synchronization', () => {
|
|
it('should sync activated state when serverActivated changes', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Initial state
|
|
expect(screen.getByRole('switch')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Accessibility', () => {
|
|
it('should have accessible switch', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
const switchElement = screen.getByRole('switch')
|
|
expect(switchElement).toBeInTheDocument()
|
|
})
|
|
|
|
it('should have accessible interactive elements', () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// The switch element with button type is an interactive element
|
|
const switchElement = screen.getByRole('switch')
|
|
expect(switchElement).toBeInTheDocument()
|
|
expect(switchElement).toHaveAttribute('type', 'button')
|
|
})
|
|
})
|
|
|
|
describe('Server URL Regeneration', () => {
|
|
it('should open confirm dialog when regenerate is clicked', async () => {
|
|
// Mock to show regenerate button
|
|
vi.doMock('@/service/use-tools', async () => {
|
|
return {
|
|
useUpdateMCPServer: () => ({
|
|
mutateAsync: vi.fn().mockResolvedValue({}),
|
|
}),
|
|
useRefreshMCPServerCode: () => ({
|
|
mutateAsync: vi.fn().mockResolvedValue({}),
|
|
isPending: false,
|
|
}),
|
|
useMCPServerDetail: () => ({
|
|
data: {
|
|
id: 'server-123',
|
|
status: 'active',
|
|
server_code: 'abc123',
|
|
description: 'Test server',
|
|
parameters: {},
|
|
},
|
|
}),
|
|
useInvalidateMCPServerDetail: () => vi.fn(),
|
|
}
|
|
})
|
|
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Find the regenerate button and click it
|
|
const regenerateButtons = document.querySelectorAll('.cursor-pointer')
|
|
const regenerateBtn = Array.from(regenerateButtons).find(btn =>
|
|
btn.querySelector('svg'),
|
|
)
|
|
if (regenerateBtn) {
|
|
fireEvent.click(regenerateBtn)
|
|
|
|
// Wait for confirm dialog to appear
|
|
await waitFor(() => {
|
|
const confirmTitle = screen.queryByText('appOverview.overview.appInfo.regenerate')
|
|
if (confirmTitle)
|
|
expect(confirmTitle).toBeInTheDocument()
|
|
}, { timeout: 100 })
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Edit Button', () => {
|
|
it('should open MCP server modal when edit button is clicked', async () => {
|
|
const appInfo = createMockAppInfo()
|
|
render(<MCPServiceCard appInfo={appInfo} />, { wrapper: createWrapper() })
|
|
|
|
// Find button with edit text - use queryAllByRole since buttons may not exist
|
|
const buttons = screen.queryAllByRole('button')
|
|
const editButton = buttons.find(btn =>
|
|
btn.textContent?.includes('tools.mcp.server.edit')
|
|
|| btn.textContent?.includes('tools.mcp.server.addDescription'),
|
|
)
|
|
|
|
if (editButton) {
|
|
fireEvent.click(editButton)
|
|
|
|
// Modal should open - check for any modal indicator
|
|
await waitFor(() => {
|
|
// If modal opens, we should see modal content
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
})
|
|
}
|
|
else {
|
|
// In minimal state, no edit button is shown - this is expected behavior
|
|
expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument()
|
|
}
|
|
})
|
|
})
|
|
})
|