mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
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:
@ -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' }))
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user