mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 08:58:09 +08:00
test(workflow): add unit tests for workflow components (#33910)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user