mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 10:28:10 +08:00
Merge branch 'main' into sandboxed-agent-rebase
Made-with: Cursor # Conflicts: # api/tests/unit_tests/controllers/console/app/test_message.py # api/tests/unit_tests/controllers/console/app/test_statistic.py # api/tests/unit_tests/controllers/console/app/test_workflow_draft_variable.py # api/tests/unit_tests/controllers/console/auth/test_data_source_bearer_auth.py # api/tests/unit_tests/controllers/console/auth/test_data_source_oauth.py # api/tests/unit_tests/controllers/console/auth/test_oauth_server.py # web/app/components/header/account-setting/data-source-page/data-source-notion/operate/index.tsx # web/app/components/header/account-setting/data-source-page/data-source-website/config-firecrawl-modal.tsx # web/app/components/header/account-setting/data-source-page/data-source-website/config-jina-reader-modal.tsx # web/app/components/header/account-setting/data-source-page/data-source-website/config-watercrawl-modal.tsx # web/app/components/header/account-setting/data-source-page/panel/config-item.tsx # web/app/components/header/account-setting/data-source-page/panel/index.tsx # web/app/components/workflow/nodes/knowledge-retrieval/node.tsx # web/package.json # web/pnpm-lock.yaml
This commit is contained in:
@ -0,0 +1,262 @@
|
||||
import type { ConversationVariable, Node } from '@/app/components/workflow/types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ChatVariablePanel from '../index'
|
||||
import { ChatVarType } from '../type'
|
||||
|
||||
type MockWorkflowStoreState = {
|
||||
setShowChatVariablePanel: (value: boolean) => void
|
||||
conversationVariables: ConversationVariable[]
|
||||
setConversationVariables: (value: ConversationVariable[]) => void
|
||||
}
|
||||
|
||||
type MockFlowStore = {
|
||||
getNodes: () => Node[]
|
||||
setNodes: (nodes: Node[]) => void
|
||||
}
|
||||
|
||||
const mockSetShowChatVariablePanel = vi.fn()
|
||||
const mockSetConversationVariables = vi.fn()
|
||||
const mockDoSyncWorkflowDraft = vi.fn((_sync: boolean, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
const mockInvalidateConversationVarValues = vi.fn()
|
||||
const mockFindUsedVarNodes = vi.fn<(selector: string[], nodes: Node[]) => Node[]>()
|
||||
const mockUpdateNodeVars = vi.fn<(node: Node, current: string[], next: string[]) => Node>()
|
||||
|
||||
let mockConversationVariables: ConversationVariable[] = []
|
||||
let mockFlowNodes: Node[] = []
|
||||
const mockSetNodes = vi.fn<(nodes: Node[]) => void>()
|
||||
|
||||
const createConversationVariable = (
|
||||
overrides: Partial<ConversationVariable> = {},
|
||||
): ConversationVariable => ({
|
||||
id: 'var-1',
|
||||
name: 'conversation_var',
|
||||
value_type: ChatVarType.String,
|
||||
value: '',
|
||||
description: 'Conversation variable',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createNode = (id: string): Node => ({
|
||||
id,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: id,
|
||||
desc: '',
|
||||
type: 'llm' as Node['data']['type'],
|
||||
},
|
||||
})
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: (): MockFlowStore => ({
|
||||
getNodes: () => mockFlowNodes,
|
||||
setNodes: mockSetNodes,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => selector({
|
||||
setShowChatVariablePanel: mockSetShowChatVariablePanel,
|
||||
conversationVariables: mockConversationVariables,
|
||||
setConversationVariables: mockSetConversationVariables,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks/use-inspect-vars-crud', () => ({
|
||||
default: () => ({
|
||||
invalidateConversationVarValues: mockInvalidateConversationVarValues,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
|
||||
findUsedVarNodes: (...args: Parameters<typeof mockFindUsedVarNodes>) => mockFindUsedVarNodes(...args),
|
||||
updateNodeVars: (...args: Parameters<typeof mockUpdateNodeVars>) => mockUpdateNodeVars(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/variable-item', () => ({
|
||||
default: ({
|
||||
item,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
item: ConversationVariable
|
||||
onEdit: (item: ConversationVariable) => void
|
||||
onDelete: (item: ConversationVariable) => void
|
||||
}) => (
|
||||
<div>
|
||||
<span>{item.name}</span>
|
||||
<button type="button" onClick={() => onEdit(item)}>{`edit-${item.name}`}</button>
|
||||
<button type="button" onClick={() => onDelete(item)}>{`delete-${item.name}`}</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger', () => ({
|
||||
default: ({
|
||||
open,
|
||||
showTip,
|
||||
chatVar,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
showTip: boolean
|
||||
chatVar?: ConversationVariable
|
||||
onSave: (chatVar: ConversationVariable) => void
|
||||
onClose: () => void
|
||||
}) => (
|
||||
<div data-testid="variable-modal-trigger">
|
||||
<span>{open ? 'open' : 'closed'}</span>
|
||||
<span>{showTip ? 'tip-on' : 'tip-off'}</span>
|
||||
<span>{chatVar?.name || 'new-variable'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave({
|
||||
id: 'var-added',
|
||||
name: 'fresh_var',
|
||||
value_type: ChatVarType.String,
|
||||
value: '',
|
||||
description: 'Added variable',
|
||||
})}
|
||||
>
|
||||
save-add
|
||||
</button>
|
||||
{chatVar && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave({
|
||||
...chatVar,
|
||||
name: `${chatVar.name}_next`,
|
||||
})}
|
||||
>
|
||||
save-edit
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onClose}>close-trigger</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm', () => ({
|
||||
default: ({
|
||||
isShow,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
isShow: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) => {
|
||||
if (!isShow)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="remove-effect-var-confirm">
|
||||
<button type="button" onClick={onConfirm}>confirm-remove</button>
|
||||
<button type="button" onClick={onCancel}>cancel-remove</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ChatVariablePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConversationVariables = [createConversationVariable()]
|
||||
mockFlowNodes = [createNode('node-1'), createNode('node-2')]
|
||||
mockFindUsedVarNodes.mockReturnValue([])
|
||||
mockUpdateNodeVars.mockImplementation((node: Node) => node)
|
||||
})
|
||||
|
||||
it('should toggle the tips area and close the panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<ChatVariablePanel />)
|
||||
|
||||
expect(screen.getByText('workflow.chatVariable.panelDescription')).toBeInTheDocument()
|
||||
|
||||
const toggleTipButton = screen.getAllByRole('button')[0]!
|
||||
await user.click(toggleTipButton)
|
||||
expect(screen.queryByText('workflow.chatVariable.panelDescription')).not.toBeInTheDocument()
|
||||
|
||||
const closeButton = container.querySelector('.flex.h-6.w-6.cursor-pointer.items-center.justify-center') as HTMLElement
|
||||
await user.click(closeButton)
|
||||
|
||||
expect(mockSetShowChatVariablePanel).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should prepend newly added variables and sync the workflow draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ChatVariablePanel />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'save-add' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetConversationVariables).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 'var-added', name: 'fresh_var' }),
|
||||
createConversationVariable(),
|
||||
])
|
||||
})
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateConversationVarValues).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should rename existing variables and update affected node references', async () => {
|
||||
const user = userEvent.setup()
|
||||
const effectedNode = createNode('node-1')
|
||||
const updatedNode = createNode('node-1-updated')
|
||||
|
||||
mockFindUsedVarNodes.mockReturnValue([effectedNode])
|
||||
mockUpdateNodeVars.mockReturnValue(updatedNode)
|
||||
|
||||
render(<ChatVariablePanel />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'edit-conversation_var' }))
|
||||
await user.click(screen.getByRole('button', { name: 'save-edit' }))
|
||||
|
||||
expect(mockSetConversationVariables).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 'var-1', name: 'conversation_var_next' }),
|
||||
])
|
||||
expect(mockUpdateNodeVars).toHaveBeenCalledWith(
|
||||
effectedNode,
|
||||
['conversation', 'conversation_var'],
|
||||
['conversation', 'conversation_var_next'],
|
||||
)
|
||||
expect(mockSetNodes).toHaveBeenCalledWith([updatedNode, createNode('node-2')])
|
||||
})
|
||||
|
||||
it('should require confirmation before deleting variables referenced by workflow nodes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const effectedNode = createNode('node-1')
|
||||
const prunedNode = createNode('node-1-pruned')
|
||||
|
||||
mockFindUsedVarNodes.mockReturnValue([effectedNode])
|
||||
mockUpdateNodeVars.mockReturnValue(prunedNode)
|
||||
|
||||
render(<ChatVariablePanel />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'delete-conversation_var' }))
|
||||
expect(screen.getByTestId('remove-effect-var-confirm')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'confirm-remove' }))
|
||||
|
||||
expect(mockUpdateNodeVars).toHaveBeenCalledWith(
|
||||
effectedNode,
|
||||
['conversation', 'conversation_var'],
|
||||
[],
|
||||
)
|
||||
expect(mockSetNodes).toHaveBeenCalledWith([prunedNode, createNode('node-2')])
|
||||
expect(mockSetConversationVariables).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,282 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { ConversationVariable } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
|
||||
import ArrayBoolList from '../array-bool-list'
|
||||
import ArrayValueList from '../array-value-list'
|
||||
import VariableItem from '../variable-item'
|
||||
import VariableModalTrigger from '../variable-modal-trigger'
|
||||
import VariableTypeSelector from '../variable-type-select'
|
||||
|
||||
vi.mock('../variable-modal', () => ({
|
||||
default: ({ chatVar, onSave, onClose }: any) => (
|
||||
<div>
|
||||
{chatVar?.name && <div>{chatVar.name}</div>}
|
||||
<button type="button" onClick={() => onSave({ id: 'saved' })}>save-modal</button>
|
||||
<button type="button" onClick={onClose}>close-modal</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createVariable = (overrides: Partial<ConversationVariable> = {}): ConversationVariable => ({
|
||||
id: 'var-1',
|
||||
name: 'conversation_var',
|
||||
description: 'Conversation scoped variable',
|
||||
value_type: ChatVarType.String,
|
||||
value: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('chat-variable-panel components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The panel leaf components should support editing, selecting types, and opening the add-variable modal.
|
||||
describe('Leaf interactions', () => {
|
||||
it('should update string array items, add rows, and remove rows', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ArrayValueList
|
||||
isString
|
||||
list={['alpha', 'beta']}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('alpha'), { target: { value: 'updated' } })
|
||||
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
|
||||
await user.click(screen.getAllByRole('button')[0]!)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should coerce number array items and append undefined rows', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ArrayValueList
|
||||
isString={false}
|
||||
list={[1]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '7' } })
|
||||
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, [7])
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, [1, undefined])
|
||||
})
|
||||
|
||||
it('should call edit and delete handlers from the variable item actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEdit = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
const { container } = render(
|
||||
<VariableItem
|
||||
item={createVariable()}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
const card = container.firstElementChild as HTMLDivElement
|
||||
const actions = container.querySelectorAll('.cursor-pointer')
|
||||
fireEvent.mouseOver(actions[1] as Element)
|
||||
expect(card.className).toContain('border-state-destructive-border')
|
||||
fireEvent.mouseOut(actions[1] as Element)
|
||||
expect(card.className).not.toContain('border-state-destructive-border')
|
||||
|
||||
const icons = container.querySelectorAll('svg')
|
||||
await user.click(icons[1] as SVGElement)
|
||||
await user.click(icons[2] as SVGElement)
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 'var-1' }))
|
||||
expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'var-1' }))
|
||||
})
|
||||
|
||||
it('should toggle the type selector and select a new value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<VariableTypeSelector
|
||||
value="string"
|
||||
list={['string', 'number', 'boolean']}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('string'))
|
||||
await user.click(screen.getByText('number'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('number')
|
||||
})
|
||||
|
||||
it('should dismiss the type selector through the real portal close flow', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<VariableTypeSelector
|
||||
value="string"
|
||||
list={['string', 'number']}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('string'))
|
||||
expect(screen.getByText('number')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('number')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the in-cell selector from its trigger and keep the popup class', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<VariableTypeSelector
|
||||
inCell
|
||||
value="string"
|
||||
list={['string', 'number']}
|
||||
popupClassName="custom-popup"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getAllByText('string')[0]!)
|
||||
|
||||
expect(screen.getByText('number').closest('.custom-popup')).not.toBeNull()
|
||||
await user.click(screen.getAllByText('string')[1]!)
|
||||
expect(onSelect).toHaveBeenCalledWith('string')
|
||||
})
|
||||
|
||||
it('should update, add, and remove boolean array values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(
|
||||
<ArrayBoolList
|
||||
list={[true]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('False'))
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, [false])
|
||||
|
||||
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, [true, false])
|
||||
|
||||
const buttons = container.querySelectorAll('button')
|
||||
await user.click(buttons[0] as HTMLButtonElement)
|
||||
expect(onChange).toHaveBeenNthCalledWith(3, [])
|
||||
})
|
||||
|
||||
it('should toggle the modal trigger without closing when it starts closed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setOpen = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<VariableModalTrigger
|
||||
open={false}
|
||||
setOpen={setOpen}
|
||||
showTip
|
||||
onClose={onClose}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('save-modal')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.chatVariable.button'))
|
||||
|
||||
expect(setOpen).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open the modal trigger and close after saving', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setOpen = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
render(
|
||||
<VariableModalTrigger
|
||||
open
|
||||
setOpen={setOpen}
|
||||
showTip={false}
|
||||
chatVar={createVariable()}
|
||||
onClose={onClose}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('conversation_var')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('save-modal'))
|
||||
await user.click(screen.getByText('close-modal'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({ id: 'saved' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(setOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should close the modal trigger when clicking the trigger while already open', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setOpen = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<VariableModalTrigger
|
||||
open
|
||||
setOpen={setOpen}
|
||||
showTip={false}
|
||||
chatVar={createVariable()}
|
||||
onClose={onClose}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.chatVariable.button' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(setOpen).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close the modal trigger when the portal dismisses', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
const TriggerHarness = () => {
|
||||
const [open, setOpen] = React.useState(true)
|
||||
|
||||
return (
|
||||
<VariableModalTrigger
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
showTip={false}
|
||||
chatVar={createVariable()}
|
||||
onClose={onClose}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<TriggerHarness />)
|
||||
|
||||
expect(screen.getByText('save-modal')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('save-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user