test: add unit tests for various workflow components

- Introduced new test files for CandidateNodeMain, CustomEdge, NodeContextmenu, PanelContextmenu, HelpLine, and several hooks.
- Each test file includes comprehensive tests to validate component rendering, interactions, and state management.
- Enhanced test coverage for dynamic test run options and data source configurations.
- Ensured proper mocking of dependencies to isolate component behavior during tests.
This commit is contained in:
CodingOnStar
2026-03-25 12:12:59 +08:00
parent 373f8245af
commit 3d10cf97f1
29 changed files with 4803 additions and 19 deletions

View File

@ -0,0 +1,260 @@
import { render, screen } from '@testing-library/react'
import CandidateNodeMain from '../candidate-node-main'
import { CUSTOM_NODE } from '../constants'
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
import { BlockEnum } from '../types'
import { createNode } from './fixtures'
const mockUseEventListener = vi.hoisted(() => vi.fn())
const mockUseStoreApi = vi.hoisted(() => vi.fn())
const mockUseReactFlow = vi.hoisted(() => vi.fn())
const mockUseViewport = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseWorkflowStore = vi.hoisted(() => vi.fn())
const mockUseHooks = vi.hoisted(() => vi.fn())
const mockCustomNode = vi.hoisted(() => vi.fn())
const mockCustomNoteNode = vi.hoisted(() => vi.fn())
const mockGetIterationStartNode = vi.hoisted(() => vi.fn())
const mockGetLoopStartNode = vi.hoisted(() => vi.fn())
vi.mock('ahooks', () => ({
useEventListener: (...args: unknown[]) => mockUseEventListener(...args),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => mockUseStoreApi(),
useReactFlow: () => mockUseReactFlow(),
useViewport: () => mockUseViewport(),
Position: {
Left: 'left',
Right: 'right',
},
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { mousePosition: {
pageX: number
pageY: number
elementX: number
elementY: number
} }) => unknown) => mockUseStore(selector),
useWorkflowStore: () => mockUseWorkflowStore(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesInteractions: () => mockUseHooks().useNodesInteractions(),
useNodesSyncDraft: () => mockUseHooks().useNodesSyncDraft(),
useWorkflowHistory: () => mockUseHooks().useWorkflowHistory(),
useAutoGenerateWebhookUrl: () => mockUseHooks().useAutoGenerateWebhookUrl(),
WorkflowHistoryEvent: {
NodeAdd: 'NodeAdd',
NoteAdd: 'NoteAdd',
},
}))
vi.mock('@/app/components/workflow/nodes', () => ({
__esModule: true,
default: (props: { id: string }) => {
mockCustomNode(props)
return <div data-testid="candidate-custom-node">{props.id}</div>
},
}))
vi.mock('@/app/components/workflow/note-node', () => ({
__esModule: true,
default: (props: { id: string }) => {
mockCustomNoteNode(props)
return <div data-testid="candidate-note-node">{props.id}</div>
},
}))
vi.mock('@/app/components/workflow/utils', () => ({
getIterationStartNode: (...args: unknown[]) => mockGetIterationStartNode(...args),
getLoopStartNode: (...args: unknown[]) => mockGetLoopStartNode(...args),
}))
describe('CandidateNodeMain', () => {
const mockSetNodes = vi.fn()
const mockHandleNodeSelect = vi.fn()
const mockSaveStateToHistory = vi.fn()
const mockHandleSyncWorkflowDraft = vi.fn()
const mockAutoGenerateWebhookUrl = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
const createNodesInteractions = () => ({
handleNodeSelect: mockHandleNodeSelect,
})
const createWorkflowHistory = () => ({
saveStateToHistory: mockSaveStateToHistory,
})
const createNodesSyncDraft = () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
})
const createAutoGenerateWebhookUrl = () => mockAutoGenerateWebhookUrl
const eventHandlers: Partial<Record<'click' | 'contextmenu', (event: { preventDefault: () => void }) => void>> = {}
let nodes = [createNode({ id: 'existing-node' })]
beforeEach(() => {
vi.clearAllMocks()
nodes = [createNode({ id: 'existing-node' })]
eventHandlers.click = undefined
eventHandlers.contextmenu = undefined
mockUseEventListener.mockImplementation((event: 'click' | 'contextmenu', handler: (event: { preventDefault: () => void }) => void) => {
eventHandlers[event] = handler
})
mockUseStoreApi.mockReturnValue({
getState: () => ({
getNodes: () => nodes,
setNodes: mockSetNodes,
}),
})
mockUseReactFlow.mockReturnValue({
screenToFlowPosition: ({ x, y }: { x: number, y: number }) => ({ x: x + 10, y: y + 20 }),
})
mockUseViewport.mockReturnValue({ zoom: 1.5 })
mockUseStore.mockImplementation((selector: (state: { mousePosition: {
pageX: number
pageY: number
elementX: number
elementY: number
} }) => unknown) => selector({
mousePosition: {
pageX: 100,
pageY: 200,
elementX: 30,
elementY: 40,
},
}))
mockUseWorkflowStore.mockReturnValue({
setState: mockWorkflowStoreSetState,
})
mockUseHooks.mockReturnValue({
useNodesInteractions: createNodesInteractions,
useWorkflowHistory: createWorkflowHistory,
useNodesSyncDraft: createNodesSyncDraft,
useAutoGenerateWebhookUrl: createAutoGenerateWebhookUrl,
})
mockHandleSyncWorkflowDraft.mockImplementation((_isSync: boolean, _force: boolean, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
mockGetIterationStartNode.mockReturnValue(createNode({ id: 'iteration-start' }))
mockGetLoopStartNode.mockReturnValue(createNode({ id: 'loop-start' }))
})
it('should render the candidate node and commit a webhook node on click', () => {
const candidateNode = createNode({
id: 'candidate-webhook',
type: CUSTOM_NODE,
data: {
type: BlockEnum.TriggerWebhook,
title: 'Webhook Candidate',
_isCandidate: true,
},
})
const { container } = render(<CandidateNodeMain candidateNode={candidateNode} />)
expect(screen.getByTestId('candidate-custom-node')).toHaveTextContent('candidate-webhook')
expect(container.firstChild).toHaveStyle({
left: '30px',
top: '40px',
transform: 'scale(1.5)',
})
eventHandlers.click?.({ preventDefault: vi.fn() })
expect(mockSetNodes).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ id: 'existing-node' }),
expect.objectContaining({
id: 'candidate-webhook',
position: { x: 110, y: 220 },
data: expect.objectContaining({ _isCandidate: false }),
}),
]))
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodeAdd', { nodeId: 'candidate-webhook' })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined })
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true, expect.objectContaining({
onSuccess: expect.any(Function),
}))
expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('candidate-webhook')
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
})
it('should save note candidates as notes and select the inserted note', () => {
const candidateNode = createNode({
id: 'candidate-note',
type: CUSTOM_NOTE_NODE,
data: {
type: BlockEnum.Code,
title: 'Note Candidate',
_isCandidate: true,
},
})
render(<CandidateNodeMain candidateNode={candidateNode} />)
expect(screen.getByTestId('candidate-note-node')).toHaveTextContent('candidate-note')
eventHandlers.click?.({ preventDefault: vi.fn() })
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NoteAdd', { nodeId: 'candidate-note' })
expect(mockHandleNodeSelect).toHaveBeenCalledWith('candidate-note')
})
it('should append iteration and loop start helper nodes for control-flow candidates', () => {
const iterationNode = createNode({
id: 'candidate-iteration',
type: CUSTOM_NODE,
data: {
type: BlockEnum.Iteration,
title: 'Iteration Candidate',
_isCandidate: true,
},
})
const loopNode = createNode({
id: 'candidate-loop',
type: CUSTOM_NODE,
data: {
type: BlockEnum.Loop,
title: 'Loop Candidate',
_isCandidate: true,
},
})
const { rerender } = render(<CandidateNodeMain candidateNode={iterationNode} />)
eventHandlers.click?.({ preventDefault: vi.fn() })
expect(mockGetIterationStartNode).toHaveBeenCalledWith('candidate-iteration')
expect(mockSetNodes.mock.calls[0][0]).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'candidate-iteration' }),
expect.objectContaining({ id: 'iteration-start' }),
]))
rerender(<CandidateNodeMain candidateNode={loopNode} />)
eventHandlers.click?.({ preventDefault: vi.fn() })
expect(mockGetLoopStartNode).toHaveBeenCalledWith('candidate-loop')
expect(mockSetNodes.mock.calls[1][0]).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'candidate-loop' }),
expect.objectContaining({ id: 'loop-start' }),
]))
})
it('should clear the candidate node on contextmenu', () => {
const candidateNode = createNode({
id: 'candidate-context',
type: CUSTOM_NODE,
data: {
type: BlockEnum.Code,
title: 'Context Candidate',
_isCandidate: true,
},
})
render(<CandidateNodeMain candidateNode={candidateNode} />)
eventHandlers.contextmenu?.({ preventDefault: vi.fn() })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined })
})
})

View File

