test(workflow): add unit tests for workflow components (#33910)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star
2026-03-23 16:37:03 +08:00
committed by GitHub
parent abda859075
commit fdc880bc67
54 changed files with 12469 additions and 189 deletions

View File

@ -0,0 +1,226 @@
import type { UploadFileSetting } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { useFileUploadConfig } from '@/service/use-common'
import { TransferMethod } from '@/types/app'
import FileTypeItem from '../file-type-item'
import FileUploadSetting from '../file-upload-setting'
const mockUseFileUploadConfig = vi.mocked(useFileUploadConfig)
const mockUseFileSizeLimit = vi.mocked(useFileSizeLimit)
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(),
}))
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
useFileSizeLimit: vi.fn(),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: vi.fn(),
close: vi.fn(),
}),
}))
const createPayload = (overrides: Partial<UploadFileSetting> = {}): UploadFileSetting => ({
allowed_file_upload_methods: [TransferMethod.local_file],
max_length: 2,
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_extensions: ['pdf'],
...overrides,
})
describe('File upload support components', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseFileUploadConfig.mockReturnValue({ data: {} } as ReturnType<typeof useFileUploadConfig>)
mockUseFileSizeLimit.mockReturnValue({
imgSizeLimit: 10 * 1024 * 1024,
docSizeLimit: 20 * 1024 * 1024,
audioSizeLimit: 30 * 1024 * 1024,
videoSizeLimit: 40 * 1024 * 1024,
maxFileUploadLimit: 10,
} as ReturnType<typeof useFileSizeLimit>)
})
describe('FileTypeItem', () => {
it('should render built-in file types and toggle the selected type on click', () => {
const onToggle = vi.fn()
render(
<FileTypeItem
type={SupportUploadFileTypes.image}
selected={false}
onToggle={onToggle}
/>,
)
expect(screen.getByText('appDebug.variableConfig.file.image.name')).toBeInTheDocument()
expect(screen.getByText('JPG, JPEG, PNG, GIF, WEBP, SVG')).toBeInTheDocument()
fireEvent.click(screen.getByText('appDebug.variableConfig.file.image.name'))
expect(onToggle).toHaveBeenCalledWith(SupportUploadFileTypes.image)
})
it('should render the custom tag editor and emit custom extensions', async () => {
const user = userEvent.setup()
const onCustomFileTypesChange = vi.fn()
render(
<FileTypeItem
type={SupportUploadFileTypes.custom}
selected
onToggle={vi.fn()}
customFileTypes={['json']}
onCustomFileTypesChange={onCustomFileTypesChange}
/>,
)
const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder')
await user.type(input, 'csv')
fireEvent.blur(input)
expect(screen.getByText('json')).toBeInTheDocument()
expect(onCustomFileTypesChange).toHaveBeenCalledWith(['json', 'csv'])
})
})
describe('FileUploadSetting', () => {
it('should update file types, upload methods, and upload limits', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FileUploadSetting
payload={createPayload()}
isMultiple
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.image.name'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
allowed_file_types: [SupportUploadFileTypes.document, SupportUploadFileTypes.image],
}))
await user.click(screen.getByText('URL'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
allowed_file_upload_methods: [TransferMethod.remote_url],
}))
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '5' } })
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
max_length: 5,
}))
})
it('should toggle built-in and custom file type selections', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.document.name'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_types: [],
}))
rerender(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.custom.name'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_types: [SupportUploadFileTypes.custom],
}))
rerender(
<FileUploadSetting
payload={createPayload({
allowed_file_types: [SupportUploadFileTypes.custom],
})}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.custom.name'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_types: [],
}))
})
it('should support both upload methods and update custom extensions', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.both'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
}))
rerender(
<FileUploadSetting
payload={createPayload({
allowed_file_types: [SupportUploadFileTypes.custom],
})}
isMultiple={false}
onChange={onChange}
/>,
)
const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder')
await user.type(input, 'csv')
fireEvent.blur(input)
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_extensions: ['pdf', 'csv'],
}))
})
it('should render support file types in the feature panel and hide them when requested', () => {
const { rerender } = render(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
inFeaturePanel
onChange={vi.fn()}
/>,
)
expect(screen.getByText('appDebug.variableConfig.file.supportFileTypes')).toBeInTheDocument()
rerender(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
inFeaturePanel
hideSupportFileType
onChange={vi.fn()}
/>,
)
expect(screen.queryByText('appDebug.variableConfig.file.document.name')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,250 @@
import type { NodeProps } from 'reactflow'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { NodeRunningStatus, VarType } from '@/app/components/workflow/types'
import DefaultValue from '../default-value'
import ErrorHandleOnNode from '../error-handle-on-node'
import ErrorHandleOnPanel from '../error-handle-on-panel'
import ErrorHandleTip from '../error-handle-tip'
import ErrorHandleTypeSelector from '../error-handle-type-selector'
import FailBranchCard from '../fail-branch-card'
import { useDefaultValue, useErrorHandle } from '../hooks'
import { ErrorHandleTypeEnum } from '../types'
const { mockDocLink } = vi.hoisted(() => ({
mockDocLink: vi.fn((path: string) => `https://docs.example.com${path}`),
}))
vi.mock('@/context/i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/i18n')>()
return {
...actual,
useDocLink: () => mockDocLink,
}
})
vi.mock('../hooks', () => ({
useDefaultValue: vi.fn(),
useErrorHandle: vi.fn(),
}))
vi.mock('../../node-handle', () => ({
NodeSourceHandle: ({ handleId }: { handleId: string }) => <div className="react-flow__handle" data-handleid={handleId} />,
}))
const mockUseDefaultValue = vi.mocked(useDefaultValue)
const mockUseErrorHandle = vi.mocked(useErrorHandle)
const originalDOMMatrixReadOnly = window.DOMMatrixReadOnly
const baseData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
title: 'Code',
desc: '',
type: 'code' as CommonNodeType['type'],
...overrides,
})
const ErrorHandleNodeHarness = ({ id, data }: NodeProps<CommonNodeType>) => (
<ErrorHandleOnNode id={id} data={data} />
)
const renderErrorHandleNode = (data: CommonNodeType) =>
renderWorkflowFlowComponent(<div />, {
nodes: [createNode({
id: 'node-1',
type: 'errorHandleNode',
data,
})],
edges: [],
reactFlowProps: {
nodeTypes: {
errorHandleNode: ErrorHandleNodeHarness,
},
},
})
describe('error-handle path', () => {
beforeAll(() => {
class MockDOMMatrixReadOnly {
inverse() {
return this
}
transformPoint(point: { x: number, y: number }) {
return point
}
}
Object.defineProperty(window, 'DOMMatrixReadOnly', {
configurable: true,
writable: true,
value: MockDOMMatrixReadOnly,
})
})
beforeEach(() => {
vi.clearAllMocks()
mockDocLink.mockImplementation((path: string) => `https://docs.example.com${path}`)
mockUseDefaultValue.mockReturnValue({
handleFormChange: vi.fn(),
})
mockUseErrorHandle.mockReturnValue({
collapsed: false,
setCollapsed: vi.fn(),
handleErrorHandleTypeChange: vi.fn(),
})
})
afterAll(() => {
Object.defineProperty(window, 'DOMMatrixReadOnly', {
configurable: true,
writable: true,
value: originalDOMMatrixReadOnly,
})
})
// The error-handle leaf components should expose selectable strategies and contextual help.
describe('Leaf Components', () => {
it('should render the fail-branch card with the resolved learn-more link', () => {
render(<FailBranchCard />)
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument()
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type')
})
it('should render string forms and surface array forms in the default value editor', () => {
const onFormChange = vi.fn()
render(
<DefaultValue
forms={[
{ key: 'message', type: VarType.string, value: 'hello' },
{ key: 'items', type: VarType.arrayString, value: '["a"]' },
]}
onFormChange={onFormChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated' } })
expect(onFormChange).toHaveBeenCalledWith({
key: 'message',
type: VarType.string,
value: 'updated',
})
expect(screen.getByText('items')).toBeInTheDocument()
})
it('should toggle the selector popup and report the selected strategy', async () => {
const user = userEvent.setup()
const onSelected = vi.fn()
render(
<ErrorHandleTypeSelector
value={ErrorHandleTypeEnum.none}
onSelected={onSelected}
/>,
)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.title'))
expect(onSelected).toHaveBeenCalledWith(ErrorHandleTypeEnum.defaultValue)
})
it('should render the error tip only when a strategy exists', () => {
const { rerender, container } = render(<ErrorHandleTip />)
expect(container).toBeEmptyDOMElement()
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.failBranch} />)
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.inLog')).toBeInTheDocument()
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.defaultValue} />)
expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.inLog')).toBeInTheDocument()
})
})
// The container components should show the correct branch card or default-value editor and propagate actions.
describe('Containers', () => {
it('should render the fail-branch panel body when the strategy is active', () => {
render(
<ErrorHandleOnPanel
id="node-1"
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
/>,
)
expect(screen.getByText('workflow.nodes.common.errorHandle.title')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument()
})
it('should render the default-value panel body and delegate form updates', () => {
const handleFormChange = vi.fn()
mockUseDefaultValue.mockReturnValue({ handleFormChange })
render(
<ErrorHandleOnPanel
id="node-1"
data={baseData({
error_strategy: ErrorHandleTypeEnum.defaultValue,
default_value: [{ key: 'answer', type: VarType.string, value: 'draft' }],
})}
/>,
)
fireEvent.change(screen.getByDisplayValue('draft'), { target: { value: 'next' } })
expect(handleFormChange).toHaveBeenCalledWith(
{ key: 'answer', type: VarType.string, value: 'next' },
expect.objectContaining({ error_strategy: ErrorHandleTypeEnum.defaultValue }),
)
})
it('should hide the panel body when the hook reports a collapsed section', () => {
mockUseErrorHandle.mockReturnValue({
collapsed: true,
setCollapsed: vi.fn(),
handleErrorHandleTypeChange: vi.fn(),
})
render(
<ErrorHandleOnPanel
id="node-1"
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
/>,
)
expect(screen.queryByText('workflow.nodes.common.errorHandle.failBranch.customize')).not.toBeInTheDocument()
})
it('should render the default-value node badge', () => {
renderWorkflowFlowComponent(
<ErrorHandleOnNode
id="node-1"
data={baseData({
error_strategy: ErrorHandleTypeEnum.defaultValue,
})}
/>,
{
nodes: [],
edges: [],
},
)
expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument()
})
it('should render the fail-branch node badge when the node throws an exception', () => {
const { container } = renderErrorHandleNode(baseData({
error_strategy: ErrorHandleTypeEnum.failBranch,
_runningStatus: NodeRunningStatus.Exception,
}))
return waitFor(() => {
expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument()
expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch)
})
})
})
})

