mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox
# Conflicts: # api/uv.lock # web/app/components/apps/__tests__/app-card.spec.tsx # web/app/components/apps/__tests__/list.spec.tsx # web/app/components/datasets/create/__tests__/index.spec.tsx # web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx # web/app/components/plugins/readme-panel/__tests__/index.spec.tsx # web/app/components/rag-pipeline/__tests__/index.spec.tsx # web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts # web/eslint-suppressions.json
This commit is contained in:
@ -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,
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user