@ -0,0 +1,235 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { Position } from 'reactflow'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import CustomEdge from '../custom-edge'
import { BlockEnum, NodeRunningStatus } from '../types'
const mockUseAvailableBlocks = vi.hoisted(() => vi.fn())
const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
const mockBlockSelector = vi.hoisted(() => vi.fn())
const mockGradientRender = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
BaseEdge: (props: {
id: string
path: string
style: {
stroke: string
strokeWidth: number
opacity: number
strokeDasharray?: string
}
}) => (
<div
data-testid="base-edge"
data-id={props.id}
data-path={props.path}
data-stroke={props.style.stroke}
data-stroke-width={props.style.strokeWidth}
data-opacity={props.style.opacity}
data-dasharray={props.style.strokeDasharray}
/>
),
EdgeLabelRenderer: ({ children }: { children?: ReactNode }) => <div data-testid="edge-label">{children}</div>,
getBezierPath: () => ['M 0 0', 24, 48],
Position: {
Right: 'right',
Left: 'left',
},
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useAvailableBlocks: (...args: unknown[]) => mockUseAvailableBlocks(...args),
useNodesInteractions: () => mockUseNodesInteractions(),
}))
vi.mock('@/app/components/workflow/block-selector', () => ({
__esModule: true,
default: (props: {
open: boolean
onOpenChange: (open: boolean) => void
onSelect: (nodeType: string, pluginDefaultValue?: Record<string, unknown>) => void
availableBlocksTypes: string[]
triggerClassName?: () => string
}) => {
mockBlockSelector(props)
return (
<button
type="button"
data-testid="block-selector"
data-trigger-class={props.triggerClassName?.()}
onClick={() => {
props.onOpenChange(true)
props.onSelect('llm', { provider: 'openai' })
}}
>
{props.availableBlocksTypes.join(',')}
</button>
)
},
}))
vi.mock('@/app/components/workflow/custom-edge-linear-gradient-render', () => ({
__esModule: true,
default: (props: {
id: string
startColor: string
stopColor: string
}) => {
mockGradientRender(props)
return <div data-testid="edge-gradient">{props.id}</div>
},
}))
describe('CustomEdge', () => {
const mockHandleNodeAdd = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseNodesInteractions.mockReturnValue({
handleNodeAdd: mockHandleNodeAdd,
})
mockUseAvailableBlocks.mockImplementation((nodeType: BlockEnum) => {
if (nodeType === BlockEnum.Code)
return { availablePrevBlocks: ['code', 'llm'] }
return { availableNextBlocks: ['llm', 'tool'] }
})
})
it('should render a gradient edge and insert a node between the source and target', () => {
render(
<CustomEdge
id="edge-1"
source="source-node"
sourceHandleId="source"
target="target-node"
targetHandleId="target"
sourceX={100}
sourceY={120}
sourcePosition={Position.Right}
targetX={300}
targetY={220}
targetPosition={Position.Left}
selected={false}
data={{
sourceType: BlockEnum.Start,
targetType: BlockEnum.Code,
_sourceRunningStatus: NodeRunningStatus.Succeeded,
_targetRunningStatus: NodeRunningStatus.Failed,
_hovering: true,
_waitingRun: true,
_dimmed: true,
_isTemp: true,
isInIteration: true,
isInLoop: true,
} as never}
/>,
)
expect(screen.getByTestId('edge-gradient')).toHaveTextContent('edge-1')
expect(mockGradientRender).toHaveBeenCalledWith(expect.objectContaining({
id: 'edge-1',
startColor: 'var(--color-workflow-link-line-success-handle)',
stopColor: 'var(--color-workflow-link-line-error-handle)',
}))
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'url(#edge-1)')
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-opacity', '0.3')
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-dasharray', '8 8')
expect(screen.getByTestId('block-selector')).toHaveTextContent('llm')
expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({
transform: 'translate(-50%, -50%) translate(24px, 48px)',
opacity: '0.7',
})
fireEvent.click(screen.getByTestId('block-selector'))
expect(mockHandleNodeAdd).toHaveBeenCalledWith(
{
nodeType: 'llm',
pluginDefaultValue: { provider: 'openai' },
},
{
prevNodeId: 'source-node',
prevNodeSourceHandle: 'source',
nextNodeId: 'target-node',
nextNodeTargetHandle: 'target',
},
)
})
it('should prefer the running stroke color when the edge is selected', () => {
render(
<CustomEdge
id="edge-selected"
source="source-node"
target="target-node"
sourceX={0}
sourceY={0}
sourcePosition={Position.Right}
targetX={100}
targetY={100}
targetPosition={Position.Left}
selected
data={{
sourceType: BlockEnum.Start,
targetType: BlockEnum.Code,
_sourceRunningStatus: NodeRunningStatus.Succeeded,
_targetRunningStatus: NodeRunningStatus.Running,
} as never}
/>,
)
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-handle)')
})
it('should use the fail-branch running color while the connected node is hovering', () => {
render(
<CustomEdge
id="edge-hover"
source="source-node"
sourceHandleId={ErrorHandleTypeEnum.failBranch}
target="target-node"
sourceX={0}
sourceY={0}
sourcePosition={Position.Right}
targetX={100}
targetY={100}
targetPosition={Position.Left}
selected={false}
data={{
sourceType: BlockEnum.Start,
targetType: BlockEnum.Code,
_connectedNodeIsHovering: true,
} as never}
/>,
)
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-failure-handle)')
})
it('should fall back to the default edge color when no highlight state is active', () => {
render(
<CustomEdge
id="edge-default"
source="source-node"
target="target-node"
sourceX={0}
sourceY={0}
sourcePosition={Position.Right}
targetX={100}
targetY={100}
targetPosition={Position.Left}
selected={false}
data={{
sourceType: BlockEnum.Start,
targetType: BlockEnum.Code,
} as never}
/>,
)
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-normal)')
expect(screen.getByTestId('block-selector')).toHaveAttribute('data-trigger-class', 'hover:scale-150 transition-all')
})
})

View File

@ -0,0 +1,114 @@
import type { Node } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import NodeContextmenu from '../node-contextmenu'
const mockUseClickAway = vi.hoisted(() => vi.fn())
const mockUseNodes = vi.hoisted(() => vi.fn())
const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockPanelOperatorPopup = vi.hoisted(() => vi.fn())
vi.mock('ahooks', () => ({
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
__esModule: true,
default: () => mockUseNodes(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
usePanelInteractions: () => mockUsePanelInteractions(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => mockUseStore(selector),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup', () => ({
__esModule: true,
default: (props: {
id: string
data: Node['data']
showHelpLink: boolean
onClosePopup: () => void
}) => {
mockPanelOperatorPopup(props)
return (
<button type="button" onClick={props.onClosePopup}>
{props.id}
:
{props.data.title}
</button>
)
},
}))
describe('NodeContextmenu', () => {
const mockHandleNodeContextmenuCancel = vi.fn()
let nodeMenu: { nodeId: string, left: number, top: number } | undefined
let nodes: Node[]
let clickAwayHandler: (() => void) | undefined
beforeEach(() => {
vi.clearAllMocks()
nodeMenu = undefined
nodes = [{
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Node 1',
desc: '',
type: 'code' as never,
},
} as Node]
clickAwayHandler = undefined
mockUseClickAway.mockImplementation((handler: () => void) => {
clickAwayHandler = handler
})
mockUseNodes.mockImplementation(() => nodes)
mockUsePanelInteractions.mockReturnValue({
handleNodeContextmenuCancel: mockHandleNodeContextmenuCancel,
})
mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => selector({ nodeMenu }))
})
it('should stay hidden when the node menu is absent', () => {
render(<NodeContextmenu />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
})
it('should stay hidden when the referenced node cannot be found', () => {
nodeMenu = { nodeId: 'missing-node', left: 80, top: 120 }
render(<NodeContextmenu />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
})
it('should render the popup at the stored position and close on popup/click-away actions', () => {
nodeMenu = { nodeId: 'node-1', left: 80, top: 120 }
const { container } = render(<NodeContextmenu />)
expect(screen.getByRole('button')).toHaveTextContent('node-1:Node 1')
expect(mockPanelOperatorPopup).toHaveBeenCalledWith(expect.objectContaining({
id: 'node-1',
data: expect.objectContaining({ title: 'Node 1' }),
showHelpLink: true,
}))
expect(container.firstChild).toHaveStyle({
left: '80px',
top: '120px',
})
fireEvent.click(screen.getByRole('button'))
clickAwayHandler?.()
expect(mockHandleNodeContextmenuCancel).toHaveBeenCalledTimes(2)
})
})

View File

@ -0,0 +1,151 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import PanelContextmenu from '../panel-contextmenu'
const mockUseClickAway = vi.hoisted(() => vi.fn())
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
const mockUseWorkflowStartRun = vi.hoisted(() => vi.fn())
const mockUseOperator = vi.hoisted(() => vi.fn())
const mockUseDSL = vi.hoisted(() => vi.fn())
vi.mock('ahooks', () => ({
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: {
panelMenu?: { left: number, top: number }
clipboardElements: unknown[]
setShowImportDSLModal: (visible: boolean) => void
}) => unknown) => mockUseStore(selector),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesInteractions: () => mockUseNodesInteractions(),
usePanelInteractions: () => mockUsePanelInteractions(),
useWorkflowStartRun: () => mockUseWorkflowStartRun(),
useDSL: () => mockUseDSL(),
}))
vi.mock('@/app/components/workflow/operator/hooks', () => ({
useOperator: () => mockUseOperator(),
}))
vi.mock('@/app/components/workflow/operator/add-block', () => ({
__esModule: true,
default: ({ renderTrigger }: { renderTrigger: () => ReactNode }) => (
<div data-testid="add-block">{renderTrigger()}</div>
),
}))
vi.mock('@/app/components/base/divider', () => ({
__esModule: true,
default: ({ className }: { className?: string }) => <div data-testid="divider" className={className} />,
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
__esModule: true,
default: ({ keys }: { keys: string[] }) => <span data-testid={`shortcut-${keys.join('-')}`}>{keys.join('+')}</span>,
}))
describe('PanelContextmenu', () => {
const mockHandleNodesPaste = vi.fn()
const mockHandlePaneContextmenuCancel = vi.fn()
const mockHandleStartWorkflowRun = vi.fn()
const mockHandleAddNote = vi.fn()
const mockExportCheck = vi.fn()
const mockSetShowImportDSLModal = vi.fn()
let panelMenu: { left: number, top: number } | undefined
let clipboardElements: unknown[]
let clickAwayHandler: (() => void) | undefined
beforeEach(() => {
vi.clearAllMocks()
panelMenu = undefined
clipboardElements = []
clickAwayHandler = undefined
mockUseClickAway.mockImplementation((handler: () => void) => {
clickAwayHandler = handler
})
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseStore.mockImplementation((selector: (state: {
panelMenu?: { left: number, top: number }
clipboardElements: unknown[]
setShowImportDSLModal: (visible: boolean) => void
}) => unknown) => selector({
panelMenu,
clipboardElements,
setShowImportDSLModal: mockSetShowImportDSLModal,
}))
mockUseNodesInteractions.mockReturnValue({
handleNodesPaste: mockHandleNodesPaste,
})
mockUsePanelInteractions.mockReturnValue({
handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
})
mockUseWorkflowStartRun.mockReturnValue({
handleStartWorkflowRun: mockHandleStartWorkflowRun,
})
mockUseOperator.mockReturnValue({
handleAddNote: mockHandleAddNote,
})
mockUseDSL.mockReturnValue({
exportCheck: mockExportCheck,
})
})
it('should stay hidden when the panel menu is absent', () => {
render(<PanelContextmenu />)
expect(screen.queryByTestId('add-block')).not.toBeInTheDocument()
})
it('should keep paste disabled when the clipboard is empty', () => {
panelMenu = { left: 24, top: 48 }
render(<PanelContextmenu />)
fireEvent.click(screen.getByText('common.pasteHere'))
expect(mockHandleNodesPaste).not.toHaveBeenCalled()
expect(mockHandlePaneContextmenuCancel).not.toHaveBeenCalled()
})
it('should render actions, position the menu, and execute each action', () => {
panelMenu = { left: 24, top: 48 }
clipboardElements = [{ id: 'copied-node' }]
const { container } = render(<PanelContextmenu />)
expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock')
expect(screen.getByTestId('shortcut-alt-r')).toHaveTextContent('alt+r')
expect(screen.getByTestId('shortcut-ctrl-v')).toHaveTextContent('ctrl+v')
expect(container.firstChild).toHaveStyle({
left: '24px',
top: '48px',
})
fireEvent.click(screen.getByText('nodes.note.addNote'))
fireEvent.click(screen.getByText('common.run'))
fireEvent.click(screen.getByText('common.pasteHere'))
fireEvent.click(screen.getByText('export'))
fireEvent.click(screen.getByText('common.importDSL'))
clickAwayHandler?.()
expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1)
expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1)
expect(mockExportCheck).toHaveBeenCalledTimes(1)
expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(true)
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(4)
})
})

View File

@ -0,0 +1,61 @@
import { render } from '@testing-library/react'
import HelpLine from '../index'
const mockUseViewport = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
useViewport: () => mockUseViewport(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: {
helpLineHorizontal?: { top: number, left: number, width: number }
helpLineVertical?: { top: number, left: number, height: number }
}) => unknown) => mockUseStore(selector),
}))
describe('HelpLine', () => {
let helpLineHorizontal: { top: number, left: number, width: number } | undefined
let helpLineVertical: { top: number, left: number, height: number } | undefined
beforeEach(() => {
vi.clearAllMocks()
helpLineHorizontal = undefined
helpLineVertical = undefined
mockUseViewport.mockReturnValue({ x: 10, y: 20, zoom: 2 })
mockUseStore.mockImplementation((selector: (state: {
helpLineHorizontal?: { top: number, left: number, width: number }
helpLineVertical?: { top: number, left: number, height: number }
}) => unknown) => selector({
helpLineHorizontal,
helpLineVertical,
}))
})
it('should render nothing when both help lines are absent', () => {
const { container } = render(<HelpLine />)
expect(container).toBeEmptyDOMElement()
})
it('should render the horizontal and vertical guide lines using viewport offsets and zoom', () => {
helpLineHorizontal = { top: 30, left: 40, width: 50 }
helpLineVertical = { top: 60, left: 70, height: 80 }
const { container } = render(<HelpLine />)
const [horizontal, vertical] = Array.from(container.querySelectorAll('div'))
expect(horizontal).toHaveStyle({
top: '80px',
left: '90px',
width: '100px',
})
expect(vertical).toHaveStyle({
top: '140px',
left: '150px',
height: '160px',
})
})
})

