mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 14:19:28 +00:00
chore: tests for app agent configures (#29789)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import React from 'react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import AgentSetting from './index'
|
||||
import { MAX_ITERATIONS_NUM } from '@/config'
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('ahooks', () => {
|
||||
const actual = jest.requireActual('ahooks')
|
||||
return {
|
||||
...actual,
|
||||
useClickAway: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('react-slider', () => (props: { className?: string; min?: number; max?: number; value: number; onChange: (value: number) => void }) => (
|
||||
<input
|
||||
type="range"
|
||||
className={props.className}
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
value={props.value}
|
||||
onChange={e => props.onChange(Number(e.target.value))}
|
||||
/>
|
||||
))
|
||||
|
||||
const basePayload = {
|
||||
enabled: true,
|
||||
strategy: 'react',
|
||||
max_iteration: 5,
|
||||
tools: [],
|
||||
}
|
||||
|
||||
const renderModal = (props?: Partial<React.ComponentProps<typeof AgentSetting>>) => {
|
||||
const onCancel = jest.fn()
|
||||
const onSave = jest.fn()
|
||||
const utils = render(
|
||||
<AgentSetting
|
||||
isChatModel
|
||||
payload={basePayload as AgentConfig}
|
||||
isFunctionCall={false}
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
return { ...utils, onCancel, onSave }
|
||||
}
|
||||
|
||||
describe('AgentSetting', () => {
|
||||
test('should render agent mode description and default prompt section when not function call', () => {
|
||||
renderModal()
|
||||
|
||||
expect(screen.getByText('appDebug.agent.agentMode')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.builtInPromptTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should display function call mode when isFunctionCall true', () => {
|
||||
renderModal({ isFunctionCall: true })
|
||||
|
||||
expect(screen.getByText('appDebug.agent.agentModeType.functionCall')).toBeInTheDocument()
|
||||
expect(screen.queryByText('tools.builtInPromptTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should update iteration via slider and number input', () => {
|
||||
const { container } = renderModal()
|
||||
const slider = container.querySelector('.slider') as HTMLInputElement
|
||||
const numberInput = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(slider, { target: { value: '7' } })
|
||||
expect(screen.getAllByDisplayValue('7')).toHaveLength(2)
|
||||
|
||||
fireEvent.change(numberInput, { target: { value: '2' } })
|
||||
expect(screen.getAllByDisplayValue('2')).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('should clamp iteration value within min/max range', () => {
|
||||
renderModal()
|
||||
|
||||
const numberInput = screen.getByRole('spinbutton')
|
||||
|
||||
fireEvent.change(numberInput, { target: { value: '0' } })
|
||||
expect(screen.getAllByDisplayValue('1')).toHaveLength(2)
|
||||
|
||||
fireEvent.change(numberInput, { target: { value: '999' } })
|
||||
expect(screen.getAllByDisplayValue(String(MAX_ITERATIONS_NUM))).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('should call onCancel when cancel button clicked', () => {
|
||||
const { onCancel } = renderModal()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should call onSave with updated payload', async () => {
|
||||
const { onSave } = renderModal()
|
||||
const numberInput = screen.getByRole('spinbutton')
|
||||
fireEvent.change(numberInput, { target: { value: '6' } })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ max_iteration: 6 }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ItemPanel from './item-panel'
|
||||
|
||||
describe('AgentSetting/ItemPanel', () => {
|
||||
test('should render icon, name, and children content', () => {
|
||||
render(
|
||||
<ItemPanel
|
||||
className="custom"
|
||||
icon={<span>icon</span>}
|
||||
name="Panel name"
|
||||
description="More info"
|
||||
children={<div>child content</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Panel name')).toBeInTheDocument()
|
||||
expect(screen.getByText('child content')).toBeInTheDocument()
|
||||
expect(screen.getByText('icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,466 @@
|
||||
import type {
|
||||
PropsWithChildren,
|
||||
} from 'react'
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import AgentTools from './index'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import type { AgentTool } from '@/types/app'
|
||||
import { CollectionType, type Tool, type ToolParameter } from '@/app/components/tools/types'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
|
||||
import type { ModelConfig } from '@/models/debug'
|
||||
import { ModelModeType } from '@/types/app'
|
||||
import {
|
||||
DEFAULT_AGENT_SETTING,
|
||||
DEFAULT_CHAT_PROMPT_CONFIG,
|
||||
DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
} from '@/config'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import type ToolPickerType from '@/app/components/workflow/block-selector/tool-picker'
|
||||
import type SettingBuiltInToolType from './setting-built-in-tool'
|
||||
|
||||
const formattingDispatcherMock = jest.fn()
|
||||
jest.mock('@/app/components/app/configuration/debug/hooks', () => ({
|
||||
useFormattingChangedDispatcher: () => formattingDispatcherMock,
|
||||
}))
|
||||
|
||||
let pluginInstallHandler: ((names: string[]) => void) | null = null
|
||||
const subscribeMock = jest.fn((event: string, handler: any) => {
|
||||
if (event === 'plugin:install:success')
|
||||
pluginInstallHandler = handler
|
||||
})
|
||||
jest.mock('@/context/mitt-context', () => ({
|
||||
useMittContextSelector: (selector: any) => selector({
|
||||
useSubscribe: subscribeMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
let builtInTools: ToolWithProvider[] = []
|
||||
let customTools: ToolWithProvider[] = []
|
||||
let workflowTools: ToolWithProvider[] = []
|
||||
let mcpTools: ToolWithProvider[] = []
|
||||
jest.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => ({ data: builtInTools }),
|
||||
useAllCustomTools: () => ({ data: customTools }),
|
||||
useAllWorkflowTools: () => ({ data: workflowTools }),
|
||||
useAllMCPTools: () => ({ data: mcpTools }),
|
||||
}))
|
||||
|
||||
type ToolPickerProps = React.ComponentProps<typeof ToolPickerType>
|
||||
let singleToolSelection: ToolDefaultValue | null = null
|
||||
let multipleToolSelection: ToolDefaultValue[] = []
|
||||
const ToolPickerMock = (props: ToolPickerProps) => (
|
||||
<div data-testid="tool-picker">
|
||||
<div>{props.trigger}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => singleToolSelection && props.onSelect(singleToolSelection)}
|
||||
>
|
||||
pick-single
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onSelectMultiple(multipleToolSelection)}
|
||||
>
|
||||
pick-multiple
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
jest.mock('@/app/components/workflow/block-selector/tool-picker', () => ({
|
||||
__esModule: true,
|
||||
default: (props: ToolPickerProps) => <ToolPickerMock {...props} />,
|
||||
}))
|
||||
|
||||
type SettingBuiltInToolProps = React.ComponentProps<typeof SettingBuiltInToolType>
|
||||
let latestSettingPanelProps: SettingBuiltInToolProps | null = null
|
||||
let settingPanelSavePayload: Record<string, any> = {}
|
||||
let settingPanelCredentialId = 'credential-from-panel'
|
||||
const SettingBuiltInToolMock = (props: SettingBuiltInToolProps) => {
|
||||
latestSettingPanelProps = props
|
||||
return (
|
||||
<div data-testid="setting-built-in-tool">
|
||||
<span>{props.toolName}</span>
|
||||
<button type="button" onClick={() => props.onSave?.(settingPanelSavePayload)}>save-from-panel</button>
|
||||
<button type="button" onClick={() => props.onAuthorizationItemClick?.(settingPanelCredentialId)}>auth-from-panel</button>
|
||||
<button type="button" onClick={props.onHide}>close-panel</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
jest.mock('./setting-built-in-tool', () => ({
|
||||
__esModule: true,
|
||||
default: (props: SettingBuiltInToolProps) => <SettingBuiltInToolMock {...props} />,
|
||||
}))
|
||||
|
||||
jest.mock('copy-to-clipboard')
|
||||
|
||||
const copyMock = copy as jest.Mock
|
||||
|
||||
const createToolParameter = (overrides?: Partial<ToolParameter>): ToolParameter => ({
|
||||
name: 'api_key',
|
||||
label: {
|
||||
en_US: 'API Key',
|
||||
zh_Hans: 'API Key',
|
||||
},
|
||||
human_description: {
|
||||
en_US: 'desc',
|
||||
zh_Hans: 'desc',
|
||||
},
|
||||
type: 'string',
|
||||
form: 'config',
|
||||
llm_description: '',
|
||||
required: true,
|
||||
multiple: false,
|
||||
default: 'default',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createToolDefinition = (overrides?: Partial<Tool>): Tool => ({
|
||||
name: 'search',
|
||||
author: 'tester',
|
||||
label: {
|
||||
en_US: 'Search',
|
||||
zh_Hans: 'Search',
|
||||
},
|
||||
description: {
|
||||
en_US: 'desc',
|
||||
zh_Hans: 'desc',
|
||||
},
|
||||
parameters: [createToolParameter()],
|
||||
labels: [],
|
||||
output_schema: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createCollection = (overrides?: Partial<ToolWithProvider>): ToolWithProvider => ({
|
||||
id: overrides?.id || 'provider-1',
|
||||
name: overrides?.name || 'vendor/provider-1',
|
||||
author: 'tester',
|
||||
description: {
|
||||
en_US: 'desc',
|
||||
zh_Hans: 'desc',
|
||||
},
|
||||
icon: 'https://example.com/icon.png',
|
||||
label: {
|
||||
en_US: 'Provider Label',
|
||||
zh_Hans: 'Provider Label',
|
||||
},
|
||||
type: overrides?.type || CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: true,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
tools: overrides?.tools || [createToolDefinition()],
|
||||
meta: {
|
||||
version: '1.0.0',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createAgentTool = (overrides?: Partial<AgentTool>): AgentTool => ({
|
||||
provider_id: overrides?.provider_id || 'provider-1',
|
||||
provider_type: overrides?.provider_type || CollectionType.builtIn,
|
||||
provider_name: overrides?.provider_name || 'vendor/provider-1',
|
||||
tool_name: overrides?.tool_name || 'search',
|
||||
tool_label: overrides?.tool_label || 'Search Tool',
|
||||
tool_parameters: overrides?.tool_parameters || { api_key: 'key' },
|
||||
enabled: overrides?.enabled ?? true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createModelConfig = (tools: AgentTool[]): ModelConfig => ({
|
||||
provider: 'OPENAI',
|
||||
model_id: 'gpt-3.5-turbo',
|
||||
mode: ModelModeType.chat,
|
||||
configs: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [],
|
||||
},
|
||||
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
opening_statement: '',
|
||||
more_like_this: null,
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: null,
|
||||
speech_to_text: null,
|
||||
text_to_speech: null,
|
||||
file_upload: null,
|
||||
retriever_resource: null,
|
||||
sensitive_word_avoidance: null,
|
||||
annotation_reply: null,
|
||||
external_data_tools: [],
|
||||
system_parameters: {
|
||||
audio_file_size_limit: 0,
|
||||
file_size_limit: 0,
|
||||
image_file_size_limit: 0,
|
||||
video_file_size_limit: 0,
|
||||
workflow_file_upload_limit: 0,
|
||||
},
|
||||
dataSets: [],
|
||||
agentConfig: {
|
||||
...DEFAULT_AGENT_SETTING,
|
||||
tools,
|
||||
},
|
||||
})
|
||||
|
||||
const renderAgentTools = (initialTools?: AgentTool[]) => {
|
||||
const tools = initialTools ?? [createAgentTool()]
|
||||
const modelConfigRef = { current: createModelConfig(tools) }
|
||||
const Wrapper = ({ children }: PropsWithChildren) => {
|
||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(modelConfigRef.current)
|
||||
useEffect(() => {
|
||||
modelConfigRef.current = modelConfig
|
||||
}, [modelConfig])
|
||||
const value = useMemo(() => ({
|
||||
modelConfig,
|
||||
setModelConfig,
|
||||
}), [modelConfig])
|
||||
return (
|
||||
<ConfigContext.Provider value={value as any}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
const renderResult = render(
|
||||
<Wrapper>
|
||||
<AgentTools />
|
||||
</Wrapper>,
|
||||
)
|
||||
return {
|
||||
...renderResult,
|
||||
getModelConfig: () => modelConfigRef.current,
|
||||
}
|
||||
}
|
||||
|
||||
const hoverInfoIcon = async (rowIndex = 0) => {
|
||||
const rows = document.querySelectorAll('.group')
|
||||
const infoTrigger = rows.item(rowIndex)?.querySelector('[data-testid="tool-info-tooltip"]')
|
||||
if (!infoTrigger)
|
||||
throw new Error('Info trigger not found')
|
||||
await userEvent.hover(infoTrigger as HTMLElement)
|
||||
}
|
||||
|
||||
describe('AgentTools', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
builtInTools = [
|
||||
createCollection(),
|
||||
createCollection({
|
||||
id: 'provider-2',
|
||||
name: 'vendor/provider-2',
|
||||
tools: [createToolDefinition({
|
||||
name: 'translate',
|
||||
label: {
|
||||
en_US: 'Translate',
|
||||
zh_Hans: 'Translate',
|
||||
},
|
||||
})],
|
||||
}),
|
||||
createCollection({
|
||||
id: 'provider-3',
|
||||
name: 'vendor/provider-3',
|
||||
tools: [createToolDefinition({
|
||||
name: 'summarize',
|
||||
label: {
|
||||
en_US: 'Summary',
|
||||
zh_Hans: 'Summary',
|
||||
},
|
||||
})],
|
||||
}),
|
||||
]
|
||||
customTools = []
|
||||
workflowTools = []
|
||||
mcpTools = []
|
||||
singleToolSelection = {
|
||||
provider_id: 'provider-3',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_name: 'vendor/provider-3',
|
||||
tool_name: 'summarize',
|
||||
tool_label: 'Summary Tool',
|
||||
tool_description: 'desc',
|
||||
title: 'Summary Tool',
|
||||
is_team_authorization: true,
|
||||
params: { api_key: 'picker-value' },
|
||||
paramSchemas: [],
|
||||
output_schema: {},
|
||||
}
|
||||
multipleToolSelection = [
|
||||
{
|
||||
provider_id: 'provider-2',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_name: 'vendor/provider-2',
|
||||
tool_name: 'translate',
|
||||
tool_label: 'Translate Tool',
|
||||
tool_description: 'desc',
|
||||
title: 'Translate Tool',
|
||||
is_team_authorization: true,
|
||||
params: { api_key: 'multi-a' },
|
||||
paramSchemas: [],
|
||||
output_schema: {},
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-3',
|
||||
provider_type: CollectionType.builtIn,
|
||||
provider_name: 'vendor/provider-3',
|
||||
tool_name: 'summarize',
|
||||
tool_label: 'Summary Tool',
|
||||
tool_description: 'desc',
|
||||
title: 'Summary Tool',
|
||||
is_team_authorization: true,
|
||||
params: { api_key: 'multi-b' },
|
||||
paramSchemas: [],
|
||||
output_schema: {},
|
||||
},
|
||||
]
|
||||
latestSettingPanelProps = null
|
||||
settingPanelSavePayload = {}
|
||||
settingPanelCredentialId = 'credential-from-panel'
|
||||
pluginInstallHandler = null
|
||||
})
|
||||
|
||||
test('should show enabled count and provider information', () => {
|
||||
renderAgentTools([
|
||||
createAgentTool(),
|
||||
createAgentTool({
|
||||
provider_id: 'provider-2',
|
||||
provider_name: 'vendor/provider-2',
|
||||
tool_name: 'translate',
|
||||
tool_label: 'Translate Tool',
|
||||
enabled: false,
|
||||
}),
|
||||
])
|
||||
|
||||
const enabledText = screen.getByText(content => content.includes('appDebug.agent.tools.enabled'))
|
||||
expect(enabledText).toHaveTextContent('1/2')
|
||||
expect(screen.getByText('provider-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Translate Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should copy tool name from tooltip action', async () => {
|
||||
renderAgentTools()
|
||||
|
||||
await hoverInfoIcon()
|
||||
const copyButton = await screen.findByText('tools.copyToolName')
|
||||
await userEvent.click(copyButton)
|
||||
expect(copyMock).toHaveBeenCalledWith('search')
|
||||
})
|
||||
|
||||
test('should toggle tool enabled state via switch', async () => {
|
||||
const { getModelConfig } = renderAgentTools()
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
await userEvent.click(switchButton)
|
||||
|
||||
await waitFor(() => {
|
||||
const tools = getModelConfig().agentConfig.tools as Array<{ tool_name?: string; enabled?: boolean }>
|
||||
const toggledTool = tools.find(tool => tool.tool_name === 'search')
|
||||
expect(toggledTool?.enabled).toBe(false)
|
||||
})
|
||||
expect(formattingDispatcherMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should remove tool when delete action is clicked', async () => {
|
||||
const { getModelConfig } = renderAgentTools()
|
||||
const deleteButton = screen.getByTestId('delete-removed-tool')
|
||||
if (!deleteButton)
|
||||
throw new Error('Delete button not found')
|
||||
await userEvent.click(deleteButton)
|
||||
await waitFor(() => {
|
||||
expect(getModelConfig().agentConfig.tools).toHaveLength(0)
|
||||
})
|
||||
expect(formattingDispatcherMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should add a tool when ToolPicker selects one', async () => {
|
||||
const { getModelConfig } = renderAgentTools([])
|
||||
const addSingleButton = screen.getByRole('button', { name: 'pick-single' })
|
||||
await userEvent.click(addSingleButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Summary Tool')).toBeInTheDocument()
|
||||
})
|
||||
expect(getModelConfig().agentConfig.tools).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('should append multiple selected tools at once', async () => {
|
||||
const { getModelConfig } = renderAgentTools([])
|
||||
await userEvent.click(screen.getByRole('button', { name: 'pick-multiple' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Translate Tool')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Summary Tool')).toHaveLength(1)
|
||||
})
|
||||
expect(getModelConfig().agentConfig.tools).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('should open settings panel for not authorized tool', async () => {
|
||||
renderAgentTools([
|
||||
createAgentTool({
|
||||
notAuthor: true,
|
||||
}),
|
||||
])
|
||||
|
||||
const notAuthorizedButton = screen.getByRole('button', { name: /tools.notAuthorized/ })
|
||||
await userEvent.click(notAuthorizedButton)
|
||||
expect(screen.getByTestId('setting-built-in-tool')).toBeInTheDocument()
|
||||
expect(latestSettingPanelProps?.toolName).toBe('search')
|
||||
})
|
||||
|
||||
test('should persist tool parameters when SettingBuiltInTool saves values', async () => {
|
||||
const { getModelConfig } = renderAgentTools([
|
||||
createAgentTool({
|
||||
notAuthor: true,
|
||||
}),
|
||||
])
|
||||
await userEvent.click(screen.getByRole('button', { name: /tools.notAuthorized/ }))
|
||||
settingPanelSavePayload = { api_key: 'updated' }
|
||||
await userEvent.click(screen.getByRole('button', { name: 'save-from-panel' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect((getModelConfig().agentConfig.tools[0] as { tool_parameters: Record<string, any> }).tool_parameters).toEqual({ api_key: 'updated' })
|
||||
})
|
||||
})
|
||||
|
||||
test('should update credential id when authorization selection changes', async () => {
|
||||
const { getModelConfig } = renderAgentTools([
|
||||
createAgentTool({
|
||||
notAuthor: true,
|
||||
}),
|
||||
])
|
||||
await userEvent.click(screen.getByRole('button', { name: /tools.notAuthorized/ }))
|
||||
settingPanelCredentialId = 'credential-123'
|
||||
await userEvent.click(screen.getByRole('button', { name: 'auth-from-panel' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect((getModelConfig().agentConfig.tools[0] as { credential_id: string }).credential_id).toBe('credential-123')
|
||||
})
|
||||
expect(formattingDispatcherMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should reinstate deleted tools after plugin install success event', async () => {
|
||||
const { getModelConfig } = renderAgentTools([
|
||||
createAgentTool({
|
||||
provider_id: 'provider-1',
|
||||
provider_name: 'vendor/provider-1',
|
||||
tool_name: 'search',
|
||||
tool_label: 'Search Tool',
|
||||
isDeleted: true,
|
||||
}),
|
||||
])
|
||||
if (!pluginInstallHandler)
|
||||
throw new Error('Plugin handler not registered')
|
||||
|
||||
await act(async () => {
|
||||
pluginInstallHandler?.(['provider-1'])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect((getModelConfig().agentConfig.tools[0] as { isDeleted: boolean }).isDeleted).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -217,7 +217,7 @@ const AgentTools: FC = () => {
|
||||
}
|
||||
>
|
||||
<div className='h-4 w-4'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block' data-testid='tool-info-tooltip'>
|
||||
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,6 +277,7 @@ const AgentTools: FC = () => {
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
data-testid='delete-removed-tool'
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SettingBuiltInTool from './setting-built-in-tool'
|
||||
import I18n from '@/context/i18n'
|
||||
import { CollectionType, type Tool, type ToolParameter } from '@/app/components/tools/types'
|
||||
|
||||
const fetchModelToolList = jest.fn()
|
||||
const fetchBuiltInToolList = jest.fn()
|
||||
const fetchCustomToolList = jest.fn()
|
||||
const fetchWorkflowToolList = jest.fn()
|
||||
jest.mock('@/service/tools', () => ({
|
||||
fetchModelToolList: (collectionName: string) => fetchModelToolList(collectionName),
|
||||
fetchBuiltInToolList: (collectionName: string) => fetchBuiltInToolList(collectionName),
|
||||
fetchCustomToolList: (collectionName: string) => fetchCustomToolList(collectionName),
|
||||
fetchWorkflowToolList: (appId: string) => fetchWorkflowToolList(appId),
|
||||
}))
|
||||
|
||||
type MockFormProps = {
|
||||
value: Record<string, any>
|
||||
onChange: (val: Record<string, any>) => void
|
||||
}
|
||||
let nextFormValue: Record<string, any> = {}
|
||||
const FormMock = ({ value, onChange }: MockFormProps) => {
|
||||
return (
|
||||
<div data-testid="mock-form">
|
||||
<div data-testid="form-value">{JSON.stringify(value)}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...value, ...nextFormValue })}
|
||||
>
|
||||
update-form
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
|
||||
__esModule: true,
|
||||
default: (props: MockFormProps) => <FormMock {...props} />,
|
||||
}))
|
||||
|
||||
let pluginAuthClickValue = 'credential-from-plugin'
|
||||
jest.mock('@/app/components/plugins/plugin-auth', () => ({
|
||||
AuthCategory: { tool: 'tool' },
|
||||
PluginAuthInAgent: (props: { onAuthorizationItemClick?: (id: string) => void }) => (
|
||||
<div data-testid="plugin-auth">
|
||||
<button type="button" onClick={() => props.onAuthorizationItemClick?.(pluginAuthClickValue)}>
|
||||
choose-plugin-credential
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/plugins/readme-panel/entrance', () => ({
|
||||
ReadmeEntrance: ({ className }: { className?: string }) => <div className={className}>readme</div>,
|
||||
}))
|
||||
|
||||
const createParameter = (overrides?: Partial<ToolParameter>): ToolParameter => ({
|
||||
name: 'settingParam',
|
||||
label: {
|
||||
en_US: 'Setting Param',
|
||||
zh_Hans: 'Setting Param',
|
||||
},
|
||||
human_description: {
|
||||
en_US: 'desc',
|
||||
zh_Hans: 'desc',
|
||||
},
|
||||
type: 'string',
|
||||
form: 'config',
|
||||
llm_description: '',
|
||||
required: true,
|
||||
multiple: false,
|
||||
default: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createTool = (overrides?: Partial<Tool>): Tool => ({
|
||||
name: 'search',
|
||||
author: 'tester',
|
||||
label: {
|
||||
en_US: 'Search Tool',
|
||||
zh_Hans: 'Search Tool',
|
||||
},
|
||||
description: {
|
||||
en_US: 'tool description',
|
||||
zh_Hans: 'tool description',
|
||||
},
|
||||
parameters: [
|
||||
createParameter({
|
||||
name: 'infoParam',
|
||||
label: {
|
||||
en_US: 'Info Param',
|
||||
zh_Hans: 'Info Param',
|
||||
},
|
||||
form: 'llm',
|
||||
required: false,
|
||||
}),
|
||||
createParameter(),
|
||||
],
|
||||
labels: [],
|
||||
output_schema: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const baseCollection = {
|
||||
id: 'provider-1',
|
||||
name: 'vendor/provider-1',
|
||||
author: 'tester',
|
||||
description: {
|
||||
en_US: 'desc',
|
||||
zh_Hans: 'desc',
|
||||
},
|
||||
icon: 'https://example.com/icon.png',
|
||||
label: {
|
||||
en_US: 'Provider Label',
|
||||
zh_Hans: 'Provider Label',
|
||||
},
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: true,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
tools: [createTool()],
|
||||
}
|
||||
|
||||
const renderComponent = (props?: Partial<React.ComponentProps<typeof SettingBuiltInTool>>) => {
|
||||
const onHide = jest.fn()
|
||||
const onSave = jest.fn()
|
||||
const onAuthorizationItemClick = jest.fn()
|
||||
const utils = render(
|
||||
<I18n.Provider value={{ locale: 'en-US', i18n: {}, setLocaleOnClient: jest.fn() as any }}>
|
||||
<SettingBuiltInTool
|
||||
collection={baseCollection as any}
|
||||
toolName="search"
|
||||
isModel
|
||||
setting={{ settingParam: 'value' }}
|
||||
onHide={onHide}
|
||||
onSave={onSave}
|
||||
onAuthorizationItemClick={onAuthorizationItemClick}
|
||||
{...props}
|
||||
/>
|
||||
</I18n.Provider>,
|
||||
)
|
||||
return {
|
||||
...utils,
|
||||
onHide,
|
||||
onSave,
|
||||
onAuthorizationItemClick,
|
||||
}
|
||||
}
|
||||
|
||||
describe('SettingBuiltInTool', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
nextFormValue = {}
|
||||
pluginAuthClickValue = 'credential-from-plugin'
|
||||
})
|
||||
|
||||
test('should fetch tool list when collection has no tools', async () => {
|
||||
fetchModelToolList.mockResolvedValueOnce([createTool()])
|
||||
renderComponent({
|
||||
collection: {
|
||||
...baseCollection,
|
||||
tools: [],
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchModelToolList).toHaveBeenCalledTimes(1)
|
||||
expect(fetchModelToolList).toHaveBeenCalledWith('vendor/provider-1')
|
||||
})
|
||||
expect(await screen.findByText('Search Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should switch between info and setting tabs', async () => {
|
||||
renderComponent()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mock-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText('tools.setBuiltInTools.parameters'))
|
||||
expect(screen.getByText('Info Param')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByText('tools.setBuiltInTools.setting'))
|
||||
expect(screen.getByTestId('mock-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should call onSave with updated values when save button clicked', async () => {
|
||||
const { onSave } = renderComponent()
|
||||
await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument())
|
||||
nextFormValue = { settingParam: 'updated' }
|
||||
await userEvent.click(screen.getByRole('button', { name: 'update-form' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ settingParam: 'updated' }))
|
||||
})
|
||||
|
||||
test('should keep save disabled until required field provided', async () => {
|
||||
renderComponent({
|
||||
setting: {},
|
||||
})
|
||||
await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument())
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
expect(saveButton).toBeDisabled()
|
||||
nextFormValue = { settingParam: 'filled' }
|
||||
await userEvent.click(screen.getByRole('button', { name: 'update-form' }))
|
||||
expect(saveButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
test('should call onHide when cancel button is pressed', async () => {
|
||||
const { onHide } = renderComponent()
|
||||
await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument())
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should trigger authorization callback from plugin auth section', async () => {
|
||||
const { onAuthorizationItemClick } = renderComponent()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'choose-plugin-credential' }))
|
||||
expect(onAuthorizationItemClick).toHaveBeenCalledWith('credential-from-plugin')
|
||||
})
|
||||
|
||||
test('should call onHide when back button is clicked', async () => {
|
||||
const { onHide } = renderComponent({
|
||||
showBackButton: true,
|
||||
})
|
||||
await userEvent.click(screen.getByText('plugin.detailPanel.operation.back'))
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should load workflow tools when workflow collection is provided', async () => {
|
||||
fetchWorkflowToolList.mockResolvedValueOnce([createTool({
|
||||
name: 'workflow-tool',
|
||||
})])
|
||||
renderComponent({
|
||||
collection: {
|
||||
...baseCollection,
|
||||
type: CollectionType.workflow,
|
||||
tools: [],
|
||||
id: 'workflow-1',
|
||||
} as any,
|
||||
isBuiltIn: false,
|
||||
isModel: false,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchWorkflowToolList).toHaveBeenCalledWith('workflow-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user