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

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:
Coding On Star
2026-03-19 18:35:16 +08:00
committed by GitHub
parent df0ded210f
commit 4df602684b
115 changed files with 8239 additions and 1470 deletions

View File

@ -3,11 +3,10 @@ import type { RunFile } from '../../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { TransferMethod } from '@/types/app'
import { FlowType } from '@/types/common'
import { createStartNode } from '../../__tests__/fixtures'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env'
import { InputVarType, WorkflowRunningStatus } from '../../types'
import InputsPanel from '../inputs-panel'
@ -64,18 +63,17 @@ const createHooksStoreProps = (
const renderInputsPanel = (
startNode: ReturnType<typeof createStartNode>,
options?: Parameters<typeof renderWorkflowComponent>[1],
) => {
return renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow nodes={[startNode]} edges={[]} fitView />
<InputsPanel onRun={vi.fn()} />
</ReactFlowProvider>
</div>,
options,
options?: Omit<Parameters<typeof renderWorkflowFlowComponent>[1], 'nodes' | 'edges'>,
onRun = vi.fn(),
) =>
renderWorkflowFlowComponent(
<InputsPanel onRun={onRun} />,
{
nodes: [startNode],
edges: [],
...options,
},
)
}
describe('InputsPanel', () => {
beforeEach(() => {
@ -169,34 +167,24 @@ describe('InputsPanel', () => {
const onRun = vi.fn()
const handleRun = vi.fn()
renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow
nodes={[
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
required: true,
default: 'default question',
},
],
},
}),
]}
edges={[]}
fitView
/>
<InputsPanel onRun={onRun} />
</ReactFlowProvider>
</div>,
renderInputsPanel(
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
required: true,
default: 'default question',
},
],
},
}),
{
hooksStoreProps: createHooksStoreProps({ handleRun }),
},
onRun,
)
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
@ -217,36 +205,25 @@ describe('InputsPanel', () => {
const onRun = vi.fn()
const handleRun = vi.fn()
renderWorkflowComponent(
<div style={{ width: 800, height: 600 }}>
<ReactFlowProvider>
<ReactFlow
nodes={[
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
required: true,
},
{
type: InputVarType.checkbox,
variable: 'confirmed',
label: 'Confirmed',
required: false,
},
],
},
}),
]}
edges={[]}
fitView
/>
<InputsPanel onRun={onRun} />
</ReactFlowProvider>
</div>,
renderInputsPanel(
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
required: true,
},
{
type: InputVarType.checkbox,
variable: 'confirmed',
label: 'Confirmed',
required: false,
},
],
},
}),
{
initialStoreState: {
inputs: {
@ -266,6 +243,7 @@ describe('InputsPanel', () => {
},
}),
},
onRun,
)
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))

View File