View File

@ -0,0 +1,171 @@
import type { ModelConfig, VisionSetting } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { Resolution } from '@/types/app'
import useConfigVision from '../use-config-vision'
const mockUseTextGenerationCurrentProviderAndModelAndModelList = vi.hoisted(() => vi.fn())
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: (...args: unknown[]) =>
mockUseTextGenerationCurrentProviderAndModelAndModelList(...args),
}))
vi.mock('../use-workflow', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
const createModel = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({
provider: 'openai',
name: 'gpt-4o',
mode: 'chat',
completion_params: [],
...overrides,
})
const createVisionPayload = (overrides: Partial<{ enabled: boolean, configs?: VisionSetting }> = {}) => ({
enabled: false,
...overrides,
})
describe('useConfigVision', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseIsChatMode.mockReturnValue(false)
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
currentModel: {
features: [],
},
})
})
it('should expose vision capability and enable default chat configs for vision models', () => {
const onChange = vi.fn()
mockUseIsChatMode.mockReturnValue(true)
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
currentModel: {
features: [ModelFeatureEnum.vision],
},
})
const { result } = renderHook(() => useConfigVision(createModel(), {
payload: createVisionPayload(),
onChange,
}))
expect(result.current.isVisionModel).toBe(true)
act(() => {
result.current.handleVisionResolutionEnabledChange(true)
})
expect(onChange).toHaveBeenCalledWith({
enabled: true,
configs: {
detail: Resolution.high,
variable_selector: ['sys', 'files'],
},
})
})
it('should clear configs when disabling vision resolution', () => {
const onChange = vi.fn()
const { result } = renderHook(() => useConfigVision(createModel(), {
payload: createVisionPayload({
enabled: true,
configs: {
detail: Resolution.low,
variable_selector: ['node', 'files'],
},
}),
onChange,
}))
act(() => {
result.current.handleVisionResolutionEnabledChange(false)
})
expect(onChange).toHaveBeenCalledWith({
enabled: false,
})
})
it('should update the resolution config payload directly', () => {
const onChange = vi.fn()
const config: VisionSetting = {
detail: Resolution.low,
variable_selector: ['upstream', 'images'],
}
const { result } = renderHook(() => useConfigVision(createModel(), {
payload: createVisionPayload({ enabled: true }),
onChange,
}))
act(() => {
result.current.handleVisionResolutionChange(config)
})
expect(onChange).toHaveBeenCalledWith({
enabled: true,
configs: config,
})
})
it('should disable vision settings when the selected model is no longer a vision model', () => {
const onChange = vi.fn()
const { result } = renderHook(() => useConfigVision(createModel(), {
payload: createVisionPayload({
enabled: true,
configs: {
detail: Resolution.high,
variable_selector: ['sys', 'files'],
},
}),
onChange,
}))
act(() => {
result.current.handleModelChanged()
})
expect(onChange).toHaveBeenCalledWith({
enabled: false,
})
})
it('should reset enabled vision configs when the model changes but still supports vision', () => {
const onChange = vi.fn()
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
currentModel: {
features: [ModelFeatureEnum.vision],
},
})
const { result } = renderHook(() => useConfigVision(createModel(), {
payload: createVisionPayload({
enabled: true,
configs: {
detail: Resolution.low,
variable_selector: ['old', 'files'],
},
}),
onChange,
}))
act(() => {
result.current.handleModelChanged()
})
expect(onChange).toHaveBeenCalledWith({
enabled: true,
configs: {
detail: Resolution.high,
variable_selector: [],
},
})
})
})

View File

@ -0,0 +1,146 @@
import { renderHook } from '@testing-library/react'
import { BlockEnum } from '../../types'
import { useDynamicTestRunOptions } from '../use-dynamic-test-run-options'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseNodes = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseAllTriggerPlugins = vi.hoisted(() => vi.fn())
const mockGetWorkflowEntryNode = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
__esModule: true,
default: () => mockUseNodes(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: {
buildInTools: unknown[]
customTools: unknown[]
workflowTools: unknown[]
mcpTools: unknown[]
}) => unknown) => mockUseStore(selector),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: () => mockUseAllTriggerPlugins(),
}))
vi.mock('@/app/components/workflow/utils/workflow-entry', () => ({
getWorkflowEntryNode: (...args: unknown[]) => mockGetWorkflowEntryNode(...args),
}))
describe('useDynamicTestRunOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseStore.mockImplementation((selector: (state: {
buildInTools: unknown[]
customTools: unknown[]
workflowTools: unknown[]
mcpTools: unknown[]
}) => unknown) => selector({
buildInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
}))
mockUseAllTriggerPlugins.mockReturnValue({
data: [{
name: 'plugin-provider',
icon: '/plugin-icon.png',
}],
})
})
it('should build user input, trigger options, and a run-all option from workflow nodes', () => {
mockUseNodes.mockReturnValue([
{
id: 'start-1',
data: { type: BlockEnum.Start, title: 'User Input' },
},
{
id: 'schedule-1',
data: { type: BlockEnum.TriggerSchedule, title: 'Daily Schedule' },
},
{
id: 'webhook-1',
data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' },
},
{
id: 'plugin-1',
data: {
type: BlockEnum.TriggerPlugin,
title: '',
plugin_name: 'Plugin Trigger',
provider_id: 'plugin-provider',
},
},
])
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.userInput).toEqual(expect.objectContaining({
id: 'start-1',
type: 'user_input',
name: 'User Input',
nodeId: 'start-1',
enabled: true,
}))
expect(result.current.triggers).toEqual([
expect.objectContaining({
id: 'schedule-1',
type: 'schedule',
name: 'Daily Schedule',
nodeId: 'schedule-1',
}),
expect.objectContaining({
id: 'webhook-1',
type: 'webhook',
name: 'Webhook Trigger',
nodeId: 'webhook-1',
}),
expect.objectContaining({
id: 'plugin-1',
type: 'plugin',
name: 'Plugin Trigger',
nodeId: 'plugin-1',
}),
])
expect(result.current.runAll).toEqual(expect.objectContaining({
id: 'run-all',
type: 'all',
relatedNodeIds: ['schedule-1', 'webhook-1', 'plugin-1'],
}))
})
it('should fall back to the workflow entry node and omit run-all when only one trigger exists', () => {
mockUseNodes.mockReturnValue([
{
id: 'webhook-1',
data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' },
},
])
mockGetWorkflowEntryNode.mockReturnValue({
id: 'fallback-start',
data: { type: BlockEnum.Start, title: '' },
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.userInput).toEqual(expect.objectContaining({
id: 'fallback-start',
type: 'user_input',
name: 'blocks.start',
nodeId: 'fallback-start',
}))
expect(result.current.triggers).toHaveLength(1)
expect(result.current.runAll).toBeUndefined()
})
})

View File

@ -0,0 +1,235 @@
import { act, render, screen } from '@testing-library/react'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import LastRun from '../index'
const mockUseHooksStore = vi.hoisted(() => vi.fn())
const mockUseLastRun = vi.hoisted(() => vi.fn())
const mockResultPanel = vi.hoisted(() => vi.fn())
vi.mock('@remixicon/react', () => ({
RiLoader2Line: () => <div data-testid="loading-icon" />,
}))
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: (selector: (state: {
configsMap?: { flowType?: string, flowId?: string }
}) => unknown) => mockUseHooksStore(selector),
}))
vi.mock('@/service/use-workflow', () => ({
useLastRun: (...args: unknown[]) => mockUseLastRun(...args),
}))
vi.mock('@/app/components/workflow/run/result-panel', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
mockResultPanel(props)
return <div data-testid="result-panel">{String(props.status)}</div>
},
}))
vi.mock('../no-data', () => ({
__esModule: true,
default: ({ onSingleRun }: { onSingleRun: () => void }) => (
<button type="button" onClick={onSingleRun}>
no-data
</button>
),
}))
describe('LastRun', () => {
const updateNodeRunningStatus = vi.fn()
const onSingleRunClicked = vi.fn()
let visibilityState = 'visible'
beforeEach(() => {
vi.clearAllMocks()
mockUseHooksStore.mockImplementation((selector: (state: {
configsMap?: { flowType?: string, flowId?: string }
}) => unknown) => selector({
configsMap: {
flowType: 'appFlow',
flowId: 'flow-1',
},
}))
mockUseLastRun.mockReturnValue({
data: undefined,
isFetching: false,
error: undefined,
})
visibilityState = 'visible'
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => visibilityState,
})
})
it('should show a loader while fetching the last run before any single run starts', () => {
mockUseLastRun.mockReturnValue({
data: undefined,
isFetching: true,
error: undefined,
})
render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun={false}
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
/>,
)
expect(screen.getByTestId('loading-icon')).toBeInTheDocument()
expect(screen.queryByTestId('result-panel')).not.toBeInTheDocument()
})
it('should show a running result panel while a single run is still executing', () => {
render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
runningStatus={NodeRunningStatus.Running}
/>,
)
expect(screen.getByTestId('result-panel')).toHaveTextContent('running')
expect(mockResultPanel).toHaveBeenCalledWith(expect.objectContaining({
status: 'running',
showSteps: false,
}))
})
it('should render the no-data state for 404 last-run responses and forward single-run clicks', () => {
mockUseLastRun.mockReturnValue({
data: undefined,
isFetching: false,
error: { status: 404 },
})
render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun={false}
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
/>,
)
act(() => {
screen.getByText('no-data').click()
})
expect(onSingleRunClicked).toHaveBeenCalledTimes(1)
})
it('should render resolved result data and let paused state override the final status', () => {
mockUseLastRun.mockReturnValue({
data: {
status: NodeRunningStatus.Succeeded,
execution_metadata: { total_tokens: 9 },
created_by_account: { created_by: 'Alice' },
},
isFetching: false,
error: undefined,
})
render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
runningStatus={NodeRunningStatus.Succeeded}
isPaused
/>,
)
expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Stopped)
expect(mockResultPanel).toHaveBeenCalledWith(expect.objectContaining({
status: NodeRunningStatus.Stopped,
total_tokens: 9,
created_by: 'Alice',
showSteps: false,
}))
})
it('should respect stopped and listening one-step statuses', () => {
mockUseLastRun.mockReturnValue({
data: {
status: NodeRunningStatus.Succeeded,
},
isFetching: false,
error: undefined,
})
const { rerender } = render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
runningStatus={NodeRunningStatus.Stopped}
/>,
)
expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Stopped)
rerender(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
runningStatus={NodeRunningStatus.Listening}
/>,
)
expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Listening)
})
it('should react to page visibility changes while keeping the current result rendered', () => {
mockUseLastRun.mockReturnValue({
data: {
status: NodeRunningStatus.Succeeded,
},
isFetching: false,
error: undefined,
})
render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
runningStatus={NodeRunningStatus.Succeeded}
/>,
)
act(() => {
visibilityState = 'hidden'
document.dispatchEvent(new Event('visibilitychange'))
visibilityState = 'visible'
document.dispatchEvent(new Event('visibilitychange'))
})
expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Succeeded)
})
})

View File

