mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox
This commit is contained in:
127
web/app/components/workflow/run/__tests__/hooks.spec.ts
Normal file
127
web/app/components/workflow/run/__tests__/hooks.spec.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import type {
|
||||
AgentLogItemWithChildren,
|
||||
IterationDurationMap,
|
||||
LoopDurationMap,
|
||||
LoopVariableMap,
|
||||
NodeTracing,
|
||||
} from '@/types/workflow'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { BlockEnum } from '../../types'
|
||||
import { useLogs } from '../hooks'
|
||||
|
||||
const createNodeTracing = (id: string): NodeTracing => ({
|
||||
id,
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: id,
|
||||
node_type: BlockEnum.Tool,
|
||||
title: id,
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
elapsed_time: 1,
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 1,
|
||||
})
|
||||
|
||||
const createAgentLog = (id: string, children: AgentLogItemWithChildren[] = []): AgentLogItemWithChildren => ({
|
||||
node_execution_id: `execution-${id}`,
|
||||
node_id: `node-${id}`,
|
||||
parent_id: undefined,
|
||||
label: id,
|
||||
status: 'success',
|
||||
data: {},
|
||||
metadata: {},
|
||||
message_id: id,
|
||||
children,
|
||||
})
|
||||
|
||||
describe('useLogs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should manage retry, iteration, and loop detail panels', () => {
|
||||
const { result } = renderHook(() => useLogs())
|
||||
const retryDetail = [createNodeTracing('retry-node')]
|
||||
const iterationDetail = [[createNodeTracing('iteration-node')]]
|
||||
const loopDetail = [[createNodeTracing('loop-node')]]
|
||||
const iterationDurationMap: IterationDurationMap = { 'iteration-node': 2 }
|
||||
const loopDurationMap: LoopDurationMap = { 'loop-node': 3 }
|
||||
const loopVariableMap: LoopVariableMap = { 'loop-node': { item: 'value' } }
|
||||
|
||||
expect(result.current.showSpecialResultPanel).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.handleShowRetryResultList(retryDetail)
|
||||
})
|
||||
|
||||
expect(result.current.showRetryDetail).toBe(true)
|
||||
expect(result.current.retryResultList).toEqual(retryDetail)
|
||||
expect(result.current.showSpecialResultPanel).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.setShowRetryDetailFalse()
|
||||
result.current.handleShowIterationResultList(iterationDetail, iterationDurationMap)
|
||||
result.current.handleShowLoopResultList(loopDetail, loopDurationMap, loopVariableMap)
|
||||
})
|
||||
|
||||
expect(result.current.showRetryDetail).toBe(false)
|
||||
expect(result.current.showIteratingDetail).toBe(true)
|
||||
expect(result.current.iterationResultList).toEqual(iterationDetail)
|
||||
expect(result.current.iterationResultDurationMap).toEqual(iterationDurationMap)
|
||||
expect(result.current.showLoopingDetail).toBe(true)
|
||||
expect(result.current.loopResultList).toEqual(loopDetail)
|
||||
expect(result.current.loopResultDurationMap).toEqual(loopDurationMap)
|
||||
expect(result.current.loopResultVariableMap).toEqual(loopVariableMap)
|
||||
})
|
||||
|
||||
it('should push, trim, and clear agent/tool log navigation state', () => {
|
||||
const { result } = renderHook(() => useLogs())
|
||||
const childLog = createAgentLog('child-log')
|
||||
const rootLog = createAgentLog('root-log', [childLog])
|
||||
const siblingLog = createAgentLog('sibling-log')
|
||||
|
||||
act(() => {
|
||||
result.current.handleShowAgentOrToolLog(rootLog)
|
||||
})
|
||||
|
||||
expect(result.current.agentOrToolLogItemStack).toEqual([rootLog])
|
||||
expect(result.current.agentOrToolLogListMap).toEqual({
|
||||
'root-log': [childLog],
|
||||
})
|
||||
expect(result.current.showSpecialResultPanel).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.handleShowAgentOrToolLog(siblingLog)
|
||||
})
|
||||
|
||||
expect(result.current.agentOrToolLogItemStack).toEqual([rootLog, siblingLog])
|
||||
|
||||
act(() => {
|
||||
result.current.handleShowAgentOrToolLog(rootLog)
|
||||
})
|
||||
|
||||
expect(result.current.agentOrToolLogItemStack).toEqual([rootLog])
|
||||
|
||||
act(() => {
|
||||
result.current.handleShowAgentOrToolLog(undefined)
|
||||
})
|
||||
|
||||
expect(result.current.agentOrToolLogItemStack).toEqual([])
|
||||
})
|
||||
})
|
||||
356
web/app/components/workflow/run/__tests__/result-panel.spec.tsx
Normal file
356
web/app/components/workflow/run/__tests__/result-panel.spec.tsx
Normal file
@ -0,0 +1,356 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum, NodeRunningStatus } from '../../types'
|
||||
import ResultPanel from '../result-panel'
|
||||
|
||||
const mockUseTranslation = vi.hoisted(() => vi.fn())
|
||||
const mockCodeEditor = vi.hoisted(() => vi.fn())
|
||||
const mockLargeDataAlert = vi.hoisted(() => vi.fn())
|
||||
const mockStatusPanel = vi.hoisted(() => vi.fn())
|
||||
const mockMetaData = vi.hoisted(() => vi.fn())
|
||||
const mockErrorHandleTip = vi.hoisted(() => vi.fn())
|
||||
const mockIterationLogTrigger = vi.hoisted(() => vi.fn())
|
||||
const mockLoopLogTrigger = vi.hoisted(() => vi.fn())
|
||||
const mockRetryLogTrigger = vi.hoisted(() => vi.fn())
|
||||
const mockAgentLogTrigger = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
title: ReactNode
|
||||
value: unknown
|
||||
footer?: ReactNode
|
||||
tip?: ReactNode
|
||||
}) => {
|
||||
mockCodeEditor(props)
|
||||
return (
|
||||
<section data-testid="code-editor">
|
||||
<div>{props.title}</div>
|
||||
<div>{typeof props.value === 'string' ? props.value : JSON.stringify(props.value)}</div>
|
||||
{props.tip}
|
||||
{props.footer}
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip', () => ({
|
||||
__esModule: true,
|
||||
default: ({ type }: { type?: string }) => {
|
||||
mockErrorHandleTip(type)
|
||||
return <div data-testid="error-handle-tip">{type}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/iteration-log', () => ({
|
||||
IterationLogTrigger: (props: {
|
||||
onShowIterationResultList: (detail: unknown, durationMap: unknown) => void
|
||||
nodeInfo: { details?: unknown, iterDurationMap?: unknown }
|
||||
}) => {
|
||||
mockIterationLogTrigger(props)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onShowIterationResultList(props.nodeInfo.details, props.nodeInfo.iterDurationMap)}
|
||||
>
|
||||
iteration-trigger
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/loop-log', () => ({
|
||||
LoopLogTrigger: (props: {
|
||||
onShowLoopResultList: (detail: unknown, durationMap: unknown) => void
|
||||
nodeInfo: { details?: unknown, loopDurationMap?: unknown }
|
||||
}) => {
|
||||
mockLoopLogTrigger(props)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onShowLoopResultList(props.nodeInfo.details, props.nodeInfo.loopDurationMap)}
|
||||
>
|
||||
loop-trigger
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/retry-log', () => ({
|
||||
RetryLogTrigger: (props: {
|
||||
onShowRetryResultList: (detail: unknown) => void
|
||||
nodeInfo: { retryDetail?: unknown }
|
||||
}) => {
|
||||
mockRetryLogTrigger(props)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onShowRetryResultList(props.nodeInfo.retryDetail)}
|
||||
>
|
||||
retry-trigger
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/agent-log', () => ({
|
||||
AgentLogTrigger: (props: {
|
||||
onShowAgentOrToolLog: (detail: unknown) => void
|
||||
nodeInfo: { agentLog?: unknown }
|
||||
}) => {
|
||||
mockAgentLogTrigger(props)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onShowAgentOrToolLog(props.nodeInfo.agentLog)}
|
||||
>
|
||||
agent-trigger
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/variable-inspect/large-data-alert', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { downloadUrl?: string }) => {
|
||||
mockLargeDataAlert(props)
|
||||
return <div data-testid="large-data-alert">{props.downloadUrl ?? 'no-download'}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/meta', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockMetaData(props)
|
||||
return <div data-testid="meta-data">{JSON.stringify(props)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/status', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockStatusPanel(props)
|
||||
return <div data-testid="status-panel">{JSON.stringify(props)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const createNodeInfo = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
|
||||
id: 'trace-node-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_truncated: false,
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
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: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 1,
|
||||
details: undefined,
|
||||
retryDetail: undefined,
|
||||
agentLog: undefined,
|
||||
iterDurationMap: undefined,
|
||||
loopDurationMap: undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createLogDetail = (id: string): NodeTracing => createNodeInfo({
|
||||
id: `trace-${id}`,
|
||||
node_id: id,
|
||||
title: id,
|
||||
})
|
||||
|
||||
const createAgentLog = (label: string): AgentLogItemWithChildren => ({
|
||||
node_execution_id: `execution-${label}`,
|
||||
message_id: `message-${label}`,
|
||||
node_id: `node-${label}`,
|
||||
parent_id: undefined,
|
||||
label,
|
||||
status: 'success',
|
||||
data: {},
|
||||
metadata: {},
|
||||
children: [],
|
||||
})
|
||||
|
||||
describe('ResultPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string) => key,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render status, editors, alerts, error strategy tip, and metadata', () => {
|
||||
render(
|
||||
<ResultPanel
|
||||
nodeInfo={createNodeInfo()}
|
||||
inputs={JSON.stringify({ topic: 'AI' })}
|
||||
inputs_truncated
|
||||
process_data={JSON.stringify({ step: 1 })}
|
||||
process_data_truncated
|
||||
outputs={{ answer: 'done' }}
|
||||
outputs_truncated
|
||||
outputs_full_content={{ download_url: 'https://example.com/output.json' }}
|
||||
status={NodeRunningStatus.Succeeded}
|
||||
error="boom"
|
||||
elapsed_time={2.5}
|
||||
total_tokens={42}
|
||||
created_at={1710000000}
|
||||
created_by="Alice"
|
||||
steps={3}
|
||||
showSteps
|
||||
exceptionCounts={1}
|
||||
execution_metadata={{ error_strategy: 'continue-on-error' }}
|
||||
isListening
|
||||
workflowRunId="run-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('status-panel')).toBeInTheDocument()
|
||||
expect(screen.getByText('COMMON.INPUT')).toBeInTheDocument()
|
||||
expect(screen.getByText('COMMON.PROCESSDATA')).toBeInTheDocument()
|
||||
expect(screen.getByText('COMMON.OUTPUT')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('code-editor')).toHaveLength(3)
|
||||
expect(screen.getAllByTestId('large-data-alert')).toHaveLength(3)
|
||||
expect(screen.getByTestId('error-handle-tip')).toHaveTextContent('continue-on-error')
|
||||
expect(screen.getByTestId('meta-data')).toBeInTheDocument()
|
||||
expect(mockStatusPanel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
time: 2.5,
|
||||
tokens: 42,
|
||||
error: 'boom',
|
||||
exceptionCounts: 1,
|
||||
isListening: true,
|
||||
workflowRunId: 'run-1',
|
||||
}))
|
||||
expect(mockMetaData).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
executor: 'Alice',
|
||||
startTime: 1710000000,
|
||||
time: 2.5,
|
||||
tokens: 42,
|
||||
steps: 3,
|
||||
showSteps: true,
|
||||
}))
|
||||
expect(mockLargeDataAlert).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
downloadUrl: 'https://example.com/output.json',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render and invoke iteration and loop triggers only when their handlers are provided', () => {
|
||||
const handleShowIterationResultList = vi.fn()
|
||||
const handleShowLoopResultList = vi.fn()
|
||||
const details = [[createLogDetail('iter-1')]]
|
||||
|
||||
const { rerender } = render(
|
||||
<ResultPanel
|
||||
nodeInfo={createNodeInfo({
|
||||
node_type: BlockEnum.Iteration,
|
||||
details,
|
||||
iterDurationMap: { 0: 3 },
|
||||
})}
|
||||
status={NodeRunningStatus.Running}
|
||||
handleShowIterationResultList={handleShowIterationResultList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'iteration-trigger' }))
|
||||
expect(handleShowIterationResultList).toHaveBeenCalledWith(details, { 0: 3 })
|
||||
|
||||
rerender(
|
||||
<ResultPanel
|
||||
nodeInfo={createNodeInfo({
|
||||
node_type: BlockEnum.Loop,
|
||||
details,
|
||||
loopDurationMap: { 0: 5 },
|
||||
})}
|
||||
status={NodeRunningStatus.Running}
|
||||
handleShowLoopResultList={handleShowLoopResultList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'loop-trigger' }))
|
||||
expect(handleShowLoopResultList).toHaveBeenCalledWith(details, { 0: 5 })
|
||||
})
|
||||
|
||||
it('should render retry and agent/tool triggers when the node shape supports them', () => {
|
||||
const onShowRetryDetail = vi.fn()
|
||||
const handleShowAgentOrToolLog = vi.fn()
|
||||
const retryDetail = [createLogDetail('retry-1')]
|
||||
const agentLog = [createAgentLog('tool-call')]
|
||||
|
||||
const { rerender } = render(
|
||||
<ResultPanel
|
||||
nodeInfo={createNodeInfo({
|
||||
node_type: BlockEnum.Code,
|
||||
retryDetail,
|
||||
})}
|
||||
status={NodeRunningStatus.Succeeded}
|
||||
onShowRetryDetail={onShowRetryDetail}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'retry-trigger' }))
|
||||
expect(onShowRetryDetail).toHaveBeenCalledWith(retryDetail)
|
||||
|
||||
rerender(
|
||||
<ResultPanel
|
||||
nodeInfo={createNodeInfo({
|
||||
node_type: BlockEnum.Agent,
|
||||
agentLog,
|
||||
})}
|
||||
status={NodeRunningStatus.Succeeded}
|
||||
handleShowAgentOrToolLog={handleShowAgentOrToolLog}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'agent-trigger' }))
|
||||
expect(handleShowAgentOrToolLog).toHaveBeenCalledWith(agentLog)
|
||||
|
||||
rerender(
|
||||
<ResultPanel
|
||||
nodeInfo={createNodeInfo({
|
||||
node_type: BlockEnum.Tool,
|
||||
agentLog,
|
||||
})}
|
||||
status={NodeRunningStatus.Succeeded}
|
||||
handleShowAgentOrToolLog={handleShowAgentOrToolLog}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'agent-trigger' }))
|
||||
expect(handleShowAgentOrToolLog).toHaveBeenLastCalledWith(agentLog)
|
||||
})
|
||||
|
||||
it('should still render the output editor while the node is running even without outputs', () => {
|
||||
render(
|
||||
<ResultPanel
|
||||
nodeInfo={createNodeInfo()}
|
||||
inputs="{}"
|
||||
status={NodeRunningStatus.Running}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('COMMON.OUTPUT')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
199
web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx
Normal file
199
web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { getHoveredParallelId } from '../get-hovered-parallel-id'
|
||||
import TracingPanel from '../tracing-panel'
|
||||
|
||||
const mockUseTranslation = vi.hoisted(() => vi.fn())
|
||||
const mockFormatNodeList = vi.hoisted(() => vi.fn())
|
||||
const mockUseLogs = vi.hoisted(() => vi.fn())
|
||||
const mockNodePanel = vi.hoisted(() => vi.fn())
|
||||
const mockSpecialResultPanel = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockFormatNodeList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useLogs: () => mockUseLogs(),
|
||||
}))
|
||||
|
||||
vi.mock('../node', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
nodeInfo: { id: string }
|
||||
}) => {
|
||||
mockNodePanel(props)
|
||||
return <div data-testid={`node-${props.nodeInfo.id}`}>{props.nodeInfo.id}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../special-result-panel', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockSpecialResultPanel(props)
|
||||
return <div data-testid="special-result-panel">special</div>
|
||||
},
|
||||
}))
|
||||
|
||||
describe('TracingPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string) => key,
|
||||
})
|
||||
mockUseLogs.mockReturnValue({
|
||||
showSpecialResultPanel: false,
|
||||
showRetryDetail: false,
|
||||
setShowRetryDetailFalse: vi.fn(),
|
||||
retryResultList: [],
|
||||
handleShowRetryResultList: vi.fn(),
|
||||
showIteratingDetail: false,
|
||||
setShowIteratingDetailFalse: vi.fn(),
|
||||
iterationResultList: [],
|
||||
iterationResultDurationMap: {},
|
||||
handleShowIterationResultList: vi.fn(),
|
||||
showLoopingDetail: false,
|
||||
setShowLoopingDetailFalse: vi.fn(),
|
||||
loopResultList: [],
|
||||
loopResultDurationMap: {},
|
||||
loopResultVariableMap: {},
|
||||
handleShowLoopResultList: vi.fn(),
|
||||
agentOrToolLogItemStack: [],
|
||||
agentOrToolLogListMap: {},
|
||||
handleShowAgentOrToolLog: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('should render formatted nodes, preserve branch labels, and collapse parallel groups', () => {
|
||||
mockFormatNodeList.mockReturnValue([
|
||||
{
|
||||
id: 'parallel-1',
|
||||
parallelDetail: {
|
||||
isParallelStartNode: true,
|
||||
parallelTitle: 'Parallel Group',
|
||||
children: [{
|
||||
id: 'child-1',
|
||||
title: 'Child Node',
|
||||
parallelDetail: {
|
||||
branchTitle: 'Branch A',
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'node-2',
|
||||
title: 'Standalone Node',
|
||||
parallelDetail: {
|
||||
branchTitle: 'Branch B',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const parentClick = vi.fn()
|
||||
const { container } = render(
|
||||
<div onClick={parentClick}>
|
||||
<TracingPanel
|
||||
list={[{ id: 'raw-node' } as never]}
|
||||
className="custom-class"
|
||||
hideNodeInfo
|
||||
hideNodeProcessDetail
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Parallel Group')).toBeInTheDocument()
|
||||
expect(screen.getByText('Branch A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Branch B')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('node-child-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('node-node-2')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(container.querySelector('.py-2') as HTMLElement)
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
|
||||
const hoverTarget = screen.getByText('Parallel Group').closest('[data-parallel-id="parallel-1"]') as HTMLElement
|
||||
const nestedParallelTarget = document.createElement('div')
|
||||
nestedParallelTarget.setAttribute('data-parallel-id', 'parallel-1')
|
||||
const unrelatedTarget = document.createElement('div')
|
||||
document.body.appendChild(nestedParallelTarget)
|
||||
document.body.appendChild(unrelatedTarget)
|
||||
|
||||
fireEvent.mouseEnter(hoverTarget)
|
||||
const sameParallelOut = new MouseEvent('mouseout', { bubbles: true })
|
||||
Object.defineProperty(sameParallelOut, 'relatedTarget', { value: nestedParallelTarget })
|
||||
hoverTarget.dispatchEvent(sameParallelOut)
|
||||
|
||||
const differentTargetOut = new MouseEvent('mouseout', { bubbles: true })
|
||||
Object.defineProperty(differentTargetOut, 'relatedTarget', { value: unrelatedTarget })
|
||||
hoverTarget.dispatchEvent(differentTargetOut)
|
||||
|
||||
fireEvent.mouseLeave(hoverTarget)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child')).toHaveClass('hidden')
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child')).not.toHaveClass('hidden')
|
||||
expect(mockNodePanel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
hideInfo: true,
|
||||
hideProcessDetail: true,
|
||||
}))
|
||||
|
||||
nestedParallelTarget.remove()
|
||||
unrelatedTarget.remove()
|
||||
})
|
||||
|
||||
it('should switch to the special result panel when the log state requests it', () => {
|
||||
mockUseLogs.mockReturnValue({
|
||||
showSpecialResultPanel: true,
|
||||
showRetryDetail: true,
|
||||
setShowRetryDetailFalse: vi.fn(),
|
||||
retryResultList: [{ id: 'retry-1' }],
|
||||
handleShowRetryResultList: vi.fn(),
|
||||
showIteratingDetail: true,
|
||||
setShowIteratingDetailFalse: vi.fn(),
|
||||
iterationResultList: [[{ id: 'iter-1' }]],
|
||||
iterationResultDurationMap: { 0: 1 },
|
||||
handleShowIterationResultList: vi.fn(),
|
||||
showLoopingDetail: true,
|
||||
setShowLoopingDetailFalse: vi.fn(),
|
||||
loopResultList: [[{ id: 'loop-1' }]],
|
||||
loopResultDurationMap: { 0: 2 },
|
||||
loopResultVariableMap: { 0: {} },
|
||||
handleShowLoopResultList: vi.fn(),
|
||||
agentOrToolLogItemStack: [{ id: 'agent-1' }],
|
||||
agentOrToolLogListMap: { agent: [] },
|
||||
handleShowAgentOrToolLog: vi.fn(),
|
||||
})
|
||||
|
||||
render(<TracingPanel list={[]} />)
|
||||
|
||||
expect(screen.getByTestId('special-result-panel')).toBeInTheDocument()
|
||||
expect(mockSpecialResultPanel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
showRetryDetail: true,
|
||||
retryResultList: [{ id: 'retry-1' }],
|
||||
showIteratingDetail: true,
|
||||
showLoopingDetail: true,
|
||||
agentOrToolLogItemStack: [{ id: 'agent-1' }],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should resolve hovered parallel ids from related targets', () => {
|
||||
const sameParallelTarget = document.createElement('div')
|
||||
sameParallelTarget.setAttribute('data-parallel-id', 'parallel-1')
|
||||
document.body.appendChild(sameParallelTarget)
|
||||
|
||||
const nestedChild = document.createElement('span')
|
||||
sameParallelTarget.appendChild(nestedChild)
|
||||
|
||||
const unrelatedTarget = document.createElement('div')
|
||||
|
||||
expect(getHoveredParallelId(nestedChild)).toBe('parallel-1')
|
||||
expect(getHoveredParallelId(unrelatedTarget)).toBeNull()
|
||||
expect(getHoveredParallelId(null)).toBeNull()
|
||||
|
||||
sameParallelTarget.remove()
|
||||
})
|
||||
})
|
||||
10
web/app/components/workflow/run/get-hovered-parallel-id.ts
Normal file
10
web/app/components/workflow/run/get-hovered-parallel-id.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export const getHoveredParallelId = (relatedTarget: EventTarget | null) => {
|
||||
const element = relatedTarget as Element | null
|
||||
if (element && 'closest' in element) {
|
||||
const closestParallel = element.closest('[data-parallel-id]')
|
||||
if (closestParallel)
|
||||
return closestParallel.getAttribute('data-parallel-id')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@ -1,10 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiMenu4Line,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
@ -14,6 +10,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import formatNodeList from '@/app/components/workflow/run/utils/format-log'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getHoveredParallelId } from './get-hovered-parallel-id'
|
||||
import { useLogs } from './hooks'
|
||||
import NodePanel from './node'
|
||||
import SpecialResultPanel from './special-result-panel'
|
||||
@ -54,18 +51,7 @@ const TracingPanel: FC<TracingPanelProps> = ({
|
||||
}, [])
|
||||
|
||||
const handleParallelMouseLeave = useCallback((e: React.MouseEvent) => {
|
||||
const relatedTarget = e.relatedTarget as Element | null
|
||||
if (relatedTarget && 'closest' in relatedTarget) {
|
||||
const closestParallel = relatedTarget.closest('[data-parallel-id]')
|
||||
if (closestParallel)
|
||||
setHoveredParallel(closestParallel.getAttribute('data-parallel-id'))
|
||||
|
||||
else
|
||||
setHoveredParallel(null)
|
||||
}
|
||||
else {
|
||||
setHoveredParallel(null)
|
||||
}
|
||||
setHoveredParallel(getHoveredParallelId(e.relatedTarget))
|
||||
}, [])
|
||||
|
||||
const {
|
||||
@ -130,7 +116,9 @@ const TracingPanel: FC<TracingPanelProps> = ({
|
||||
isHovered ? 'rounded border-components-button-primary-border bg-components-button-primary-bg text-text-primary-on-surface' : 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
>
|
||||
{isHovered ? <RiArrowDownSLine className="h-3 w-3" /> : <RiMenu4Line className="h-3 w-3 text-text-tertiary" />}
|
||||
{isHovered
|
||||
? <span aria-hidden className="i-ri-arrow-down-s-line h-3 w-3" />
|
||||
: <span aria-hidden className="i-ri-menu-4-line h-3 w-3 text-text-tertiary" />}
|
||||
</button>
|
||||
<div className="flex items-center text-text-secondary system-xs-semibold-uppercase">
|
||||
<span>{parallelDetail.parallelTitle}</span>
|
||||
|
||||
@ -0,0 +1,199 @@
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
import formatToTracingNodeList from '../index'
|
||||
|
||||
const mockFormatAgentNode = vi.hoisted(() => vi.fn())
|
||||
const mockFormatHumanInputNode = vi.hoisted(() => vi.fn())
|
||||
const mockFormatRetryNode = vi.hoisted(() => vi.fn())
|
||||
const mockAddChildrenToLoopNode = vi.hoisted(() => vi.fn())
|
||||
const mockAddChildrenToIterationNode = vi.hoisted(() => vi.fn())
|
||||
const mockFormatParallelNode = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../agent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockFormatAgentNode(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../human-input', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockFormatHumanInputNode(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../retry', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockFormatRetryNode(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../loop', () => ({
|
||||
addChildrenToLoopNode: (...args: unknown[]) => mockAddChildrenToLoopNode(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../iteration', () => ({
|
||||
addChildrenToIterationNode: (...args: unknown[]) => mockAddChildrenToIterationNode(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../parallel', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockFormatParallelNode(...args),
|
||||
}))
|
||||
|
||||
const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
|
||||
id: overrides.id ?? overrides.node_id ?? 'node-1',
|
||||
index: overrides.index ?? 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: overrides.node_id ?? 'node-1',
|
||||
node_type: overrides.node_type ?? BlockEnum.Tool,
|
||||
title: overrides.title ?? 'Node',
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
status: overrides.status ?? 'succeeded',
|
||||
error: overrides.error,
|
||||
elapsed_time: 1,
|
||||
execution_metadata: overrides.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: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 1,
|
||||
})
|
||||
|
||||
const createExecutionMetadata = (overrides: Partial<NonNullable<NodeTracing['execution_metadata']>> = {}): NonNullable<NodeTracing['execution_metadata']> => ({
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('formatToTracingNodeList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFormatAgentNode.mockImplementation((list: NodeTracing[]) => list)
|
||||
mockFormatHumanInputNode.mockImplementation((list: NodeTracing[]) => list)
|
||||
mockFormatRetryNode.mockImplementation((list: NodeTracing[]) => list)
|
||||
mockAddChildrenToLoopNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({
|
||||
...item,
|
||||
loopChildren: children.map(child => child.node_id),
|
||||
details: [[{ id: 'loop-detail-row' }]],
|
||||
}))
|
||||
mockAddChildrenToIterationNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({
|
||||
...item,
|
||||
iterationChildren: children.map(child => child.node_id),
|
||||
details: [[{ id: 'iteration-detail-row' }]],
|
||||
}))
|
||||
mockFormatParallelNode.mockImplementation((list: unknown[]) =>
|
||||
list.map(item => ({
|
||||
...(item as Record<string, unknown>),
|
||||
parallelFormatted: true,
|
||||
})))
|
||||
})
|
||||
|
||||
it('should sort the input by index and run the formatter pipeline in order', () => {
|
||||
const t = vi.fn((key: string) => key)
|
||||
const traces = [
|
||||
createTrace({ id: 'b', node_id: 'b', title: 'B', index: 2 }),
|
||||
createTrace({ id: 'a', node_id: 'a', title: 'A', index: 0 }),
|
||||
createTrace({ id: 'c', node_id: 'c', title: 'C', index: 1 }),
|
||||
]
|
||||
|
||||
const result = formatToTracingNodeList(traces, t)
|
||||
|
||||
expect(mockFormatAgentNode).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ node_id: 'a' }),
|
||||
expect.objectContaining({ node_id: 'c' }),
|
||||
expect.objectContaining({ node_id: 'b' }),
|
||||
])
|
||||
expect(mockFormatHumanInputNode).toHaveBeenCalledWith(mockFormatAgentNode.mock.results[0].value)
|
||||
expect(mockFormatRetryNode).toHaveBeenCalledWith(mockFormatHumanInputNode.mock.results[0].value)
|
||||
expect(mockFormatParallelNode).toHaveBeenLastCalledWith(expect.any(Array), t)
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ node_id: 'a', parallelFormatted: true }),
|
||||
expect.objectContaining({ node_id: 'c', parallelFormatted: true }),
|
||||
expect.objectContaining({ node_id: 'b', parallelFormatted: true }),
|
||||
])
|
||||
})
|
||||
|
||||
it('should collapse loop and iteration children into parent nodes and propagate child failures', () => {
|
||||
const t = vi.fn((key: string) => key)
|
||||
const loopParent = createTrace({
|
||||
id: 'loop-parent',
|
||||
node_id: 'loop-parent',
|
||||
node_type: BlockEnum.Loop,
|
||||
index: 0,
|
||||
})
|
||||
const loopChild = createTrace({
|
||||
id: 'loop-child',
|
||||
node_id: 'loop-child',
|
||||
index: 1,
|
||||
status: 'failed',
|
||||
error: 'loop child failed',
|
||||
execution_metadata: createExecutionMetadata({ loop_id: 'loop-parent' }),
|
||||
})
|
||||
const iterationParent = createTrace({
|
||||
id: 'iteration-parent',
|
||||
node_id: 'iteration-parent',
|
||||
node_type: BlockEnum.Iteration,
|
||||
index: 2,
|
||||
})
|
||||
const iterationChild = createTrace({
|
||||
id: 'iteration-child',
|
||||
node_id: 'iteration-child',
|
||||
index: 3,
|
||||
status: 'failed',
|
||||
error: 'iteration child failed',
|
||||
execution_metadata: createExecutionMetadata({ iteration_id: 'iteration-parent' }),
|
||||
})
|
||||
|
||||
const result = formatToTracingNodeList([
|
||||
loopParent,
|
||||
loopChild,
|
||||
iterationParent,
|
||||
iterationChild,
|
||||
], t)
|
||||
|
||||
expect(mockAddChildrenToLoopNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
node_id: 'loop-parent',
|
||||
status: 'failed',
|
||||
error: 'loop child failed',
|
||||
}),
|
||||
[expect.objectContaining({ node_id: 'loop-child' })],
|
||||
)
|
||||
expect(mockAddChildrenToIterationNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
node_id: 'iteration-parent',
|
||||
status: 'failed',
|
||||
error: 'iteration child failed',
|
||||
}),
|
||||
[expect.objectContaining({ node_id: 'iteration-child' })],
|
||||
)
|
||||
expect(mockFormatParallelNode).toHaveBeenCalledTimes(3)
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
node_id: 'loop-parent',
|
||||
loopChildren: ['loop-child'],
|
||||
parallelFormatted: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
node_id: 'iteration-parent',
|
||||
iterationChildren: ['iteration-child'],
|
||||
parallelFormatted: true,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user