@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Empty from '../empty'
describe('VersionHistory Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Empty state should show the reset action and forward user clicks.
describe('User Interactions', () => {
it('should call onResetFilter when the reset button is clicked', async () => {
const user = userEvent.setup()
const onResetFilter = vi.fn()
render(<Empty onResetFilter={onResetFilter} />)
expect(screen.getByText('workflow.versionHistory.filter.empty')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.versionHistory.filter.reset' }))
expect(onResetFilter).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -1,10 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { WorkflowVersion } from '../../types'
import { WorkflowVersion } from '../../../types'
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockSetCurrentVersion = vi.fn()
type MockWorkflowStoreState = {
setShowWorkflowVersionHistoryPanel: ReturnType<typeof vi.fn>
currentVersion: null
setCurrentVersion: typeof mockSetCurrentVersion
}
vi.mock('@/context/app-context', () => ({
useSelector: () => ({ id: 'test-user-id' }),
}))
@ -69,7 +75,7 @@ vi.mock('@/service/use-workflow', () => ({
}),
}))
vi.mock('../../hooks', () => ({
vi.mock('../../../hooks', () => ({
useDSL: () => ({ handleExportDSL: vi.fn() }),
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }),
useWorkflowRun: () => ({
@ -78,16 +84,16 @@ vi.mock('../../hooks', () => ({
}),
}))
vi.mock('../../hooks-store', () => ({
vi.mock('../../../hooks-store', () => ({
useHooksStore: () => ({
flowId: 'test-flow-id',
flowType: 'workflow',
}),
}))
vi.mock('../../store', () => ({
useStore: (selector: (state: any) => any) => {
const state = {
vi.mock('../../../store', () => ({
useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => {
const state: MockWorkflowStoreState = {
setShowWorkflowVersionHistoryPanel: vi.fn(),
currentVersion: null,
setCurrentVersion: mockSetCurrentVersion,
@ -104,11 +110,11 @@ vi.mock('../../store', () => ({
}),
}))
vi.mock('./delete-confirm-modal', () => ({
vi.mock('../delete-confirm-modal', () => ({
default: () => null,
}))
vi.mock('./restore-confirm-modal', () => ({
vi.mock('../restore-confirm-modal', () => ({
default: () => null,
}))
@ -123,7 +129,7 @@ describe('VersionHistoryPanel', () => {
describe('Version Click Behavior', () => {
it('should call handleLoadBackupDraft when draft version is selected on mount', async () => {
const { VersionHistoryPanel } = await import('./index')
const { VersionHistoryPanel } = await import('../index')
render(
<VersionHistoryPanel
@ -137,7 +143,7 @@ describe('VersionHistoryPanel', () => {
})
it('should call handleRestoreFromPublishedWorkflow when clicking published version', async () => {
const { VersionHistoryPanel } = await import('./index')
const { VersionHistoryPanel } = await import('../index')
render(
<VersionHistoryPanel

View File

@ -0,0 +1,151 @@
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
import VersionHistoryItem from '../version-history-item'
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { pipelineId?: string }) => unknown) => selector({ pipelineId: undefined }),
}))
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-1',
graph: {
nodes: [],
edges: [],
viewport: undefined,
},
features: {},
created_at: 1710000000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
hash: 'hash-1',
updated_at: 1710000000,
updated_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
tool_published: false,
environment_variables: [],
conversation_variables: [],
rag_pipeline_variables: undefined,
version: '2024-01-01T00:00:00Z',
marked_name: 'Release 1',
marked_comment: 'Initial release',
...overrides,
})
describe('VersionHistoryItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Draft items should auto-select on mount and hide published-only metadata.
describe('Draft Behavior', () => {
it('should auto-select the draft version on mount', async () => {
const onClick = vi.fn()
render(
<VersionHistoryItem
item={createVersionHistory({
id: 'draft-version',
version: WorkflowVersion.Draft,
marked_name: '',
marked_comment: '',
})}
currentVersion={null}
latestVersionId="latest-version"
onClick={onClick}
handleClickMenuItem={vi.fn()}
isLast={false}
/>,
)
expect(screen.getByText('workflow.versionHistory.currentDraft')).toBeInTheDocument()
await waitFor(() => {
expect(onClick).toHaveBeenCalledWith(expect.objectContaining({
version: WorkflowVersion.Draft,
}))
})
expect(screen.queryByText('Initial release')).not.toBeInTheDocument()
})
})
// Published items should expose metadata and the hover context menu.
describe('Published Items', () => {
it('should open the context menu for a latest named version and forward restore', async () => {
const user = userEvent.setup()
const handleClickMenuItem = vi.fn()
const onClick = vi.fn()
render(
<VersionHistoryItem
item={createVersionHistory()}
currentVersion={null}
latestVersionId="version-1"
onClick={onClick}
handleClickMenuItem={handleClickMenuItem}
isLast={false}
/>,
)
const title = screen.getByText('Release 1')
const itemContainer = title.closest('.group')
if (!itemContainer)
throw new Error('Expected version history item container')
fireEvent.mouseEnter(itemContainer)
const triggerButton = await screen.findByRole('button')
await user.click(triggerButton)
expect(screen.getByText('workflow.versionHistory.latest')).toBeInTheDocument()
expect(screen.getByText('Initial release')).toBeInTheDocument()
expect(screen.getByText(/Alice$/)).toBeInTheDocument()
expect(screen.getByText('workflow.common.restore')).toBeInTheDocument()
expect(screen.getByText('workflow.versionHistory.editVersionInfo')).toBeInTheDocument()
expect(screen.getByText('app.export')).toBeInTheDocument()
expect(screen.getByText('workflow.versionHistory.copyId')).toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
const restoreItem = screen.getByText('workflow.common.restore').closest('.cursor-pointer')
if (!restoreItem)
throw new Error('Expected restore menu item')
fireEvent.click(restoreItem)
expect(handleClickMenuItem).toHaveBeenCalledTimes(1)
expect(handleClickMenuItem).toHaveBeenCalledWith(
VersionHistoryContextMenuOptions.restore,
VersionHistoryContextMenuOptions.restore,
)
})
it('should ignore clicks when the item is already selected', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
const item = createVersionHistory()
render(
<VersionHistoryItem
item={item}
currentVersion={item}
latestVersionId="other-version"
onClick={onClick}
handleClickMenuItem={vi.fn()}
isLast
/>,
)
await user.click(screen.getByText('Release 1'))
expect(onClick).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,102 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowVersionFilterOptions } from '../../../../types'
import FilterItem from '../filter-item'
import FilterSwitch from '../filter-switch'
import Filter from '../index'
describe('VersionHistory Filter Components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The standalone switch should reflect state and emit checked changes.
describe('FilterSwitch', () => {
it('should render the switch label and emit toggled value', async () => {
const user = userEvent.setup()
const handleSwitch = vi.fn()
render(<FilterSwitch enabled={false} handleSwitch={handleSwitch} />)
expect(screen.getByText('workflow.versionHistory.filter.onlyShowNamedVersions')).toBeInTheDocument()
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
await user.click(screen.getByRole('switch'))
expect(handleSwitch).toHaveBeenCalledWith(true)
})
})
// Filter items should show the current selection and forward the option key.
describe('FilterItem', () => {
it('should call onClick with the selected filter key', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
const { container } = render(
<FilterItem
item={{
key: WorkflowVersionFilterOptions.onlyYours,
name: 'Only Yours',
}}
isSelected
onClick={onClick}
/>,
)
expect(screen.getByText('Only Yours')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
await user.click(screen.getByText('Only Yours'))
expect(onClick).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
})
})
// The composed filter popover should open, list options, and delegate actions.
describe('Filter', () => {
it('should open the menu and forward option and switch actions', async () => {
const user = userEvent.setup()
const onClickFilterItem = vi.fn()
const handleSwitch = vi.fn()
const { container } = render(
<Filter
filterValue={WorkflowVersionFilterOptions.all}
isOnlyShowNamedVersions={false}
onClickFilterItem={onClickFilterItem}
handleSwitch={handleSwitch}
/>,
)
const trigger = container.querySelector('.h-6.w-6')
if (!trigger)
throw new Error('Expected filter trigger to exist')
await user.click(trigger)
expect(screen.getByText('workflow.versionHistory.filter.all')).toBeInTheDocument()
expect(screen.getByText('workflow.versionHistory.filter.onlyYours')).toBeInTheDocument()
await user.click(screen.getByText('workflow.versionHistory.filter.onlyYours'))
expect(onClickFilterItem).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
fireEvent.click(screen.getByRole('switch'))
expect(handleSwitch).toHaveBeenCalledWith(true)
})
it('should mark the trigger as active when a filter is applied', () => {
const { container } = render(
<Filter
filterValue={WorkflowVersionFilterOptions.onlyYours}
isOnlyShowNamedVersions={false}
onClickFilterItem={vi.fn()}
handleSwitch={vi.fn()}
/>,
)
expect(container.querySelector('.bg-state-accent-active-alt')).toBeInTheDocument()
expect(container.querySelector('.text-text-accent')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,51 @@
import { render } from '@testing-library/react'
import Loading from '../index'
import Item from '../item'
describe('VersionHistory Loading', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Individual skeleton items should hide optional rows based on edge flags.
describe('Item', () => {
it('should hide the release note placeholder for the first row', () => {
const { container } = render(
<Item
titleWidth="w-1/3"
releaseNotesWidth="w-3/4"
isFirst
isLast={false}
/>,
)
expect(container.querySelectorAll('.opacity-20')).toHaveLength(1)
expect(container.querySelector('.bg-divider-subtle')).toBeInTheDocument()
})
it('should hide the timeline connector for the last row', () => {
const { container } = render(
<Item
titleWidth="w-2/5"
releaseNotesWidth="w-4/6"
isFirst={false}
isLast
/>,
)
expect(container.querySelectorAll('.opacity-20')).toHaveLength(2)
expect(container.querySelector('.absolute.left-4.top-6')).not.toBeInTheDocument()
})
})
// The loading list should render the configured number of timeline skeleton rows.
describe('Loading List', () => {
it('should render eight loading rows with the overlay mask', () => {
const { container } = render(<Loading />)
expect(container.querySelector('.bg-dataset-chunk-list-mask-bg')).toBeInTheDocument()
expect(container.querySelectorAll('.relative.flex.gap-x-1.p-2')).toHaveLength(8)
expect(container.querySelectorAll('.opacity-20')).toHaveLength(15)
})
})
})