@ -0,0 +1,139 @@
import type { DataSourceNodeType } from '../../types'
import { renderHook } from '@testing-library/react'
import { VarType as VarKindType } from '../../types'
import { useConfig } from '../use-config'
const mockUseStoreApi = vi.hoisted(() => vi.fn())
const mockUseNodeDataUpdate = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
useStoreApi: () => mockUseStoreApi(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodeDataUpdate: () => mockUseNodeDataUpdate(),
}))
const createNode = (overrides: Partial<DataSourceNodeType> = {}): { id: string, data: DataSourceNodeType } => ({
id: 'data-source-node',
data: {
title: 'Datasource',
desc: '',
type: 'data-source',
plugin_id: 'plugin-1',
provider_type: 'local_file',
provider_name: 'provider',
datasource_name: 'source-a',
datasource_label: 'Source A',
datasource_parameters: {},
datasource_configurations: {},
_dataSourceStartToAdd: true,
...overrides,
} as DataSourceNodeType,
})
describe('data-source/hooks/use-config', () => {
const mockHandleNodeDataUpdateWithSyncDraft = vi.fn()
let currentNode = createNode()
beforeEach(() => {
vi.clearAllMocks()
currentNode = createNode()
mockUseStoreApi.mockReturnValue({
getState: () => ({
getNodes: () => [currentNode],
}),
})
mockUseNodeDataUpdate.mockReturnValue({
handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft,
})
})
it('should clear the local-file auto-add flag on mount and update datasource payloads', () => {
const { result } = renderHook(() => useConfig('data-source-node'))
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
id: 'data-source-node',
data: expect.objectContaining({
_dataSourceStartToAdd: false,
}),
})
mockHandleNodeDataUpdateWithSyncDraft.mockClear()
result.current.handleFileExtensionsChange(['pdf', 'csv'])
result.current.handleParametersChange({
dataset: {
type: VarKindType.constant,
value: 'docs',
},
})
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(1, {
id: 'data-source-node',
data: expect.objectContaining({
fileExtensions: ['pdf', 'csv'],
}),
})
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(2, {
id: 'data-source-node',
data: expect.objectContaining({
datasource_parameters: {
dataset: {
type: VarKindType.constant,
value: 'docs',
},
},
}),
})
})
it('should derive output schema metadata and detect object outputs', () => {
const dataSourceList = [{
plugin_id: 'plugin-1',
tools: [{
name: 'source-a',
output_schema: {
properties: {
items: {
type: 'array',
items: { type: 'string' },
description: 'List of items',
},
metadata: {
type: 'object',
description: 'Object field',
},
count: {
type: 'number',
description: 'Total count',
},
},
},
}],
}]
const { result } = renderHook(() => useConfig('data-source-node', dataSourceList))
expect(result.current.outputSchema).toEqual([
{
name: 'items',
type: 'Array[String]',
description: 'List of items',
},
{
name: 'metadata',
value: {
type: 'object',
description: 'Object field',
},
},
{
name: 'count',
type: 'Number',
description: 'Total count',
},
])
expect(result.current.hasObjectOutput).toBe(true)
})
})

View File

@ -0,0 +1,149 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { UserActionButtonType } from '../../types'
import ButtonStyleDropdown from '../button-style-dropdown'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockButton = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/button', () => ({
__esModule: true,
default: (props: {
variant?: string
children?: React.ReactNode
className?: string
}) => {
mockButton(props)
return <div data-testid={`button-${props.variant ?? 'default'}`}>{props.children}</div>
},
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const OpenContext = React.createContext(false)
return {
PortalToFollowElem: ({
open,
children,
}: {
open: boolean
children?: React.ReactNode
}) => (
<OpenContext value={open}>
<div data-testid="portal" data-open={String(open)}>{children}</div>
</OpenContext>
),
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children?: React.ReactNode
onClick?: () => void
}) => (
<button type="button" data-testid="portal-trigger" onClick={onClick}>
{children}
</button>
),
PortalToFollowElemContent: ({
children,
}: {
children?: React.ReactNode
}) => {
const open = React.use(OpenContext)
return open ? <div data-testid="portal-content">{children}</div> : null
},
}
})
describe('ButtonStyleDropdown', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
})
it('should map the current style to the trigger button and update the selected style', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Ghost}
onChange={onChange}
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'ghost',
}))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
expect(screen.getByText('nodes.humanInput.userActions.chooseStyle')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('button-primary').parentElement as HTMLElement)
fireEvent.click(screen.getByTestId('button-secondary').parentElement as HTMLElement)
fireEvent.click(screen.getByTestId('button-secondary-accent').parentElement as HTMLElement)
fireEvent.click(screen.getAllByTestId('button-ghost')[1].parentElement as HTMLElement)
expect(onChange).toHaveBeenNthCalledWith(1, UserActionButtonType.Primary)
expect(onChange).toHaveBeenNthCalledWith(2, UserActionButtonType.Default)
expect(onChange).toHaveBeenNthCalledWith(3, UserActionButtonType.Accent)
expect(onChange).toHaveBeenNthCalledWith(4, UserActionButtonType.Ghost)
})
it('should keep the dropdown closed in readonly mode', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Default}
onChange={onChange}
readonly
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'secondary',
}))
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(onChange).not.toHaveBeenCalled()
})
it('should map the accent style to the secondary-accent trigger button', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Accent}
onChange={onChange}
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'secondary-accent',
}))
})
it('should map the primary style to the primary trigger button', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Primary}
onChange={onChange}
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'primary',
}))
})
})

View File

@ -0,0 +1,135 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { UserActionButtonType } from '../../types'
import FormContentPreview from '../form-content-preview'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseNodes = vi.hoisted(() => vi.fn())
const mockGetButtonStyle = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { panelWidth: number }) => unknown) => mockUseStore(selector),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
__esModule: true,
default: () => mockUseNodes(),
}))
vi.mock('@/app/components/base/action-button', () => ({
__esModule: true,
default: ({ children, onClick }: { children?: ReactNode, onClick?: () => void }) => (
<button type="button" aria-label="close-preview" onClick={onClick}>
{children}
</button>
),
}))
vi.mock('@/app/components/base/badge', () => ({
__esModule: true,
default: ({ children }: { children?: ReactNode }) => <div data-testid="badge">{children}</div>,
}))
vi.mock('@/app/components/base/button', () => ({
__esModule: true,
default: ({ children, variant }: { children?: ReactNode, variant?: string }) => (
<button type="button" data-testid={`action-${variant}`}>{children}</button>
),
}))
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/utils', () => ({
getButtonStyle: (...args: unknown[]) => mockGetButtonStyle(...args),
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ customComponents }: {
customComponents: {
variable: (props: { node: { properties: { dataPath: string } } }) => ReactNode
section: (props: { node: { properties: { dataName: string } } }) => ReactNode
}
}) => (
<div>
{customComponents.variable({ node: { properties: { dataPath: '#node-1.answer#' } } })}
{customComponents.section({ node: { properties: { dataName: 'field_1' } } })}
{customComponents.section({ node: { properties: { dataName: 'missing_field' } } })}
</div>
),
}))
vi.mock('../variable-in-markdown', () => ({
rehypeNotes: vi.fn(),
rehypeVariable: vi.fn(),
Variable: ({ path }: { path: string }) => <div data-testid="variable-path">{path}</div>,
Note: ({ defaultInput, nodeName }: {
defaultInput: { selector: string[] }
nodeName: (nodeId: string) => string
}) => <div data-testid="note">{nodeName(defaultInput.selector[0])}</div>,
}))
describe('FormContentPreview', () => {
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseStore.mockImplementation((selector: (state: { panelWidth: number }) => unknown) => selector({ panelWidth: 320 }))
mockUseNodes.mockReturnValue([{
id: 'node-1',
data: { title: 'Classifier' },
}])
mockGetButtonStyle.mockImplementation((style: UserActionButtonType) => style.toLowerCase())
})
it('should render preview content with resolved node names, note fallbacks, and action buttons', () => {
const { container } = render(
<FormContentPreview
content="content"
formInputs={[{
type: 'text-input' as never,
output_variable_name: 'field_1',
default: {
type: 'variable',
selector: ['node-1', 'answer'],
value: '',
},
}]}
userActions={[{
id: 'approve',
title: 'Approve',
button_style: UserActionButtonType.Primary,
}]}
onClose={onClose}
/>,
)
expect(container.firstChild).toHaveStyle({ right: '328px' })
expect(screen.getByTestId('badge')).toHaveTextContent('nodes.humanInput.formContent.preview')
expect(screen.getByTestId('variable-path')).toHaveTextContent('#Classifier.answer#')
expect(screen.getByTestId('note')).toHaveTextContent('Classifier')
expect(screen.getByText(/Can't find note:/)).toHaveTextContent('missing_field')
expect(screen.getByTestId('action-primary')).toHaveTextContent('Approve')
expect(screen.getByText('nodes.humanInput.editor.previewTip')).toBeInTheDocument()
})
it('should close the preview when the close action is clicked', () => {
render(
<FormContentPreview
content="content"
formInputs={[]}
userActions={[]}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'close-preview' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,258 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import FormContent from '../form-content'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseWorkflowVariableType = vi.hoisted(() => vi.fn())
const mockIsMac = vi.hoisted(() => vi.fn())
const mockPromptEditor = vi.hoisted(() => vi.fn())
const mockAddInputField = vi.hoisted(() => vi.fn())
const mockOnInsert = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
Trans: ({
i18nKey,
components,
}: {
i18nKey: string
components?: Record<string, ReactNode>
}) => (
<div>
<div>{i18nKey}</div>
{components?.CtrlKey}
{components?.Key}
</div>
),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowVariableType: () => mockUseWorkflowVariableType(),
}))
vi.mock('@/app/components/workflow/utils', () => ({
isMac: () => mockIsMac(),
}))
vi.mock('@/app/components/base/prompt-editor', () => ({
__esModule: true,
default: (props: {
onChange: (value: string) => void
onFocus: () => void
onBlur: () => void
shortcutPopups?: Array<{
Popup: (props: { onClose: () => void, onInsert: typeof mockOnInsert }) => ReactNode
}>
editable?: boolean
hitlInputBlock: {
workflowNodesMap: Record<string, unknown>
}
}) => {
mockPromptEditor(props)
const popup = props.shortcutPopups?.[0]
return (
<div>
<button type="button" onClick={props.onFocus}>focus-editor</button>
<button type="button" onClick={props.onBlur}>blur-editor</button>
<button type="button" onClick={() => props.onChange('updated value')}>change-editor</button>
{popup && popup.Popup({ onClose: vi.fn(), onInsert: mockOnInsert })}
</div>
)
},
}))
vi.mock('../add-input-field', () => ({
__esModule: true,
default: (props: {
onSave: (payload: {
type: string
output_variable_name: string
default: {
type: string
selector: string[]
value: string
}
}) => void
onCancel: () => void
}) => {
mockAddInputField(props)
return (
<div>
<button
type="button"
onClick={() => props.onSave({
type: 'text-input',
output_variable_name: 'approval',
default: {
type: 'variable',
selector: ['node-1', 'answer'],
value: '',
},
})}
>
save-input
</button>
<button type="button" onClick={props.onCancel}>cancel-input</button>
</div>
)
},
}))
vi.mock('@/app/components/base/prompt-editor/plugins/hitl-input-block', () => ({
INSERT_HITL_INPUT_BLOCK_COMMAND: 'INSERT_HITL_INPUT_BLOCK_COMMAND',
}))
describe('FormContent', () => {
const onChange = vi.fn()
const onFormInputsChange = vi.fn()
const onFormInputItemRename = vi.fn()
const onFormInputItemRemove = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseWorkflowVariableType.mockReturnValue(() => 'string')
mockIsMac.mockReturnValue(false)
})
it('should build workflow node maps, show the hotkey tip on focus, and defer form-input sync until value changes', async () => {
const { rerender } = render(
<FormContent
nodeId="node-2"
value="Initial content"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[
{
id: 'node-1',
data: { title: 'Start', type: 'start' },
position: { x: 0, y: 0 },
width: 100,
height: 40,
} as never,
{
id: 'node-2',
data: { title: 'Classifier', type: 'code' },
position: { x: 120, y: 0 },
width: 100,
height: 40,
} as never,
]}
/>,
)
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
editable: true,
hitlInputBlock: expect.objectContaining({
workflowNodesMap: expect.objectContaining({
'node-1': expect.objectContaining({ title: 'Start' }),
'node-2': expect.objectContaining({ title: 'Classifier' }),
'sys': expect.objectContaining({ title: 'blocks.start' }),
}),
}),
}))
fireEvent.click(screen.getByText('focus-editor'))
expect(screen.getByText('nodes.humanInput.formContent.hotkeyTip')).toBeInTheDocument()
fireEvent.click(screen.getByText('save-input'))
expect(mockOnInsert).toHaveBeenCalledWith('INSERT_HITL_INPUT_BLOCK_COMMAND', expect.objectContaining({
variableName: 'approval',
nodeId: 'node-2',
formInputs: [expect.objectContaining({ output_variable_name: 'approval' })],
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
}))
expect(onFormInputsChange).not.toHaveBeenCalled()
rerender(
<FormContent
nodeId="node-2"
value="Initial content {{approval}}"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[
{
id: 'node-1',
data: { title: 'Start', type: 'start' },
position: { x: 0, y: 0 },
width: 100,
height: 40,
} as never,
]}
/>,
)
await waitFor(() => {
expect(onFormInputsChange).toHaveBeenCalledWith([
expect.objectContaining({ output_variable_name: 'approval' }),
])
})
})
it('should disable editing helpers in readonly mode', () => {
const { container } = render(
<FormContent
nodeId="node-2"
value="Initial content"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[]}
readonly
/>,
)
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
editable: false,
shortcutPopups: [],
}))
expect(screen.queryByText('save-input')).not.toBeInTheDocument()
expect(container.firstChild).toHaveClass('pointer-events-none')
})
it('should render the mac hotkey hint when focused on macOS', () => {
mockIsMac.mockReturnValue(true)
render(
<FormContent
nodeId="node-2"
value="Initial content"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[]}
/>,
)
fireEvent.click(screen.getByText('focus-editor'))
expect(screen.getByText('⌘')).toBeInTheDocument()
expect(screen.getByText('/')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,77 @@
import { fireEvent, render, screen } from '@testing-library/react'
import TimeoutInput from '../timeout'
const mockUseTranslation = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/input', () => ({
__esModule: true,
default: (props: {
value: number
disabled?: boolean
onChange: (event: { target: { value: string } }) => void
}) => (
<input
data-testid="timeout-input"
value={props.value}
disabled={props.disabled}
onChange={e => props.onChange({ target: { value: e.target.value } })}
/>
),
}))
describe('TimeoutInput', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
})
it('should update the numeric timeout value and switch units', () => {
render(
<TimeoutInput
timeout={3}
unit="day"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: '12' } })
fireEvent.click(screen.getByText('nodes.humanInput.timeout.hours'))
expect(onChange).toHaveBeenNthCalledWith(1, { timeout: 12, unit: 'day' })
expect(onChange).toHaveBeenNthCalledWith(2, { timeout: 3, unit: 'hour' })
})
it('should fall back to 1 on invalid input and stay read-only when disabled', () => {
const { rerender } = render(
<TimeoutInput
timeout={5}
unit="hour"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: 'abc' } })
expect(onChange).toHaveBeenCalledWith({ timeout: 1, unit: 'hour' })
rerender(
<TimeoutInput
timeout={5}
unit="hour"
onChange={onChange}
readonly
/>,
)
fireEvent.click(screen.getByText('nodes.humanInput.timeout.days'))
expect(onChange).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('timeout-input')).toBeDisabled()
})
})

