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

@ -0,0 +1,168 @@
import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '../../types'
import SpecialResultPanel from '../special-result-panel'
const mocks = vi.hoisted(() => ({
retryPanel: vi.fn(),
iterationPanel: vi.fn(),
loopPanel: vi.fn(),
agentPanel: vi.fn(),
}))
vi.mock('../retry-log', () => ({
RetryResultPanel: ({ list }: { list: NodeTracing[] }) => {
mocks.retryPanel(list)
return <div data-testid="retry-result-panel">{list.length}</div>
},
}))
vi.mock('../iteration-log', () => ({
IterationResultPanel: ({ list }: { list: NodeTracing[][] }) => {
mocks.iterationPanel(list)
return <div data-testid="iteration-result-panel">{list.length}</div>
},
}))
vi.mock('../loop-log', () => ({
LoopResultPanel: ({ list }: { list: NodeTracing[][] }) => {
mocks.loopPanel(list)
return <div data-testid="loop-result-panel">{list.length}</div>
},
}))
vi.mock('../agent-log', () => ({
AgentResultPanel: ({ agentOrToolLogItemStack }: { agentOrToolLogItemStack: AgentLogItemWithChildren[] }) => {
mocks.agentPanel(agentOrToolLogItemStack)
return <div data-testid="agent-result-panel">{agentOrToolLogItemStack.length}</div>
},
}))
const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'node-1',
node_type: BlockEnum.Code,
title: 'Code',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: 'succeeded',
error: '',
elapsed_time: 0.2,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 1710000000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
finished_at: 1710000001,
execution_metadata: undefined,
...overrides,
})
const createAgentLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
node_execution_id: 'exec-1',
message_id: 'message-1',
node_id: 'node-1',
label: 'Step 1',
data: {},
status: 'succeeded',
children: [],
...overrides,
})
describe('SpecialResultPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The wrapper should isolate clicks from the parent tracing card.
describe('Event Isolation', () => {
it('should stop click propagation at the wrapper level', () => {
const parentClick = vi.fn()
const { container } = render(
<div onClick={parentClick}>
<SpecialResultPanel />
</div>,
)
const panelRoot = container.firstElementChild?.firstElementChild
if (!panelRoot)
throw new Error('Expected panel root element')
fireEvent.click(panelRoot)
expect(parentClick).not.toHaveBeenCalled()
})
})
// Panel branches should render only when their required props are present.
describe('Conditional Panels', () => {
it('should render retry, iteration, loop, and agent panels when their data is provided', () => {
const retryList = [createNodeTracing()]
const iterationList = [[createNodeTracing({ id: 'iter-1' })]]
const loopList = [[createNodeTracing({ id: 'loop-1' })]]
const agentStack = [createAgentLogItem()]
const agentMap = {
'message-1': [createAgentLogItem()],
}
render(
<SpecialResultPanel
showRetryDetail
setShowRetryDetailFalse={vi.fn()}
retryResultList={retryList}
showIteratingDetail
setShowIteratingDetailFalse={vi.fn()}
iterationResultList={iterationList}
showLoopingDetail
setShowLoopingDetailFalse={vi.fn()}
loopResultList={loopList}
agentOrToolLogItemStack={agentStack}
agentOrToolLogListMap={agentMap}
handleShowAgentOrToolLog={vi.fn()}
/>,
)
expect(screen.getByTestId('retry-result-panel')).toHaveTextContent('1')
expect(screen.getByTestId('iteration-result-panel')).toHaveTextContent('1')
expect(screen.getByTestId('loop-result-panel')).toHaveTextContent('1')
expect(screen.getByTestId('agent-result-panel')).toHaveTextContent('1')
expect(mocks.retryPanel).toHaveBeenCalledWith(retryList)
expect(mocks.iterationPanel).toHaveBeenCalledWith(iterationList)
expect(mocks.loopPanel).toHaveBeenCalledWith(loopList)
expect(mocks.agentPanel).toHaveBeenCalledWith(agentStack)
})
it('should keep panels hidden when required guards are missing', () => {
render(
<SpecialResultPanel
showRetryDetail
retryResultList={[]}
showIteratingDetail
iterationResultList={[]}
showLoopingDetail
loopResultList={[]}
agentOrToolLogItemStack={[createAgentLogItem()]}
/>,
)
expect(screen.queryByTestId('retry-result-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('iteration-result-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('loop-result-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('agent-result-panel')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import StatusContainer from '../status-container'
vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(),
}))
const mockUseTheme = vi.mocked(useTheme)
describe('StatusContainer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
})
// Status styling should follow the current theme and runtime status.
describe('Status Variants', () => {
it('should render success styling for the light theme', () => {
const { container } = render(
<StatusContainer status="succeeded">
<span>Finished</span>
</StatusContainer>,
)
expect(screen.getByText('Finished')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('bg-workflow-display-success-bg')
expect(container.firstElementChild).toHaveClass('text-text-success')
expect(container.querySelector('.bg-\\[url\\(\\~\\@\\/app\\/components\\/workflow\\/run\\/assets\\/highlight\\.svg\\)\\]')).toBeInTheDocument()
})
it('should render failed styling for the dark theme', () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark } as ReturnType<typeof useTheme>)
const { container } = render(
<StatusContainer status="failed">
<span>Failed</span>
</StatusContainer>,
)
expect(container.firstElementChild).toHaveClass('bg-workflow-display-error-bg')
expect(container.firstElementChild).toHaveClass('text-text-warning')
expect(container.querySelector('.bg-\\[url\\(\\~\\@\\/app\\/components\\/workflow\\/run\\/assets\\/highlight-dark\\.svg\\)\\]')).toBeInTheDocument()
})
it('should render warning styling for paused runs', () => {
const { container } = render(
<StatusContainer status="paused">
<span>Paused</span>
</StatusContainer>,
)
expect(container.firstElementChild).toHaveClass('bg-workflow-display-warning-bg')
expect(container.firstElementChild).toHaveClass('text-text-destructive')
})
})
})

