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,116 @@
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import LoopResultPanel from '../loop-result-panel'
|
||||
|
||||
const mockTracingPanel = vi.fn()
|
||||
|
||||
vi.mock('../tracing-panel', () => ({
|
||||
default: ({
|
||||
list,
|
||||
className,
|
||||
}: {
|
||||
list: NodeTracing[]
|
||||
className?: string
|
||||
}) => {
|
||||
mockTracingPanel({ list, className })
|
||||
return <div data-testid="tracing-panel">{list.length}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const createNodeTracing = (id: string): NodeTracing => ({
|
||||
id,
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: `node-${id}`,
|
||||
node_type: BlockEnum.Code,
|
||||
title: `Node ${id}`,
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs: {},
|
||||
outputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
error: '',
|
||||
elapsed_time: 0,
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Tester',
|
||||
email: 'tester@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
execution_metadata: undefined,
|
||||
})
|
||||
|
||||
describe('LoopResultPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should show loop rows, expand tracing details, and handle back and close actions', () => {
|
||||
const onHide = vi.fn()
|
||||
const onBack = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<LoopResultPanel
|
||||
list={[
|
||||
[createNodeTracing('1')],
|
||||
[createNodeTracing('2'), createNodeTracing('3')],
|
||||
]}
|
||||
onHide={onHide}
|
||||
onBack={onBack}
|
||||
noWrap
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.singleRun.testRunLoop')).toBeInTheDocument()
|
||||
const contentPanels = container.querySelectorAll('.transition-all.duration-200')
|
||||
expect(contentPanels[0]).toHaveClass('max-h-0')
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.singleRun.loop 1'))
|
||||
expect(contentPanels[0]).not.toHaveClass('max-h-0')
|
||||
expect(screen.getAllByTestId('tracing-panel')[0]).toHaveTextContent('1')
|
||||
expect(mockTracingPanel).toHaveBeenCalledWith({
|
||||
list: [expect.objectContaining({ id: '1' })],
|
||||
className: 'bg-background-section-burn',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.singleRun.back'))
|
||||
const closeTrigger = container.querySelector('.ml-2.shrink-0.cursor-pointer.p-1')
|
||||
if (!closeTrigger)
|
||||
throw new Error('Expected close trigger to be rendered')
|
||||
fireEvent.click(closeTrigger)
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should stop click propagation when rendered inside the overlay wrapper', () => {
|
||||
const parentClick = vi.fn()
|
||||
const { container } = render(
|
||||
<div onClick={parentClick}>
|
||||
<LoopResultPanel
|
||||
list={[[createNodeTracing('1')]]}
|
||||
onHide={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const overlay = container.querySelector('.absolute.inset-0')
|
||||
if (!overlay)
|
||||
throw new Error('Expected overlay wrapper to be rendered')
|
||||
|
||||
fireEvent.click(overlay)
|
||||
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,101 @@
|
||||
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
|
||||
import type { AgentLogItemWithChildren } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import AgentLogNav from '../agent-log-nav'
|
||||
import AgentLogNavMore from '../agent-log-nav-more'
|
||||
import AgentResultPanel from '../agent-result-panel'
|
||||
|
||||
vi.mock('../agent-log-item', () => ({
|
||||
default: ({ item, onShowAgentOrToolLog }: any) => (
|
||||
<button type="button" onClick={() => onShowAgentOrToolLog(item)}>
|
||||
item-{item.label}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
|
||||
message_id: 'message-1',
|
||||
label: 'Planner',
|
||||
children: [],
|
||||
status: 'succeeded',
|
||||
node_execution_id: 'exec-1',
|
||||
node_id: 'node-1',
|
||||
data: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('agent-log leaf components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The navigation and result views should expose stack navigation and nested agent log entries.
|
||||
describe('Navigation and Results', () => {
|
||||
it('should navigate back, open intermediate entries, and show the tail label', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowAgentOrToolLog = vi.fn()
|
||||
const stack = [
|
||||
createLogItem({ message_id: 'root', label: 'Strategy' }),
|
||||
createLogItem({ message_id: 'mid', label: 'Tool A' }),
|
||||
createLogItem({ message_id: 'tail', label: 'Tool B' }),
|
||||
]
|
||||
|
||||
render(
|
||||
<AgentLogNav
|
||||
agentOrToolLogItemStack={stack}
|
||||
onShowAgentOrToolLog={onShowAgentOrToolLog}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^AGENT$/i }))
|
||||
await user.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.strategy\.label$/ }))
|
||||
await user.click(screen.getAllByRole('button')[2]!)
|
||||
await user.click(screen.getByText('Tool A'))
|
||||
|
||||
expect(onShowAgentOrToolLog.mock.calls[0]).toHaveLength(0)
|
||||
expect(onShowAgentOrToolLog).toHaveBeenNthCalledWith(2, stack[0])
|
||||
expect(onShowAgentOrToolLog).toHaveBeenNthCalledWith(3, stack[1])
|
||||
expect(screen.getByText('Tool B')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the more menu options as shortcuts to nested logs', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowAgentOrToolLog = vi.fn()
|
||||
const option = createLogItem({ message_id: 'mid', label: 'Intermediate Tool' })
|
||||
|
||||
render(
|
||||
<AgentLogNavMore
|
||||
options={[option]}
|
||||
onShowAgentOrToolLog={onShowAgentOrToolLog}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('Intermediate Tool'))
|
||||
|
||||
expect(onShowAgentOrToolLog).toHaveBeenCalledWith(option)
|
||||
})
|
||||
|
||||
it('should render result items and the circular invocation warning', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowAgentOrToolLog = vi.fn()
|
||||
const top = createLogItem({ message_id: 'top', label: 'Top', hasCircle: true })
|
||||
const child = createLogItem({ message_id: 'child', label: 'Child Tool' })
|
||||
|
||||
render(
|
||||
<AgentResultPanel
|
||||
agentOrToolLogItemStack={[top]}
|
||||
agentOrToolLogListMap={{ top: [child] }}
|
||||
onShowAgentOrToolLog={onShowAgentOrToolLog}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('runLog.circularInvocationTip')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('item-Child Tool'))
|
||||
|
||||
expect(onShowAgentOrToolLog).toHaveBeenCalledWith(child)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -28,7 +28,7 @@ const AgentLogNavMore = ({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<Button
|
||||
className="h-6 w-6"
|
||||
variant="ghost-accent"
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
import type { IterationDurationMap, NodeTracing } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
import IterationResultPanel from '../iteration-result-panel'
|
||||
|
||||
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
|
||||
default: ({ list }: { list: NodeTracing[] }) => (
|
||||
<div data-testid="tracing-panel">
|
||||
{list.map(item => (
|
||||
<div key={`${item.node_id}-${item.execution_metadata?.iteration_index}`}>{item.node_id}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createTracing = (
|
||||
nodeId: string,
|
||||
status: NodeRunningStatus,
|
||||
iterationIndex: number,
|
||||
parallelModeRunId?: string,
|
||||
): NodeTracing => {
|
||||
return {
|
||||
node_id: nodeId,
|
||||
status,
|
||||
execution_metadata: {
|
||||
iteration_index: iterationIndex,
|
||||
parallel_mode_run_id: parallelModeRunId,
|
||||
},
|
||||
} as NodeTracing
|
||||
}
|
||||
|
||||
describe('IterationResultPanel integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render failed, running, and completed iterations and toggle tracing details', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onBack = vi.fn()
|
||||
const list: NodeTracing[][] = [
|
||||
[createTracing('failed-node', NodeRunningStatus.Failed, 0, 'iter-1')],
|
||||
[createTracing('running-node', NodeRunningStatus.Running, 1, 'iter-2')],
|
||||
[createTracing('done-node', NodeRunningStatus.Succeeded, 2, 'iter-3')],
|
||||
]
|
||||
const durationMap: IterationDurationMap = {
|
||||
'iter-3': 0.001,
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<IterationResultPanel
|
||||
list={list}
|
||||
onBack={onBack}
|
||||
iterDurationMap={durationMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('0.01s')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.singleRun.back'))
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
|
||||
await user.click(screen.getByText((_, node) => node?.textContent === 'workflow.singleRun.iteration 3'))
|
||||
expect(container.querySelectorAll('.opacity-100')).toHaveLength(1)
|
||||
expect(screen.getByText('done-node')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText((_, node) => node?.textContent === 'workflow.singleRun.iteration 3'))
|
||||
expect(container.querySelectorAll('.opacity-100')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,75 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
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 RetryResultPanel from '../retry-result-panel'
|
||||
|
||||
vi.mock('../../tracing-panel', () => ({
|
||||
default: ({ list }: any) => (
|
||||
<div>
|
||||
{list.map((item: any) => (
|
||||
<div key={item.id}>{item.title}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createTrace = (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.1,
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 1,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
finished_at: 2,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('RetryResultPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The retry result panel should expose a back action and relabel each retry attempt in the tracing list.
|
||||
describe('Rendering', () => {
|
||||
it('should render retry titles and call onBack from the back header', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onBack = vi.fn()
|
||||
render(
|
||||
<RetryResultPanel
|
||||
list={[createTrace({ id: 'retry-1' }), createTrace({ id: 'retry-2' })]}
|
||||
onBack={onBack}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.common.retry.retry 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.common.retry.retry 2')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.singleRun.back'))
|
||||
|
||||
expect(onBack).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user