mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 00:48:04 +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,308 @@
|
||||
import type { Shape } from '../../store/workflow'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { WorkflowVersion } from '../../types'
|
||||
import HeaderInNormal from '../header-in-normal'
|
||||
import HeaderInRestoring from '../header-in-restoring'
|
||||
import HeaderInHistory from '../header-in-view-history'
|
||||
|
||||
const mockUseNodes = vi.fn()
|
||||
const mockHandleBackupDraft = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockHandleNodeSelect = vi.fn()
|
||||
const mockHandleRefreshWorkflowDraft = vi.fn()
|
||||
const mockCloseAllInputFieldPanels = vi.fn()
|
||||
const mockInvalidAllLastRun = vi.fn()
|
||||
const mockRestoreWorkflow = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
const mockRunAndHistory = vi.fn()
|
||||
const mockViewHistory = vi.fn()
|
||||
|
||||
let mockNodesReadOnly = false
|
||||
let mockTheme: 'light' | 'dark' = 'light'
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useNodes: () => mockUseNodes(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly }),
|
||||
useNodesInteractions: () => ({ handleNodeSelect: mockHandleNodeSelect }),
|
||||
useWorkflowRun: () => ({
|
||||
handleBackupDraft: mockHandleBackupDraft,
|
||||
handleLoadBackupDraft: mockHandleLoadBackupDraft,
|
||||
}),
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: vi.fn(),
|
||||
}),
|
||||
useWorkflowRefreshDraft: () => ({
|
||||
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
|
||||
useInputFieldPanel: () => ({
|
||||
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: mockTheme,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidAllLastRun: () => mockInvalidAllLastRun,
|
||||
useRestoreWorkflow: () => ({
|
||||
mutateAsync: mockRestoreWorkflow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/toast', () => ({
|
||||
default: {
|
||||
notify: (payload: unknown) => mockNotify(payload),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../editing-title', () => ({
|
||||
default: () => <div>editing-title</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../scroll-to-selected-node-button', () => ({
|
||||
default: () => <div>scroll-button</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../env-button', () => ({
|
||||
default: ({ disabled }: { disabled: boolean }) => <div data-testid="env-button">{`${disabled}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../global-variable-button', () => ({
|
||||
default: ({ disabled }: { disabled: boolean }) => <div data-testid="global-variable-button">{`${disabled}`}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../run-and-history', () => ({
|
||||
default: (props: object) => {
|
||||
mockRunAndHistory(props)
|
||||
return <div data-testid="run-and-history" />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../version-history-button', () => ({
|
||||
default: ({ onClick }: { onClick: () => void }) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
version-history
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../restoring-title', () => ({
|
||||
default: () => <div>restoring-title</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../running-title', () => ({
|
||||
default: () => <div>running-title</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../view-history', () => ({
|
||||
default: (props: { withText?: boolean }) => {
|
||||
mockViewHistory(props)
|
||||
return <div data-testid="view-history">{props.withText ? 'with-text' : 'icon-only'}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const createSelectedNode = (selected = true) => ({
|
||||
id: 'node-selected',
|
||||
data: {
|
||||
selected,
|
||||
},
|
||||
})
|
||||
|
||||
const createBackupDraft = (): NonNullable<Shape['backupDraft']> => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
environmentVariables: [],
|
||||
})
|
||||
|
||||
const createCurrentVersion = (): NonNullable<Shape['currentVersion']> => ({
|
||||
id: 'version-1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Tester',
|
||||
email: 'tester@example.com',
|
||||
},
|
||||
hash: 'hash-1',
|
||||
updated_at: 0,
|
||||
updated_by: {
|
||||
id: 'user-1',
|
||||
name: 'Tester',
|
||||
email: 'tester@example.com',
|
||||
},
|
||||
tool_published: false,
|
||||
environment_variables: [],
|
||||
version: WorkflowVersion.Latest,
|
||||
marked_name: '',
|
||||
marked_comment: '',
|
||||
})
|
||||
|
||||
describe('Header layout components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockNodesReadOnly = false
|
||||
mockTheme = 'light'
|
||||
mockUseNodes.mockReturnValue([])
|
||||
mockRestoreWorkflow.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('HeaderInNormal', () => {
|
||||
it('should render slots, pass read-only state to action buttons, and start restoring mode', () => {
|
||||
mockNodesReadOnly = true
|
||||
mockUseNodes.mockReturnValue([createSelectedNode()])
|
||||
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInNormal
|
||||
components={{
|
||||
left: <div>left-slot</div>,
|
||||
middle: <div>middle-slot</div>,
|
||||
chatVariableTrigger: <div>chat-trigger</div>,
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
initialStoreState: {
|
||||
showEnvPanel: true,
|
||||
showDebugAndPreviewPanel: true,
|
||||
showVariableInspectPanel: true,
|
||||
showChatVariablePanel: true,
|
||||
showGlobalVariablePanel: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText('editing-title')).toBeInTheDocument()
|
||||
expect(screen.getByText('scroll-button')).toBeInTheDocument()
|
||||
expect(screen.getByText('left-slot')).toBeInTheDocument()
|
||||
expect(screen.getByText('middle-slot')).toBeInTheDocument()
|
||||
expect(screen.getByText('chat-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('env-button')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('global-variable-button')).toHaveTextContent('true')
|
||||
expect(mockRunAndHistory).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'version-history' }))
|
||||
|
||||
expect(mockHandleBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-selected', true)
|
||||
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().isRestoring).toBe(true)
|
||||
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(true)
|
||||
expect(store.getState().showEnvPanel).toBe(false)
|
||||
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
|
||||
expect(store.getState().showVariableInspectPanel).toBe(false)
|
||||
expect(store.getState().showChatVariablePanel).toBe(false)
|
||||
expect(store.getState().showGlobalVariablePanel).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeaderInRestoring', () => {
|
||||
it('should cancel restoring mode and reopen the editor state', () => {
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInRestoring />,
|
||||
{
|
||||
initialStoreState: {
|
||||
isRestoring: true,
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowType: FlowType.appFlow,
|
||||
flowId: 'flow-1',
|
||||
fileSettings: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.exitVersions' }))
|
||||
|
||||
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().isRestoring).toBe(false)
|
||||
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
|
||||
})
|
||||
|
||||
it('should restore the selected version, clear backup state, and forward lifecycle callbacks', async () => {
|
||||
const onRestoreSettled = vi.fn()
|
||||
const deleteAllInspectVars = vi.fn()
|
||||
const currentVersion = createCurrentVersion()
|
||||
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInRestoring onRestoreSettled={onRestoreSettled} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
isRestoring: true,
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
backupDraft: createBackupDraft(),
|
||||
currentVersion,
|
||||
deleteAllInspectVars,
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowType: FlowType.appFlow,
|
||||
flowId: 'flow-1',
|
||||
fileSettings: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/flow-1/workflows/version-1/restore')
|
||||
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
|
||||
expect(store.getState().isRestoring).toBe(false)
|
||||
expect(store.getState().backupDraft).toBeUndefined()
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(deleteAllInspectVars).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidAllLastRun).toHaveBeenCalledTimes(1)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'workflow.versionHistory.action.restoreSuccess',
|
||||
})
|
||||
})
|
||||
expect(onRestoreSettled).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeaderInHistory', () => {
|
||||
it('should render the history trigger with text and return to edit mode', () => {
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInHistory viewHistoryProps={{ historyUrl: '/history' } as never} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'history-1',
|
||||
} as Shape['historyWorkflowData'],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText('running-title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('view-history')).toHaveTextContent('with-text')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.goBackToEdit' }))
|
||||
|
||||
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().historyWorkflowData).toBeUndefined()
|
||||
expect(mockViewHistory).toHaveBeenCalledWith(expect.objectContaining({
|
||||
withText: true,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
106
web/app/components/workflow/header/__tests__/index.spec.tsx
Normal file
106
web/app/components/workflow/header/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Header from '../index'
|
||||
|
||||
let mockPathname = '/apps/demo/workflow'
|
||||
let mockMaximizeCanvas = false
|
||||
let mockWorkflowMode = {
|
||||
normal: true,
|
||||
restoring: false,
|
||||
viewHistory: false,
|
||||
}
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useWorkflowMode: () => mockWorkflowMode,
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: <T,>(selector: (state: { maximizeCanvas: boolean }) => T) => selector({
|
||||
maximizeCanvas: mockMaximizeCanvas,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/dynamic', async () => {
|
||||
const ReactModule = await import('react')
|
||||
|
||||
return {
|
||||
default: (
|
||||
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
|
||||
) => {
|
||||
const DynamicComponent = (props: Record<string, unknown>) => {
|
||||
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
|
||||
|
||||
ReactModule.useEffect(() => {
|
||||
let mounted = true
|
||||
loader().then((mod) => {
|
||||
if (mounted)
|
||||
setLoaded(() => mod.default)
|
||||
})
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return Loaded ? <Loaded {...props} /> : null
|
||||
}
|
||||
|
||||
return DynamicComponent
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../header-in-normal', () => ({
|
||||
default: () => <div data-testid="header-normal">normal-layout</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../header-in-view-history', () => ({
|
||||
default: () => <div data-testid="header-history">history-layout</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../header-in-restoring', () => ({
|
||||
default: () => <div data-testid="header-restoring">restoring-layout</div>,
|
||||
}))
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPathname = '/apps/demo/workflow'
|
||||
mockMaximizeCanvas = false
|
||||
mockWorkflowMode = {
|
||||
normal: true,
|
||||
restoring: false,
|
||||
viewHistory: false,
|
||||
}
|
||||
})
|
||||
|
||||
it('should render the normal layout and show the maximize spacer on workflow canvases', () => {
|
||||
mockMaximizeCanvas = true
|
||||
|
||||
const { container } = render(<Header />)
|
||||
|
||||
expect(screen.getByTestId('header-normal')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('header-history')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('header-restoring')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.h-14.w-\\[52px\\]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should switch between history and restoring layouts and skip the spacer outside canvas routes', async () => {
|
||||
mockPathname = '/apps/demo/logs'
|
||||
mockWorkflowMode = {
|
||||
normal: false,
|
||||
restoring: true,
|
||||
viewHistory: true,
|
||||
}
|
||||
|
||||
const { container } = render(<Header />)
|
||||
|
||||
expect(await screen.findByTestId('header-history')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('header-restoring')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('header-normal')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.h-14.w-\\[52px\\]')).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user