View File

@ -1,8 +1,9 @@
import type { WorkflowPausedDetailsResponse } from '@/models/log'
import { render, screen } from '@testing-library/react'
import { createDocLinkMock, resolveDocLink } from '../../__tests__/i18n'
import Status from '../status'
const mockDocLink = vi.fn((path: string) => `https://docs.example.com${path}`)
const mockDocLink = createDocLinkMock()
const mockUseWorkflowPausedDetails = vi.fn()
vi.mock('@/context/i18n', () => ({
@ -79,7 +80,7 @@ describe('Status', () => {
const learnMoreLink = screen.getByRole('link', { name: 'workflow.common.learnMore' })
expect(screen.getByText('EXCEPTION')).toBeInTheDocument()
expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type')
expect(learnMoreLink).toHaveAttribute('href', resolveDocLink('/use-dify/debug/error-type'))
expect(mockDocLink).toHaveBeenCalledWith('/use-dify/debug/error-type')
})

View File

@ -0,0 +1,112 @@
import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '../../../types'
import AgentLogTrigger from '../agent-log-trigger'
const createAgentLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
node_execution_id: 'exec-1',
message_id: 'message-1',
node_id: 'node-1',
label: 'Step 1',
data: {},
status: 'succeeded',
children: [],
...overrides,
})
const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'node-1',
node_type: BlockEnum.Agent,
title: 'Agent',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: 'succeeded',
error: '',
elapsed_time: 0.2,
execution_metadata: {
total_tokens: 0,
total_price: 0,
currency: 'USD',
tool_info: {
agent_strategy: 'Plan and execute',
},
},
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 1710000000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
finished_at: 1710000001,
agentLog: [createAgentLogItem()],
...overrides,
})
describe('AgentLogTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Agent triggers should expose strategy text and open the log stack payload.
describe('User Interactions', () => {
it('should show the agent strategy and pass the log payload on click', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const agentLog = [createAgentLogItem({ message_id: 'message-1' })]
render(
<AgentLogTrigger
nodeInfo={createNodeTracing({ agentLog })}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
expect(screen.getByText('workflow.nodes.agent.strategy.label')).toBeInTheDocument()
expect(screen.getByText('Plan and execute')).toBeInTheDocument()
expect(screen.getByText('runLog.detail')).toBeInTheDocument()
await user.click(screen.getByText('Plan and execute'))
expect(onShowAgentOrToolLog).toHaveBeenCalledWith({
message_id: 'trace-1',
children: agentLog,
})
})
it('should still open the detail view when no strategy label is available', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
render(
<AgentLogTrigger
nodeInfo={createNodeTracing({
execution_metadata: {
total_tokens: 0,
total_price: 0,
currency: 'USD',
},
})}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
await user.click(screen.getByText('runLog.detail'))
expect(onShowAgentOrToolLog).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,149 @@
import type { LoopDurationMap, LoopVariableMap, NodeTracing } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '../../../types'
import LoopLogTrigger from '../loop-log-trigger'
const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'loop-node',
node_type: BlockEnum.Loop,
title: 'Loop',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: 'succeeded',
error: '',
elapsed_time: 0.2,
execution_metadata: {
total_tokens: 0,
total_price: 0,
currency: 'USD',
},
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 1710000000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
finished_at: 1710000001,
...overrides,
})
describe('LoopLogTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Loop triggers should summarize count/error status and forward structured details.
describe('Structured Detail Handling', () => {
it('should pass existing loop details, durations, and variables to the callback', async () => {
const user = userEvent.setup()
const onShowLoopResultList = vi.fn()
const detailList = [
[createNodeTracing({ id: 'loop-1-step-1', status: 'succeeded' })],
[createNodeTracing({ id: 'loop-2-step-1', status: 'failed' })],
]
const loopDurationMap: LoopDurationMap = { 0: 1.2, 1: 2.5 }
const loopVariableMap: LoopVariableMap = { 1: { item: 'alpha' } }
render(
<div onClick={vi.fn()}>
<LoopLogTrigger
nodeInfo={createNodeTracing({
details: detailList,
loopDurationMap,
execution_metadata: {
total_tokens: 0,
total_price: 0,
currency: 'USD',
loop_duration_map: loopDurationMap,
loop_variable_map: loopVariableMap,
},
})}
onShowLoopResultList={onShowLoopResultList}
/>
</div>,
)
expect(screen.getByText(/workflow\.nodes\.loop\.loop/)).toBeInTheDocument()
expect(screen.getByText(/workflow\.nodes\.loop\.error/)).toBeInTheDocument()
await user.click(screen.getByRole('button'))
expect(onShowLoopResultList).toHaveBeenCalledWith(detailList, loopDurationMap, loopVariableMap)
})
it('should reconstruct loop detail groups from execution metadata when details are absent', async () => {
const user = userEvent.setup()
const onShowLoopResultList = vi.fn()
const loopDurationMap: LoopDurationMap = {
'parallel-1': 1.5,
'2': 2.2,
}
const allExecutions = [
createNodeTracing({
id: 'parallel-child',
execution_metadata: {
total_tokens: 0,
total_price: 0,
currency: 'USD',
parallel_mode_run_id: 'parallel-1',
},
}),
createNodeTracing({
id: 'serial-child',
execution_metadata: {
total_tokens: 0,
total_price: 0,
currency: 'USD',
loop_id: 'loop-node',
loop_index: 2,
},
}),
]
render(
<LoopLogTrigger
nodeInfo={createNodeTracing({
details: undefined,
execution_metadata: {
total_tokens: 0,
total_price: 0,
currency: 'USD',
loop_duration_map: loopDurationMap,
loop_variable_map: {},
},
})}
allExecutions={allExecutions}
onShowLoopResultList={onShowLoopResultList}
/>,
)
await user.click(screen.getByRole('button'))
expect(onShowLoopResultList).toHaveBeenCalledTimes(1)
const [structuredList, durations, variableMap] = onShowLoopResultList.mock.calls[0]
expect(structuredList).toHaveLength(2)
expect(structuredList).toEqual(
expect.arrayContaining([
[allExecutions[0]],
[allExecutions[1]],
]),
)
expect(durations).toEqual(loopDurationMap)
expect(variableMap).toEqual({})
})
})
})

View File

@ -0,0 +1,90 @@
import type { NodeTracing } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '../../../types'
import RetryLogTrigger from '../retry-log-trigger'
const createNodeTracing = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'node-1',
node_type: BlockEnum.Code,
title: 'Code',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: 'succeeded',
error: '',
elapsed_time: 0.2,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 1710000000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
finished_at: 1710000001,
outputs_full_content: undefined,
execution_metadata: undefined,
extras: undefined,
retryDetail: [],
...overrides,
})
describe('RetryLogTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Clicking the trigger should stop bubbling and expose the retry detail list.
describe('User Interactions', () => {
it('should forward retry details and stop parent clicks', async () => {
const user = userEvent.setup()
const onShowRetryResultList = vi.fn()
const parentClick = vi.fn()
const retryDetail = [
createNodeTracing({ id: 'retry-1' }),
createNodeTracing({ id: 'retry-2' }),
]
render(
<div onClick={parentClick}>
<RetryLogTrigger
nodeInfo={createNodeTracing({ retryDetail })}
onShowRetryResultList={onShowRetryResultList}
/>
</div>,
)
await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.retry.retries:{"num":2}' }))
expect(onShowRetryResultList).toHaveBeenCalledWith(retryDetail)
expect(parentClick).not.toHaveBeenCalled()
})
it('should fall back to an empty retry list when details are missing', async () => {
const user = userEvent.setup()
const onShowRetryResultList = vi.fn()
render(
<RetryLogTrigger
nodeInfo={createNodeTracing({ retryDetail: undefined })}
onShowRetryResultList={onShowRetryResultList}
/>,
)
await user.click(screen.getByRole('button'))
expect(onShowRetryResultList).toHaveBeenCalledWith([])
})
})
})

