test(web): add comprehensive unit and integration tests for plugins and tools modules (#32220)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star
2026-02-12 10:04:56 +08:00
committed by GitHub
parent 10f85074e8
commit d6b025e91e
195 changed files with 12219 additions and 7840 deletions

View File

@ -0,0 +1,328 @@
import type { CustomCollectionBackend } from '../../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthType } from '../../types'
import CustomCreateCard from '../custom-create-card'
// Mock workspace manager state
let mockIsWorkspaceManager = true
// Mock useAppContext
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsWorkspaceManager,
}),
}))
// Mock useLocale and useDocLink
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
useDocLink: () => (path: string) => `https://docs.dify.ai/en/${path?.startsWith('/') ? path.slice(1) : path}`,
}))
// Mock getLanguage
vi.mock('@/i18n-config/language', () => ({
getLanguage: () => 'en-US',
}))
// Mock createCustomCollection service
const mockCreateCustomCollection = vi.fn()
vi.mock('@/service/tools', () => ({
createCustomCollection: (data: CustomCollectionBackend) => mockCreateCustomCollection(data),
}))
// Track modal state
let mockModalVisible = false
// Mock EditCustomToolModal - complex component
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
default: ({ payload, onHide, onAdd }: {
payload: null
onHide: () => void
onAdd: (data: CustomCollectionBackend) => void
}) => {
mockModalVisible = true
void onAdd // Keep reference to avoid lint warning about unused param
return (
<div data-testid="edit-custom-collection-modal">
<span data-testid="modal-payload">{payload === null ? 'null' : 'not-null'}</span>
<button data-testid="close-modal" onClick={onHide}>Close</button>
<button
data-testid="submit-modal"
onClick={() => {
onAdd({
provider: 'test-provider',
credentials: { auth_type: AuthType.none },
icon: { background: '#000', content: '🔧' },
schema_type: 'json',
schema: '{}',
privacy_policy: '',
custom_disclaimer: '',
id: 'test-id',
labels: [],
})
}}
>
Submit
</button>
</div>
)
},
}))
// Mock Toast
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (options: { type: string, message: string }) => mockToastNotify(options),
},
}))
describe('CustomCreateCard', () => {
const mockOnRefreshData = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockIsWorkspaceManager = true
mockModalVisible = false
mockCreateCustomCollection.mockResolvedValue({})
})
// Tests for conditional rendering based on workspace manager status
describe('Workspace Manager Conditional Rendering', () => {
it('should render card when user is workspace manager', () => {
mockIsWorkspaceManager = true
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// Card should be visible with create text
expect(screen.getByText(/createCustomTool/i)).toBeInTheDocument()
})
it('should not render anything when user is not workspace manager', () => {
mockIsWorkspaceManager = false
const { container } = render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// Container should be empty (firstChild is null when nothing renders)
expect(container.firstChild).toBeNull()
})
})
// Tests for card rendering and styling
describe('Card Rendering', () => {
it('should render without crashing', () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
expect(screen.getByText(/createCustomTool/i)).toBeInTheDocument()
})
it('should render add icon', () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// RiAddCircleFill icon should be present
const iconContainer = document.querySelector('.h-10.w-10')
expect(iconContainer).toBeInTheDocument()
})
it('should have proper card styling', () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
const card = document.querySelector('.min-h-\\[135px\\]')
expect(card).toBeInTheDocument()
expect(card).toHaveClass('cursor-pointer')
})
})
// Tests for modal interaction
describe('Modal Interaction', () => {
it('should open modal when card is clicked', () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// Click on the card area (the group div)
const cardClickArea = document.querySelector('.group.grow')
fireEvent.click(cardClickArea!)
expect(screen.getByTestId('edit-custom-collection-modal')).toBeInTheDocument()
expect(mockModalVisible).toBe(true)
})
it('should pass null payload to modal', () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
const cardClickArea = document.querySelector('.group.grow')
fireEvent.click(cardClickArea!)
expect(screen.getByTestId('modal-payload')).toHaveTextContent('null')
})
it('should close modal when onHide is called', () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// Open modal
const cardClickArea = document.querySelector('.group.grow')
fireEvent.click(cardClickArea!)
expect(screen.getByTestId('edit-custom-collection-modal')).toBeInTheDocument()
// Close modal
fireEvent.click(screen.getByTestId('close-modal'))
expect(screen.queryByTestId('edit-custom-collection-modal')).not.toBeInTheDocument()
})
})
// Tests for custom collection creation
describe('Custom Collection Creation', () => {
it('should call createCustomCollection when form is submitted', async () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// Open modal
const cardClickArea = document.querySelector('.group.grow')
fireEvent.click(cardClickArea!)
// Submit form
fireEvent.click(screen.getByTestId('submit-modal'))
await waitFor(() => {
expect(mockCreateCustomCollection).toHaveBeenCalledTimes(1)
})
})
it('should show success toast after successful creation', async () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// Open modal
const cardClickArea = document.querySelector('.group.grow')
fireEvent.click(cardClickArea!)
// Submit form
fireEvent.click(screen.getByTestId('submit-modal'))
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
})
})
})
it('should close modal after successful creation', async () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// Open modal
const cardClickArea = document.querySelector('.group.grow')
fireEvent.click(cardClickArea!)
expect(screen.getByTestId('edit-custom-collection-modal')).toBeInTheDocument()
// Submit form
fireEvent.click(screen.getByTestId('submit-modal'))
await waitFor(() => {
expect(screen.queryByTestId('edit-custom-collection-modal')).not.toBeInTheDocument()
})
})
it('should call onRefreshData after successful creation', async () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// Open modal
const cardClickArea = document.querySelector('.group.grow')
fireEvent.click(cardClickArea!)
// Submit form
fireEvent.click(screen.getByTestId('submit-modal'))
await waitFor(() => {
expect(mockOnRefreshData).toHaveBeenCalledTimes(1)
})
})
it('should pass correct data to createCustomCollection', async () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// Open modal
const cardClickArea = document.querySelector('.group.grow')
fireEvent.click(cardClickArea!)
// Submit form
fireEvent.click(screen.getByTestId('submit-modal'))
await waitFor(() => {
expect(mockCreateCustomCollection).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'test-provider',
schema_type: 'json',
}),
)
})
})
})
// Tests for edge cases
describe('Edge Cases', () => {
it('should call createCustomCollection and handle successful response', async () => {
mockCreateCustomCollection.mockResolvedValue({ success: true })
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// Open modal
const cardClickArea = document.querySelector('.group.grow')
fireEvent.click(cardClickArea!)
// Submit form
fireEvent.click(screen.getByTestId('submit-modal'))
// The API should be called
await waitFor(() => {
expect(mockCreateCustomCollection).toHaveBeenCalled()
})
// And refresh should be triggered
await waitFor(() => {
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
it('should not call onRefreshData if modal is just closed without submitting', () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
// Open modal
const cardClickArea = document.querySelector('.group.grow')
fireEvent.click(cardClickArea!)
// Close modal without submitting
fireEvent.click(screen.getByTestId('close-modal'))
expect(mockOnRefreshData).not.toHaveBeenCalled()
})
it('should handle rapid open/close of modal', () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
const cardClickArea = document.querySelector('.group.grow')
// Rapid open/close
fireEvent.click(cardClickArea!)
fireEvent.click(screen.getByTestId('close-modal'))
fireEvent.click(cardClickArea!)
expect(screen.getByTestId('edit-custom-collection-modal')).toBeInTheDocument()
})
})
// Tests for hover styling
describe('Hover Styling', () => {
it('should have hover styles on card', () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
const card = document.querySelector('.transition-all.duration-200')
expect(card).toBeInTheDocument()
})
it('should have group hover styles on icon container', () => {
render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
const iconContainer = document.querySelector('.group-hover\\:border-state-accent-hover-alt')
expect(iconContainer).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,713 @@
import type { Collection } from '../../types'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthType, CollectionType } from '../../types'
import ProviderDetail from '../detail'
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/i18n-config/language', () => ({
getLanguage: () => 'en_US',
}))
const mockIsCurrentWorkspaceManager = vi.fn(() => true)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
}),
}))
const mockSetShowModelModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowModelModal: mockSetShowModelModal,
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [
{ provider: 'model-collection-id', name: 'TestModel' },
],
}),
}))
const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([])
const mockFetchCustomToolList = vi.fn().mockResolvedValue([])
const mockFetchModelToolList = vi.fn().mockResolvedValue([])
const mockFetchCustomCollection = vi.fn().mockResolvedValue({
credentials: { auth_type: 'none' },
})
const mockFetchWorkflowToolDetail = vi.fn().mockResolvedValue({
workflow_app_id: 'wf-123',
workflow_tool_id: 'wt-456',
tool: { parameters: [], labels: [] },
})
const mockUpdateBuiltInToolCredential = vi.fn().mockResolvedValue({})
const mockRemoveBuiltInToolCredential = vi.fn().mockResolvedValue({})
const mockUpdateCustomCollection = vi.fn().mockResolvedValue({})
const mockRemoveCustomCollection = vi.fn().mockResolvedValue({})
const mockDeleteWorkflowTool = vi.fn().mockResolvedValue({})
const mockSaveWorkflowToolProvider = vi.fn().mockResolvedValue({})
vi.mock('@/service/tools', () => ({
fetchBuiltInToolList: (...args: unknown[]) => mockFetchBuiltInToolList(...args),
fetchCustomToolList: (...args: unknown[]) => mockFetchCustomToolList(...args),
fetchModelToolList: (...args: unknown[]) => mockFetchModelToolList(...args),
fetchCustomCollection: (...args: unknown[]) => mockFetchCustomCollection(...args),
fetchWorkflowToolDetail: (...args: unknown[]) => mockFetchWorkflowToolDetail(...args),
updateBuiltInToolCredential: (...args: unknown[]) => mockUpdateBuiltInToolCredential(...args),
removeBuiltInToolCredential: (...args: unknown[]) => mockRemoveBuiltInToolCredential(...args),
updateCustomCollection: (...args: unknown[]) => mockUpdateCustomCollection(...args),
removeCustomCollection: (...args: unknown[]) => mockRemoveCustomCollection(...args),
deleteWorkflowTool: (...args: unknown[]) => mockDeleteWorkflowTool(...args),
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
}))
vi.mock('@/service/use-tools', () => ({
useInvalidateAllWorkflowTools: () => vi.fn(),
}))
vi.mock('@/utils/var', () => ({
basePath: '',
}))
vi.mock('@/app/components/base/drawer', () => ({
default: ({ children, isOpen }: { children: React.ReactNode, isOpen: boolean }) =>
isOpen ? <div data-testid="drawer">{children}</div> : null,
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) =>
isShow
? (
<div data-testid="confirm-dialog">
<span>{title}</span>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
</div>
)
: null,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/app/components/header/indicator', () => ({
default: () => <span data-testid="indicator" />,
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: () => <span data-testid="card-icon" />,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName }: { orgName: string }) => <span data-testid="org-info">{orgName}</span>,
}))
vi.mock('@/app/components/plugins/card/base/title', () => ({
default: ({ title }: { title: string }) => <span data-testid="title">{title}</span>,
}))
vi.mock('../tool-item', () => ({
default: ({ tool }: { tool: { name: string } }) => <div data-testid={`tool-${tool.name}`}>{tool.name}</div>,
}))
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
default: ({ onHide, onEdit, onRemove }: { onHide: () => void, onEdit: (data: unknown) => void, onRemove: () => void }) => (
<div data-testid="edit-custom-modal">
<button data-testid="edit-save" onClick={() => onEdit({ labels: ['test'] })}>Save</button>
<button data-testid="edit-remove" onClick={onRemove}>Remove</button>
<button data-testid="edit-close" onClick={onHide}>Close</button>
</div>
),
}))
vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
default: ({ onCancel, onSaved, onRemove }: { onCancel: () => void, onSaved: (val: Record<string, string>) => Promise<void>, onRemove: () => Promise<void> }) => (
<div data-testid="config-credential">
<button data-testid="credential-save" onClick={() => onSaved({ key: 'val' })}>Save</button>
<button data-testid="credential-remove" onClick={onRemove}>Remove</button>
<button data-testid="credential-cancel" onClick={onCancel}>Cancel</button>
</div>
),
}))
vi.mock('@/app/components/tools/workflow-tool', () => ({
default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
<div data-testid="workflow-tool-modal">
<button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}>Save</button>
<button data-testid="wf-remove" onClick={onRemove}>Remove</button>
<button data-testid="wf-close" onClick={onHide}>Close</button>
</div>
),
}))
const createMockCollection = (overrides?: Partial<Collection>): Collection => ({
id: 'test-id',
name: 'test-collection',
author: 'Test Author',
description: { en_US: 'A test collection', zh_Hans: '测试集合' },
icon: 'icon-url',
label: { en_US: 'Test Collection', zh_Hans: '测试集合' },
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: ['search'],
...overrides,
})
describe('ProviderDetail', () => {
const mockOnHide = vi.fn()
const mockOnRefreshData = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockFetchBuiltInToolList.mockResolvedValue([
{ name: 'tool-1', label: { en_US: 'Tool 1' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} },
{ name: 'tool-2', label: { en_US: 'Tool 2' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} },
])
mockFetchCustomToolList.mockResolvedValue([])
mockFetchModelToolList.mockResolvedValue([])
})
afterEach(() => {
cleanup()
})
describe('Rendering', () => {
it('renders title, org info and description for a builtIn collection', async () => {
render(
<ProviderDetail
collection={createMockCollection()}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
expect(screen.getByTestId('title')).toHaveTextContent('Test Collection')
expect(screen.getByTestId('org-info')).toHaveTextContent('Test Author')
expect(screen.getByTestId('description')).toHaveTextContent('A test collection')
})
it('shows loading state initially', () => {
render(
<ProviderDetail
collection={createMockCollection()}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('renders tool list after loading for builtIn type', async () => {
render(
<ProviderDetail
collection={createMockCollection()}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('tool-tool-1')).toBeInTheDocument()
expect(screen.getByTestId('tool-tool-2')).toBeInTheDocument()
})
})
it('hides description when description is empty', () => {
render(
<ProviderDetail
collection={createMockCollection({ description: { en_US: '', zh_Hans: '' } })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
expect(screen.queryByTestId('description')).not.toBeInTheDocument()
})
})
describe('BuiltIn Collection Auth', () => {
it('shows "Set up credentials" button when not authorized and allow_delete', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: false })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
})
it('shows "Authorized" button when authorized and allow_delete', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: true })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
})
})
})
describe('Custom Collection', () => {
it('fetches custom collection and shows edit button', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: { auth_type: 'none' },
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchCustomCollection).toHaveBeenCalledWith('test-collection')
})
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
})
})
describe('Workflow Collection', () => {
it('fetches workflow tool detail and shows workflow buttons', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-id')
})
await waitFor(() => {
expect(screen.getByText('tools.openInStudio')).toBeInTheDocument()
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
})
})
describe('Model Collection', () => {
it('opens model modal when clicking auth button for model type', async () => {
mockFetchModelToolList.mockResolvedValue([
{ name: 'model-tool-1', label: { en_US: 'MT1' }, description: { en_US: '' }, parameters: [], labels: [], author: '', output_schema: {} },
])
render(
<ProviderDetail
collection={createMockCollection({
id: 'model-collection-id',
type: CollectionType.model,
is_team_authorization: false,
allow_delete: true,
})}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
expect(mockSetShowModelModal).toHaveBeenCalled()
})
})
describe('Close Action', () => {
it('calls onHide when close button is clicked', () => {
render(
<ProviderDetail
collection={createMockCollection()}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
expect(mockOnHide).toHaveBeenCalled()
})
})
describe('API calls by collection type', () => {
it('calls fetchBuiltInToolList for builtIn type', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.builtIn })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchBuiltInToolList).toHaveBeenCalledWith('test-collection')
})
})
it('calls fetchModelToolList for model type', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.model })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchModelToolList).toHaveBeenCalledWith('test-collection')
})
})
it('calls fetchCustomToolList for custom type', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchCustomToolList).toHaveBeenCalledWith('test-collection')
})
})
})
describe('BuiltIn Auth Flow', () => {
it('opens ConfigCredential when clicking auth button for builtIn type', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: false })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
})
it('saves credentials and refreshes data', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: false })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
await act(async () => {
fireEvent.click(screen.getByTestId('credential-save'))
})
await waitFor(() => {
expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test-collection', { key: 'val' })
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
it('removes credentials and refreshes data', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: false })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
await act(async () => {
fireEvent.click(screen.getByTestId('credential-remove'))
})
await waitFor(() => {
expect(mockRemoveBuiltInToolCredential).toHaveBeenCalledWith('test-collection')
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
it('opens auth modal from Authorized button for builtIn type', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: true })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.authorized'))
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
})
})
describe('Model Auth Flow', () => {
it('calls onRefreshData via model modal onSaveCallback', async () => {
render(
<ProviderDetail
collection={createMockCollection({
id: 'model-collection-id',
type: CollectionType.model,
is_team_authorization: false,
allow_delete: true,
})}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
const call = mockSetShowModelModal.mock.calls[0][0]
act(() => {
call.onSaveCallback()
})
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
describe('Custom Collection Operations', () => {
it('sets api_key_header_prefix when auth_type is apiKey and has value', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: {
auth_type: AuthType.apiKey,
api_key_value: 'secret-key',
},
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(mockFetchCustomCollection).toHaveBeenCalled()
})
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
})
it('opens edit modal and saves custom collection', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: { auth_type: 'none' },
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('edit-save'))
})
await waitFor(() => {
expect(mockUpdateCustomCollection).toHaveBeenCalledWith({ labels: ['test'] })
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
it('removes custom collection via delete confirmation', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: { auth_type: 'none' },
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
fireEvent.click(screen.getByTestId('edit-remove'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('confirm-btn'))
})
await waitFor(() => {
expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test-collection')
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
})
describe('Workflow Collection Operations', () => {
it('displays workflow tool parameters', async () => {
mockFetchWorkflowToolDetail.mockResolvedValue({
workflow_app_id: 'wf-123',
workflow_tool_id: 'wt-456',
tool: {
parameters: [
{ name: 'query', type: 'string', llm_description: 'Search query', form: 'llm', required: true },
{ name: 'limit', type: 'number', llm_description: 'Max results', form: 'form', required: false },
],
labels: ['search'],
},
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('query')).toBeInTheDocument()
expect(screen.getByText('string')).toBeInTheDocument()
expect(screen.getByText('Search query')).toBeInTheDocument()
expect(screen.getByText('limit')).toBeInTheDocument()
})
})
it('saves workflow tool via workflow modal', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('wf-save'))
})
await waitFor(() => {
expect(mockSaveWorkflowToolProvider).toHaveBeenCalledWith({ name: 'test' })
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
it('removes workflow tool via delete confirmation', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
fireEvent.click(screen.getByTestId('wf-remove'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('confirm-btn'))
})
await waitFor(() => {
expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-id')
expect(mockOnRefreshData).toHaveBeenCalled()
})
})
})
describe('Modal Close Actions', () => {
it('closes ConfigCredential when cancel is clicked', async () => {
render(
<ProviderDetail
collection={createMockCollection({ allow_delete: true, is_team_authorization: false })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.auth.unauthorized')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.auth.unauthorized'))
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('credential-cancel'))
expect(screen.queryByTestId('config-credential')).not.toBeInTheDocument()
})
it('closes EditCustomToolModal via onHide', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: { auth_type: 'none' },
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('edit-close'))
expect(screen.queryByTestId('edit-custom-modal')).not.toBeInTheDocument()
})
it('closes WorkflowToolModal via onHide', async () => {
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.workflow })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('wf-close'))
expect(screen.queryByTestId('workflow-tool-modal')).not.toBeInTheDocument()
})
})
describe('Delete Confirmation', () => {
it('cancels delete confirmation', async () => {
mockFetchCustomCollection.mockResolvedValue({
credentials: { auth_type: 'none' },
})
render(
<ProviderDetail
collection={createMockCollection({ type: CollectionType.custom })}
onHide={mockOnHide}
onRefreshData={mockOnRefreshData}
/>,
)
await waitFor(() => {
expect(screen.getByText('tools.createTool.editAction')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('tools.createTool.editAction'))
fireEvent.click(screen.getByTestId('edit-remove'))
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('cancel-btn'))
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,179 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import the mock to control it in tests
import useTheme from '@/hooks/use-theme'
import { ToolTypeEnum } from '../../../workflow/block-selector/types'
import Empty from '../empty'
// Mock useTheme hook
vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(() => ({ theme: 'light' })),
}))
describe('Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useTheme).mockReturnValue({ theme: 'light' } as ReturnType<typeof useTheme>)
})
// Tests for basic rendering scenarios
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Empty />)
expect(screen.getByText('No tools available')).toBeInTheDocument()
})
it('should render placeholder icon', () => {
render(<Empty />)
// NoToolPlaceholder should be rendered
const container = document.querySelector('.flex.flex-col')
expect(container).toBeInTheDocument()
})
it('should render fallback title when no type provided', () => {
render(<Empty />)
expect(screen.getByText('No tools available')).toBeInTheDocument()
})
})
// Tests for different type prop values
describe('Type Props', () => {
it('should render with Custom type and include link to /tools?category=api', () => {
render(<Empty type={ToolTypeEnum.Custom} />)
const link = document.querySelector('a[href="/tools?category=api"]')
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('target', '_blank')
})
it('should render with MCP type and include link to /tools?category=mcp', () => {
render(<Empty type={ToolTypeEnum.MCP} />)
const link = document.querySelector('a[href="/tools?category=mcp"]')
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('target', '_blank')
})
it('should render arrow icon for types with links', () => {
render(<Empty type={ToolTypeEnum.Custom} />)
// Check for RiArrowRightUpLine icon (has class h-3 w-3)
const arrowIcon = document.querySelector('.h-3.w-3')
expect(arrowIcon).toBeInTheDocument()
})
it('should not render link for BuiltIn type', () => {
render(<Empty type={ToolTypeEnum.BuiltIn} />)
const link = document.querySelector('a')
expect(link).not.toBeInTheDocument()
})
it('should not render link for Workflow type', () => {
render(<Empty type={ToolTypeEnum.Workflow} />)
const link = document.querySelector('a')
expect(link).not.toBeInTheDocument()
})
})
// Tests for isAgent prop
describe('isAgent Prop', () => {
it('should render as agent without link', () => {
render(<Empty type={ToolTypeEnum.Custom} isAgent />)
// When isAgent is true, no link should be rendered
const link = document.querySelector('a')
expect(link).not.toBeInTheDocument()
})
it('should not render tip text when isAgent is true', () => {
render(<Empty type={ToolTypeEnum.Custom} isAgent />)
// Arrow icon should not be present when isAgent is true
const arrowIcon = document.querySelector('.h-3.w-3')
expect(arrowIcon).not.toBeInTheDocument()
})
})
// Tests for theme-based styling
describe('Theme Support', () => {
it('should not apply invert class in light theme', () => {
vi.mocked(useTheme).mockReturnValue({ theme: 'light' } as ReturnType<typeof useTheme>)
render(<Empty />)
// The NoToolPlaceholder should not have 'invert' class in light mode
// We check the first svg or container within the component
const placeholder = document.querySelector('.flex.flex-col > *:first-child')
expect(placeholder).not.toHaveClass('invert')
})
it('should apply invert class in dark theme', () => {
vi.mocked(useTheme).mockReturnValue({ theme: 'dark' } as ReturnType<typeof useTheme>)
render(<Empty />)
// The NoToolPlaceholder should have 'invert' class in dark mode
const placeholder = document.querySelector('.invert')
expect(placeholder).toBeInTheDocument()
})
})
// Tests for translation key handling
describe('Translation Keys', () => {
it('should use correct translation namespace for tools', () => {
render(<Empty type={ToolTypeEnum.Custom} />)
// The component should render translation keys with 'tools' namespace
// Translation mock returns the key itself
expect(screen.getByText(/addToolModal\.custom\.title/i)).toBeInTheDocument()
})
it('should render tip text for types with hasTitle', () => {
render(<Empty type={ToolTypeEnum.Custom} />)
// Should show the tip text with translation key
expect(screen.getByText(/addToolModal\.custom\.tip/i)).toBeInTheDocument()
})
})
// Tests for edge cases
describe('Edge Cases', () => {
it('should handle undefined type gracefully', () => {
render(<Empty type={undefined} />)
expect(screen.getByText('No tools available')).toBeInTheDocument()
})
it('should handle All type without link', () => {
render(<Empty type={ToolTypeEnum.All} />)
const link = document.querySelector('a')
expect(link).not.toBeInTheDocument()
})
})
// Tests for link styling
describe('Link Styling', () => {
it('should apply hover styling classes to link', () => {
render(<Empty type={ToolTypeEnum.Custom} />)
const link = document.querySelector('a')
expect(link).toHaveClass('cursor-pointer')
expect(link).toHaveClass('hover:text-text-accent')
})
it('should render div instead of link when hasLink is false', () => {
render(<Empty type={ToolTypeEnum.BuiltIn} />)
// No anchor tags should be rendered
const anchors = document.querySelectorAll('a')
expect(anchors.length).toBe(0)
})
})
})