View File

@ -0,0 +1,143 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { UserActionButtonType } from '../../types'
import UserActionItem from '../user-action'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockNotify = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/input', () => ({
__esModule: true,
default: (props: {
value: string
placeholder?: string
disabled?: boolean
onChange: (event: { target: { value: string } }) => void
}) => (
<input
data-testid={props.placeholder}
value={props.value}
disabled={props.disabled}
onChange={e => props.onChange({ target: { value: e.target.value } })}
/>
),
}))
vi.mock('@/app/components/base/button', () => ({
__esModule: true,
default: (props: {
children?: ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={props.onClick}>
{props.children}
</button>
),
}))
vi.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: (...args: unknown[]) => mockNotify(...args),
},
}))
vi.mock('../button-style-dropdown', () => ({
__esModule: true,
default: (props: {
onChange: (type: UserActionButtonType) => void
}) => (
<button type="button" onClick={() => props.onChange(UserActionButtonType.Ghost)}>
change-style
</button>
),
}))
describe('UserActionItem', () => {
const onChange = vi.fn()
const onDelete = vi.fn()
const action = {
id: 'approve',
title: 'Approve',
button_style: UserActionButtonType.Primary,
}
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
})
it('should sanitize ids, enforce length limits, and update the button text', () => {
render(
<UserActionItem
data={action}
onChange={onChange}
onDelete={onDelete}
/>,
)
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'Approve action' } })
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: '1invalid' } })
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'averyveryveryverylongidentifier' } })
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder'), { target: { value: 'A very very very long button title' } })
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({
id: 'Approve_action',
}))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({
id: 'averyveryveryverylon',
}))
expect(onChange).toHaveBeenNthCalledWith(3, expect.objectContaining({
title: 'A very very very lon',
}))
expect(mockNotify).toHaveBeenNthCalledWith(1, expect.objectContaining({
type: 'error',
message: 'nodes.humanInput.userActions.actionIdFormatTip',
}))
expect(mockNotify).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: 'error',
message: 'nodes.humanInput.userActions.actionIdTooLong',
}))
expect(mockNotify).toHaveBeenNthCalledWith(3, expect.objectContaining({
type: 'error',
message: 'nodes.humanInput.userActions.buttonTextTooLong',
}))
})
it('should support clearing ids, updating button style, deleting, and readonly mode', () => {
const { rerender } = render(
<UserActionItem
data={action}
onChange={onChange}
onDelete={onDelete}
/>,
)
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: ' ' } })
fireEvent.click(screen.getByText('change-style'))
fireEvent.click(screen.getAllByRole('button')[1])
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: '' }))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ button_style: UserActionButtonType.Ghost }))
expect(onDelete).toHaveBeenCalledWith('approve')
rerender(
<UserActionItem
data={action}
onChange={onChange}
onDelete={onDelete}
readonly
/>,
)
expect(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder')).toBeDisabled()
expect(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder')).toBeDisabled()
expect(screen.getAllByRole('button')).toHaveLength(1)
})
})

View File

@ -0,0 +1,150 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { DeliveryMethodType } from '../../../types'
import DeliveryMethodForm from '../index'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseNodesSyncDraft = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/tooltip', () => ({
__esModule: true,
default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>,
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesSyncDraft: () => mockUseNodesSyncDraft(),
}))
vi.mock('../method-selector', () => ({
__esModule: true,
default: (props: {
onAdd: (method: { id: string, type: DeliveryMethodType, enabled: boolean }) => void
onShowUpgradeTip: () => void
}) => (
<div>
<button
type="button"
onClick={() => props.onAdd({ id: 'email-1', type: DeliveryMethodType.Email, enabled: false })}
>
add-method
</button>
<button type="button" onClick={props.onShowUpgradeTip}>
show-upgrade
</button>
</div>
),
}))
vi.mock('../method-item', () => ({
__esModule: true,
default: (props: {
method: { type: DeliveryMethodType, enabled: boolean }
onChange: (method: { type: DeliveryMethodType, enabled: boolean }) => void
onDelete: (type: DeliveryMethodType) => void
}) => (
<div data-testid={`method-${props.method.type}`}>
<button
type="button"
onClick={() => props.onChange({ ...props.method, enabled: !props.method.enabled })}
>
change-method
</button>
<button
type="button"
onClick={() => props.onDelete(props.method.type)}
>
delete-method
</button>
</div>
),
}))
vi.mock('../upgrade-modal', () => ({
__esModule: true,
default: ({ onClose }: { onClose: () => void }) => (
<button type="button" onClick={onClose}>
upgrade-modal
</button>
),
}))
describe('DeliveryMethodForm', () => {
const onChange = vi.fn()
const mockHandleSyncWorkflowDraft = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseNodesSyncDraft.mockReturnValue({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
})
})
it('should render the empty state and add methods through the selector', () => {
render(
<DeliveryMethodForm
nodeId="node-1"
value={[]}
onChange={onChange}
/>,
)
expect(screen.getByText('nodes.humanInput.deliveryMethod.emptyTip')).toBeInTheDocument()
fireEvent.click(screen.getByText('add-method'))
expect(onChange).toHaveBeenCalledWith([
{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: false,
},
])
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should change and delete methods, syncing the draft after updates', () => {
render(
<DeliveryMethodForm
nodeId="node-1"
value={[{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: false,
}]}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByText('change-method'))
fireEvent.click(screen.getByText('delete-method'))
expect(onChange).toHaveBeenNthCalledWith(1, [{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: true,
}])
expect(onChange).toHaveBeenNthCalledWith(2, [])
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true)
})
it('should open and close the upgrade modal', () => {
render(
<DeliveryMethodForm
nodeId="node-1"
value={[]}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByText('show-upgrade'))
expect(screen.getByText('upgrade-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('upgrade-modal'))
expect(screen.queryByText('upgrade-modal')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,156 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Recipient from '../index'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseAppContext = vi.hoisted(() => vi.fn())
const mockUseMembers = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockUseAppContext(),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => mockUseMembers(),
}))
vi.mock('@/app/components/base/switch', () => ({
__esModule: true,
default: (props: {
value: boolean
onChange: (value: boolean) => void
}) => (
<button type="button" onClick={() => props.onChange(!props.value)}>
toggle-workspace
</button>
),
}))
vi.mock('../member-selector', () => ({
__esModule: true,
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
<button type="button" onClick={() => onSelect('member-2')}>
add-member
</button>
),
}))
vi.mock('../email-input', () => ({
__esModule: true,
default: (props: {
onAdd: (email: string) => void
onSelect: (id: string) => void
onDelete: (recipient: { type: 'member' | 'external', user_id?: string, email?: string }) => void
}) => (
<div>
<button type="button" onClick={() => props.onAdd('new@example.com')}>
add-email
</button>
<button type="button" onClick={() => props.onSelect('member-3')}>
add-email-member
</button>
<button type="button" onClick={() => props.onDelete({ type: 'member', user_id: 'member-1' })}>
delete-member
</button>
<button type="button" onClick={() => props.onDelete({ type: 'external', email: 'external@example.com' })}>
delete-external
</button>
</div>
),
}))
describe('Recipient', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string, options?: { workspaceName?: string }) => options?.workspaceName ?? key,
})
mockUseAppContext.mockReturnValue({
userProfile: { email: 'owner@example.com' },
currentWorkspace: { name: 'Dify\'s Lab' },
})
mockUseMembers.mockReturnValue({
data: {
accounts: [
{ id: 'member-1', email: 'member-1@example.com', name: 'Member One' },
{ id: 'member-2', email: 'member-2@example.com', name: 'Member Two' },
{ id: 'member-3', email: 'member-3@example.com', name: 'Member Three' },
],
},
})
})
it('should render workspace details and update recipients through member/email actions', () => {
render(
<Recipient
data={{
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
],
}}
onChange={onChange}
/>,
)
expect(screen.getByText('D')).toBeInTheDocument()
expect(screen.getByText('Difys Lab')).toBeInTheDocument()
fireEvent.click(screen.getByText('add-member'))
fireEvent.click(screen.getByText('add-email'))
fireEvent.click(screen.getByText('add-email-member'))
fireEvent.click(screen.getByText('delete-member'))
fireEvent.click(screen.getByText('delete-external'))
fireEvent.click(screen.getByText('toggle-workspace'))
expect(onChange).toHaveBeenNthCalledWith(1, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
{ type: 'member', user_id: 'member-2' },
],
})
expect(onChange).toHaveBeenNthCalledWith(2, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
{ type: 'external', email: 'new@example.com' },
],
})
expect(onChange).toHaveBeenNthCalledWith(3, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
{ type: 'member', user_id: 'member-3' },
],
})
expect(onChange).toHaveBeenNthCalledWith(4, {
whole_workspace: false,
items: [
{ type: 'external', email: 'external@example.com' },
],
})
expect(onChange).toHaveBeenNthCalledWith(5, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
],
})
expect(onChange).toHaveBeenNthCalledWith(6, {
whole_workspace: true,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
],
})
})
})

