mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
test(workflow): reorganize specs into __tests__ and align with shared test infrastructure (#33625)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,40 @@
|
||||
import type { Node } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import CandidateNode from '../candidate-node'
|
||||
import { BlockEnum } from '../types'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
|
||||
vi.mock('../candidate-node-main', () => ({
|
||||
default: ({ candidateNode }: { candidateNode: Node }) => (
|
||||
<div data-testid="candidate-node-main">{candidateNode.id}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createCandidateNode = (): Node => ({
|
||||
id: 'candidate-node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'Candidate node',
|
||||
desc: 'candidate',
|
||||
},
|
||||
})
|
||||
|
||||
describe('CandidateNode', () => {
|
||||
it('should not render when candidateNode is missing from the workflow store', () => {
|
||||
renderWorkflowComponent(<CandidateNode />)
|
||||
|
||||
expect(screen.queryByTestId('candidate-node-main')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CandidateNodeMain with the stored candidate node', () => {
|
||||
renderWorkflowComponent(<CandidateNode />, {
|
||||
initialStoreState: {
|
||||
candidateNode: createCandidateNode(),
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('candidate-node-main')).toHaveTextContent('candidate-node-1')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,81 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { getBezierPath, Position } from 'reactflow'
|
||||
import CustomConnectionLine from '../custom-connection-line'
|
||||
|
||||
const createConnectionLineProps = (
|
||||
overrides: Partial<ComponentProps<typeof CustomConnectionLine>> = {},
|
||||
): ComponentProps<typeof CustomConnectionLine> => ({
|
||||
fromX: 10,
|
||||
fromY: 20,
|
||||
toX: 70,
|
||||
toY: 80,
|
||||
fromPosition: Position.Right,
|
||||
toPosition: Position.Left,
|
||||
connectionLineType: undefined,
|
||||
connectionStatus: null,
|
||||
...overrides,
|
||||
} as ComponentProps<typeof CustomConnectionLine>)
|
||||
|
||||
describe('CustomConnectionLine', () => {
|
||||
it('should render the bezier path and target marker', () => {
|
||||
const [expectedPath] = getBezierPath({
|
||||
sourceX: 10,
|
||||
sourceY: 20,
|
||||
sourcePosition: Position.Right,
|
||||
targetX: 70,
|
||||
targetY: 80,
|
||||
targetPosition: Position.Left,
|
||||
curvature: 0.16,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomConnectionLine {...createConnectionLineProps()} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
const marker = container.querySelector('rect')
|
||||
|
||||
expect(path).toHaveAttribute('fill', 'none')
|
||||
expect(path).toHaveAttribute('stroke', '#D0D5DD')
|
||||
expect(path).toHaveAttribute('stroke-width', '2')
|
||||
expect(path).toHaveAttribute('d', expectedPath)
|
||||
|
||||
expect(marker).toHaveAttribute('x', '70')
|
||||
expect(marker).toHaveAttribute('y', '76')
|
||||
expect(marker).toHaveAttribute('width', '2')
|
||||
expect(marker).toHaveAttribute('height', '8')
|
||||
expect(marker).toHaveAttribute('fill', '#2970FF')
|
||||
})
|
||||
|
||||
it('should update the path when the endpoints change', () => {
|
||||
const [expectedPath] = getBezierPath({
|
||||
sourceX: 30,
|
||||
sourceY: 40,
|
||||
sourcePosition: Position.Right,
|
||||
targetX: 160,
|
||||
targetY: 200,
|
||||
targetPosition: Position.Left,
|
||||
curvature: 0.16,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomConnectionLine
|
||||
{...createConnectionLineProps({
|
||||
fromX: 30,
|
||||
fromY: 40,
|
||||
toX: 160,
|
||||
toY: 200,
|
||||
})}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('path')).toHaveAttribute('d', expectedPath)
|
||||
expect(container.querySelector('rect')).toHaveAttribute('x', '160')
|
||||
expect(container.querySelector('rect')).toHaveAttribute('y', '196')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,57 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import CustomEdgeLinearGradientRender from '../custom-edge-linear-gradient-render'
|
||||
|
||||
describe('CustomEdgeLinearGradientRender', () => {
|
||||
it('should render gradient definition with the provided id and positions', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomEdgeLinearGradientRender
|
||||
id="edge-gradient"
|
||||
startColor="#123456"
|
||||
stopColor="#abcdef"
|
||||
position={{
|
||||
x1: 10,
|
||||
y1: 20,
|
||||
x2: 30,
|
||||
y2: 40,
|
||||
}}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
|
||||
const gradient = container.querySelector('linearGradient')
|
||||
expect(gradient).toHaveAttribute('id', 'edge-gradient')
|
||||
expect(gradient).toHaveAttribute('gradientUnits', 'userSpaceOnUse')
|
||||
expect(gradient).toHaveAttribute('x1', '10')
|
||||
expect(gradient).toHaveAttribute('y1', '20')
|
||||
expect(gradient).toHaveAttribute('x2', '30')
|
||||
expect(gradient).toHaveAttribute('y2', '40')
|
||||
})
|
||||
|
||||
it('should render start and stop colors at both ends of the gradient', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomEdgeLinearGradientRender
|
||||
id="gradient-colors"
|
||||
startColor="#111111"
|
||||
stopColor="#222222"
|
||||
position={{
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 100,
|
||||
y2: 100,
|
||||
}}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
|
||||
const stops = container.querySelectorAll('stop')
|
||||
expect(stops).toHaveLength(2)
|
||||
expect(stops[0]).toHaveAttribute('offset', '0%')
|
||||
expect(stops[0].getAttribute('style')).toContain('stop-color: rgb(17, 17, 17)')
|
||||
expect(stops[0].getAttribute('style')).toContain('stop-opacity: 1')
|
||||
expect(stops[1]).toHaveAttribute('offset', '100%')
|
||||
expect(stops[1].getAttribute('style')).toContain('stop-color: rgb(34, 34, 34)')
|
||||
expect(stops[1].getAttribute('style')).toContain('stop-opacity: 1')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,127 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import DSLExportConfirmModal from '../dsl-export-confirm-modal'
|
||||
|
||||
const envList = [
|
||||
{
|
||||
id: 'env-1',
|
||||
name: 'SECRET_TOKEN',
|
||||
value: 'masked-value',
|
||||
value_type: 'secret' as const,
|
||||
description: 'secret token',
|
||||
},
|
||||
]
|
||||
|
||||
const multiEnvList = [
|
||||
...envList,
|
||||
{
|
||||
id: 'env-2',
|
||||
name: 'SERVICE_KEY',
|
||||
value: 'another-secret',
|
||||
value_type: 'secret' as const,
|
||||
description: 'service key',
|
||||
},
|
||||
]
|
||||
|
||||
describe('DSLExportConfirmModal', () => {
|
||||
it('should render environment rows and close when cancel is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument()
|
||||
expect(screen.getByText('masked-value')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should confirm with exportSecrets=false by default', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.env.export.ignore' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(false)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should confirm with exportSecrets=true after toggling the checkbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('checkbox'))
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should also toggle exportSecrets when the label text is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.env.export.checkbox'))
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render border separators for all rows except the last one', () => {
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={multiEnvList}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const firstNameCell = screen.getByText('SECRET_TOKEN').closest('td')
|
||||
const lastNameCell = screen.getByText('SERVICE_KEY').closest('td')
|
||||
const firstValueCell = screen.getByText('masked-value').closest('td')
|
||||
const lastValueCell = screen.getByText('another-secret').closest('td')
|
||||
|
||||
expect(firstNameCell).toHaveClass('border-b')
|
||||
expect(firstValueCell).toHaveClass('border-b')
|
||||
expect(lastNameCell).not.toHaveClass('border-b')
|
||||
expect(lastValueCell).not.toHaveClass('border-b')
|
||||
})
|
||||
})
|
||||
193
web/app/components/workflow/__tests__/features.spec.tsx
Normal file
193
web/app/components/workflow/__tests__/features.spec.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import type { InputVar } from '../types'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ReactFlow, { ReactFlowProvider, useNodes } from 'reactflow'
|
||||
import Features from '../features'
|
||||
import { InputVarType } from '../types'
|
||||
import { createStartNode } from './fixtures'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockHandleAddVariable = vi.fn()
|
||||
|
||||
let mockIsChatMode = true
|
||||
let mockNodesReadOnly = false
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useIsChatMode: () => mockIsChatMode,
|
||||
useNodesReadOnly: () => ({
|
||||
nodesReadOnly: mockNodesReadOnly,
|
||||
}),
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../nodes/start/use-config', () => ({
|
||||
default: () => ({
|
||||
handleAddVariable: mockHandleAddVariable,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
|
||||
default: ({
|
||||
show,
|
||||
isChatMode,
|
||||
disabled,
|
||||
onChange,
|
||||
onClose,
|
||||
onAutoAddPromptVariable,
|
||||
workflowVariables,
|
||||
}: {
|
||||
show: boolean
|
||||
isChatMode: boolean
|
||||
disabled: boolean
|
||||
onChange: () => void
|
||||
onClose: () => void
|
||||
onAutoAddPromptVariable: (variables: PromptVariable[]) => void
|
||||
workflowVariables: InputVar[]
|
||||
}) => {
|
||||
if (!show)
|
||||
return null
|
||||
|
||||
return (
|
||||
<section aria-label="new feature panel">
|
||||
<div>{isChatMode ? 'chat mode' : 'completion mode'}</div>
|
||||
<div>{disabled ? 'panel disabled' : 'panel enabled'}</div>
|
||||
<ul aria-label="workflow variables">
|
||||
{workflowVariables.map(variable => (
|
||||
<li key={variable.variable}>
|
||||
{`${variable.label}:${variable.variable}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={onChange}>open features</button>
|
||||
<button type="button" onClick={onClose}>close features</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAutoAddPromptVariable([{
|
||||
key: 'opening_statement',
|
||||
name: 'Opening Statement',
|
||||
type: 'string',
|
||||
max_length: 200,
|
||||
required: true,
|
||||
}])}
|
||||
>
|
||||
add required variable
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAutoAddPromptVariable([{
|
||||
key: 'optional_statement',
|
||||
name: 'Optional Statement',
|
||||
type: 'string',
|
||||
max_length: 120,
|
||||
}])}
|
||||
>
|
||||
add optional variable
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const startNode = createStartNode({
|
||||
id: 'start-node',
|
||||
data: {
|
||||
variables: [{ variable: 'existing_variable', label: 'Existing Variable' }],
|
||||
},
|
||||
})
|
||||
|
||||
const DelayedFeatures = () => {
|
||||
const nodes = useNodes()
|
||||
|
||||
if (!nodes.length)
|
||||
return null
|
||||
|
||||
return <Features />
|
||||
}
|
||||
|
||||
const renderFeatures = (options?: Parameters<typeof renderWorkflowComponent>[1]) => {
|
||||
return renderWorkflowComponent(
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow nodes={[startNode]} edges={[]} fitView />
|
||||
<DelayedFeatures />
|
||||
</ReactFlowProvider>
|
||||
</div>,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Features', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsChatMode = true
|
||||
mockNodesReadOnly = false
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should pass workflow context to the feature panel', () => {
|
||||
renderFeatures()
|
||||
|
||||
expect(screen.getByText('chat mode')).toBeInTheDocument()
|
||||
expect(screen.getByText('panel enabled')).toBeInTheDocument()
|
||||
expect(screen.getByRole('list', { name: 'workflow variables' })).toHaveTextContent('Existing Variable:existing_variable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should sync the draft and open the workflow feature panel when users change features', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderFeatures()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'open features' }))
|
||||
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().showFeaturesPanel).toBe(true)
|
||||
})
|
||||
|
||||
it('should close the workflow feature panel and transform required prompt variables', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderFeatures({
|
||||
initialStoreState: {
|
||||
showFeaturesPanel: true,
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'close features' }))
|
||||
expect(store.getState().showFeaturesPanel).toBe(false)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'add required variable' }))
|
||||
expect(mockHandleAddVariable).toHaveBeenCalledWith({
|
||||
variable: 'opening_statement',
|
||||
label: 'Opening Statement',
|
||||
type: InputVarType.textInput,
|
||||
max_length: 200,
|
||||
required: true,
|
||||
options: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should default prompt variables to optional when required is omitted', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderFeatures()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'add optional variable' }))
|
||||
expect(mockHandleAddVariable).toHaveBeenCalledWith({
|
||||
variable: 'optional_statement',
|
||||
label: 'Optional Statement',
|
||||
type: InputVarType.textInput,
|
||||
max_length: 120,
|
||||
required: false,
|
||||
options: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -16,8 +16,8 @@ import * as React from 'react'
|
||||
type MockNode = {
|
||||
id: string
|
||||
position: { x: number, y: number }
|
||||
width?: number
|
||||
height?: number
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
parentId?: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
import SyncingDataModal from '../syncing-data-modal'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
|
||||
describe('SyncingDataModal', () => {
|
||||
it('should not render when workflow draft syncing is disabled', () => {
|
||||
const { container } = renderWorkflowComponent(<SyncingDataModal />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render the fullscreen overlay when workflow draft syncing is enabled', () => {
|
||||
const { container } = renderWorkflowComponent(<SyncingDataModal />, {
|
||||
initialStoreState: {
|
||||
isSyncingWorkflowDraft: true,
|
||||
},
|
||||
})
|
||||
|
||||
const overlay = container.firstElementChild
|
||||
expect(overlay).toHaveClass('absolute', 'inset-0')
|
||||
expect(overlay).toHaveClass('z-[9999]')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user