View File

@ -1,4 +1,4 @@
import parseDSL from './graph-to-log-struct'
import parseDSL from '../graph-to-log-struct'
describe('parseDSL', () => {
it('should parse plain nodes correctly', () => {

View File

@ -0,0 +1,13 @@
import format from '..'
import { agentNodeData, multiStepsCircle, oneStepCircle } from '../data'
describe('agent', () => {
it('list should transform to tree', () => {
expect(format(agentNodeData.in as unknown as Parameters<typeof format>[0])).toEqual(agentNodeData.expect)
})
it('list should remove circle log item', () => {
expect(format(oneStepCircle.in as unknown as Parameters<typeof format>[0])).toEqual(oneStepCircle.expect)
expect(format(multiStepsCircle.in as unknown as Parameters<typeof format>[0])).toEqual(multiStepsCircle.expect)
})
})

View File

@ -1,15 +0,0 @@
import format from '.'
import { agentNodeData, multiStepsCircle, oneStepCircle } from './data'
describe('agent', () => {
it('list should transform to tree', () => {
// console.log(format(agentNodeData.in as any))
expect(format(agentNodeData.in as any)).toEqual(agentNodeData.expect)
})
it('list should remove circle log item', () => {
// format(oneStepCircle.in as any)
expect(format(oneStepCircle.in as any)).toEqual(oneStepCircle.expect)
expect(format(multiStepsCircle.in as any)).toEqual(multiStepsCircle.expect)
})
})

View File

@ -1,16 +1,16 @@
import type { NodeTracing } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
import format from '.'
import graphToLogStruct from '../graph-to-log-struct'
import format from '..'
import graphToLogStruct from '../../graph-to-log-struct'
describe('iteration', () => {
const list = graphToLogStruct('start -> (iteration, iterationNode, plainNode1 -> plainNode2)')
// const [startNode, iterationNode, ...iterations] = list
const result = format(list as any, noop)
const result = format(list as NodeTracing[], noop)
it('result should have no nodes in iteration node', () => {
expect((result as any).find((item: any) => !!item.execution_metadata?.iteration_id)).toBeUndefined()
expect(result.find(item => !!item.execution_metadata?.iteration_id)).toBeUndefined()
})
// test('iteration should put nodes in details', () => {
// expect(result as any).toEqual([
// expect(result).toEqual([
// startNode,
// {
// ...iterationNode,

View File

@ -1,11 +1,12 @@
import type { NodeTracing } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
import format from '.'
import graphToLogStruct from '../graph-to-log-struct'
import format from '..'
import graphToLogStruct from '../../graph-to-log-struct'
describe('loop', () => {
const list = graphToLogStruct('start -> (loop, loopNode, plainNode1 -> plainNode2)')
const [startNode, loopNode, ...loops] = list
const result = format(list as any, noop)
const result = format(list as NodeTracing[], noop)
it('result should have no nodes in loop node', () => {
expect(result.find(item => !!item.execution_metadata?.loop_id)).toBeUndefined()
})

View File

@ -1,11 +1,12 @@
import format from '.'
import graphToLogStruct from '../graph-to-log-struct'
import type { NodeTracing } from '@/types/workflow'
import format from '..'
import graphToLogStruct from '../../graph-to-log-struct'
describe('retry', () => {
// retry nodeId:1 3 times.
const steps = graphToLogStruct('start -> (retry, retryNode, 3)')
const [startNode, retryNode, ...retryDetail] = steps
const result = format(steps as any)
const result = format(steps as NodeTracing[])
it('should have no retry status nodes', () => {
expect(result.find(item => item.status === 'retry')).toBeUndefined()
})