View File

@ -0,0 +1,156 @@
import type { DeliveryMethod, HumanInputNodeType, UserAction } from '../../types'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import useConfig from '../use-config'
const mockUseUpdateNodeInternals = vi.hoisted(() => vi.fn())
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
const mockUseEdgesInteractions = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
const mockUseFormContent = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
useUpdateNodeInternals: () => mockUseUpdateNodeInternals(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => mockUseNodesReadOnly(),
}))
vi.mock('@/app/components/workflow/hooks/use-edges-interactions', () => ({
useEdgesInteractions: () => mockUseEdgesInteractions(),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
vi.mock('../use-form-content', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseFormContent(...args),
}))
const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNodeType => ({
title: 'Human Input',
desc: '',
type: BlockEnum.HumanInput,
delivery_methods: [{
id: 'webapp',
type: 'webapp',
enabled: true,
} as DeliveryMethod],
form_content: 'Body',
inputs: [],
user_actions: [{
id: 'approve',
title: 'Approve',
button_style: 'primary',
} as UserAction],
timeout: 3,
timeout_unit: 'day',
...overrides,
})
describe('human-input/hooks/use-config', () => {
const mockSetInputs = vi.fn()
const mockHandleEdgeDeleteByDeleteBranch = vi.fn()
const mockHandleEdgeSourceHandleChange = vi.fn()
const mockUpdateNodeInternals = vi.fn()
const formContentHook = {
editorKey: 3,
handleFormContentChange: vi.fn(),
handleFormInputsChange: vi.fn(),
handleFormInputItemRename: vi.fn(),
handleFormInputItemRemove: vi.fn(),
}
let currentInputs = createPayload()
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
mockUseUpdateNodeInternals.mockReturnValue(mockUpdateNodeInternals)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
mockUseEdgesInteractions.mockReturnValue({
handleEdgeDeleteByDeleteBranch: mockHandleEdgeDeleteByDeleteBranch,
handleEdgeSourceHandleChange: mockHandleEdgeSourceHandleChange,
})
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
setInputs: mockSetInputs,
}))
mockUseFormContent.mockReturnValue(formContentHook)
})
it('should expose form-content helpers and update delivery methods, timeout, and collapsed state', () => {
const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
const methods = [{
id: 'email',
type: 'email',
enabled: true,
} as DeliveryMethod]
expect(result.current.editorKey).toBe(3)
expect(result.current.readOnly).toBe(false)
expect(result.current.structuredOutputCollapsed).toBe(true)
act(() => {
result.current.handleDeliveryMethodChange(methods)
result.current.handleTimeoutChange({ timeout: 12, unit: 'hour' })
result.current.setStructuredOutputCollapsed(false)
})
expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
delivery_methods: methods,
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
timeout: 12,
timeout_unit: 'hour',
}))
expect(result.current.structuredOutputCollapsed).toBe(false)
})
it('should append and delete user actions while syncing branch-edge cleanup', () => {
const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
const newAction = {
id: 'reject',
title: 'Reject',
button_style: 'default',
} as UserAction
act(() => {
result.current.handleUserActionAdd(newAction)
result.current.handleUserActionDelete('approve')
})
expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
user_actions: [
expect.objectContaining({ id: 'approve' }),
newAction,
],
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
user_actions: [],
}))
expect(mockHandleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('human-input-node', 'approve')
})
it('should update user action ids and refresh source handles when the branch key changes', () => {
const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
const renamedAction = {
id: 'approved',
title: 'Approve',
button_style: 'primary',
} as UserAction
act(() => {
result.current.handleUserActionChange(0, renamedAction)
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
user_actions: [renamedAction],
}))
expect(mockHandleEdgeSourceHandleChange).toHaveBeenCalledWith('human-input-node', 'approve', 'approved')
expect(mockUpdateNodeInternals).toHaveBeenCalledWith('human-input-node')
})
})

View File

@ -0,0 +1,112 @@
import type { FormInputItem, HumanInputNodeType } from '../../types'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import useFormContent from '../use-form-content'
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflow: () => mockUseWorkflow(),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem => ({
type: InputVarType.textInput,
output_variable_name: 'old_name',
default: {
selector: [],
type: 'constant',
value: '',
},
...overrides,
})
const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNodeType => ({
title: 'Human Input',
desc: '',
type: BlockEnum.HumanInput,
delivery_methods: [],
form_content: 'Hello {{#$output.old_name#}}',
inputs: [createFormInput()],
user_actions: [],
timeout: 1,
timeout_unit: 'day',
...overrides,
})
describe('human-input/use-form-content', () => {
const mockSetInputs = vi.fn()
const mockHandleOutVarRenameChange = vi.fn()
let currentInputs = createPayload()
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
mockUseWorkflow.mockReturnValue({
handleOutVarRenameChange: mockHandleOutVarRenameChange,
})
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
setInputs: mockSetInputs,
}))
})
it('should update raw form content and replace the form input list', () => {
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
const nextInputs = [
createFormInput({
output_variable_name: 'approval',
}),
]
act(() => {
result.current.handleFormContentChange('Updated body')
result.current.handleFormInputsChange(nextInputs)
})
expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
form_content: 'Updated body',
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
inputs: nextInputs,
}))
expect(result.current.editorKey).toBe(1)
})
it('should rename input placeholders inside markdown and notify downstream references', () => {
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
const renamedInput = createFormInput({
output_variable_name: 'new_name',
})
act(() => {
result.current.handleFormInputItemRename(renamedInput, 'old_name')
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
form_content: 'Hello {{#$output.new_name#}}',
inputs: [renamedInput],
}))
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith('human-input-node', ['human-input-node', 'old_name'], ['human-input-node', 'new_name'])
expect(result.current.editorKey).toBe(1)
})
it('should remove an input placeholder and its form input metadata', () => {
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
act(() => {
result.current.handleFormInputItemRemove('old_name')
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
form_content: 'Hello ',
inputs: [],
}))
expect(result.current.editorKey).toBe(1)
})
})

View File