View File

@ -0,0 +1,279 @@
import type { Collection, Tool } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ToolItem from '../tool-item'
// Mock useLocale hook
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
// Mock getLanguage - returns key format used in TypeWithI18N (en_US, not en-US)
vi.mock('@/i18n-config/language', () => ({
getLanguage: () => 'en_US',
}))
// Track modal visibility for assertions
let mockModalVisible = false
// Mock SettingBuiltInTool modal - complex component that needs mocking
vi.mock('@/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool', () => ({
default: ({ onHide, collection, toolName, readonly, isBuiltIn, isModel }: {
onHide: () => void
collection: Collection
toolName: string
readonly: boolean
isBuiltIn: boolean
isModel: boolean
}) => {
mockModalVisible = true
return (
<div data-testid="setting-built-in-tool-modal">
<span data-testid="modal-tool-name">{toolName}</span>
<span data-testid="modal-collection-id">{collection.id}</span>
<span data-testid="modal-readonly">{readonly.toString()}</span>
<span data-testid="modal-is-builtin">{isBuiltIn.toString()}</span>
<span data-testid="modal-is-model">{isModel.toString()}</span>
<button data-testid="close-modal" onClick={onHide}>Close</button>
</div>
)
},
}))
describe('ToolItem', () => {
// Factory function for creating mock collection
const createMockCollection = (overrides?: Partial<Collection>): Collection => ({
id: 'test-collection-id',
name: 'test-collection',
author: 'Test Author',
description: { en_US: 'Test collection description', zh_Hans: '测试集合描述' },
icon: '🔧',
label: { en_US: 'Test Collection', zh_Hans: '测试集合' },
type: 'builtin',
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
...overrides,
})
// Factory function for creating mock tool
const createMockTool = (overrides?: Partial<Tool>): Tool => ({
name: 'test-tool',
author: 'Test Author',
label: {
en_US: 'Test Tool Label',
zh_Hans: '测试工具标签',
},
description: {
en_US: 'Test tool description for testing purposes',
zh_Hans: '测试工具描述',
},
parameters: [],
labels: [],
output_schema: {},
...overrides,
})
const defaultProps = {
collection: createMockCollection(),
tool: createMockTool(),
isBuiltIn: true,
isModel: false,
}
beforeEach(() => {
vi.clearAllMocks()
mockModalVisible = false
})
// Tests for basic rendering
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ToolItem {...defaultProps} />)
expect(screen.getByText('Test Tool Label')).toBeInTheDocument()
})
it('should display tool label in current language', () => {
render(<ToolItem {...defaultProps} />)
expect(screen.getByText('Test Tool Label')).toBeInTheDocument()
})
it('should display tool description in current language', () => {
render(<ToolItem {...defaultProps} />)
expect(screen.getByText('Test tool description for testing purposes')).toBeInTheDocument()
})
it('should have cursor-pointer class by default', () => {
render(<ToolItem {...defaultProps} />)
const card = document.querySelector('.cursor-pointer')
expect(card).toBeInTheDocument()
})
it('should have correct card styling', () => {
render(<ToolItem {...defaultProps} />)
const card = document.querySelector('.rounded-xl.border-\\[0\\.5px\\]')
expect(card).toBeInTheDocument()
})
})
// Tests for disabled state
describe('Disabled State', () => {
it('should apply disabled styles when disabled is true', () => {
render(<ToolItem {...defaultProps} disabled />)
const card = document.querySelector('.opacity-50')
expect(card).toBeInTheDocument()
})
it('should have cursor-not-allowed when disabled', () => {
render(<ToolItem {...defaultProps} disabled />)
const card = document.querySelector('.\\!cursor-not-allowed')
expect(card).toBeInTheDocument()
})
it('should not open modal when clicking disabled card', () => {
render(<ToolItem {...defaultProps} disabled />)
const card = document.querySelector('.rounded-xl')
fireEvent.click(card!)
expect(screen.queryByTestId('setting-built-in-tool-modal')).not.toBeInTheDocument()
expect(mockModalVisible).toBe(false)
})
})
// Tests for click interaction and modal
describe('Click Interaction', () => {
it('should open detail modal on click', () => {
render(<ToolItem {...defaultProps} />)
const card = document.querySelector('.rounded-xl')
fireEvent.click(card!)
expect(screen.getByTestId('setting-built-in-tool-modal')).toBeInTheDocument()
expect(mockModalVisible).toBe(true)
})
it('should pass correct props to modal', () => {
render(<ToolItem {...defaultProps} />)
const card = document.querySelector('.rounded-xl')
fireEvent.click(card!)
expect(screen.getByTestId('modal-tool-name')).toHaveTextContent('test-tool')
expect(screen.getByTestId('modal-collection-id')).toHaveTextContent('test-collection-id')
expect(screen.getByTestId('modal-readonly')).toHaveTextContent('true')
expect(screen.getByTestId('modal-is-builtin')).toHaveTextContent('true')
expect(screen.getByTestId('modal-is-model')).toHaveTextContent('false')
})
it('should close modal when onHide is called', () => {
render(<ToolItem {...defaultProps} />)
// Open modal
const card = document.querySelector('.rounded-xl')
fireEvent.click(card!)
expect(screen.getByTestId('setting-built-in-tool-modal')).toBeInTheDocument()
// Close modal
fireEvent.click(screen.getByTestId('close-modal'))
expect(screen.queryByTestId('setting-built-in-tool-modal')).not.toBeInTheDocument()
})
})
// Tests for different prop combinations
describe('Props Variations', () => {
it('should pass isBuiltIn=false to modal when not built-in', () => {
render(<ToolItem {...defaultProps} isBuiltIn={false} />)
const card = document.querySelector('.rounded-xl')
fireEvent.click(card!)
expect(screen.getByTestId('modal-is-builtin')).toHaveTextContent('false')
})
it('should pass isModel=true to modal when it is a model tool', () => {
render(<ToolItem {...defaultProps} isModel />)
const card = document.querySelector('.rounded-xl')
fireEvent.click(card!)
expect(screen.getByTestId('modal-is-model')).toHaveTextContent('true')
})
it('should handle tool with different collection', () => {
const customCollection = createMockCollection({
id: 'custom-collection',
name: 'Custom Collection',
})
render(<ToolItem {...defaultProps} collection={customCollection} />)
const card = document.querySelector('.rounded-xl')
fireEvent.click(card!)
expect(screen.getByTestId('modal-collection-id')).toHaveTextContent('custom-collection')
})
})
// Tests for edge cases
describe('Edge Cases', () => {
it('should handle tool with empty description', () => {
const toolWithEmptyDesc = createMockTool({
description: { 'en-US': '' },
})
render(<ToolItem {...defaultProps} tool={toolWithEmptyDesc} />)
expect(screen.getByText('Test Tool Label')).toBeInTheDocument()
})
it('should handle missing language in label', () => {
const toolWithMissingLang = createMockTool({
label: { en_US: '', zh_Hans: '中文标签' },
description: { en_US: '', zh_Hans: '中文描述' },
})
// Should render without crashing (will show empty string for missing en_US)
render(<ToolItem {...defaultProps} tool={toolWithMissingLang} />)
const card = document.querySelector('.rounded-xl')
expect(card).toBeInTheDocument()
})
it('should show description title attribute', () => {
render(<ToolItem {...defaultProps} />)
const descriptionElement = screen.getByText('Test tool description for testing purposes')
expect(descriptionElement).toHaveAttribute('title', 'Test tool description for testing purposes')
})
it('should apply line-clamp-2 to description for text overflow', () => {
render(<ToolItem {...defaultProps} />)
const descriptionElement = document.querySelector('.line-clamp-2')
expect(descriptionElement).toBeInTheDocument()
})
})
// Tests for accessibility
describe('Accessibility', () => {
it('should be clickable with keyboard', () => {
render(<ToolItem {...defaultProps} />)
const card = document.querySelector('.rounded-xl')
// The div is clickable, test that it can receive focus-like interaction
fireEvent.click(card!)
expect(screen.getByTestId('setting-built-in-tool-modal')).toBeInTheDocument()
})
})
})