View File

@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react'
import Add from '../add'
import InputField from '../index'
describe('InputField', () => {
@ -14,5 +15,12 @@ describe('InputField', () => {
expect(screen.getAllByText('input field')).toHaveLength(2)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render the standalone add action button', () => {
const { container } = render(<Add />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})
})

View File

@ -1,13 +1,47 @@
import { render, screen } from '@testing-library/react'
import { BoxGroupField, FieldTitle } from '../index'
import userEvent from '@testing-library/user-event'
import { Box, BoxGroup, BoxGroupField, Field, Group, GroupField } from '../index'
describe('layout index', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The barrel exports should compose the public layout primitives without extra wrappers.
// The layout primitives should preserve their composition contracts and collapse behavior.
describe('Rendering', () => {
it('should render Box and Group with optional border styles', () => {
render(
<div>
<Box withBorderBottom className="box-test">Box content</Box>
<Group withBorderBottom className="group-test">Group content</Group>
</div>,
)
expect(screen.getByText('Box content')).toHaveClass('border-b', 'box-test')
expect(screen.getByText('Group content')).toHaveClass('border-b', 'group-test')
})
it('should render BoxGroup and GroupField with nested children', () => {
render(
<div>
<BoxGroup>Inside box group</BoxGroup>
<GroupField
fieldProps={{
fieldTitleProps: {
title: 'Grouped field',
},
}}
>
Group field body
</GroupField>
</div>,
)
expect(screen.getByText('Inside box group')).toBeInTheDocument()
expect(screen.getByText('Grouped field')).toBeInTheDocument()
expect(screen.getByText('Group field body')).toBeInTheDocument()
})
it('should render BoxGroupField from the barrel export', () => {
render(
<BoxGroupField
@ -25,10 +59,23 @@ describe('layout index', () => {
expect(screen.getByText('Body content')).toBeInTheDocument()
})
it('should render FieldTitle from the barrel export', () => {
render(<FieldTitle title="Advanced" subTitle="Extra details" />)
it('should collapse and expand Field children when supportCollapse is enabled', async () => {
const user = userEvent.setup()
render(
<Field
supportCollapse
fieldTitleProps={{ title: 'Advanced' }}
>
<div>Extra details</div>
</Field>,
)
expect(screen.getByText('Advanced')).toBeInTheDocument()
expect(screen.getByText('Extra details')).toBeInTheDocument()
await user.click(screen.getByText('Advanced'))
expect(screen.queryByText('Extra details')).not.toBeInTheDocument()
await user.click(screen.getByText('Advanced'))
expect(screen.getByText('Extra details')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,114 @@
import type { PromptEditorProps } from '@/app/components/base/prompt-editor'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { render } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import MixedVariableTextInput from '../index'
let capturedPromptEditorProps: PromptEditorProps[] = []
vi.mock('@/app/components/base/prompt-editor', () => ({
default: ({
editable,
value,
workflowVariableBlock,
onChange,
}: PromptEditorProps) => {
capturedPromptEditorProps.push({
editable,
value,
onChange,
workflowVariableBlock,
})
return (
<div data-testid="prompt-editor">
<div data-testid="editable-flag">{editable ? 'editable' : 'readonly'}</div>
<div data-testid="value-flag">{value || 'empty'}</div>
<button type="button" onClick={() => onChange?.('updated text')}>trigger-change</button>
</div>
)
},
}))
describe('MixedVariableTextInput', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedPromptEditorProps = []
})
it('should pass workflow variable metadata to the prompt editor and include system variables for start nodes', () => {
const nodesOutputVars: NodeOutPutVar[] = [{
nodeId: 'node-1',
title: 'Question Node',
vars: [],
}]
const availableNodes: Node[] = [
{
id: 'start-node',
position: { x: 0, y: 0 },
data: {
title: 'Start Node',
desc: 'Start description',
type: BlockEnum.Start,
},
},
{
id: 'llm-node',
position: { x: 120, y: 0 },
data: {
title: 'LLM Node',
desc: 'LLM description',
type: BlockEnum.LLM,
},
},
]
render(
<MixedVariableTextInput
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
/>,
)
const latestProps = capturedPromptEditorProps.at(-1)
expect(latestProps?.editable).toBe(true)
expect(latestProps?.workflowVariableBlock?.variables).toHaveLength(1)
expect(latestProps?.workflowVariableBlock?.workflowNodesMap).toEqual({
'start-node': {
title: 'Start Node',
type: 'start',
},
'sys': {
title: 'workflow.blocks.start',
type: 'start',
},
'llm-node': {
title: 'LLM Node',
type: 'llm',
},
})
})
it('should forward read-only state, current value, and change callbacks', async () => {
const onChange = vi.fn()
const { findByRole, getByTestId } = render(
<MixedVariableTextInput
readOnly
value="seed value"
onChange={onChange}
/>,
)
expect(getByTestId('editable-flag')).toHaveTextContent('readonly')
expect(getByTestId('value-flag')).toHaveTextContent('seed value')
const changeButton = await findByRole('button', { name: 'trigger-change' })
changeButton.click()
expect(onChange).toHaveBeenCalledWith('updated text')
})
})

View File

@ -0,0 +1,78 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { createEvent, fireEvent, render, screen } from '@testing-library/react'
import { $insertNodes, FOCUS_COMMAND } from 'lexical'
import Placeholder from '../placeholder'
const mockEditorUpdate = vi.fn((callback: () => void) => callback())
const mockDispatchCommand = vi.fn()
const mockInsertNodes = vi.fn()
const mockTextNode = vi.fn()
const mockEditor = {
update: mockEditorUpdate,
dispatchCommand: mockDispatchCommand,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
vi.mock('@lexical/react/LexicalComposerContext', () => ({
useLexicalComposerContext: vi.fn(),
}))
vi.mock('lexical', () => ({
$insertNodes: vi.fn(),
FOCUS_COMMAND: 'focus-command',
}))
vi.mock('@/app/components/base/prompt-editor/plugins/custom-text/node', () => ({
CustomTextNode: class MockCustomTextNode {
value: string
constructor(value: string) {
this.value = value
mockTextNode(value)
}
},
}))
describe('Mixed variable placeholder', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useLexicalComposerContext).mockReturnValue(lexicalContextValue)
vi.mocked($insertNodes).mockImplementation(nodes => mockInsertNodes(nodes))
})
it('should insert an empty text node and focus the editor when the placeholder background is clicked', () => {
const parentClick = vi.fn()
render(
<div onClick={parentClick}>
<Placeholder />
</div>,
)
fireEvent.click(screen.getByText('workflow.nodes.tool.insertPlaceholder1'))
expect(parentClick).not.toHaveBeenCalled()
expect(mockTextNode).toHaveBeenCalledWith('')
expect(mockInsertNodes).toHaveBeenCalledTimes(1)
expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined)
})
it('should insert a slash shortcut from the highlighted action and prevent the native mouse down behavior', () => {
render(<Placeholder />)
const shortcut = screen.getByText('workflow.nodes.tool.insertPlaceholder2')
const event = createEvent.mouseDown(shortcut)
fireEvent(shortcut, event)
expect(event.defaultPrevented).toBe(true)
expect(mockTextNode).toHaveBeenCalledWith('/')
expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined)
})
})

View File

@ -0,0 +1,268 @@
/* eslint-disable ts/no-explicit-any */
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
useAvailableBlocks,
useIsChatMode,
useNodeDataUpdate,
useNodeMetaData,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAllWorkflowTools } from '@/service/use-tools'
import { FlowType } from '@/types/common'
import ChangeBlock from '../change-block'
import PanelOperatorPopup from '../panel-operator-popup'
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => (
<div>
<div>{trigger()}</div>
<div>{`available:${(availableBlocksTypes || []).join(',')}`}</div>
<div>{`show-start:${String(showStartTab)}`}</div>
<div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div>
<div>{`force-start:${String(forceEnableStartTab)}`}</div>
<div>{`allow-start:${String(allowUserInputSelection)}`}</div>
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button>
</div>
),
}))
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
return {
...actual,
useAvailableBlocks: vi.fn(),
useIsChatMode: vi.fn(),
useNodeDataUpdate: vi.fn(),
useNodeMetaData: vi.fn(),
useNodesInteractions: vi.fn(),
useNodesReadOnly: vi.fn(),
useNodesSyncDraft: vi.fn(),
}
})
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: vi.fn(),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useAllWorkflowTools: vi.fn(),
}))
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
const mockUseIsChatMode = vi.mocked(useIsChatMode)
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
const mockUseHooksStore = vi.mocked(useHooksStore)
const mockUseNodes = vi.mocked(useNodes)
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
describe('panel-operator details', () => {
const handleNodeChange = vi.fn()
const handleNodeDelete = vi.fn()
const handleNodesDuplicate = vi.fn()
const handleNodeSelect = vi.fn()
const handleNodesCopy = vi.fn()
const handleNodeDataUpdate = vi.fn()
const handleSyncWorkflowDraft = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableBlocks.mockReturnValue({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [BlockEnum.HttpRequest],
availableNextBlocks: [BlockEnum.HttpRequest],
})),
availablePrevBlocks: [BlockEnum.HttpRequest],
availableNextBlocks: [BlockEnum.HttpRequest],
} as ReturnType<typeof useAvailableBlocks>)
mockUseIsChatMode.mockReturnValue(false)
mockUseNodeDataUpdate.mockReturnValue({
handleNodeDataUpdate,
handleNodeDataUpdateWithSyncDraft: vi.fn(),
})
mockUseNodeMetaData.mockReturnValue({
isTypeFixed: false,
isSingleton: false,
isUndeletable: false,
description: 'Node description',
author: 'Dify',
helpLinkUri: 'https://docs.example.com/node',
} as ReturnType<typeof useNodeMetaData>)
mockUseNodesInteractions.mockReturnValue({
handleNodeChange,
handleNodeDelete,
handleNodesDuplicate,
handleNodeSelect,
handleNodesCopy,
} as unknown as ReturnType<typeof useNodesInteractions>)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>)
mockUseNodesSyncDraft.mockReturnValue({
doSyncWorkflowDraft: vi.fn(),
handleSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: vi.fn(),
} as ReturnType<typeof useNodesSyncDraft>)
mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any)
})
// The panel operator internals should expose block-change and popup actions using the real workflow popup composition.
describe('Internal Actions', () => {
it('should select a replacement block through ChangeBlock', async () => {
const user = userEvent.setup()
render(
<ChangeBlock
nodeId="node-1"
nodeData={{ type: BlockEnum.Code } as any}
sourceHandle="source"
/>,
)
await user.click(screen.getByText('select-http'))
expect(screen.getByText('available:http-request')).toBeInTheDocument()
expect(screen.getByText('show-start:true')).toBeInTheDocument()
expect(screen.getByText('ignore:')).toBeInTheDocument()
expect(screen.getByText('force-start:false')).toBeInTheDocument()
expect(screen.getByText('allow-start:false')).toBeInTheDocument()
expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined)
})
it('should expose trigger and start-node specific block selector options', () => {
mockUseAvailableBlocks.mockReturnValueOnce({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.HttpRequest],
})),
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.HttpRequest],
} as ReturnType<typeof useAvailableBlocks>)
mockUseIsChatMode.mockReturnValueOnce(true)
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
mockUseNodes.mockReturnValueOnce([] as any)
const { rerender } = render(
<ChangeBlock
nodeId="trigger-node"
nodeData={{ type: BlockEnum.TriggerWebhook } as any}
sourceHandle="source"
/>,
)
expect(screen.getByText('available:http-request')).toBeInTheDocument()
expect(screen.getByText('show-start:true')).toBeInTheDocument()
expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument()
expect(screen.getByText('allow-start:true')).toBeInTheDocument()
mockUseAvailableBlocks.mockReturnValueOnce({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [BlockEnum.Code],
availableNextBlocks: [],
})),
availablePrevBlocks: [BlockEnum.Code],
availableNextBlocks: [],
} as ReturnType<typeof useAvailableBlocks>)
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } }))
mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
rerender(
<ChangeBlock
nodeId="start-node"
nodeData={{ type: BlockEnum.Start } as any}
sourceHandle="source"
/>,
)
expect(screen.getByText('available:code')).toBeInTheDocument()
expect(screen.getByText('show-start:false')).toBeInTheDocument()
expect(screen.getByText('ignore:start-node')).toBeInTheDocument()
expect(screen.getByText('force-start:true')).toBeInTheDocument()
})
it('should run, copy, duplicate, delete, and expose the help link in the popup', async () => {
const user = userEvent.setup()
renderWorkflowFlowComponent(
<PanelOperatorPopup
id="node-1"
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '' } as any}
onClosePopup={vi.fn()}
showHelpLink
/>,
{
nodes: [],
edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }],
},
)
await user.click(screen.getByText('workflow.panel.runThisStep'))
await user.click(screen.getByText('workflow.common.copy'))
await user.click(screen.getByText('workflow.common.duplicate'))
await user.click(screen.getByText('common.operation.delete'))
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } })
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
expect(handleNodesCopy).toHaveBeenCalledWith('node-1')
expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1')
expect(handleNodeDelete).toHaveBeenCalledWith('node-1')
expect(screen.getByRole('link', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node')
})
it('should render workflow-tool and readonly popup variants', () => {
mockUseAllWorkflowTools.mockReturnValueOnce({
data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }],
} as any)
const { rerender } = renderWorkflowFlowComponent(
<PanelOperatorPopup
id="node-2"
data={{ type: BlockEnum.Tool, title: 'Workflow Tool', desc: '', provider_type: 'workflow', provider_id: 'workflow-tool' } as any}
onClosePopup={vi.fn()}
showHelpLink={false}
/>,
{
nodes: [],
edges: [],
},
)
expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow')
mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType<typeof useNodesReadOnly>)
mockUseNodeMetaData.mockReturnValueOnce({
isTypeFixed: true,
isSingleton: true,
isUndeletable: true,
description: 'Read only node',
author: 'Dify',
} as ReturnType<typeof useNodeMetaData>)
rerender(
<PanelOperatorPopup
id="node-3"
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
onClosePopup={vi.fn()}
showHelpLink={false}
/>,
)
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SupportVarInput from '../index'
describe('SupportVarInput', () => {
it('should render plain text, highlighted variables, and preserved line breaks', () => {
render(<SupportVarInput value={'Hello {{user_name}}\nWorld'} />)
expect(screen.getByText('World').closest('[title]')).toHaveAttribute('title', 'Hello {{user_name}}\nWorld')
expect(screen.getByText('user_name')).toBeInTheDocument()
expect(screen.getByText('Hello')).toBeInTheDocument()
expect(screen.getByText('World')).toBeInTheDocument()
})
it('should show the focused child content and call onFocus when activated', async () => {
const user = userEvent.setup()
const onFocus = vi.fn()
render(
<SupportVarInput
isFocus
value="draft"
onFocus={onFocus}
>
<input aria-label="inline-editor" />
</SupportVarInput>,
)
const editor = screen.getByRole('textbox', { name: 'inline-editor' })
expect(editor).toBeInTheDocument()
expect(screen.queryByTitle('draft')).not.toBeInTheDocument()
await user.click(editor)
expect(onFocus).toHaveBeenCalledTimes(1)
})
it('should keep the static preview visible when the input is read-only', () => {
render(
<SupportVarInput
isFocus
readonly
value="readonly content"
>
<input aria-label="hidden-editor" />
</SupportVarInput>,
)
expect(screen.queryByRole('textbox', { name: 'hidden-editor' })).not.toBeInTheDocument()
expect(screen.getByTitle('readonly content')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,72 @@
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { VarType } from '@/app/components/workflow/types'
import AssignedVarReferencePopup from '../assigned-var-reference-popup'
const mockVarReferenceVars = vi.fn()
vi.mock('../var-reference-vars', () => ({
default: ({
vars,
onChange,
itemWidth,
isSupportFileVar,
}: {
vars: NodeOutPutVar[]
onChange: (value: ValueSelector, item: Var) => void
itemWidth?: number
isSupportFileVar?: boolean
}) => {
mockVarReferenceVars({ vars, onChange, itemWidth, isSupportFileVar })
return <div data-testid="var-reference-vars">{vars.length}</div>
},
}))
const createOutputVar = (overrides: Partial<NodeOutPutVar> = {}): NodeOutPutVar => ({
nodeId: 'node-1',
title: 'Node One',
vars: [{
variable: 'answer',
type: VarType.string,
}],
...overrides,
})
describe('AssignedVarReferencePopup', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the empty state when there are no assigned variables', () => {
render(
<AssignedVarReferencePopup
vars={[]}
onChange={vi.fn()}
/>,
)
expect(screen.getByText('workflow.nodes.assigner.noAssignedVars')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.assignedVarsDescription')).toBeInTheDocument()
expect(screen.queryByTestId('var-reference-vars')).not.toBeInTheDocument()
})
it('should delegate populated variable lists to the variable picker with file support enabled', () => {
const onChange = vi.fn()
render(
<AssignedVarReferencePopup
vars={[createOutputVar()]}
itemWidth={280}
onChange={onChange}
/>,
)
expect(screen.getByTestId('var-reference-vars')).toHaveTextContent('1')
expect(mockVarReferenceVars).toHaveBeenCalledWith({
vars: [createOutputVar()],
onChange,
itemWidth: 280,
isSupportFileVar: true,
})
})
})

View File

@ -1,6 +1,11 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VariableLabelInNode, VariableLabelInText } from '../index'
import VariableIcon from '../base/variable-icon'
import VariableLabel from '../base/variable-label'
import VariableName from '../base/variable-name'
import VariableNodeLabel from '../base/variable-node-label'
import { VariableIconWithColor, VariableLabelInEditor, VariableLabelInNode, VariableLabelInSelect, VariableLabelInText } from '../index'
describe('variable-label index', () => {
beforeEach(() => {
@ -39,5 +44,96 @@ describe('variable-label index', () => {
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('answer')).toBeInTheDocument()
})
it('should render the select variant with the full variable path', () => {
render(
<VariableLabelInSelect
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['source-node', 'payload', 'answer']}
/>,
)
expect(screen.getByText('payload.answer')).toBeInTheDocument()
})
it('should render the editor variant with selected styles and inline error feedback', async () => {
const user = userEvent.setup()
const { container } = render(
<VariableLabelInEditor
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['source-node', 'payload']}
isSelected
errorMsg="Invalid variable"
rightSlot={<span>suffix</span>}
/>,
)
const badge = screen.getByText('payload').closest('div')
expect(badge).toBeInTheDocument()
expect(screen.getByText('suffix')).toBeInTheDocument()
await user.hover(screen.getByText('payload'))
expect(container.querySelector('[data-icon="Warning"]')).not.toBeNull()
})
it('should render the icon helpers for environment and exception variables', () => {
const { container } = render(
<div>
<VariableIcon variables={['env', 'API_KEY']} />
<VariableIconWithColor
variables={['conversation', 'message']}
isExceptionVariable
/>
</div>,
)
expect(container.querySelectorAll('svg').length).toBeGreaterThan(0)
})
it('should render the base variable name with shortened path and title', () => {
render(
<VariableName
variables={['node-id', 'payload', 'answer']}
notShowFullPath
/>,
)
expect(screen.getByText('answer')).toHaveAttribute('title', 'answer')
})
it('should render the base node label only when node type exists', () => {
const { container, rerender } = render(<VariableNodeLabel />)
expect(container).toBeEmptyDOMElement()
rerender(
<VariableNodeLabel
nodeType={BlockEnum.Code}
nodeTitle="Code Node"
/>,
)
expect(screen.getByText('Code Node')).toBeInTheDocument()
})
it('should render the base label with variable type and right slot', () => {
render(
<VariableLabel
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['sys', 'query']}
variableType={VarType.string}
rightSlot={<span>slot</span>}
/>,
)
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('query')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('slot')).toBeInTheDocument()
})
})
})