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

@ -18,9 +18,9 @@ import {
ToolItem,
ToolSettingsPanel,
ToolTrigger,
} from './components'
import { usePluginInstalledCheck, useToolSelectorState } from './hooks'
import ToolSelector from './index'
} from '../components'
import { usePluginInstalledCheck, useToolSelectorState } from '../hooks'
import ToolSelector from '../index'
// ==================== Mock Setup ====================
@ -181,11 +181,11 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
),
}))
vi.mock('../../../readme-panel/entrance', () => ({
vi.mock('../../../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
vi.mock('./components/reasoning-config-form', () => ({
vi.mock('../components/reasoning-config-form', () => ({
default: ({
onChange,
value,

View File

@ -0,0 +1,107 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, disabled, placeholder }: {
value?: string
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
disabled?: boolean
placeholder?: string
}) => (
<textarea
data-testid="description-textarea"
value={value || ''}
onChange={onChange}
disabled={disabled}
placeholder={placeholder}
/>
),
}))
vi.mock('../../../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({
default: ({ trigger }: { trigger: React.ReactNode }) => (
<div data-testid="tool-picker">{trigger}</div>
),
}))
vi.mock('../tool-trigger', () => ({
default: ({ value, provider }: { open?: boolean, value?: unknown, provider?: unknown }) => (
<div data-testid="tool-trigger" data-has-value={!!value} data-has-provider={!!provider} />
),
}))
const mockOnDescriptionChange = vi.fn()
const mockOnShowChange = vi.fn()
const mockOnSelectTool = vi.fn()
const mockOnSelectMultipleTool = vi.fn()
const defaultProps = {
isShowChooseTool: false,
hasTrigger: true,
onShowChange: mockOnShowChange,
onSelectTool: mockOnSelectTool,
onSelectMultipleTool: mockOnSelectMultipleTool,
onDescriptionChange: mockOnDescriptionChange,
}
describe('ToolBaseForm', () => {
let ToolBaseForm: (typeof import('../tool-base-form'))['default']
beforeEach(async () => {
vi.clearAllMocks()
const mod = await import('../tool-base-form')
ToolBaseForm = mod.default
})
it('should render tool trigger within tool picker', () => {
render(<ToolBaseForm {...defaultProps} />)
expect(screen.getByTestId('tool-trigger')).toBeInTheDocument()
expect(screen.getByTestId('tool-picker')).toBeInTheDocument()
})
it('should render description textarea', () => {
render(<ToolBaseForm {...defaultProps} />)
expect(screen.getByTestId('description-textarea')).toBeInTheDocument()
})
it('should disable textarea when no provider_name in value', () => {
render(<ToolBaseForm {...defaultProps} />)
expect(screen.getByTestId('description-textarea')).toBeDisabled()
})
it('should enable textarea when value has provider_name', () => {
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
render(<ToolBaseForm {...defaultProps} value={value} />)
expect(screen.getByTestId('description-textarea')).not.toBeDisabled()
})
it('should call onDescriptionChange when textarea content changes', () => {
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
render(<ToolBaseForm {...defaultProps} value={value} />)
fireEvent.change(screen.getByTestId('description-textarea'), { target: { value: 'Updated' } })
expect(mockOnDescriptionChange).toHaveBeenCalled()
})
it('should show ReadmeEntrance when provider has plugin_unique_identifier', () => {
const provider = { plugin_unique_identifier: 'test/plugin' } as never
render(<ToolBaseForm {...defaultProps} currentProvider={provider} />)
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
})
it('should not show ReadmeEntrance without plugin_unique_identifier', () => {
render(<ToolBaseForm {...defaultProps} />)
expect(screen.queryByTestId('readme-entrance')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,113 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (obj: Record<string, string> | string) => typeof obj === 'string' ? obj : obj?.en_US || '',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
useToastContext: () => ({ notify: vi.fn() }),
}))
const mockFormSchemas = [
{ name: 'api_key', label: { en_US: 'API Key' }, type: 'secret-input', required: true },
]
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
addDefaultValue: (values: Record<string, unknown>) => values,
toolCredentialToFormSchemas: () => mockFormSchemas,
}))
vi.mock('@/service/tools', () => ({
fetchBuiltInToolCredential: vi.fn().mockResolvedValue({ api_key: 'sk-existing-key' }),
fetchBuiltInToolCredentialSchema: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
default: ({ value: _value, onChange }: { formSchemas: unknown[], value: Record<string, unknown>, onChange: (v: Record<string, unknown>) => void }) => (
<div data-testid="credential-form">
<input
data-testid="form-input"
onChange={e => onChange({ api_key: e.target.value })}
/>
</div>
),
}))
describe('ToolCredentialForm', () => {
let ToolCredentialForm: (typeof import('../tool-credentials-form'))['default']
beforeEach(async () => {
vi.clearAllMocks()
const mod = await import('../tool-credentials-form')
ToolCredentialForm = mod.default
})
it('should render loading state initially', async () => {
await act(async () => {
render(
<ToolCredentialForm
collection={{ id: 'test', name: 'Test', labels: [] } as never}
onCancel={vi.fn()}
onSaved={vi.fn()}
/>,
)
})
// After act resolves async effects, form should be loaded
expect(screen.getByTestId('credential-form')).toBeInTheDocument()
})
it('should render form after loading', async () => {
await act(async () => {
render(
<ToolCredentialForm
collection={{ id: 'test', name: 'Test', labels: [] } as never}
onCancel={vi.fn()}
onSaved={vi.fn()}
/>,
)
})
expect(screen.getByTestId('credential-form')).toBeInTheDocument()
})
it('should call onCancel when cancel button clicked', async () => {
const mockOnCancel = vi.fn()
await act(async () => {
render(
<ToolCredentialForm
collection={{ id: 'test', name: 'Test', labels: [] } as never}
onCancel={mockOnCancel}
onSaved={vi.fn()}
/>,
)
})
const cancelBtn = screen.getByText('common.operation.cancel')
fireEvent.click(cancelBtn)
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onSaved when save button clicked', async () => {
const mockOnSaved = vi.fn()
await act(async () => {
render(
<ToolCredentialForm
collection={{ id: 'test', name: 'Test', labels: [] } as never}
onCancel={vi.fn()}
onSaved={mockOnSaved}
/>,
)
})
fireEvent.click(screen.getByText('common.operation.save'))
expect(mockOnSaved).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,63 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { usePluginInstalledCheck } from '../use-plugin-installed-check'
const mockManifest = {
data: {
plugin: {
name: 'test-plugin',
version: '1.0.0',
},
},
}
vi.mock('@/service/use-plugins', () => ({
usePluginManifestInfo: (pluginID: string) => ({
data: pluginID ? mockManifest : undefined,
}),
}))
describe('usePluginInstalledCheck', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should extract pluginID from provider name', () => {
const { result } = renderHook(() => usePluginInstalledCheck('org/plugin/tool'))
expect(result.current.pluginID).toBe('org/plugin')
})
it('should detect plugin in marketplace when manifest exists', () => {
const { result } = renderHook(() => usePluginInstalledCheck('org/plugin/tool'))
expect(result.current.inMarketPlace).toBe(true)
expect(result.current.manifest).toEqual(mockManifest.data.plugin)
})
it('should handle empty provider name', () => {
const { result } = renderHook(() => usePluginInstalledCheck(''))
expect(result.current.pluginID).toBe('')
expect(result.current.inMarketPlace).toBe(false)
})
it('should handle undefined provider name', () => {
const { result } = renderHook(() => usePluginInstalledCheck())
expect(result.current.pluginID).toBe('')
expect(result.current.inMarketPlace).toBe(false)
})
it('should handle provider name with only one segment', () => {
const { result } = renderHook(() => usePluginInstalledCheck('single'))
expect(result.current.pluginID).toBe('single')
})
it('should handle provider name with two segments', () => {
const { result } = renderHook(() => usePluginInstalledCheck('org/plugin'))
expect(result.current.pluginID).toBe('org/plugin')
})
})

View File

@ -0,0 +1,226 @@
import type * as React from 'react'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useToolSelectorState } from '../use-tool-selector-state'
const mockToolParams = [
{ name: 'param1', form: 'llm', type: 'string', required: true, label: { en_US: 'Param 1' } },
{ name: 'param2', form: 'form', type: 'number', required: false, label: { en_US: 'Param 2' } },
]
const mockTools = [
{
id: 'test-provider',
name: 'Test Provider',
tools: [
{
name: 'test-tool',
label: { en_US: 'Test Tool' },
description: { en_US: 'A test tool' },
parameters: mockToolParams,
},
],
},
]
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: mockTools }),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
useInvalidateAllBuiltInTools: () => vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/service/use-plugins', () => ({
useInvalidateInstalledPluginList: () => vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../use-plugin-installed-check', () => ({
usePluginInstalledCheck: () => ({
inMarketPlace: false,
manifest: null,
pluginID: '',
}),
}))
vi.mock('@/utils/get-icon', () => ({
getIconFromMarketPlace: () => '',
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
toolParametersToFormSchemas: (params: unknown[]) => (params as Record<string, unknown>[]).map(p => ({
...p,
variable: p.name,
})),
generateFormValue: (value: Record<string, unknown>) => value || {},
getPlainValue: (value: Record<string, unknown>) => value || {},
getStructureValue: (value: Record<string, unknown>) => value || {},
}))
describe('useToolSelectorState', () => {
const mockOnSelect = vi.fn()
const _mockOnSelectMultiple = vi.fn()
const toolValue: ToolValue = {
provider_name: 'test-provider',
provider_show_name: 'Test Provider',
tool_name: 'test-tool',
tool_label: 'Test Tool',
tool_description: 'A test tool',
settings: {},
parameters: {},
enabled: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should initialize with default panel states', () => {
const { result } = renderHook(() =>
useToolSelectorState({ onSelect: mockOnSelect }),
)
expect(result.current.isShow).toBe(false)
expect(result.current.isShowChooseTool).toBe(false)
expect(result.current.currType).toBe('settings')
})
it('should find current provider from tool value', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
)
expect(result.current.currentProvider).toBeDefined()
expect(result.current.currentProvider?.id).toBe('test-provider')
})
it('should find current tool from provider', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
)
expect(result.current.currentTool).toBeDefined()
expect(result.current.currentTool?.name).toBe('test-tool')
})
it('should compute tool settings and params correctly', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
)
// param2 has form='form' (not 'llm'), so it goes to settings
expect(result.current.currentToolSettings).toHaveLength(1)
expect(result.current.currentToolSettings[0].name).toBe('param2')
// param1 has form='llm', so it goes to params
expect(result.current.currentToolParams).toHaveLength(1)
expect(result.current.currentToolParams[0].name).toBe('param1')
})
it('should show tab slider when both settings and params exist', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
)
expect(result.current.showTabSlider).toBe(true)
expect(result.current.userSettingsOnly).toBe(false)
expect(result.current.reasoningConfigOnly).toBe(false)
})
it('should toggle panel visibility', () => {
const { result } = renderHook(() =>
useToolSelectorState({ onSelect: mockOnSelect }),
)
act(() => {
result.current.setIsShow(true)
})
expect(result.current.isShow).toBe(true)
act(() => {
result.current.setIsShowChooseTool(true)
})
expect(result.current.isShowChooseTool).toBe(true)
})
it('should switch tab type', () => {
const { result } = renderHook(() =>
useToolSelectorState({ onSelect: mockOnSelect }),
)
act(() => {
result.current.setCurrType('params')
})
expect(result.current.currType).toBe('params')
})
it('should handle description change', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
)
const event = { target: { value: 'New description' } } as React.ChangeEvent<HTMLTextAreaElement>
act(() => {
result.current.handleDescriptionChange(event)
})
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
extra: expect.objectContaining({ description: 'New description' }),
}))
})
it('should handle enabled change', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
)
act(() => {
result.current.handleEnabledChange(false)
})
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
enabled: false,
}))
})
it('should handle authorization item click', () => {
const { result } = renderHook(() =>
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
)
act(() => {
result.current.handleAuthorizationItemClick('cred-123')
})
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
credential_id: 'cred-123',
}))
})
it('should not call onSelect if value is undefined', () => {
const { result } = renderHook(() =>
useToolSelectorState({ onSelect: mockOnSelect }),
)
act(() => {
result.current.handleEnabledChange(true)
})
expect(mockOnSelect).not.toHaveBeenCalled()
})
it('should return empty arrays when no provider matches', () => {
const { result } = renderHook(() =>
useToolSelectorState({
value: { ...toolValue, provider_name: 'nonexistent' },
onSelect: mockOnSelect,
}),
)
expect(result.current.currentProvider).toBeUndefined()
expect(result.current.currentTool).toBeUndefined()
expect(result.current.currentToolSettings).toEqual([])
expect(result.current.currentToolParams).toEqual([])
})
})