From c58647d39cc3758fa037a6c8bae5c62d76e8feea Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Tue, 27 Jan 2026 11:05:59 +0800 Subject: [PATCH] refactor(web): extract MCP components and add comprehensive tests (#31517) Co-authored-by: CodingOnStar Co-authored-by: Claude Haiku 4.5 Co-authored-by: CodingOnStar --- .../components/tools/mcp/create-card.spec.tsx | 221 ++++ .../tools/mcp/detail/content.spec.tsx | 855 ++++++++++++++ .../tools/mcp/detail/list-loading.spec.tsx | 71 ++ .../mcp/detail/operation-dropdown.spec.tsx | 193 +++ .../tools/mcp/detail/provider-detail.spec.tsx | 153 +++ .../tools/mcp/detail/tool-item.spec.tsx | 126 ++ .../tools/mcp/headers-input.spec.tsx | 245 ++++ .../mcp/hooks/use-mcp-modal-form.spec.ts | 500 ++++++++ .../tools/mcp/hooks/use-mcp-modal-form.ts | 203 ++++ .../mcp/hooks/use-mcp-service-card.spec.ts | 451 +++++++ .../tools/mcp/hooks/use-mcp-service-card.ts | 179 +++ .../tools/mcp/mcp-server-modal.spec.tsx | 361 ++++++ .../tools/mcp/mcp-server-param-item.spec.tsx | 165 +++ .../tools/mcp/mcp-service-card.spec.tsx | 1041 +++++++++++++++++ .../components/tools/mcp/mcp-service-card.tsx | 408 +++---- web/app/components/tools/mcp/modal.spec.tsx | 745 ++++++++++++ web/app/components/tools/mcp/modal.tsx | 579 ++++----- .../tools/mcp/provider-card.spec.tsx | 524 +++++++++ .../sections/authentication-section.spec.tsx | 162 +++ .../mcp/sections/authentication-section.tsx | 78 ++ .../sections/configurations-section.spec.tsx | 100 ++ .../mcp/sections/configurations-section.tsx | 49 + .../mcp/sections/headers-section.spec.tsx | 192 +++ .../tools/mcp/sections/headers-section.tsx | 36 + web/eslint-suppressions.json | 13 - 25 files changed, 7082 insertions(+), 568 deletions(-) create mode 100644 web/app/components/tools/mcp/create-card.spec.tsx create mode 100644 web/app/components/tools/mcp/detail/content.spec.tsx create mode 100644 web/app/components/tools/mcp/detail/list-loading.spec.tsx create mode 100644 web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx create mode 100644 web/app/components/tools/mcp/detail/provider-detail.spec.tsx create mode 100644 web/app/components/tools/mcp/detail/tool-item.spec.tsx create mode 100644 web/app/components/tools/mcp/headers-input.spec.tsx create mode 100644 web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts create mode 100644 web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts create mode 100644 web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts create mode 100644 web/app/components/tools/mcp/hooks/use-mcp-service-card.ts create mode 100644 web/app/components/tools/mcp/mcp-server-modal.spec.tsx create mode 100644 web/app/components/tools/mcp/mcp-server-param-item.spec.tsx create mode 100644 web/app/components/tools/mcp/mcp-service-card.spec.tsx create mode 100644 web/app/components/tools/mcp/modal.spec.tsx create mode 100644 web/app/components/tools/mcp/provider-card.spec.tsx create mode 100644 web/app/components/tools/mcp/sections/authentication-section.spec.tsx create mode 100644 web/app/components/tools/mcp/sections/authentication-section.tsx create mode 100644 web/app/components/tools/mcp/sections/configurations-section.spec.tsx create mode 100644 web/app/components/tools/mcp/sections/configurations-section.tsx create mode 100644 web/app/components/tools/mcp/sections/headers-section.spec.tsx create mode 100644 web/app/components/tools/mcp/sections/headers-section.tsx diff --git a/web/app/components/tools/mcp/create-card.spec.tsx b/web/app/components/tools/mcp/create-card.spec.tsx new file mode 100644 index 0000000000..9ddee00460 --- /dev/null +++ b/web/app/components/tools/mcp/create-card.spec.tsx @@ -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 ( +
+ tools.mcp.modal.title + + +
+ ) + }, +})) + +// 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(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument() + }) + + it('should render card title', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument() + }) + + it('should render documentation link', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.create.cardLink')).toBeInTheDocument() + }) + + it('should render add icon', () => { + render(, { 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(, { 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(, { 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(, { wrapper: createWrapper() }) + + expect(screen.queryByText('tools.mcp.create.cardTitle')).not.toBeInTheDocument() + }) + }) + + describe('Styling', () => { + it('should have correct card structure', () => { + render(, { wrapper: createWrapper() }) + + const card = document.querySelector('.rounded-xl') + expect(card).toBeInTheDocument() + }) + + it('should have clickable cursor style', () => { + render(, { 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(, { 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(, { 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() + }) + } + }) + }) +}) diff --git a/web/app/components/tools/mcp/detail/content.spec.tsx b/web/app/components/tools/mcp/detail/content.spec.tsx new file mode 100644 index 0000000000..fe3fbd2bc3 --- /dev/null +++ b/web/app/components/tools/mcp/detail/content.spec.tsx @@ -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 ( +
+ + +
+ ) + }, +})) + +// 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 ( +
+ + +
+ ) + }, +})) + +// Mock OperationDropdown +vi.mock('./operation-dropdown', () => ({ + default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => ( +
+ + +
+ ), +})) + +// Mock ToolItem +type ToolItemData = { + name: string +} + +vi.mock('./tool-item', () => ({ + default: ({ tool }: { tool: ToolItemData }) => ( +
{tool.name}
+ ), +})) + +// 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(, { wrapper: createWrapper() }) + expect(screen.getByText('Test MCP Server')).toBeInTheDocument() + }) + + it('should display MCP name', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('Test MCP Server')).toBeInTheDocument() + }) + + it('should display server identifier', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('test-mcp')).toBeInTheDocument() + }) + + it('should display server URL', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('https://example.com/mcp')).toBeInTheDocument() + }) + + it('should render close button', () => { + render(, { wrapper: createWrapper() }) + // Close button should be present + const closeButtons = document.querySelectorAll('button') + expect(closeButtons.length).toBeGreaterThan(0) + }) + + it('should render operation dropdown', () => { + render(, { 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( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.mcp.authorize')).toBeInTheDocument() + }) + + it('should show authorized button when authorized', () => { + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { 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( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.mcp.authorizingRequired')).toBeInTheDocument() + }) + + it('should show authorization tip', () => { + const detail = createMockDetail({ is_team_authorization: false }) + render( + , + { 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( + , + { 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( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.mcp.getTools')).toBeInTheDocument() + }) + }) + + describe('Icon Display', () => { + it('should render MCP icon', () => { + render(, { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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() + }) + }) + }) +}) diff --git a/web/app/components/tools/mcp/detail/list-loading.spec.tsx b/web/app/components/tools/mcp/detail/list-loading.spec.tsx new file mode 100644 index 0000000000..679d4322d9 --- /dev/null +++ b/web/app/components/tools/mcp/detail/list-loading.spec.tsx @@ -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() + expect(container).toBeInTheDocument() + }) + + it('should render 5 skeleton items', () => { + render() + 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() + const skeletonItems = document.querySelectorAll('.rounded-xl') + expect(skeletonItems.length).toBeGreaterThanOrEqual(5) + }) + + it('should have proper spacing', () => { + render() + const container = document.querySelector('.space-y-2') + expect(container).toBeInTheDocument() + }) + + it('should render placeholder bars with different widths', () => { + render() + 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() + 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() + const items = document.querySelectorAll('.space-y-3') + expect(items.length).toBe(5) + }) + + it('should render padding on skeleton items', () => { + render() + const paddedItems = document.querySelectorAll('.p-4') + expect(paddedItems.length).toBe(5) + }) + + it('should render height-2 skeleton bars', () => { + render() + const h2Bars = document.querySelectorAll('.h-2') + // 3 bars per skeleton item * 5 items = 15 + expect(h2Bars.length).toBe(15) + }) + }) +}) diff --git a/web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx b/web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx new file mode 100644 index 0000000000..077bdc3efe --- /dev/null +++ b/web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx @@ -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() + expect(document.querySelector('button')).toBeInTheDocument() + }) + + it('should render trigger button with more icon', () => { + render() + const button = document.querySelector('button') + expect(button).toBeInTheDocument() + const svg = button?.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should render medium size by default', () => { + render() + const icon = document.querySelector('.h-4.w-4') + expect(icon).toBeInTheDocument() + }) + + it('should render large size when inCard is true', () => { + render() + const icon = document.querySelector('.h-5.w-5') + expect(icon).toBeInTheDocument() + }) + }) + + describe('Dropdown Behavior', () => { + it('should open dropdown when trigger is clicked', async () => { + render() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + // Component renders with different offset values + expect(document.querySelector('button')).toBeInTheDocument() + }) + + it('should adjust offset when inCard is true', () => { + render() + // Component renders with different offset values + expect(document.querySelector('button')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/tools/mcp/detail/provider-detail.spec.tsx b/web/app/components/tools/mcp/detail/provider-detail.spec.tsx new file mode 100644 index 0000000000..dc8a427498 --- /dev/null +++ b/web/app/components/tools/mcp/detail/provider-detail.spec.tsx @@ -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
{children}
+ }, +})) + +// Mock the content component to expose onUpdate callback +vi.mock('./content', () => ({ + default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => ( +
+ {detail.name} + + +
+ ), +})) + +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( + , + { wrapper: createWrapper() }, + ) + expect(container.innerHTML).toBe('') + }) + + it('should render drawer when detail is provided', () => { + const detail = createMockDetail() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByTestId('drawer')).toBeInTheDocument() + }) + + it('should render content when detail is provided', () => { + const detail = createMockDetail() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument() + }) + + it('should pass detail to content component', () => { + const detail = createMockDetail() + render( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { 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) + }) + }) +}) diff --git a/web/app/components/tools/mcp/detail/tool-item.spec.tsx b/web/app/components/tools/mcp/detail/tool-item.spec.tsx new file mode 100644 index 0000000000..aa04422b48 --- /dev/null +++ b/web/app/components/tools/mcp/detail/tool-item.spec.tsx @@ -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() + expect(screen.getByText('Test Tool')).toBeInTheDocument() + }) + + it('should display tool label', () => { + const tool = createMockTool() + render() + expect(screen.getByText('Test Tool')).toBeInTheDocument() + }) + + it('should display tool description', () => { + const tool = createMockTool() + render() + 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() + 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() + // 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() + const toolElement = document.querySelector('.cursor-pointer') + expect(toolElement).toBeInTheDocument() + }) + + it('should have rounded-xl class', () => { + const tool = createMockTool() + render() + const toolElement = document.querySelector('.rounded-xl') + expect(toolElement).toBeInTheDocument() + }) + + it('should have hover styles', () => { + const tool = createMockTool() + render() + 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() + // Should render without crashing + expect(document.querySelector('.cursor-pointer')).toBeInTheDocument() + }) + + it('should handle empty description', () => { + const tool = createMockTool({ + description: { en_US: '', zh_Hans: '' }, + }) + render() + 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() + 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 ', zh_Hans: '特殊工具' }, + }) + render() + expect(screen.getByText('Special Tool ')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/tools/mcp/headers-input.spec.tsx b/web/app/components/tools/mcp/headers-input.spec.tsx new file mode 100644 index 0000000000..c271268f5f --- /dev/null +++ b/web/app/components/tools/mcp/headers-input.spec.tsx @@ -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() + expect(screen.getByText('tools.mcp.modal.noHeaders')).toBeInTheDocument() + }) + + it('should render add header button when empty and not readonly', () => { + render() + expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument() + }) + + it('should not render add header button when empty and readonly', () => { + render() + 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() + + 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() + 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() + 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() + // 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() + const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]') + expect(deleteButtons.length).toBe(0) + }) + + it('should render add button at bottom when not readonly', () => { + render() + expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument() + }) + + it('should not render add button when readonly', () => { + render() + 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() + expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument() + }) + + it('should not show masked headers tip when isMasked is false', () => { + render() + 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() + + 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() + + 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() + + 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() + + 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() + 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() + + 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() + + // 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() + + 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() + + 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() + + 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() + expect(screen.getByDisplayValue('X-Custom-Header')).toBeInTheDocument() + }) + + it('should handle JSON value', () => { + const headersItems = [{ id: '1', key: 'Data', value: '{"key":"value"}' }] + render() + expect(screen.getByDisplayValue('{"key":"value"}')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts new file mode 100644 index 0000000000..72520e11d1 --- /dev/null +++ b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts @@ -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') + }) + }) +}) diff --git a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts new file mode 100644 index 0000000000..286e2bf2e8 --- /dev/null +++ b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts @@ -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 + 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(() => 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(() => getInitialHeaders(data)) + const [isFetchingIcon, setIsFetchingIcon] = useState(false) + const appIconRef = useRef(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, + } +} diff --git a/web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts b/web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts new file mode 100644 index 0000000000..b36f724857 --- /dev/null +++ b/web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts @@ -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 +} | 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 => ({ + id: 'app-123', + name: 'Test App', + mode, + api_base_url: 'https://api.example.com/v1', + } as AppDetailResponse & Partial) + + 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) + }) + }) +}) diff --git a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts new file mode 100644 index 0000000000..dfb1c75a2a --- /dev/null +++ b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts @@ -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 + +type BasicAppConfig = { + updated_at?: string + user_input_form?: Array> +} + +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({ + 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>).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, + } +} diff --git a/web/app/components/tools/mcp/mcp-server-modal.spec.tsx b/web/app/components/tools/mcp/mcp-server-modal.spec.tsx new file mode 100644 index 0000000000..62eabd0690 --- /dev/null +++ b/web/app/components/tools/mcp/mcp-server-modal.spec.tsx @@ -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(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.server.modal.addTitle')).toBeInTheDocument() + }) + + it('should render add title when no data is provided', () => { + render(, { 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(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.server.modal.editTitle')).toBeInTheDocument() + }) + + it('should render description label', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.server.modal.description')).toBeInTheDocument() + }) + + it('should render required indicator', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('*')).toBeInTheDocument() + }) + + it('should render description textarea', () => { + render(, { wrapper: createWrapper() }) + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + expect(textarea).toBeInTheDocument() + }) + + it('should render cancel button', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.modal.cancel')).toBeInTheDocument() + }) + + it('should render confirm button in add mode', () => { + render(, { 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(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.modal.save')).toBeInTheDocument() + }) + + it('should render close icon', () => { + render(, { 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(, { 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(, { 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(, { 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(, { wrapper: createWrapper() }) + expect(screen.getByText('Parameter 1')).toBeInTheDocument() + expect(screen.getByText('Parameter 2')).toBeInTheDocument() + }) + }) + + describe('Form Interactions', () => { + it('should update description when typing', () => { + render(, { 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(, { 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(, { 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(, { wrapper: createWrapper() }) + + const confirmButton = screen.getByText('tools.mcp.server.modal.confirm') + expect(confirmButton).toBeDisabled() + }) + + it('should enable confirm button when description is filled', () => { + render(, { 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(, { 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( + , + { 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(, { 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(, { 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( + , + { 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(, { 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( + , + { 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( + , + { 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( + , + { 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(, { 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() + }) + }) +}) diff --git a/web/app/components/tools/mcp/mcp-server-param-item.spec.tsx b/web/app/components/tools/mcp/mcp-server-param-item.spec.tsx new file mode 100644 index 0000000000..6e3a48e330 --- /dev/null +++ b/web/app/components/tools/mcp/mcp-server-param-item.spec.tsx @@ -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() + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('should display label', () => { + render() + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('should display variable name', () => { + render() + expect(screen.getByText('test_variable')).toBeInTheDocument() + }) + + it('should display type', () => { + render() + expect(screen.getByText('string')).toBeInTheDocument() + }) + + it('should display separator dot', () => { + render() + expect(screen.getByText('·')).toBeInTheDocument() + }) + + it('should render textarea with placeholder', () => { + render() + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + expect(textarea).toBeInTheDocument() + }) + }) + + describe('Value Display', () => { + it('should display empty value by default', () => { + render() + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + expect(textarea).toHaveValue('') + }) + + it('should display provided value', () => { + render() + 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() + 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() + + 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() + + 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() + + 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() + expect(screen.getByText('number')).toBeInTheDocument() + }) + + it('should display boolean type', () => { + const props = { + ...defaultProps, + data: { label: 'Enabled', variable: 'enabled', type: 'boolean' }, + } + render() + expect(screen.getByText('boolean')).toBeInTheDocument() + }) + + it('should display array type', () => { + const props = { + ...defaultProps, + data: { label: 'Items', variable: 'items', type: 'array' }, + } + render() + expect(screen.getByText('array')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle special characters in label', () => { + const props = { + ...defaultProps, + data: { label: 'Test