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