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:
Novice
2026-03-24 11:19:50 +08:00
294 changed files with 20298 additions and 13491 deletions

View File

@ -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([])
})
})

View File

@ -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)
})
})
})