@ -0,0 +1,234 @@
import type { HumanInputNodeType } from '../../types'
import type { InputVar } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseAppStore = vi.hoisted(() => vi.fn())
const mockFetchHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn())
const mockSubmitHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail?: { id?: string, mode?: AppModeEnum } }) => unknown) => mockUseAppStore(selector),
}))
vi.mock('@/service/workflow', () => ({
fetchHumanInputNodeStepRunForm: (...args: unknown[]) => mockFetchHumanInputNodeStepRunForm(...args),
submitHumanInputNodeStepRunForm: (...args: unknown[]) => mockSubmitHumanInputNodeStepRunForm(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNodeType => ({
title: 'Human Input',
desc: '',
type: BlockEnum.HumanInput,
delivery_methods: [],
form_content: 'Summary: {{#start.topic#}}',
inputs: [{
type: InputVarType.textInput,
output_variable_name: 'summary',
default: {
type: 'variable',
selector: ['start', 'topic'],
value: '',
},
}],
user_actions: [],
timeout: 1,
timeout_unit: 'day',
...overrides,
})
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
type: InputVarType.textInput,
label: 'Topic',
variable: '#start.topic#',
required: false,
value_selector: ['start', 'topic'],
...overrides,
})
const mockFormData: HumanInputFormData = {
form_id: 'form-1',
node_id: 'node-1',
node_title: 'Human Input',
form_content: 'Rendered content',
inputs: [],
actions: [],
form_token: 'token-1',
resolved_default_values: {
topic: 'AI',
},
display_in_ui: true,
expiration_time: 1000,
}
describe('human-input/hooks/use-single-run-form-params', () => {
const mockSetRunInputData = vi.fn()
const getInputVars = vi.fn()
let currentInputs = createPayload()
let appDetail: { id?: string, mode?: AppModeEnum } | undefined
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
appDetail = {
id: 'app-1',
mode: AppModeEnum.WORKFLOW,
}
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseAppStore.mockImplementation((selector: (state: { appDetail?: { id?: string, mode?: AppModeEnum } }) => unknown) => selector({ appDetail }))
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
}))
getInputVars.mockReturnValue([
createInputVar(),
createInputVar({
label: 'Output',
variable: '#$output.answer#',
value_selector: ['$output', 'answer'],
}),
{
...createInputVar({
label: 'Broken',
}),
variable: undefined,
} as unknown as InputVar,
])
mockFetchHumanInputNodeStepRunForm.mockResolvedValue(mockFormData)
mockSubmitHumanInputNodeStepRunForm.mockResolvedValue({})
})
it('should build a single before-run form, filter output vars, and expose dependent vars', () => {
const { result } = renderHook(() => useSingleRunFormParams({
id: 'node-1',
payload: currentInputs,
runInputData: { topic: 'AI' },
getInputVars,
setRunInputData: mockSetRunInputData,
}))
expect(getInputVars).toHaveBeenCalledWith([
'{{#start.topic#}}',
'Summary: {{#start.topic#}}',
])
expect(result.current.forms).toHaveLength(1)
expect(result.current.forms[0]).toEqual(expect.objectContaining({
label: 'nodes.humanInput.singleRun.label',
values: { topic: 'AI' },
inputs: [
expect.objectContaining({ variable: '#start.topic#' }),
expect.objectContaining({ label: 'Broken' }),
],
}))
act(() => {
result.current.forms[0].onChange?.({ topic: 'Updated' })
})
expect(mockSetRunInputData).toHaveBeenCalledWith({ topic: 'Updated' })
expect(result.current.getDependentVars()).toEqual([
['start', 'topic'],
])
})
it('should fetch and submit generated forms in workflow mode while keeping required inputs', async () => {
const { result } = renderHook(() => useSingleRunFormParams({
id: 'node-1',
payload: currentInputs,
runInputData: {},
getInputVars,
setRunInputData: mockSetRunInputData,
}))
await act(async () => {
await result.current.handleShowGeneratedForm({
topic: 'AI',
ignored: undefined as unknown as string,
})
})
expect(result.current.showGeneratedForm).toBe(true)
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledWith(
'/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
{
inputs: { topic: 'AI' },
},
)
expect(result.current.formData).toEqual(mockFormData)
await act(async () => {
await result.current.handleSubmitHumanInputForm({
inputs: { answer: 'approved' },
form_inputs: { ignored: 'value' },
action: 'approve',
})
})
expect(mockSubmitHumanInputNodeStepRunForm).toHaveBeenCalledWith(
'/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
{
inputs: { topic: 'AI' },
form_inputs: { answer: 'approved' },
action: 'approve',
},
)
act(() => {
result.current.handleHideGeneratedForm()
})
expect(result.current.showGeneratedForm).toBe(false)
})
it('should use the advanced-chat endpoint and skip remote fetches when app detail is missing', async () => {
appDetail = {
id: 'app-2',
mode: AppModeEnum.ADVANCED_CHAT,
}
const { result, rerender } = renderHook(() => useSingleRunFormParams({
id: 'node-9',
payload: currentInputs,
runInputData: {},
getInputVars,
setRunInputData: mockSetRunInputData,
}))
await act(async () => {
await result.current.handleFetchFormContent({ topic: 'hello' })
})
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledWith(
'/apps/app-2/advanced-chat/workflows/draft/human-input/nodes/node-9/form',
{
inputs: { topic: 'hello' },
},
)
appDetail = undefined
rerender()
await act(async () => {
const data = await result.current.handleFetchFormContent({ topic: 'skip' })
expect(data).toBeNull()
})
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,173 @@
import type { IterationNodeType } from '../types'
import type { Item } from '@/app/components/base/select'
import type { Var } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import { BlockEnum, ErrorHandleMode, VarType } from '@/app/components/workflow/types'
import useConfig from '../use-config'
const mockUseInspectVarsCrud = vi.hoisted(() => vi.fn())
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
const mockUseAllBuiltInTools = vi.hoisted(() => vi.fn())
const mockUseAllCustomTools = vi.hoisted(() => vi.fn())
const mockUseAllWorkflowTools = vi.hoisted(() => vi.fn())
const mockUseAllMCPTools = vi.hoisted(() => vi.fn())
const mockToNodeOutputVars = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseInspectVarsCrud(...args),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => mockUseNodesReadOnly(),
useIsChatMode: () => mockUseIsChatMode(),
useWorkflow: () => mockUseWorkflow(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { dataSourceList: unknown[] }) => unknown) =>
selector({ dataSourceList: mockUseStore() }),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => mockUseAllBuiltInTools(),
useAllCustomTools: () => mockUseAllCustomTools(),
useAllWorkflowTools: () => mockUseAllWorkflowTools(),
useAllMCPTools: () => mockUseAllMCPTools(),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
toNodeOutputVars: (...args: unknown[]) => mockToNodeOutputVars(...args),
}))
const createPayload = (overrides: Partial<IterationNodeType> = {}): IterationNodeType => ({
title: 'Iteration',
desc: '',
type: BlockEnum.Iteration,
iterator_selector: ['start', 'items'],
iterator_input_type: VarType.arrayString,
output_selector: ['child', 'result'],
output_type: VarType.arrayString,
is_parallel: false,
parallel_nums: 3,
error_handle_mode: ErrorHandleMode.Terminated,
flatten_output: false,
start_node_id: 'start-node',
_children: [],
_isShowTips: false,
...overrides,
})
const createVar = (type: VarType, variable = 'test.variable'): Var => ({
variable,
type,
})
describe('iteration/use-config', () => {
const mockSetInputs = vi.fn()
const mockDeleteNodeInspectorVars = vi.fn()
let currentInputs = createPayload()
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
mockUseInspectVarsCrud.mockReturnValue({
deleteNodeInspectorVars: mockDeleteNodeInspectorVars,
})
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
mockUseIsChatMode.mockReturnValue(false)
mockUseWorkflow.mockReturnValue({
getIterationNodeChildren: vi.fn(() => [{ id: 'child-node' }]),
})
mockUseStore.mockReturnValue([])
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
setInputs: mockSetInputs,
}))
mockUseAllBuiltInTools.mockReturnValue({ data: [] })
mockUseAllCustomTools.mockReturnValue({ data: [] })
mockUseAllWorkflowTools.mockReturnValue({ data: [] })
mockUseAllMCPTools.mockReturnValue({ data: [] })
mockToNodeOutputVars.mockReturnValue([{ variable: 'child.result' }])
})
it('should expose iteration children vars and filter only array-like iterator inputs', () => {
const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
expect(result.current.readOnly).toBe(false)
expect(result.current.childrenNodeVars).toEqual([{ variable: 'child.result' }])
expect(result.current.iterationChildrenNodes).toEqual([{ id: 'child-node' }])
expect(result.current.filterInputVar(createVar(VarType.arrayFile, 'files'))).toBe(true)
expect(result.current.filterInputVar(createVar(VarType.string, 'text'))).toBe(false)
expect(mockToNodeOutputVars).toHaveBeenCalled()
})
it('should update iterator input and output selectors and reset inspector vars on output changes', () => {
const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
act(() => {
result.current.handleInputChange(['start', 'documents'], VarKindType.variable, createVar(VarType.arrayObject, 'start.documents'))
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
iterator_selector: ['start', 'documents'],
iterator_input_type: VarType.arrayObject,
}))
mockSetInputs.mockClear()
act(() => {
result.current.handleOutputVarChange(['child', 'score'], VarKindType.variable, createVar(VarType.number, 'child.score'))
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
output_selector: ['child', 'score'],
output_type: VarType.arrayNumber,
}))
expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('iteration-node')
mockSetInputs.mockClear()
act(() => {
result.current.handleOutputVarChange(['child', 'result'], VarKindType.variable, createVar(VarType.string, 'child.result'))
})
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should update parallel, error-mode, and flatten options', () => {
const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
const item: Item = { name: 'Continue', value: ErrorHandleMode.ContinueOnError }
act(() => {
result.current.changeParallel(true)
result.current.changeErrorResponseMode(item)
result.current.changeParallelNums(6)
result.current.changeFlattenOutput(true)
})
expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
is_parallel: true,
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
error_handle_mode: ErrorHandleMode.ContinueOnError,
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(3, expect.objectContaining({
parallel_nums: 6,
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(4, expect.objectContaining({
flatten_output: true,
}))
})
})

View File

@ -0,0 +1,168 @@
import type { InputVar, Node } from '../../../types'
import type { IterationNodeType } from '../types'
import type { NodeTracing } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, ErrorHandleMode, InputVarType, VarType } from '@/app/components/workflow/types'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseIsNodeInIteration = vi.hoisted(() => vi.fn())
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockFormatTracing = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVars = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn())
const mockGetNodeInfoById = vi.hoisted(() => vi.fn())
const mockIsSystemVar = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useIsNodeInIteration: (...args: unknown[]) => mockUseIsNodeInIteration(...args),
useWorkflow: () => mockUseWorkflow(),
}))
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatTracing(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
getNodeUsedVars: (...args: unknown[]) => mockGetNodeUsedVars(...args),
getNodeUsedVarPassToServerKey: (...args: unknown[]) => mockGetNodeUsedVarPassToServerKey(...args),
getNodeInfoById: (...args: unknown[]) => mockGetNodeInfoById(...args),
isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args),
}))
const createInputVar = (variable: string): InputVar => ({
type: InputVarType.textInput,
label: variable,
variable,
required: false,
})
const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({
id,
position: { x: 0, y: 0 },
data: {
title,
type,
desc: '',
},
} as Node)
const createPayload = (overrides: Partial<IterationNodeType> = {}): IterationNodeType => ({
title: 'Iteration',
desc: '',
type: BlockEnum.Iteration,
start_node_id: 'start-node',
iterator_selector: ['start-node', 'items'],
iterator_input_type: VarType.arrayString,
output_selector: ['child-node', 'text'],
output_type: VarType.arrayString,
is_parallel: false,
parallel_nums: 2,
error_handle_mode: ErrorHandleMode.Terminated,
flatten_output: false,
_children: [],
_isShowTips: false,
...overrides,
})
describe('iteration/use-single-run-form-params', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseIsNodeInIteration.mockReturnValue({
isNodeInIteration: (nodeId: string) => nodeId === 'inner-node',
})
mockUseWorkflow.mockReturnValue({
getIterationNodeChildren: () => [
createNode('tool-a', 'Tool A'),
createNode('inner-node', 'Inner Node'),
],
getBeforeNodesInSameBranch: () => [
createNode('start-node', 'Start Node', BlockEnum.Start),
],
})
mockGetNodeUsedVars.mockImplementation((node: Node) => {
if (node.id === 'tool-a')
return [['start-node', 'answer'], ['inner-node', 'secret'], ['iteration-node', 'item']]
return []
})
mockGetNodeUsedVarPassToServerKey.mockReturnValue('passed_key')
mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id))
mockIsSystemVar.mockReturnValue(false)
mockFormatTracing.mockReturnValue([{ id: 'formatted-node' }])
})
it('should build single-run forms from external vars and keep iterator state in a dedicated form', () => {
const toVarInputs = vi.fn(() => [createInputVar('#start-node.answer#')])
const { result } = renderHook(() => useSingleRunFormParams({
id: 'iteration-node',
payload: createPayload(),
runInputData: {
'query': 'hello',
'iteration-node.input_selector': ['start-node', 'items'],
},
runInputDataRef: { current: {} },
getInputVars: vi.fn(),
setRunInputData: vi.fn(),
toVarInputs,
iterationRunResult: [],
}))
expect(toVarInputs).toHaveBeenCalledWith([
expect.objectContaining({
variable: 'start-node.answer',
value_selector: ['start-node', 'answer'],
}),
])
expect(result.current.forms).toHaveLength(2)
expect(result.current.forms[0].inputs).toEqual([createInputVar('#start-node.answer#')])
expect(result.current.forms[0].values).toEqual({
'query': 'hello',
'iteration-node.input_selector': ['start-node', 'items'],
})
expect(result.current.forms[1].values).toEqual({
'iteration-node.input_selector': ['start-node', 'items'],
})
expect(result.current.allVarObject).toEqual({
'start-node.answer@@@tool-a@@@0': {
inSingleRunPassedKey: 'passed_key',
},
})
expect(result.current.nodeInfo).toEqual({ id: 'formatted-node' })
})
it('should forward form updates and expose iterator dependencies', () => {
const setRunInputData = vi.fn()
const { result } = renderHook(() => useSingleRunFormParams({
id: 'iteration-node',
payload: createPayload({
iterator_selector: ['source-node', 'records'],
}),
runInputData: {
'query': 'old',
'iteration-node.input_selector': ['source-node', 'records'],
},
runInputDataRef: { current: {} },
getInputVars: vi.fn(),
setRunInputData,
toVarInputs: vi.fn(() => []),
iterationRunResult: [] as NodeTracing[],
}))
act(() => {
result.current.forms[0].onChange({ query: 'new' })
result.current.forms[1].onChange({
'iteration-node.input_selector': ['source-node', 'next'],
})
})
expect(setRunInputData).toHaveBeenNthCalledWith(1, { query: 'new' })
expect(setRunInputData).toHaveBeenNthCalledWith(2, {
'query': 'old',
'iteration-node.input_selector': ['source-node', 'next'],
})
expect(result.current.getDependentVars()).toEqual([['source-node', 'records']])
expect(result.current.getDependentVar('iteration-node.input_selector')).toEqual(['source-node', 'records'])
})
})

View File

@ -0,0 +1,238 @@
import type { StartNodeType } from '../types'
import type { InputVar, ValueSelector } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import { BlockEnum, ChangeType, InputVarType } from '@/app/components/workflow/types'
import useConfig from '../use-config'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
const mockUseInspectVarsCrud = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => mockUseNodesReadOnly(),
useWorkflow: () => mockUseWorkflow(),
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseInspectVarsCrud(...args),
}))
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
label: 'Question',
variable: 'query',
type: InputVarType.textInput,
required: true,
...overrides,
})
const createPayload = (overrides: Partial<StartNodeType> = {}): StartNodeType => ({
title: 'Start',
desc: '',
type: BlockEnum.Start,
variables: [
createInputVar(),
createInputVar({
label: 'Age',
variable: 'age',
type: InputVarType.number,
required: false,
}),
],
...overrides,
})
describe('start/use-config', () => {
const mockSetInputs = vi.fn()
const mockHandleOutVarRenameChange = vi.fn()
const mockIsVarUsedInNodes = vi.fn()
const mockRemoveUsedVarInNodes = vi.fn()
const mockDeleteNodeInspectorVars = vi.fn()
const mockRenameInspectVarName = vi.fn()
const mockDeleteInspectVar = vi.fn()
const toastSpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
let currentInputs: StartNodeType
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
mockUseWorkflow.mockReturnValue({
handleOutVarRenameChange: mockHandleOutVarRenameChange,
isVarUsedInNodes: mockIsVarUsedInNodes,
removeUsedVarInNodes: mockRemoveUsedVarInNodes,
})
mockUseIsChatMode.mockReturnValue(false)
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
setInputs: mockSetInputs,
}))
mockUseInspectVarsCrud.mockReturnValue({
deleteNodeInspectorVars: mockDeleteNodeInspectorVars,
renameInspectVarName: mockRenameInspectVarName,
nodesWithInspectVars: [{
nodeId: 'start-node',
vars: [{ id: 'inspect-query', name: 'query' }],
}],
deleteInspectVar: mockDeleteInspectVar,
})
mockIsVarUsedInNodes.mockReturnValue(false)
})
it('should rename variables and sync downstream variable references', () => {
const { result } = renderHook(() => useConfig('start-node', currentInputs))
const renamedList = [
createInputVar({
label: 'Question',
variable: 'prompt',
}),
createInputVar({
label: 'Age',
variable: 'age',
type: InputVarType.number,
required: false,
}),
]
act(() => {
result.current.handleVarListChange(renamedList, {
index: 0,
payload: {
type: ChangeType.changeVarName,
payload: {
beforeKey: 'query',
},
},
})
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
variables: renamedList,
}))
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith('start-node', ['start-node', 'query'], ['start-node', 'prompt'])
expect(mockRenameInspectVarName).toHaveBeenCalledWith('start-node', 'query', 'prompt')
expect(result.current.readOnly).toBe(false)
expect(result.current.isChatMode).toBe(false)
})
it('should block removal when the variable is still in use and confirm the deletion later', () => {
mockIsVarUsedInNodes.mockReturnValue(true)
const { result } = renderHook(() => useConfig('start-node', currentInputs))
const nextList = [currentInputs.variables[1]]
act(() => {
result.current.handleVarListChange(nextList, {
index: 0,
payload: {
type: ChangeType.remove,
payload: {
beforeKey: 'query',
},
},
})
})
expect(mockDeleteInspectVar).toHaveBeenCalledWith('start-node', 'inspect-query')
expect(mockSetInputs).not.toHaveBeenCalled()
expect(result.current.isShowRemoveVarConfirm).toBe(true)
act(() => {
result.current.onRemoveVarConfirm()
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
variables: [expect.objectContaining({ variable: 'age' })],
}))
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['start-node', 'query'] as ValueSelector)
expect(result.current.isShowRemoveVarConfirm).toBe(false)
})
it('should validate duplicate variables and labels before adding a new variable', () => {
const { result } = renderHook(() => useConfig('start-node', currentInputs))
let added = true
act(() => {
added = result.current.handleAddVariable(createInputVar({
label: 'Different Label',
variable: 'query',
}))
})
expect(added).toBe(false)
expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'varKeyError.keyAlreadyExists',
}))
mockSetInputs.mockClear()
let addedUnique = false
act(() => {
addedUnique = result.current.handleAddVariable(createInputVar({
label: 'Locale',
variable: 'locale',
required: false,
}))
})
expect(addedUnique).toBe(true)
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
variables: expect.arrayContaining([
expect.objectContaining({ variable: 'locale' }),
]),
}))
})
it('should clear inspector vars for non-remove list updates and reject duplicate labels', () => {
const { result } = renderHook(() => useConfig('start-node', currentInputs))
const typeEditedList = [
createInputVar({
label: 'Question',
variable: 'query',
type: InputVarType.paragraph,
}),
currentInputs.variables[1],
]
act(() => {
result.current.handleVarListChange(typeEditedList)
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
variables: typeEditedList,
}))
expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('start-node')
toastSpy.mockClear()
let added = true
act(() => {
added = result.current.handleAddVariable(createInputVar({
label: 'Age',
variable: 'new_age',
}))
})
expect(added).toBe(false)
expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'varKeyError.keyAlreadyExists',
}))
})
})

View File

@ -0,0 +1,244 @@
import { act, renderHook } from '@testing-library/react'
import { VarType } from '../../../types'
import { useGetAvailableVars, useVariableAssigner } from '../hooks'
const mockUseStoreApi = vi.hoisted(() => vi.fn())
const mockUseNodes = vi.hoisted(() => vi.fn())
const mockUseNodeDataUpdate = vi.hoisted(() => vi.fn())
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockUseWorkflowVariables = vi.hoisted(() => vi.fn())
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
const mockUseWorkflowStore = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
useStoreApi: () => mockUseStoreApi(),
useNodes: () => mockUseNodes(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodeDataUpdate: () => mockUseNodeDataUpdate(),
useWorkflow: () => mockUseWorkflow(),
useWorkflowVariables: () => mockUseWorkflowVariables(),
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockUseWorkflowStore(),
}))
describe('variable-assigner/hooks', () => {
const mockHandleNodeDataUpdate = vi.fn()
const mockSetNodes = vi.fn()
const mockSetShowAssignVariablePopup = vi.fn()
const mockSetHoveringAssignVariableGroupId = vi.fn()
const getNodes = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
getNodes.mockReturnValue([{
id: 'assigner-1',
data: {
variables: [['start', 'foo']],
output_type: VarType.string,
advanced_settings: {
groups: [{
groupId: 'group-1',
variables: [],
output_type: VarType.string,
}],
},
},
}])
mockUseStoreApi.mockReturnValue({
getState: () => ({
getNodes,
setNodes: mockSetNodes,
}),
})
mockUseNodeDataUpdate.mockReturnValue({
handleNodeDataUpdate: mockHandleNodeDataUpdate,
})
mockUseWorkflowStore.mockReturnValue({
getState: () => ({
setShowAssignVariablePopup: mockSetShowAssignVariablePopup,
setHoveringAssignVariableGroupId: mockSetHoveringAssignVariableGroupId,
connectingNodePayload: { id: 'connecting-node' },
}),
})
mockUseNodes.mockReturnValue([])
mockUseWorkflow.mockReturnValue({
getBeforeNodesInSameBranchIncludeParent: vi.fn(),
})
mockUseWorkflowVariables.mockReturnValue({
getNodeAvailableVars: vi.fn(),
})
mockUseIsChatMode.mockReturnValue(false)
})
it('should append target variables, ignore duplicates, and update grouped variables', () => {
const { result } = renderHook(() => useVariableAssigner())
act(() => {
result.current.handleAssignVariableValueChange('assigner-1', ['start', 'bar'], { type: VarType.number } as never)
result.current.handleAssignVariableValueChange('assigner-1', ['start', 'foo'], { type: VarType.number } as never)
result.current.handleAssignVariableValueChange('assigner-1', ['start', 'grouped'], { type: VarType.arrayString } as never, 'group-1')
})
expect(mockHandleNodeDataUpdate).toHaveBeenNthCalledWith(1, {
id: 'assigner-1',
data: {
variables: [
['start', 'foo'],
['start', 'bar'],
],
output_type: VarType.number,
},
})
expect(mockHandleNodeDataUpdate).toHaveBeenNthCalledWith(2, {
id: 'assigner-1',
data: {
advanced_settings: {
groups: [{
groupId: 'group-1',
variables: [['start', 'grouped']],
output_type: VarType.arrayString,
}],
},
},
})
expect(mockHandleNodeDataUpdate).toHaveBeenCalledTimes(2)
})
it('should close the popup and add variables through the positioned add-variable flow', () => {
getNodes.mockReturnValue([
{
id: 'source-node',
data: {
_showAddVariablePopup: true,
_holdAddVariablePopup: true,
},
},
{
id: 'assigner-1',
data: {
variables: [],
advanced_settings: {
groups: [{
groupId: 'group-1',
variables: [],
}],
},
_showAddVariablePopup: true,
_holdAddVariablePopup: true,
},
},
])
const { result } = renderHook(() => useVariableAssigner())
act(() => {
result.current.handleAddVariableInAddVariablePopupWithPosition(
'source-node',
'assigner-1',
'group-1',
['start', 'output'],
{ type: VarType.object } as never,
)
})
expect(mockSetNodes).toHaveBeenCalledWith([
expect.objectContaining({
id: 'source-node',
data: expect.objectContaining({
_showAddVariablePopup: false,
_holdAddVariablePopup: false,
}),
}),
expect.objectContaining({
id: 'assigner-1',
data: expect.objectContaining({
_showAddVariablePopup: false,
_holdAddVariablePopup: false,
}),
}),
])
expect(mockSetShowAssignVariablePopup).toHaveBeenCalledWith(undefined)
expect(mockHandleNodeDataUpdate).toHaveBeenCalledWith({
id: 'assigner-1',
data: {
advanced_settings: {
groups: [{
groupId: 'group-1',
variables: [['start', 'output']],
output_type: VarType.object,
}],
},
},
})
})
it('should update the hovered group state on enter and leave', () => {
const { result } = renderHook(() => useVariableAssigner())
act(() => {
result.current.handleGroupItemMouseEnter('group-1')
result.current.handleGroupItemMouseLeave()
})
expect(mockSetHoveringAssignVariableGroupId).toHaveBeenNthCalledWith(1, 'group-1')
expect(mockSetHoveringAssignVariableGroupId).toHaveBeenNthCalledWith(2, undefined)
})
it('should collect available vars and filter start-node env vars when hideEnv is enabled', () => {
mockUseNodes.mockReturnValue([
{
id: 'current-node',
parentId: 'parent-node',
},
{
id: 'before-1',
},
{
id: 'parent-node',
},
])
const getBeforeNodesInSameBranchIncludeParent = vi.fn(() => [
{ id: 'before-1' },
{ id: 'before-1' },
])
const getNodeAvailableVars = vi.fn()
.mockReturnValueOnce([{
isStartNode: true,
vars: [
{ variable: 'sys.user_id' },
{ variable: 'foo' },
],
}, {
isStartNode: false,
vars: [],
}])
.mockReturnValueOnce([{
isStartNode: false,
vars: [{ variable: 'bar' }],
}])
mockUseWorkflow.mockReturnValue({
getBeforeNodesInSameBranchIncludeParent,
})
mockUseWorkflowVariables.mockReturnValue({
getNodeAvailableVars,
})
const { result } = renderHook(() => useGetAvailableVars())
expect(result.current('current-node', 'target', () => true, true)).toEqual([{
isStartNode: true,
vars: [{ variable: 'foo' }],
}])
expect(result.current('current-node', 'target', () => true, false)).toEqual([{
isStartNode: false,
vars: [{ variable: 'bar' }],
}])
expect(result.current('missing-node', 'target', () => true)).toEqual([])
})
})

View 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([])
})
})

View 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()
})
})

View 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()
})
})

View 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
}

View File

@ -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,
@ -13,6 +9,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'
@ -53,18 +50,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 {
@ -116,9 +102,11 @@ 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="system-xs-semibold-uppercase flex items-center text-text-secondary">
<div className="flex items-center text-text-secondary system-xs-semibold-uppercase">
<span>{parallelDetail.parallelTitle}</span>
</div>
<div
@ -143,7 +131,7 @@ const TracingPanel: FC<TracingPanelProps> = ({
const isHovered = hoveredParallel === node.id
return (
<div key={node.id}>
<div className={cn('system-2xs-medium-uppercase -mb-1.5 pl-4', isHovered ? 'text-text-tertiary' : 'text-text-quaternary')}>
<div className={cn('-mb-1.5 pl-4 system-2xs-medium-uppercase', isHovered ? 'text-text-tertiary' : 'text-text-quaternary')}>
{node?.parallelDetail?.branchTitle}
</div>
<NodePanel

View File

@ -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,
}),
])
})
})