test: add unit tests for workflow components and stores

This commit is contained in:
CodingOnStar
2026-04-09 16:10:44 +08:00
parent ca60bb5812
commit 756658ed71
67 changed files with 4097 additions and 1896 deletions

View File

@ -0,0 +1,79 @@
import type { NodeDefault } from '../../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '../../types'
import Blocks from '../blocks'
import { BlockClassificationEnum } from '../types'
const runtimeState = vi.hoisted(() => ({
nodes: [] as Array<{ data: { type?: BlockEnum } }>,
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: () => runtimeState.nodes,
}),
}),
}))
const createBlock = (type: BlockEnum, title: string, classification = BlockClassificationEnum.Default): NodeDefault => ({
metaData: {
classification,
sort: 0,
type,
title,
author: 'Dify',
description: `${title} description`,
},
defaultValue: {},
checkValid: () => ({ isValid: true }),
})
describe('Blocks', () => {
beforeEach(() => {
runtimeState.nodes = []
})
it('renders grouped blocks, filters duplicate knowledge-base nodes, and selects a block', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
runtimeState.nodes = [{ data: { type: BlockEnum.KnowledgeBase } }]
render(
<Blocks
searchText=""
onSelect={onSelect}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.LoopEnd, BlockEnum.KnowledgeBase]}
blocks={[
createBlock(BlockEnum.LLM, 'LLM'),
createBlock(BlockEnum.LoopEnd, 'Exit Loop', BlockClassificationEnum.Logic),
createBlock(BlockEnum.KnowledgeBase, 'Knowledge Retrieval'),
]}
/>,
)
expect(screen.getByText('LLM')).toBeInTheDocument()
expect(screen.getByText('Exit Loop')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.loop.loopNode')).toBeInTheDocument()
expect(screen.queryByText('Knowledge Retrieval')).not.toBeInTheDocument()
await user.click(screen.getByText('LLM'))
expect(onSelect).toHaveBeenCalledWith(BlockEnum.LLM)
})
it('shows the empty state when no block matches the search', () => {
render(
<Blocks
searchText="missing"
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.LLM]}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
/>,
)
expect(screen.getByText('workflow.tabs.noResult')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,52 @@
import { act, renderHook } from '@testing-library/react'
import { useTabs, useToolTabs } from '../hooks'
import { TabsEnum, ToolTypeEnum } from '../types'
describe('block-selector hooks', () => {
it('falls back to the first valid tab when the preferred start tab is disabled', () => {
const { result } = renderHook(() => useTabs({
noStart: false,
hasUserInputNode: true,
defaultActiveTab: TabsEnum.Start,
}))
expect(result.current.tabs.find(tab => tab.key === TabsEnum.Start)?.disabled).toBe(true)
expect(result.current.activeTab).toBe(TabsEnum.Blocks)
})
it('keeps the start tab enabled when forcing it on and resets to a valid tab after disabling blocks', () => {
const props: Parameters<typeof useTabs>[0] = {
noBlocks: false,
noStart: false,
hasUserInputNode: true,
forceEnableStartTab: true,
}
const { result, rerender } = renderHook(nextProps => useTabs(nextProps), {
initialProps: props,
})
expect(result.current.tabs.find(tab => tab.key === TabsEnum.Start)?.disabled).toBeFalsy()
act(() => {
result.current.setActiveTab(TabsEnum.Blocks)
})
rerender({
...props,
noBlocks: true,
noSources: true,
noTools: true,
})
expect(result.current.activeTab).toBe(TabsEnum.Start)
})
it('returns the MCP tab only when it is not hidden', () => {
const { result: visible } = renderHook(() => useToolTabs())
const { result: hidden } = renderHook(() => useToolTabs(true))
expect(visible.current.some(tab => tab.key === ToolTypeEnum.MCP)).toBe(true)
expect(hidden.current.some(tab => tab.key === ToolTypeEnum.MCP)).toBe(false)
})
})

View File

@ -0,0 +1,95 @@
import type { NodeDefault } from '../../types'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import NodeSelector from '../main'
import { BlockClassificationEnum } from '../types'
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: () => [],
}),
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({
systemFeatures: { enable_marketplace: false },
}),
}))
vi.mock('@/service/use-plugins', () => ({
useFeaturedToolsRecommendations: () => ({
plugins: [],
isLoading: false,
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: [] }),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
useInvalidateAllBuiltInTools: () => vi.fn(),
}))
const createBlock = (type: BlockEnum, title: string): NodeDefault => ({
metaData: {
classification: BlockClassificationEnum.Default,
sort: 0,
type,
title,
author: 'Dify',
description: `${title} description`,
},
defaultValue: {},
checkValid: () => ({ isValid: true }),
})
describe('NodeSelector', () => {
it('opens with the real blocks tab, filters by search, selects a block, and clears search after close', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
renderWorkflowComponent(
<NodeSelector
onSelect={onSelect}
blocks={[
createBlock(BlockEnum.LLM, 'LLM'),
createBlock(BlockEnum.End, 'End'),
]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.End]}
trigger={open => (
<button type="button">
{open ? 'selector-open' : 'selector-closed'}
</button>
)}
/>,
)
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock')
expect(screen.getByText('LLM')).toBeInTheDocument()
expect(screen.getByText('End')).toBeInTheDocument()
await user.type(searchInput, 'LLM')
expect(screen.getByText('LLM')).toBeInTheDocument()
expect(screen.queryByText('End')).not.toBeInTheDocument()
await user.click(screen.getByText('LLM'))
expect(onSelect).toHaveBeenCalledWith(BlockEnum.LLM, undefined)
await waitFor(() => {
expect(screen.queryByPlaceholderText('workflow.tabs.searchBlock')).not.toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
const reopenedInput = screen.getByPlaceholderText('workflow.tabs.searchBlock') as HTMLInputElement
expect(reopenedInput.value).toBe('')
expect(screen.getByText('End')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,90 @@
import { act, waitFor } from '@testing-library/react'
import { useEdges } from 'reactflow'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { NodeRunningStatus } from '../../types'
import { useEdgesInteractionsWithoutSync } from '../use-edges-interactions-without-sync'
type EdgeRuntimeState = {
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean
}
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
const createFlowNodes = () => [
createNode({ id: 'a' }),
createNode({ id: 'b' }),
createNode({ id: 'c' }),
]
const createFlowEdges = () => [
createEdge({
id: 'e1',
source: 'a',
target: 'b',
data: {
_sourceRunningStatus: NodeRunningStatus.Running,
_targetRunningStatus: NodeRunningStatus.Running,
_waitingRun: true,
},
}),
createEdge({
id: 'e2',
source: 'b',
target: 'c',
data: {
_sourceRunningStatus: NodeRunningStatus.Succeeded,
_targetRunningStatus: undefined,
_waitingRun: false,
},
}),
]
const renderEdgesInteractionsHook = () =>
renderWorkflowFlowHook(() => ({
...useEdgesInteractionsWithoutSync(),
edges: useEdges(),
}), {
nodes: createFlowNodes(),
edges: createFlowEdges(),
})
describe('useEdgesInteractionsWithoutSync', () => {
it('clears running status and waitingRun on all edges', () => {
const { result } = renderEdgesInteractionsHook()
act(() => {
result.current.handleEdgeCancelRunningStatus()
})
return waitFor(() => {
result.current.edges.forEach((edge) => {
const edgeState = getEdgeRuntimeState(edge)
expect(edgeState._sourceRunningStatus).toBeUndefined()
expect(edgeState._targetRunningStatus).toBeUndefined()
expect(edgeState._waitingRun).toBe(false)
})
})
})
it('does not mutate the original edges array', () => {
const edges = createFlowEdges()
const originalData = { ...getEdgeRuntimeState(edges[0]) }
const { result } = renderWorkflowFlowHook(() => ({
...useEdgesInteractionsWithoutSync(),
edges: useEdges(),
}), {
nodes: createFlowNodes(),
edges,
})
act(() => {
result.current.handleEdgeCancelRunningStatus()
})
expect(getEdgeRuntimeState(edges[0])._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
})
})

View File

@ -0,0 +1,110 @@
import type { Node, NodeOutPutVar, Var } from '../../types'
import { renderHook } from '@testing-library/react'
import { BlockEnum, VarType } from '../../types'
import useNodesAvailableVarList, { useGetNodesAvailableVarList } from '../use-nodes-available-var-list'
const mockGetTreeLeafNodes = vi.hoisted(() => vi.fn())
const mockGetBeforeNodesInSameBranchIncludeParent = vi.hoisted(() => vi.fn())
const mockGetNodeAvailableVars = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useIsChatMode: () => true,
useWorkflow: () => ({
getTreeLeafNodes: mockGetTreeLeafNodes,
getBeforeNodesInSameBranchIncludeParent: mockGetBeforeNodesInSameBranchIncludeParent,
}),
useWorkflowVariables: () => ({
getNodeAvailableVars: mockGetNodeAvailableVars,
}),
}))
const createNode = (overrides: Partial<Node> = {}): Node => ({
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.LLM,
title: 'Node',
desc: '',
},
...overrides,
} as Node)
const outputVars: NodeOutPutVar[] = [{
nodeId: 'vars-node',
title: 'Vars',
vars: [{
variable: 'name',
type: VarType.string,
}] satisfies Var[],
}]
describe('useNodesAvailableVarList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetBeforeNodesInSameBranchIncludeParent.mockImplementation((nodeId: string) => [createNode({ id: `before-${nodeId}` })])
mockGetTreeLeafNodes.mockImplementation((nodeId: string) => [createNode({ id: `leaf-${nodeId}` })])
mockGetNodeAvailableVars.mockReturnValue(outputVars)
})
it('builds availability per node, carrying loop nodes and parent iteration context', () => {
const loopNode = createNode({
id: 'loop-1',
data: {
type: BlockEnum.Loop,
title: 'Loop',
desc: '',
},
})
const childNode = createNode({
id: 'child-1',
parentId: 'loop-1',
data: {
type: BlockEnum.LLM,
title: 'Writer',
desc: '',
},
})
const filterVar = vi.fn(() => true)
const { result } = renderHook(() => useNodesAvailableVarList([loopNode, childNode], {
filterVar,
hideEnv: true,
hideChatVar: true,
}))
expect(mockGetBeforeNodesInSameBranchIncludeParent).toHaveBeenCalledWith('loop-1')
expect(mockGetBeforeNodesInSameBranchIncludeParent).toHaveBeenCalledWith('child-1')
expect(result.current['loop-1']?.availableNodes.map(node => node.id)).toEqual(['before-loop-1', 'loop-1'])
expect(result.current['child-1']?.availableVars).toBe(outputVars)
expect(mockGetNodeAvailableVars).toHaveBeenNthCalledWith(2, expect.objectContaining({
parentNode: loopNode,
isChatMode: true,
filterVar,
hideEnv: true,
hideChatVar: true,
}))
})
it('returns a callback version that can use leaf nodes or caller-provided nodes', () => {
const firstNode = createNode({ id: 'node-a' })
const secondNode = createNode({ id: 'node-b' })
const filterVar = vi.fn(() => true)
const passedInAvailableNodes = [createNode({ id: 'manual-node' })]
const { result } = renderHook(() => useGetNodesAvailableVarList())
const leafMap = result.current.getNodesAvailableVarList([firstNode], {
onlyLeafNodeVar: true,
filterVar,
})
const manualMap = result.current.getNodesAvailableVarList([secondNode], {
filterVar,
passedInAvailableNodes,
})
expect(mockGetTreeLeafNodes).toHaveBeenCalledWith('node-a')
expect(leafMap['node-a']?.availableNodes.map(node => node.id)).toEqual(['leaf-node-a'])
expect(manualMap['node-b']?.availableNodes).toBe(passedInAvailableNodes)
})
})

View File

@ -0,0 +1,119 @@
import { act, waitFor } from '@testing-library/react'
import { useNodes } from 'reactflow'
import { createNode } from '../../__tests__/fixtures'
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { NodeRunningStatus } from '../../types'
import { useNodesInteractionsWithoutSync } from '../use-nodes-interactions-without-sync'
type NodeRuntimeState = {
_runningStatus?: NodeRunningStatus
_waitingRun?: boolean
}
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
const createFlowNodes = () => [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } }),
createNode({ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } }),
createNode({ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } }),
]
const renderNodesInteractionsHook = () =>
renderWorkflowFlowHook(() => ({
...useNodesInteractionsWithoutSync(),
nodes: useNodes(),
}), {
nodes: createFlowNodes(),
edges: [],
})
describe('useNodesInteractionsWithoutSync', () => {
it('clears _runningStatus and _waitingRun on all nodes', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleNodeCancelRunningStatus()
})
await waitFor(() => {
result.current.nodes.forEach((node) => {
const nodeState = getNodeRuntimeState(node)
expect(nodeState._runningStatus).toBeUndefined()
expect(nodeState._waitingRun).toBe(false)
})
})
})
it('clears _runningStatus only for Succeeded nodes', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleCancelAllNodeSuccessStatus()
})
await waitFor(() => {
const n1 = result.current.nodes.find(node => node.id === 'n1')
const n2 = result.current.nodes.find(node => node.id === 'n2')
const n3 = result.current.nodes.find(node => node.id === 'n3')
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
expect(getNodeRuntimeState(n3)._runningStatus).toBe(NodeRunningStatus.Failed)
})
})
it('does not modify _waitingRun when clearing all success status', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleCancelAllNodeSuccessStatus()
})
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._waitingRun).toBe(true)
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n3'))._waitingRun).toBe(true)
})
})
it('clears _runningStatus and _waitingRun for the specified succeeded node', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleCancelNodeSuccessStatus('n2')
})
await waitFor(() => {
const n2 = result.current.nodes.find(node => node.id === 'n2')
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
expect(getNodeRuntimeState(n2)._waitingRun).toBe(false)
})
})
it('does not modify nodes that are not succeeded', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleCancelNodeSuccessStatus('n1')
})
await waitFor(() => {
const n1 = result.current.nodes.find(node => node.id === 'n1')
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(n1)._waitingRun).toBe(true)
})
})
it('does not modify other nodes', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleCancelNodeSuccessStatus('n2')
})
await waitFor(() => {
const n1 = result.current.nodes.find(node => node.id === 'n1')
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
})
})
})

View File

@ -0,0 +1,153 @@
import type { Node } from '../../types'
import { CollectionType } from '@/app/components/tools/types'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import { useNodeMetaData, useNodesMetaData } from '../use-nodes-meta-data'
const buildInToolsState = vi.hoisted(() => [] as Array<{ id: string, author: string, description: Record<string, string> }>)
const customToolsState = vi.hoisted(() => [] as Array<{ id: string, author: string, description: Record<string, string> }>)
const workflowToolsState = vi.hoisted(() => [] as Array<{ id: string, author: string, description: Record<string, string> }>)
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: buildInToolsState }),
useAllCustomTools: () => ({ data: customToolsState }),
useAllWorkflowTools: () => ({ data: workflowToolsState }),
}))
const createNode = (overrides: Partial<Node> = {}): Node => ({
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.LLM,
title: 'Node',
desc: '',
},
...overrides,
} as Node)
describe('useNodesMetaData', () => {
beforeEach(() => {
vi.clearAllMocks()
buildInToolsState.length = 0
customToolsState.length = 0
workflowToolsState.length = 0
})
it('returns empty metadata collections when the hooks store has no node map', () => {
const { result } = renderWorkflowHook(() => useNodesMetaData(), {
hooksStoreProps: {},
})
expect(result.current).toEqual({
nodes: [],
nodesMap: {},
})
})
it('resolves built-in tool metadata from tool providers', () => {
buildInToolsState.push({
id: 'provider-1',
author: 'Provider Author',
description: {
'en-US': 'Built-in provider description',
},
})
const toolNode = createNode({
data: {
type: BlockEnum.Tool,
title: 'Tool Node',
desc: '',
provider_type: CollectionType.builtIn,
provider_id: 'provider-1',
},
})
const { result } = renderWorkflowHook(() => useNodeMetaData(toolNode), {
hooksStoreProps: {
availableNodesMetaData: {
nodes: [],
},
},
})
expect(result.current).toEqual(expect.objectContaining({
author: 'Provider Author',
description: 'Built-in provider description',
}))
})
it('prefers workflow store data for datasource nodes and keeps generic metadata for normal blocks', () => {
const datasourceNode = createNode({
data: {
type: BlockEnum.DataSource,
title: 'Dataset',
desc: '',
plugin_id: 'datasource-1',
},
})
const normalNode = createNode({
data: {
type: BlockEnum.LLM,
title: 'Writer',
desc: '',
},
})
const datasource = {
plugin_id: 'datasource-1',
author: 'Datasource Author',
description: {
'en-US': 'Datasource description',
},
}
const metadataMap = {
[BlockEnum.LLM]: {
metaData: {
type: BlockEnum.LLM,
title: 'LLM',
author: 'Dify',
description: 'Node description',
},
},
}
const datasourceResult = renderWorkflowHook(() => useNodeMetaData(datasourceNode), {
initialStoreState: {
dataSourceList: [datasource as never],
},
hooksStoreProps: {
availableNodesMetaData: {
nodes: [],
nodesMap: metadataMap as never,
},
},
})
const normalResult = renderWorkflowHook(() => useNodeMetaData(normalNode), {
hooksStoreProps: {
availableNodesMetaData: {
nodes: [],
nodesMap: metadataMap as never,
},
},
})
expect(datasourceResult.result.current).toEqual(expect.objectContaining({
author: 'Datasource Author',
description: 'Datasource description',
}))
expect(normalResult.result.current).toEqual(expect.objectContaining({
author: 'Dify',
description: 'Node description',
title: 'LLM',
}))
})
})

View File

@ -0,0 +1,168 @@
import { act } from '@testing-library/react'
import { ZEN_TOGGLE_EVENT } from '@/app/components/goto-anything/actions/commands/zen'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useShortcuts } from '../use-shortcuts'
type KeyPressRegistration = {
keyFilter: unknown
handler: (event: KeyboardEvent) => void
options?: {
events?: string[]
}
}
const keyPressRegistrations = vi.hoisted<KeyPressRegistration[]>(() => [])
const mockZoomTo = vi.hoisted(() => vi.fn())
const mockGetZoom = vi.hoisted(() => vi.fn(() => 1))
const mockFitView = vi.hoisted(() => vi.fn())
const mockHandleNodesDelete = vi.hoisted(() => vi.fn())
const mockHandleEdgeDelete = vi.hoisted(() => vi.fn())
const mockHandleNodesCopy = vi.hoisted(() => vi.fn())
const mockHandleNodesPaste = vi.hoisted(() => vi.fn())
const mockHandleNodesDuplicate = vi.hoisted(() => vi.fn())
const mockHandleHistoryBack = vi.hoisted(() => vi.fn())
const mockHandleHistoryForward = vi.hoisted(() => vi.fn())
const mockDimOtherNodes = vi.hoisted(() => vi.fn())
const mockUndimAllNodes = vi.hoisted(() => vi.fn())
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
const mockHandleModeHand = vi.hoisted(() => vi.fn())
const mockHandleModePointer = vi.hoisted(() => vi.fn())
const mockHandleLayout = vi.hoisted(() => vi.fn())
const mockHandleToggleMaximizeCanvas = vi.hoisted(() => vi.fn())
vi.mock('ahooks', () => ({
useKeyPress: (keyFilter: unknown, handler: (event: KeyboardEvent) => void, options?: { events?: string[] }) => {
keyPressRegistrations.push({ keyFilter, handler, options })
},
}))
vi.mock('reactflow', () => ({
useReactFlow: () => ({
zoomTo: mockZoomTo,
getZoom: mockGetZoom,
fitView: mockFitView,
}),
}))
vi.mock('..', () => ({
useNodesInteractions: () => ({
handleNodesCopy: mockHandleNodesCopy,
handleNodesPaste: mockHandleNodesPaste,
handleNodesDuplicate: mockHandleNodesDuplicate,
handleNodesDelete: mockHandleNodesDelete,
handleHistoryBack: mockHandleHistoryBack,
handleHistoryForward: mockHandleHistoryForward,
dimOtherNodes: mockDimOtherNodes,
undimAllNodes: mockUndimAllNodes,
}),
useEdgesInteractions: () => ({
handleEdgeDelete: mockHandleEdgeDelete,
}),
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
}),
useWorkflowCanvasMaximize: () => ({
handleToggleMaximizeCanvas: mockHandleToggleMaximizeCanvas,
}),
useWorkflowMoveMode: () => ({
handleModeHand: mockHandleModeHand,
handleModePointer: mockHandleModePointer,
}),
useWorkflowOrganize: () => ({
handleLayout: mockHandleLayout,
}),
}))
vi.mock('../../workflow-history-store', () => ({
useWorkflowHistoryStore: () => ({
shortcutsEnabled: true,
}),
}))
const createKeyboardEvent = (target: HTMLElement = document.body) => ({
preventDefault: vi.fn(),
target,
}) as unknown as KeyboardEvent
const findRegistration = (matcher: (registration: KeyPressRegistration) => boolean) => {
const registration = keyPressRegistrations.find(matcher)
expect(registration).toBeDefined()
return registration as KeyPressRegistration
}
describe('useShortcuts', () => {
beforeEach(() => {
keyPressRegistrations.length = 0
vi.clearAllMocks()
})
it('deletes selected nodes and edges only outside editable inputs', () => {
renderWorkflowHook(() => useShortcuts())
const deleteShortcut = findRegistration(registration =>
Array.isArray(registration.keyFilter)
&& registration.keyFilter.includes('delete'),
)
const bodyEvent = createKeyboardEvent()
deleteShortcut.handler(bodyEvent)
expect(bodyEvent.preventDefault).toHaveBeenCalled()
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
expect(mockHandleEdgeDelete).toHaveBeenCalledTimes(1)
const inputEvent = createKeyboardEvent(document.createElement('input'))
deleteShortcut.handler(inputEvent)
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
expect(mockHandleEdgeDelete).toHaveBeenCalledTimes(1)
})
it('runs layout and zoom shortcuts through the workflow actions', () => {
renderWorkflowHook(() => useShortcuts())
const layoutShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.o' || registration.keyFilter === 'meta.o')
const fitViewShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.1' || registration.keyFilter === 'meta.1')
const halfZoomShortcut = findRegistration(registration => registration.keyFilter === 'shift.5')
const zoomOutShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.dash' || registration.keyFilter === 'meta.dash')
const zoomInShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.equalsign' || registration.keyFilter === 'meta.equalsign')
layoutShortcut.handler(createKeyboardEvent())
fitViewShortcut.handler(createKeyboardEvent())
halfZoomShortcut.handler(createKeyboardEvent())
zoomOutShortcut.handler(createKeyboardEvent())
zoomInShortcut.handler(createKeyboardEvent())
expect(mockHandleLayout).toHaveBeenCalledTimes(1)
expect(mockFitView).toHaveBeenCalledTimes(1)
expect(mockZoomTo).toHaveBeenNthCalledWith(1, 0.5)
expect(mockZoomTo).toHaveBeenNthCalledWith(2, 0.9)
expect(mockZoomTo).toHaveBeenNthCalledWith(3, 1.1)
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(4)
})
it('dims on shift down, undims on shift up, and responds to zen toggle events', () => {
const { unmount } = renderWorkflowHook(() => useShortcuts())
const shiftDownShortcut = findRegistration(registration => registration.keyFilter === 'shift' && registration.options?.events?.[0] === 'keydown')
const shiftUpShortcut = findRegistration(registration => typeof registration.keyFilter === 'function' && registration.options?.events?.[0] === 'keyup')
shiftDownShortcut.handler(createKeyboardEvent())
shiftUpShortcut.handler({ ...createKeyboardEvent(), key: 'Shift' } as KeyboardEvent)
expect(mockDimOtherNodes).toHaveBeenCalledTimes(1)
expect(mockUndimAllNodes).toHaveBeenCalledTimes(1)
act(() => {
window.dispatchEvent(new Event(ZEN_TOGGLE_EVENT))
})
expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
unmount()
act(() => {
window.dispatchEvent(new Event(ZEN_TOGGLE_EVENT))
})
expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,209 +0,0 @@
import { act, waitFor } from '@testing-library/react'
import { useEdges, useNodes } from 'reactflow'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { NodeRunningStatus } from '../../types'
import { useEdgesInteractionsWithoutSync } from '../use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from '../use-nodes-interactions-without-sync'
type EdgeRuntimeState = {
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean
}
type NodeRuntimeState = {
_runningStatus?: NodeRunningStatus
_waitingRun?: boolean
}
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
describe('useEdgesInteractionsWithoutSync', () => {
const createFlowNodes = () => [
createNode({ id: 'a' }),
createNode({ id: 'b' }),
createNode({ id: 'c' }),
]
const createFlowEdges = () => [
createEdge({
id: 'e1',
source: 'a',
target: 'b',
data: {
_sourceRunningStatus: NodeRunningStatus.Running,
_targetRunningStatus: NodeRunningStatus.Running,
_waitingRun: true,
},
}),
createEdge({
id: 'e2',
source: 'b',
target: 'c',
data: {
_sourceRunningStatus: NodeRunningStatus.Succeeded,
_targetRunningStatus: undefined,
_waitingRun: false,
},
}),
]
const renderEdgesInteractionsHook = () =>
renderWorkflowFlowHook(() => ({
...useEdgesInteractionsWithoutSync(),
edges: useEdges(),
}), {
nodes: createFlowNodes(),
edges: createFlowEdges(),
})
it('should clear running status and waitingRun on all edges', () => {
const { result } = renderEdgesInteractionsHook()
act(() => {
result.current.handleEdgeCancelRunningStatus()
})
return waitFor(() => {
result.current.edges.forEach((edge) => {
const edgeState = getEdgeRuntimeState(edge)
expect(edgeState._sourceRunningStatus).toBeUndefined()
expect(edgeState._targetRunningStatus).toBeUndefined()
expect(edgeState._waitingRun).toBe(false)
})
})
})
it('should not mutate original edges', () => {
const edges = createFlowEdges()
const originalData = { ...getEdgeRuntimeState(edges[0]) }
const { result } = renderWorkflowFlowHook(() => ({
...useEdgesInteractionsWithoutSync(),
edges: useEdges(),
}), {
nodes: createFlowNodes(),
edges,
})
act(() => {
result.current.handleEdgeCancelRunningStatus()
})
expect(getEdgeRuntimeState(edges[0])._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
})
})
describe('useNodesInteractionsWithoutSync', () => {
const createFlowNodes = () => [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } }),
createNode({ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } }),
createNode({ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } }),
]
const renderNodesInteractionsHook = () =>
renderWorkflowFlowHook(() => ({
...useNodesInteractionsWithoutSync(),
nodes: useNodes(),
}), {
nodes: createFlowNodes(),
edges: [],
})
describe('handleNodeCancelRunningStatus', () => {
it('should clear _runningStatus and _waitingRun on all nodes', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleNodeCancelRunningStatus()
})
await waitFor(() => {
result.current.nodes.forEach((node) => {
const nodeState = getNodeRuntimeState(node)
expect(nodeState._runningStatus).toBeUndefined()
expect(nodeState._waitingRun).toBe(false)
})
})
})
})
describe('handleCancelAllNodeSuccessStatus', () => {
it('should clear _runningStatus only for Succeeded nodes', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleCancelAllNodeSuccessStatus()
})
await waitFor(() => {
const n1 = result.current.nodes.find(node => node.id === 'n1')
const n2 = result.current.nodes.find(node => node.id === 'n2')
const n3 = result.current.nodes.find(node => node.id === 'n3')
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
expect(getNodeRuntimeState(n3)._runningStatus).toBe(NodeRunningStatus.Failed)
})
})
it('should not modify _waitingRun', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleCancelAllNodeSuccessStatus()
})
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._waitingRun).toBe(true)
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n3'))._waitingRun).toBe(true)
})
})
})
describe('handleCancelNodeSuccessStatus', () => {
it('should clear _runningStatus and _waitingRun for the specified Succeeded node', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleCancelNodeSuccessStatus('n2')
})
await waitFor(() => {
const n2 = result.current.nodes.find(node => node.id === 'n2')
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
expect(getNodeRuntimeState(n2)._waitingRun).toBe(false)
})
})
it('should not modify nodes that are not Succeeded', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleCancelNodeSuccessStatus('n1')
})
await waitFor(() => {
const n1 = result.current.nodes.find(node => node.id === 'n1')
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(n1)._waitingRun).toBe(true)
})
})
it('should not modify other nodes', async () => {
const { result } = renderNodesInteractionsHook()
act(() => {
result.current.handleCancelNodeSuccessStatus('n2')
})
await waitFor(() => {
const n1 = result.current.nodes.find(node => node.id === 'n1')
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
})
})
})
})

View File

@ -0,0 +1,59 @@
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import { useWorkflowCanvasMaximize } from '../use-workflow-canvas-maximize'
const mockEmit = vi.hoisted(() => vi.fn())
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
describe('useWorkflowCanvasMaximize', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
it('toggles maximize state, persists it, and emits the canvas event', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowCanvasMaximize(), {
initialStoreState: {
maximizeCanvas: false,
},
})
result.current.handleToggleMaximizeCanvas()
expect(store.getState().maximizeCanvas).toBe(true)
expect(localStorage.getItem('workflow-canvas-maximize')).toBe('true')
expect(mockEmit).toHaveBeenCalledWith({
type: 'workflow-canvas-maximize',
payload: true,
})
})
it('does nothing while workflow nodes are read-only', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowCanvasMaximize(), {
initialStoreState: {
maximizeCanvas: false,
workflowRunningData: {
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
},
},
})
result.current.handleToggleMaximizeCanvas()
expect(store.getState().maximizeCanvas).toBe(false)
expect(localStorage.getItem('workflow-canvas-maximize')).toBeNull()
expect(mockEmit).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,152 @@
import { act } from '@testing-library/react'
import { createLoopNode, createNode } from '../../__tests__/fixtures'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useWorkflowOrganize } from '../use-workflow-organize'
const mockSetViewport = vi.hoisted(() => vi.fn())
const mockSetNodes = vi.hoisted(() => vi.fn())
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
const mockSaveStateToHistory = vi.hoisted(() => vi.fn())
const mockGetLayoutForChildNodes = vi.hoisted(() => vi.fn())
const mockGetLayoutByELK = vi.hoisted(() => vi.fn())
const runtimeState = vi.hoisted(() => ({
nodes: [] as ReturnType<typeof createNode>[],
edges: [] as { id: string, source: string, target: string }[],
nodesReadOnly: false,
}))
vi.mock('reactflow', () => ({
Position: {
Left: 'left',
Right: 'right',
Top: 'top',
Bottom: 'bottom',
},
useStoreApi: () => ({
getState: () => ({
getNodes: () => runtimeState.nodes,
edges: runtimeState.edges,
setNodes: mockSetNodes,
}),
setState: vi.fn(),
}),
useReactFlow: () => ({
setViewport: mockSetViewport,
}),
}))
vi.mock('../use-workflow', () => ({
useNodesReadOnly: () => ({
getNodesReadOnly: () => runtimeState.nodesReadOnly,
nodesReadOnly: runtimeState.nodesReadOnly,
}),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: (...args: unknown[]) => mockHandleSyncWorkflowDraft(...args),
}),
}))
vi.mock('../use-workflow-history', () => ({
useWorkflowHistory: () => ({
saveStateToHistory: (...args: unknown[]) => mockSaveStateToHistory(...args),
}),
WorkflowHistoryEvent: {
LayoutOrganize: 'LayoutOrganize',
},
}))
vi.mock('../../utils/elk-layout', async importOriginal => ({
...(await importOriginal<typeof import('../../utils/elk-layout')>()),
getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args),
getLayoutByELK: (...args: unknown[]) => mockGetLayoutByELK(...args),
}))
describe('useWorkflowOrganize', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
runtimeState.nodesReadOnly = false
runtimeState.nodes = []
runtimeState.edges = []
})
afterEach(() => {
vi.useRealTimers()
})
it('resizes containers, lays out nodes, and syncs draft when editable', async () => {
runtimeState.nodes = [
createLoopNode({
id: 'loop-node',
width: 200,
height: 160,
}),
createNode({
id: 'loop-child',
parentId: 'loop-node',
position: { x: 20, y: 20 },
width: 100,
height: 60,
}),
createNode({
id: 'top-node',
position: { x: 400, y: 0 },
}),
]
runtimeState.edges = []
mockGetLayoutForChildNodes.mockResolvedValue({
bounds: { minX: 0, minY: 0, maxX: 320, maxY: 220 },
nodes: new Map([
['loop-child', { x: 40, y: 60, width: 100, height: 60 }],
]),
})
mockGetLayoutByELK.mockResolvedValue({
nodes: new Map([
['loop-node', { x: 10, y: 20, width: 360, height: 260, layer: 0 }],
['top-node', { x: 500, y: 30, width: 240, height: 100, layer: 0 }],
]),
})
const { result } = renderWorkflowHook(() => useWorkflowOrganize())
await act(async () => {
await result.current.handleLayout()
})
act(() => {
vi.runAllTimers()
})
expect(mockSetNodes).toHaveBeenCalledTimes(1)
const nextNodes = mockSetNodes.mock.calls[0][0]
expect(nextNodes.find((node: { id: string }) => node.id === 'loop-node')).toEqual(expect.objectContaining({
width: expect.any(Number),
height: expect.any(Number),
position: { x: 10, y: 20 },
}))
expect(nextNodes.find((node: { id: string }) => node.id === 'loop-child')).toEqual(expect.objectContaining({
position: { x: 100, y: 120 },
}))
expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 0.7 })
expect(mockSaveStateToHistory).toHaveBeenCalledWith('LayoutOrganize')
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
})
it('skips layout when nodes are read-only', async () => {
runtimeState.nodesReadOnly = true
runtimeState.nodes = [createNode({ id: 'n1' })]
const { result } = renderWorkflowHook(() => useWorkflowOrganize())
await act(async () => {
await result.current.handleLayout()
})
expect(mockGetLayoutForChildNodes).not.toHaveBeenCalled()
expect(mockGetLayoutByELK).not.toHaveBeenCalled()
expect(mockSetNodes).not.toHaveBeenCalled()
expect(mockSetViewport).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,110 @@
import { act } from '@testing-library/react'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { ControlMode } from '../../types'
import {
useWorkflowInteractions,
useWorkflowMoveMode,
} from '../use-workflow-panel-interactions'
const mockHandleSelectionCancel = vi.hoisted(() => vi.fn())
const mockHandleNodeCancelRunningStatus = vi.hoisted(() => vi.fn())
const mockHandleEdgeCancelRunningStatus = vi.hoisted(() => vi.fn())
const runtimeState = vi.hoisted(() => ({
nodesReadOnly: false,
}))
vi.mock('../use-workflow', () => ({
useNodesReadOnly: () => ({
getNodesReadOnly: () => runtimeState.nodesReadOnly,
nodesReadOnly: runtimeState.nodesReadOnly,
}),
}))
vi.mock('../use-selection-interactions', () => ({
useSelectionInteractions: () => ({
handleSelectionCancel: (...args: unknown[]) => mockHandleSelectionCancel(...args),
}),
}))
vi.mock('../use-nodes-interactions-without-sync', () => ({
useNodesInteractionsWithoutSync: () => ({
handleNodeCancelRunningStatus: (...args: unknown[]) => mockHandleNodeCancelRunningStatus(...args),
}),
}))
vi.mock('../use-edges-interactions-without-sync', () => ({
useEdgesInteractionsWithoutSync: () => ({
handleEdgeCancelRunningStatus: (...args: unknown[]) => mockHandleEdgeCancelRunningStatus(...args),
}),
}))
describe('useWorkflowInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
runtimeState.nodesReadOnly = false
})
it('closes the debug panel and clears running state', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowInteractions(), {
initialStoreState: {
showDebugAndPreviewPanel: true,
workflowRunningData: { task_id: 'task-1' } as never,
},
})
act(() => {
result.current.handleCancelDebugAndPreviewPanel()
})
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
expect(store.getState().workflowRunningData).toBeUndefined()
expect(mockHandleNodeCancelRunningStatus).toHaveBeenCalledTimes(1)
expect(mockHandleEdgeCancelRunningStatus).toHaveBeenCalledTimes(1)
})
})
describe('useWorkflowMoveMode', () => {
beforeEach(() => {
vi.clearAllMocks()
runtimeState.nodesReadOnly = false
})
it('switches between hand and pointer modes when editable', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowMoveMode(), {
initialStoreState: {
controlMode: ControlMode.Pointer,
},
})
act(() => {
result.current.handleModeHand()
})
expect(store.getState().controlMode).toBe(ControlMode.Hand)
expect(mockHandleSelectionCancel).toHaveBeenCalledTimes(1)
act(() => {
result.current.handleModePointer()
})
expect(store.getState().controlMode).toBe(ControlMode.Pointer)
})
it('does not switch modes when nodes are read-only', () => {
runtimeState.nodesReadOnly = true
const { result, store } = renderWorkflowHook(() => useWorkflowMoveMode(), {
initialStoreState: {
controlMode: ControlMode.Pointer,
},
})
act(() => {
result.current.handleModeHand()
result.current.handleModePointer()
})
expect(store.getState().controlMode).toBe(ControlMode.Pointer)
expect(mockHandleSelectionCancel).not.toHaveBeenCalled()
})
})

View File

@ -1,242 +0,0 @@
import type {
AgentLogResponse,
HumanInputFormFilledResponse,
HumanInputFormTimeoutResponse,
TextChunkResponse,
TextReplaceResponse,
WorkflowFinishedResponse,
} from '@/types/workflow'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import { useWorkflowAgentLog } from '../use-workflow-run-event/use-workflow-agent-log'
import { useWorkflowFailed } from '../use-workflow-run-event/use-workflow-failed'
import { useWorkflowFinished } from '../use-workflow-run-event/use-workflow-finished'
import { useWorkflowNodeHumanInputFormFilled } from '../use-workflow-run-event/use-workflow-node-human-input-form-filled'
import { useWorkflowNodeHumanInputFormTimeout } from '../use-workflow-run-event/use-workflow-node-human-input-form-timeout'
import { useWorkflowPaused } from '../use-workflow-run-event/use-workflow-paused'
import { useWorkflowTextChunk } from '../use-workflow-run-event/use-workflow-text-chunk'
import { useWorkflowTextReplace } from '../use-workflow-run-event/use-workflow-text-replace'
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getFilesInLogs: vi.fn(() => []),
}))
describe('useWorkflowFailed', () => {
it('should set status to Failed', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFailed(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFailed()
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Failed)
})
})
describe('useWorkflowPaused', () => {
it('should set status to Paused', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowPaused(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowPaused()
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Paused)
})
})
describe('useWorkflowTextChunk', () => {
it('should append text and activate result tab', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowTextChunk(), {
initialStoreState: {
workflowRunningData: baseRunningData({ resultText: 'Hello' }),
},
})
result.current.handleWorkflowTextChunk({ data: { text: ' World' } } as TextChunkResponse)
const state = store.getState().workflowRunningData!
expect(state.resultText).toBe('Hello World')
expect(state.resultTabActive).toBe(true)
})
})
describe('useWorkflowTextReplace', () => {
it('should replace resultText', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowTextReplace(), {
initialStoreState: {
workflowRunningData: baseRunningData({ resultText: 'old text' }),
},
})
result.current.handleWorkflowTextReplace({ data: { text: 'new text' } } as TextReplaceResponse)
expect(store.getState().workflowRunningData!.resultText).toBe('new text')
})
})
describe('useWorkflowFinished', () => {
it('should merge data into result and activate result tab for single string output', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFinished({
data: { status: 'succeeded', outputs: { answer: 'hello' } },
} as WorkflowFinishedResponse)
const state = store.getState().workflowRunningData!
expect(state.result.status).toBe('succeeded')
expect(state.resultTabActive).toBe(true)
expect(state.resultText).toBe('hello')
})
it('should not activate result tab for multi-key outputs', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFinished({
data: { status: 'succeeded', outputs: { a: 'hello', b: 'world' } },
} as WorkflowFinishedResponse)
expect(store.getState().workflowRunningData!.resultTabActive).toBeFalsy()
})
})
describe('useWorkflowAgentLog', () => {
it('should create agent_log array when execution_metadata has no agent_log', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', execution_metadata: {} }],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1' },
} as AgentLogResponse)
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.execution_metadata!.agent_log).toHaveLength(1)
expect(trace.execution_metadata!.agent_log![0].message_id).toBe('m1')
})
it('should append to existing agent_log', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{
node_id: 'n1',
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] },
}],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm2' },
} as AgentLogResponse)
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(2)
})
it('should update existing log entry by message_id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{
node_id: 'n1',
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] },
}],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1', text: 'new' },
} as unknown as AgentLogResponse)
const log = store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log!
expect(log).toHaveLength(1)
expect((log[0] as unknown as { text: string }).text).toBe('new')
})
it('should create execution_metadata when it does not exist', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1' }],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1' },
} as AgentLogResponse)
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1)
})
})
describe('useWorkflowNodeHumanInputFormFilled', () => {
it('should remove form from humanInputFormDataList and add to humanInputFilledFormDataList', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormFilled({
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
} as HumanInputFormFilledResponse)
const state = store.getState().workflowRunningData!
expect(state.humanInputFormDataList).toHaveLength(0)
expect(state.humanInputFilledFormDataList).toHaveLength(1)
expect(state.humanInputFilledFormDataList![0].node_id).toBe('n1')
})
it('should create humanInputFilledFormDataList when it does not exist', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormFilled({
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
} as HumanInputFormFilledResponse)
expect(store.getState().workflowRunningData!.humanInputFilledFormDataList).toBeDefined()
})
})
describe('useWorkflowNodeHumanInputFormTimeout', () => {
it('should set expiration_time on the matching form', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormTimeout(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '', expiration_time: 0 },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormTimeout({
data: { node_id: 'n1', node_title: 'Node 1', expiration_time: 1000 },
} as HumanInputFormTimeoutResponse)
expect(store.getState().workflowRunningData!.humanInputFormDataList![0].expiration_time).toBe(1000)
})
})

View File

@ -1,336 +0,0 @@
import type { WorkflowRunningData } from '../../types'
import type {
IterationFinishedResponse,
IterationNextResponse,
LoopFinishedResponse,
LoopNextResponse,
NodeFinishedResponse,
WorkflowStartedResponse,
} from '@/types/workflow'
import { act, waitFor } from '@testing-library/react'
import { useEdges, useNodes } from 'reactflow'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { baseRunningData, renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../constants'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
import { useWorkflowNodeFinished } from '../use-workflow-run-event/use-workflow-node-finished'
import { useWorkflowNodeIterationFinished } from '../use-workflow-run-event/use-workflow-node-iteration-finished'
import { useWorkflowNodeIterationNext } from '../use-workflow-run-event/use-workflow-node-iteration-next'
import { useWorkflowNodeLoopFinished } from '../use-workflow-run-event/use-workflow-node-loop-finished'
import { useWorkflowNodeLoopNext } from '../use-workflow-run-event/use-workflow-node-loop-next'
import { useWorkflowNodeRetry } from '../use-workflow-run-event/use-workflow-node-retry'
import { useWorkflowStarted } from '../use-workflow-run-event/use-workflow-started'
type NodeRuntimeState = {
_waitingRun?: boolean
_runningStatus?: NodeRunningStatus
_retryIndex?: number
_iterationIndex?: number
_loopIndex?: number
_runningBranchId?: string
}
type EdgeRuntimeState = {
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean
}
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
function createRunNodes() {
return [
createNode({
id: 'n1',
width: 200,
height: 80,
data: { _waitingRun: false },
}),
]
}
function createRunEdges() {
return [
createEdge({
id: 'e1',
source: 'n0',
target: 'n1',
data: {},
}),
]
}
function renderRunEventHook<T extends Record<string, unknown>>(
useHook: () => T,
options?: {
nodes?: ReturnType<typeof createRunNodes>
edges?: ReturnType<typeof createRunEdges>
initialStoreState?: Record<string, unknown>
},
) {
const { nodes = createRunNodes(), edges = createRunEdges(), initialStoreState } = options ?? {}
return renderWorkflowFlowHook(() => ({
...useHook(),
nodes: useNodes(),
edges: useEdges(),
}), {
nodes,
edges,
reactFlowProps: { fitView: false },
initialStoreState,
})
}
describe('useWorkflowStarted', () => {
it('should initialize workflow running data and reset nodes/edges', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
} as WorkflowStartedResponse)
})
const state = store.getState().workflowRunningData!
expect(state.task_id).toBe('task-2')
expect(state.result.status).toBe(WorkflowRunningStatus.Running)
expect(state.resultText).toBe('')
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(true)
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBeUndefined()
expect(getEdgeRuntimeState(result.current.edges[0])._sourceRunningStatus).toBeUndefined()
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBeUndefined()
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBe(true)
})
})
it('should resume from Paused without resetting nodes/edges', () => {
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
result: { status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'],
}),
},
})
act(() => {
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
} as WorkflowStartedResponse)
})
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running)
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBeUndefined()
})
})
describe('useWorkflowNodeFinished', () => {
it('should update tracing and node running status', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
act(() => {
result.current.handleWorkflowNodeFinished({
data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as NodeFinishedResponse)
})
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
})
})
it('should set _runningBranchId for IfElse node', async () => {
const { result } = renderRunEventHook(() => useWorkflowNodeFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
act(() => {
result.current.handleWorkflowNodeFinished({
data: {
id: 'trace-1',
node_id: 'n1',
node_type: 'if-else',
status: NodeRunningStatus.Succeeded,
outputs: { selected_case_id: 'branch-a' },
},
} as unknown as NodeFinishedResponse)
})
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBe('branch-a')
})
})
})
describe('useWorkflowNodeRetry', () => {
it('should push retry data to tracing and update _retryIndex', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeRetry(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeRetry({
data: { node_id: 'n1', retry_index: 2 },
} as NodeFinishedResponse)
})
expect(store.getState().workflowRunningData!.tracing).toHaveLength(1)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._retryIndex).toBe(2)
})
})
})
describe('useWorkflowNodeIterationNext', () => {
it('should set _iterationIndex and increment iterTimes', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationNext(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 3,
},
})
act(() => {
result.current.handleWorkflowNodeIterationNext({
data: { node_id: 'n1' },
} as IterationNextResponse)
})
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._iterationIndex).toBe(3)
})
expect(store.getState().iterTimes).toBe(4)
})
})
describe('useWorkflowNodeIterationFinished', () => {
it('should update tracing, reset iterTimes, update node status and edges', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
iterTimes: 10,
},
})
act(() => {
result.current.handleWorkflowNodeIterationFinished({
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as IterationFinishedResponse)
})
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
})
})
})
describe('useWorkflowNodeLoopNext', () => {
it('should set _loopIndex and reset child nodes to waiting', async () => {
const { result } = renderRunEventHook(() => useWorkflowNodeLoopNext(), {
nodes: [
createNode({ id: 'n1', data: {} }),
createNode({
id: 'n2',
position: { x: 300, y: 0 },
parentId: 'n1',
data: { _waitingRun: false },
}),
],
edges: [],
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeLoopNext({
data: { node_id: 'n1', index: 5 },
} as LoopNextResponse)
})
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._loopIndex).toBe(5)
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._waitingRun).toBe(true)
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Waiting)
})
})
})
describe('useWorkflowNodeLoopFinished', () => {
it('should update tracing, node status and edges', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeLoopFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
act(() => {
result.current.handleWorkflowNodeLoopFinished({
data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as LoopFinishedResponse)
})
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
})
})
})

View File

@ -1,331 +0,0 @@
import type {
HumanInputRequiredResponse,
IterationStartedResponse,
LoopStartedResponse,
NodeStartedResponse,
} from '@/types/workflow'
import { act, waitFor } from '@testing-library/react'
import { useEdges, useNodes, useStoreApi } from 'reactflow'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { baseRunningData, renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../constants'
import { NodeRunningStatus } from '../../types'
import { useWorkflowNodeHumanInputRequired } from '../use-workflow-run-event/use-workflow-node-human-input-required'
import { useWorkflowNodeIterationStarted } from '../use-workflow-run-event/use-workflow-node-iteration-started'
import { useWorkflowNodeLoopStarted } from '../use-workflow-run-event/use-workflow-node-loop-started'
import { useWorkflowNodeStarted } from '../use-workflow-run-event/use-workflow-node-started'
type NodeRuntimeState = {
_waitingRun?: boolean
_runningStatus?: NodeRunningStatus
_iterationLength?: number
_loopLength?: number
}
type EdgeRuntimeState = {
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean
}
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
const containerParams = { clientWidth: 1200, clientHeight: 800 }
function createViewportNodes() {
return [
createNode({
id: 'n0',
width: 200,
height: 80,
data: { _runningStatus: NodeRunningStatus.Succeeded },
}),
createNode({
id: 'n1',
position: { x: 100, y: 50 },
width: 200,
height: 80,
data: { _waitingRun: true },
}),
createNode({
id: 'n2',
position: { x: 400, y: 50 },
width: 200,
height: 80,
parentId: 'n1',
data: { _waitingRun: true },
}),
]
}
function createViewportEdges() {
return [
createEdge({
id: 'e1',
source: 'n0',
target: 'n1',
sourceHandle: 'source',
data: {},
}),
]
}
function renderViewportHook<T extends Record<string, unknown>>(
useHook: () => T,
options?: {
nodes?: ReturnType<typeof createViewportNodes>
edges?: ReturnType<typeof createViewportEdges>
initialStoreState?: Record<string, unknown>
},
) {
const {
nodes = createViewportNodes(),
edges = createViewportEdges(),
initialStoreState,
} = options ?? {}
return renderWorkflowFlowHook(() => ({
...useHook(),
nodes: useNodes(),
edges: useEdges(),
reactFlowStore: useStoreApi(),
}), {
nodes,
edges,
reactFlowProps: { fitView: false },
initialStoreState,
})
}
describe('useWorkflowNodeStarted', () => {
it('should push to tracing, set node running, and adjust viewport for root node', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
})
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(1)
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(200)
expect(transform[1]).toBe(310)
expect(transform[2]).toBe(1)
const node = result.current.nodes.find(item => item.id === 'n1')
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
})
})
it('should not adjust viewport for child node (has parentId)', async () => {
const { result } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n2' } } as NodeStartedResponse,
containerParams,
)
})
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(0)
expect(transform[1]).toBe(0)
expect(transform[2]).toBe(1)
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Running)
})
})
it('should update existing tracing entry if node_id exists at non-zero index', () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
{ node_id: 'n0', status: NodeRunningStatus.Succeeded },
{ node_id: 'n1', status: NodeRunningStatus.Succeeded },
],
}),
},
})
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
})
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(2)
expect(tracing[1].status).toBe(NodeRunningStatus.Running)
})
})
describe('useWorkflowNodeIterationStarted', () => {
it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeIterationStarted(), {
nodes: createViewportNodes().slice(0, 2),
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 99,
},
})
act(() => {
result.current.handleWorkflowNodeIterationStarted(
{ data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
containerParams,
)
})
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(200)
expect(transform[1]).toBe(310)
expect(transform[2]).toBe(1)
const node = result.current.nodes.find(item => item.id === 'n1')
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(node)._iterationLength).toBe(10)
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
})
})
})
describe('useWorkflowNodeLoopStarted', () => {
it('should push to tracing, set viewport, and update node with _loopLength', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeLoopStarted(), {
nodes: createViewportNodes().slice(0, 2),
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeLoopStarted(
{ data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
containerParams,
)
})
expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Running)
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(200)
expect(transform[1]).toBe(310)
expect(transform[2]).toBe(1)
const node = result.current.nodes.find(item => item.id === 'n1')
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(node)._loopLength).toBe(5)
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
})
})
})
describe('useWorkflowNodeHumanInputRequired', () => {
it('should create humanInputFormDataList and set tracing/node to Paused', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
nodes: [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
],
edges: [],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
act(() => {
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
} as HumanInputRequiredResponse)
})
const state = store.getState().workflowRunningData!
expect(state.humanInputFormDataList).toHaveLength(1)
expect(state.humanInputFormDataList![0].form_id).toBe('f1')
expect(state.tracing![0].status).toBe(NodeRunningStatus.Paused)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n1'))._runningStatus).toBe(NodeRunningStatus.Paused)
})
})
it('should update existing form entry for same node_id', () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
nodes: [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
],
edges: [],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'old', node_title: 'Node 1', form_content: 'old' },
],
}),
},
})
act(() => {
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
} as HumanInputRequiredResponse)
})
const formList = store.getState().workflowRunningData!.humanInputFormDataList!
expect(formList).toHaveLength(1)
expect(formList[0].form_id).toBe('new')
})
it('should append new form entry for different node_id', () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
nodes: [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
],
edges: [],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n2', status: NodeRunningStatus.Running }],
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
act(() => {
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
} as HumanInputRequiredResponse)
})
expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2)
})
})

View File

@ -0,0 +1,119 @@
import type { CommonNodeType, Node, ToolWithProvider } from '../../types'
import { act, renderHook } from '@testing-library/react'
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '../../types'
import { useWorkflowSearch } from '../use-workflow-search'
const mockHandleNodeSelect = vi.hoisted(() => vi.fn())
const runtimeNodes = vi.hoisted(() => [] as Node[])
vi.mock('reactflow', () => ({
useNodes: () => runtimeNodes,
}))
vi.mock('../use-nodes-interactions', () => ({
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({
data: [{
id: 'provider-1',
icon: 'tool-icon',
tools: [],
}] satisfies Partial<ToolWithProvider>[],
}),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
}))
const createNode = (overrides: Partial<Node> = {}): Node => ({
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.LLM,
title: 'Writer',
desc: 'Draft content',
} as CommonNodeType,
...overrides,
})
describe('useWorkflowSearch', () => {
beforeEach(() => {
vi.clearAllMocks()
runtimeNodes.length = 0
workflowNodesAction.searchFn = undefined
})
it('registers workflow node search results with tool icons and llm metadata scoring', async () => {
runtimeNodes.push(
createNode({
id: 'llm-1',
data: {
type: BlockEnum.LLM,
title: 'Writer',
desc: 'Draft content',
model: {
provider: 'openai',
name: 'gpt-4o',
mode: 'chat',
},
} as CommonNodeType,
}),
createNode({
id: 'tool-1',
data: {
type: BlockEnum.Tool,
title: 'Google Search',
desc: 'Search the web',
provider_type: CollectionType.builtIn,
provider_id: 'provider-1',
} as CommonNodeType,
}),
createNode({
id: 'internal-start',
data: {
type: BlockEnum.IterationStart,
title: 'Internal Start',
desc: '',
} as CommonNodeType,
}),
)
const { unmount } = renderHook(() => useWorkflowSearch())
const llmResults = await workflowNodesAction.search('', 'gpt')
expect(llmResults.map(item => item.id)).toEqual(['llm-1'])
expect(llmResults[0]?.title).toBe('Writer')
const toolResults = await workflowNodesAction.search('', 'search')
expect(toolResults.map(item => item.id)).toEqual(['tool-1'])
expect(toolResults[0]?.description).toBe('Search the web')
unmount()
expect(workflowNodesAction.searchFn).toBeUndefined()
})
it('binds the node selection listener to handleNodeSelect', () => {
const { unmount } = renderHook(() => useWorkflowSearch())
act(() => {
document.dispatchEvent(new CustomEvent('workflow:select-node', {
detail: {
nodeId: 'node-42',
focus: false,
},
}))
})
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-42')
unmount()
})
})

View File

@ -0,0 +1,66 @@
import { act } from '@testing-library/react'
import { createNode } from '../../__tests__/fixtures'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useWorkflowUpdate } from '../use-workflow-update'
const mockSetViewport = vi.hoisted(() => vi.fn())
const mockEventEmit = vi.hoisted(() => vi.fn())
const mockInitialNodes = vi.hoisted(() => vi.fn((nodes: unknown[], _edges: unknown[]) => nodes))
const mockInitialEdges = vi.hoisted(() => vi.fn((edges: unknown[], _nodes: unknown[]) => edges))
vi.mock('reactflow', () => ({
Position: {
Left: 'left',
Right: 'right',
Top: 'top',
Bottom: 'bottom',
},
useReactFlow: () => ({
setViewport: mockSetViewport,
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: (...args: unknown[]) => mockEventEmit(...args),
},
}),
}))
vi.mock('../../utils', async importOriginal => ({
...(await importOriginal<typeof import('../../utils')>()),
initialNodes: (nodes: unknown[], edges: unknown[]) => mockInitialNodes(nodes, edges),
initialEdges: (edges: unknown[], nodes: unknown[]) => mockInitialEdges(edges, nodes),
}))
describe('useWorkflowUpdate', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('emits initialized data and only sets a valid viewport', () => {
const { result } = renderWorkflowHook(() => useWorkflowUpdate())
act(() => {
result.current.handleUpdateWorkflowCanvas({
nodes: [createNode({ id: 'n1' })],
edges: [],
viewport: { x: 10, y: 20, zoom: 0.5 },
} as never)
result.current.handleUpdateWorkflowCanvas({
nodes: [],
edges: [],
viewport: { x: 'bad' } as never,
})
})
expect(mockInitialNodes).toHaveBeenCalled()
expect(mockInitialEdges).toHaveBeenCalled()
expect(mockEventEmit).toHaveBeenCalledWith(expect.objectContaining({
type: 'WORKFLOW_DATA_UPDATE',
}))
expect(mockSetViewport).toHaveBeenCalledTimes(1)
expect(mockSetViewport).toHaveBeenCalledWith({ x: 10, y: 20, zoom: 0.5 })
})
})

View File

@ -0,0 +1,86 @@
import { act, renderHook } from '@testing-library/react'
import { useWorkflowZoom } from '../use-workflow-zoom'
const {
mockFitView,
mockZoomIn,
mockZoomOut,
mockZoomTo,
mockHandleSyncWorkflowDraft,
runtimeState,
} = vi.hoisted(() => ({
mockFitView: vi.fn(),
mockZoomIn: vi.fn(),
mockZoomOut: vi.fn(),
mockZoomTo: vi.fn(),
mockHandleSyncWorkflowDraft: vi.fn(),
runtimeState: {
workflowReadOnly: false,
},
}))
vi.mock('reactflow', () => ({
useReactFlow: () => ({
fitView: mockFitView,
zoomIn: mockZoomIn,
zoomOut: mockZoomOut,
zoomTo: mockZoomTo,
}),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: (...args: unknown[]) => mockHandleSyncWorkflowDraft(...args),
}),
}))
vi.mock('../use-workflow', () => ({
useWorkflowReadOnly: () => ({
getWorkflowReadOnly: () => runtimeState.workflowReadOnly,
}),
}))
describe('useWorkflowZoom', () => {
beforeEach(() => {
vi.clearAllMocks()
runtimeState.workflowReadOnly = false
})
it('runs zoom actions and syncs the workflow draft when editable', () => {
const { result } = renderHook(() => useWorkflowZoom())
act(() => {
result.current.handleFitView()
result.current.handleBackToOriginalSize()
result.current.handleSizeToHalf()
result.current.handleZoomOut()
result.current.handleZoomIn()
})
expect(mockFitView).toHaveBeenCalledTimes(1)
expect(mockZoomTo).toHaveBeenCalledWith(1)
expect(mockZoomTo).toHaveBeenCalledWith(0.5)
expect(mockZoomOut).toHaveBeenCalledTimes(1)
expect(mockZoomIn).toHaveBeenCalledTimes(1)
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(5)
})
it('blocks zoom actions when the workflow is read-only', () => {
runtimeState.workflowReadOnly = true
const { result } = renderHook(() => useWorkflowZoom())
act(() => {
result.current.handleFitView()
result.current.handleBackToOriginalSize()
result.current.handleSizeToHalf()
result.current.handleZoomOut()
result.current.handleZoomIn()
})
expect(mockFitView).not.toHaveBeenCalled()
expect(mockZoomTo).not.toHaveBeenCalled()
expect(mockZoomOut).not.toHaveBeenCalled()
expect(mockZoomIn).not.toHaveBeenCalled()
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,186 @@
import type { WorkflowRunningData } from '../../../types'
import type {
IterationFinishedResponse,
IterationNextResponse,
LoopFinishedResponse,
LoopNextResponse,
NodeFinishedResponse,
NodeStartedResponse,
WorkflowStartedResponse,
} from '@/types/workflow'
import { useEdges, useNodes, useStoreApi } from 'reactflow'
import { createEdge, createNode } from '../../../__tests__/fixtures'
import { renderWorkflowFlowHook } from '../../../__tests__/workflow-test-env'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../../types'
type NodeRuntimeState = {
_waitingRun?: boolean
_runningStatus?: NodeRunningStatus
_retryIndex?: number
_iterationIndex?: number
_iterationLength?: number
_loopIndex?: number
_loopLength?: number
_runningBranchId?: string
}
type EdgeRuntimeState = {
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean
}
export const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
export const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
function createRunNodes() {
return [
createNode({
id: 'n1',
width: 200,
height: 80,
data: { _waitingRun: false },
}),
]
}
function createRunEdges() {
return [
createEdge({
id: 'e1',
source: 'n0',
target: 'n1',
data: {},
}),
]
}
export function createViewportNodes() {
return [
createNode({
id: 'n0',
width: 200,
height: 80,
data: { _runningStatus: NodeRunningStatus.Succeeded },
}),
createNode({
id: 'n1',
position: { x: 100, y: 50 },
width: 200,
height: 80,
data: { _waitingRun: true },
}),
createNode({
id: 'n2',
position: { x: 400, y: 50 },
width: 200,
height: 80,
parentId: 'n1',
data: { _waitingRun: true },
}),
]
}
function createViewportEdges() {
return [
createEdge({
id: 'e1',
source: 'n0',
target: 'n1',
sourceHandle: 'source',
data: {},
}),
]
}
export const containerParams = { clientWidth: 1200, clientHeight: 800 }
export function renderRunEventHook<T extends Record<string, unknown>>(
useHook: () => T,
options?: {
nodes?: ReturnType<typeof createRunNodes>
edges?: ReturnType<typeof createRunEdges>
initialStoreState?: Record<string, unknown>
},
) {
const { nodes = createRunNodes(), edges = createRunEdges(), initialStoreState } = options ?? {}
return renderWorkflowFlowHook(() => ({
...useHook(),
nodes: useNodes(),
edges: useEdges(),
}), {
nodes,
edges,
reactFlowProps: { fitView: false },
initialStoreState,
})
}
export function renderViewportHook<T extends Record<string, unknown>>(
useHook: () => T,
options?: {
nodes?: ReturnType<typeof createViewportNodes>
edges?: ReturnType<typeof createViewportEdges>
initialStoreState?: Record<string, unknown>
},
) {
const {
nodes = createViewportNodes(),
edges = createViewportEdges(),
initialStoreState,
} = options ?? {}
return renderWorkflowFlowHook(() => ({
...useHook(),
nodes: useNodes(),
edges: useEdges(),
reactFlowStore: useStoreApi(),
}), {
nodes,
edges,
reactFlowProps: { fitView: false },
initialStoreState,
})
}
export const createStartedResponse = (overrides: Partial<WorkflowStartedResponse> = {}): WorkflowStartedResponse => ({
task_id: 'task-2',
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
...overrides,
} as WorkflowStartedResponse)
export const createNodeFinishedResponse = (overrides: Partial<NodeFinishedResponse> = {}): NodeFinishedResponse => ({
data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
...overrides,
} as NodeFinishedResponse)
export const createIterationNextResponse = (overrides: Partial<IterationNextResponse> = {}): IterationNextResponse => ({
data: { node_id: 'n1' },
...overrides,
} as IterationNextResponse)
export const createIterationFinishedResponse = (overrides: Partial<IterationFinishedResponse> = {}): IterationFinishedResponse => ({
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
...overrides,
} as IterationFinishedResponse)
export const createLoopNextResponse = (overrides: Partial<LoopNextResponse> = {}): LoopNextResponse => ({
data: { node_id: 'n1', index: 5 },
...overrides,
} as LoopNextResponse)
export const createLoopFinishedResponse = (overrides: Partial<LoopFinishedResponse> = {}): LoopFinishedResponse => ({
data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
...overrides,
} as LoopFinishedResponse)
export const createNodeStartedResponse = (overrides: Partial<NodeStartedResponse> = {}): NodeStartedResponse => ({
data: { node_id: 'n1' },
...overrides,
} as NodeStartedResponse)
export const pausedRunningData = (): WorkflowRunningData['result'] => ({ status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'])

View File

@ -0,0 +1,83 @@
import type { AgentLogResponse } from '@/types/workflow'
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
import { useWorkflowAgentLog } from '../use-workflow-agent-log'
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getFilesInLogs: vi.fn(() => []),
}))
describe('useWorkflowAgentLog', () => {
it('creates agent_log when execution_metadata has none', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', execution_metadata: {} }],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1' },
} as AgentLogResponse)
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.execution_metadata!.agent_log).toHaveLength(1)
expect(trace.execution_metadata!.agent_log![0].message_id).toBe('m1')
})
it('appends to existing agent_log', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{
node_id: 'n1',
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] },
}],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm2' },
} as AgentLogResponse)
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(2)
})
it('updates an existing log entry by message_id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{
node_id: 'n1',
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] },
}],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1', text: 'new' },
} as unknown as AgentLogResponse)
const log = store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log!
expect(log).toHaveLength(1)
expect((log[0] as unknown as { text: string }).text).toBe('new')
})
it('creates execution_metadata when it does not exist', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1' }],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', message_id: 'm1' },
} as AgentLogResponse)
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1)
})
})

View File

@ -0,0 +1,15 @@
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../../types'
import { useWorkflowFailed } from '../use-workflow-failed'
describe('useWorkflowFailed', () => {
it('sets status to Failed', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFailed(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFailed()
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Failed)
})
})

View File

@ -0,0 +1,32 @@
import type { WorkflowFinishedResponse } from '@/types/workflow'
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
import { useWorkflowFinished } from '../use-workflow-finished'
describe('useWorkflowFinished', () => {
it('merges data into result and activates result tab for single string output', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFinished({
data: { status: 'succeeded', outputs: { answer: 'hello' } },
} as WorkflowFinishedResponse)
const state = store.getState().workflowRunningData!
expect(state.result.status).toBe('succeeded')
expect(state.resultTabActive).toBe(true)
expect(state.resultText).toBe('hello')
})
it('does not activate the result tab for multi-key outputs', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowFinished(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowFinished({
data: { status: 'succeeded', outputs: { a: 'hello', b: 'world' } },
} as WorkflowFinishedResponse)
expect(store.getState().workflowRunningData!.resultTabActive).toBeFalsy()
})
})

View File

@ -0,0 +1,73 @@
import { act, waitFor } from '@testing-library/react'
import { createNode } from '../../../__tests__/fixtures'
import { baseRunningData } from '../../../__tests__/workflow-test-env'
import { BlockEnum, NodeRunningStatus } from '../../../types'
import { useWorkflowNodeFinished } from '../use-workflow-node-finished'
import {
createNodeFinishedResponse,
getEdgeRuntimeState,
getNodeRuntimeState,
renderRunEventHook,
} from './test-helpers'
describe('useWorkflowNodeFinished', () => {
it('updates tracing and node running status', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
act(() => {
result.current.handleWorkflowNodeFinished(createNodeFinishedResponse())
})
const trace = store.getState().workflowRunningData!.tracing![0]
expect(trace.status).toBe(NodeRunningStatus.Succeeded)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
})
})
it('sets _runningBranchId for IfElse nodes', async () => {
const { result } = renderRunEventHook(() => useWorkflowNodeFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
act(() => {
result.current.handleWorkflowNodeFinished(createNodeFinishedResponse({
data: {
id: 'trace-1',
node_id: 'n1',
node_type: BlockEnum.IfElse,
status: NodeRunningStatus.Succeeded,
outputs: { selected_case_id: 'branch-a' },
} as never,
}))
})
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBe('branch-a')
})
})
})

View File

@ -0,0 +1,44 @@
import type { HumanInputFormFilledResponse } from '@/types/workflow'
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
import { useWorkflowNodeHumanInputFormFilled } from '../use-workflow-node-human-input-form-filled'
describe('useWorkflowNodeHumanInputFormFilled', () => {
it('removes the form from pending and adds it to filled', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormFilled({
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
} as HumanInputFormFilledResponse)
const state = store.getState().workflowRunningData!
expect(state.humanInputFormDataList).toHaveLength(0)
expect(state.humanInputFilledFormDataList).toHaveLength(1)
expect(state.humanInputFilledFormDataList![0].node_id).toBe('n1')
})
it('creates humanInputFilledFormDataList when it does not exist', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormFilled(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormFilled({
data: { node_id: 'n1', node_title: 'Node 1', rendered_content: 'done' },
} as HumanInputFormFilledResponse)
expect(store.getState().workflowRunningData!.humanInputFilledFormDataList).toBeDefined()
})
})

View File

@ -0,0 +1,23 @@
import type { HumanInputFormTimeoutResponse } from '@/types/workflow'
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
import { useWorkflowNodeHumanInputFormTimeout } from '../use-workflow-node-human-input-form-timeout'
describe('useWorkflowNodeHumanInputFormTimeout', () => {
it('sets expiration_time on the matching form', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputFormTimeout(), {
initialStoreState: {
workflowRunningData: baseRunningData({
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '', expiration_time: 0 },
],
}),
},
})
result.current.handleWorkflowNodeHumanInputFormTimeout({
data: { node_id: 'n1', node_title: 'Node 1', expiration_time: 1000 },
} as HumanInputFormTimeoutResponse)
expect(store.getState().workflowRunningData!.humanInputFormDataList![0].expiration_time).toBe(1000)
})
})

View File

@ -0,0 +1,96 @@
import type { HumanInputRequiredResponse } from '@/types/workflow'
import { act, waitFor } from '@testing-library/react'
import { createNode } from '../../../__tests__/fixtures'
import { baseRunningData } from '../../../__tests__/workflow-test-env'
import { NodeRunningStatus } from '../../../types'
import { useWorkflowNodeHumanInputRequired } from '../use-workflow-node-human-input-required'
import {
getNodeRuntimeState,
renderViewportHook,
} from './test-helpers'
describe('useWorkflowNodeHumanInputRequired', () => {
it('creates humanInputFormDataList and sets tracing and node to Paused', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
nodes: [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
],
edges: [],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
act(() => {
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
} as HumanInputRequiredResponse)
})
const state = store.getState().workflowRunningData!
expect(state.humanInputFormDataList).toHaveLength(1)
expect(state.humanInputFormDataList![0].form_id).toBe('f1')
expect(state.tracing![0].status).toBe(NodeRunningStatus.Paused)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n1'))._runningStatus).toBe(NodeRunningStatus.Paused)
})
})
it('updates existing form entry for the same node_id', () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
nodes: [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
],
edges: [],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n1', status: NodeRunningStatus.Running }],
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'old', node_title: 'Node 1', form_content: 'old' },
],
}),
},
})
act(() => {
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
} as HumanInputRequiredResponse)
})
const formList = store.getState().workflowRunningData!.humanInputFormDataList!
expect(formList).toHaveLength(1)
expect(formList[0].form_id).toBe('new')
})
it('appends a new form entry for a different node_id', () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeHumanInputRequired(), {
nodes: [
createNode({ id: 'n1', data: { _runningStatus: NodeRunningStatus.Running } }),
createNode({ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } }),
],
edges: [],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ node_id: 'n2', status: NodeRunningStatus.Running }],
humanInputFormDataList: [
{ node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: '' },
],
}),
},
})
act(() => {
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
} as HumanInputRequiredResponse)
})
expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2)
})
})

View File

@ -0,0 +1,42 @@
import { act, waitFor } from '@testing-library/react'
import { createNode } from '../../../__tests__/fixtures'
import { baseRunningData } from '../../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../../constants'
import { NodeRunningStatus } from '../../../types'
import { useWorkflowNodeIterationFinished } from '../use-workflow-node-iteration-finished'
import {
createIterationFinishedResponse,
getEdgeRuntimeState,
getNodeRuntimeState,
renderRunEventHook,
} from './test-helpers'
describe('useWorkflowNodeIterationFinished', () => {
it('updates tracing, resets iterTimes, updates node status and edges', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
iterTimes: 10,
},
})
act(() => {
result.current.handleWorkflowNodeIterationFinished(createIterationFinishedResponse())
})
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
})
})
})

View File

@ -0,0 +1,28 @@
import { act, waitFor } from '@testing-library/react'
import { baseRunningData } from '../../../__tests__/workflow-test-env'
import { useWorkflowNodeIterationNext } from '../use-workflow-node-iteration-next'
import {
createIterationNextResponse,
getNodeRuntimeState,
renderRunEventHook,
} from './test-helpers'
describe('useWorkflowNodeIterationNext', () => {
it('sets _iterationIndex and increments iterTimes', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationNext(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 3,
},
})
act(() => {
result.current.handleWorkflowNodeIterationNext(createIterationNextResponse())
})
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._iterationIndex).toBe(3)
})
expect(store.getState().iterTimes).toBe(4)
})
})

View File

@ -0,0 +1,49 @@
import type { IterationStartedResponse } from '@/types/workflow'
import { act, waitFor } from '@testing-library/react'
import { baseRunningData } from '../../../__tests__/workflow-test-env'
import { DEFAULT_ITER_TIMES } from '../../../constants'
import { NodeRunningStatus } from '../../../types'
import { useWorkflowNodeIterationStarted } from '../use-workflow-node-iteration-started'
import {
containerParams,
createViewportNodes,
getEdgeRuntimeState,
getNodeRuntimeState,
renderViewportHook,
} from './test-helpers'
describe('useWorkflowNodeIterationStarted', () => {
it('pushes to tracing, resets iterTimes, sets viewport, and updates node with _iterationLength', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeIterationStarted(), {
nodes: createViewportNodes().slice(0, 2),
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 99,
},
})
act(() => {
result.current.handleWorkflowNodeIterationStarted(
{ data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
containerParams,
)
})
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(200)
expect(transform[1]).toBe(310)
expect(transform[2]).toBe(1)
const node = result.current.nodes.find(item => item.id === 'n1')
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(node)._iterationLength).toBe(10)
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
})
})
})

View File

@ -0,0 +1,40 @@
import { act, waitFor } from '@testing-library/react'
import { createNode } from '../../../__tests__/fixtures'
import { baseRunningData } from '../../../__tests__/workflow-test-env'
import { NodeRunningStatus } from '../../../types'
import { useWorkflowNodeLoopFinished } from '../use-workflow-node-loop-finished'
import {
createLoopFinishedResponse,
getEdgeRuntimeState,
getNodeRuntimeState,
renderRunEventHook,
} from './test-helpers'
describe('useWorkflowNodeLoopFinished', () => {
it('updates tracing, node status and edges', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeLoopFinished(), {
nodes: [
createNode({
id: 'n1',
data: { _runningStatus: NodeRunningStatus.Running },
}),
],
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [{ id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Running }],
}),
},
})
act(() => {
result.current.handleWorkflowNodeLoopFinished(createLoopFinishedResponse())
})
expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Succeeded)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
})
})
})

View File

@ -0,0 +1,38 @@
import { act, waitFor } from '@testing-library/react'
import { createNode } from '../../../__tests__/fixtures'
import { baseRunningData } from '../../../__tests__/workflow-test-env'
import { NodeRunningStatus } from '../../../types'
import { useWorkflowNodeLoopNext } from '../use-workflow-node-loop-next'
import {
createLoopNextResponse,
getNodeRuntimeState,
renderRunEventHook,
} from './test-helpers'
describe('useWorkflowNodeLoopNext', () => {
it('sets _loopIndex and resets child nodes to waiting', async () => {
const { result } = renderRunEventHook(() => useWorkflowNodeLoopNext(), {
nodes: [
createNode({ id: 'n1', data: {} }),
createNode({
id: 'n2',
position: { x: 300, y: 0 },
parentId: 'n1',
data: { _waitingRun: false },
}),
],
edges: [],
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeLoopNext(createLoopNextResponse())
})
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n1'))._loopIndex).toBe(5)
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._waitingRun).toBe(true)
expect(getNodeRuntimeState(result.current.nodes.find(node => node.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Waiting)
})
})
})

View File

@ -0,0 +1,43 @@
import type { LoopStartedResponse } from '@/types/workflow'
import { act, waitFor } from '@testing-library/react'
import { baseRunningData } from '../../../__tests__/workflow-test-env'
import { NodeRunningStatus } from '../../../types'
import { useWorkflowNodeLoopStarted } from '../use-workflow-node-loop-started'
import {
containerParams,
createViewportNodes,
getEdgeRuntimeState,
getNodeRuntimeState,
renderViewportHook,
} from './test-helpers'
describe('useWorkflowNodeLoopStarted', () => {
it('pushes to tracing, sets viewport, and updates node with _loopLength', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeLoopStarted(), {
nodes: createViewportNodes().slice(0, 2),
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeLoopStarted(
{ data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
containerParams,
)
})
expect(store.getState().workflowRunningData!.tracing![0].status).toBe(NodeRunningStatus.Running)
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(200)
expect(transform[1]).toBe(310)
expect(transform[2]).toBe(1)
const node = result.current.nodes.find(item => item.id === 'n1')
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(node)._loopLength).toBe(5)
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
})
})
})

View File

@ -0,0 +1,27 @@
import { act, waitFor } from '@testing-library/react'
import { baseRunningData } from '../../../__tests__/workflow-test-env'
import { useWorkflowNodeRetry } from '../use-workflow-node-retry'
import {
getNodeRuntimeState,
renderRunEventHook,
} from './test-helpers'
describe('useWorkflowNodeRetry', () => {
it('pushes retry data to tracing and updates _retryIndex', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeRetry(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeRetry({
data: { node_id: 'n1', retry_index: 2 },
} as never)
})
expect(store.getState().workflowRunningData!.tracing).toHaveLength(1)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._retryIndex).toBe(2)
})
})
})

View File

@ -0,0 +1,80 @@
import { act, waitFor } from '@testing-library/react'
import { baseRunningData } from '../../../__tests__/workflow-test-env'
import { NodeRunningStatus } from '../../../types'
import { useWorkflowNodeStarted } from '../use-workflow-node-started'
import {
containerParams,
createNodeStartedResponse,
getEdgeRuntimeState,
getNodeRuntimeState,
renderViewportHook,
} from './test-helpers'
describe('useWorkflowNodeStarted', () => {
it('pushes to tracing, sets node running, and adjusts viewport for root node', async () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeStarted(createNodeStartedResponse(), containerParams)
})
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(1)
expect(tracing[0].status).toBe(NodeRunningStatus.Running)
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(200)
expect(transform[1]).toBe(310)
expect(transform[2]).toBe(1)
const node = result.current.nodes.find(item => item.id === 'n1')
expect(getNodeRuntimeState(node)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(node)._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Running)
})
})
it('does not adjust viewport for child nodes', async () => {
const { result } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowNodeStarted(createNodeStartedResponse({
data: { node_id: 'n2' } as never,
}), containerParams)
})
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(0)
expect(transform[1]).toBe(0)
expect(transform[2]).toBe(1)
expect(getNodeRuntimeState(result.current.nodes.find(item => item.id === 'n2'))._runningStatus).toBe(NodeRunningStatus.Running)
})
})
it('updates existing tracing entry when node_id already exists', () => {
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
{ node_id: 'n0', status: NodeRunningStatus.Succeeded } as never,
{ node_id: 'n1', status: NodeRunningStatus.Succeeded } as never,
],
}),
},
})
act(() => {
result.current.handleWorkflowNodeStarted(createNodeStartedResponse(), containerParams)
})
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(2)
expect(tracing[1].status).toBe(NodeRunningStatus.Running)
})
})

View File

@ -0,0 +1,15 @@
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../../types'
import { useWorkflowPaused } from '../use-workflow-paused'
describe('useWorkflowPaused', () => {
it('sets status to Paused', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowPaused(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowPaused()
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Paused)
})
})

View File

@ -0,0 +1,54 @@
import { renderHook } from '@testing-library/react'
import { useWorkflowRunEvent } from '../use-workflow-run-event'
const handlers = vi.hoisted(() => ({
handleWorkflowStarted: vi.fn(),
handleWorkflowFinished: vi.fn(),
handleWorkflowFailed: vi.fn(),
handleWorkflowNodeStarted: vi.fn(),
handleWorkflowNodeFinished: vi.fn(),
handleWorkflowNodeIterationStarted: vi.fn(),
handleWorkflowNodeIterationNext: vi.fn(),
handleWorkflowNodeIterationFinished: vi.fn(),
handleWorkflowNodeLoopStarted: vi.fn(),
handleWorkflowNodeLoopNext: vi.fn(),
handleWorkflowNodeLoopFinished: vi.fn(),
handleWorkflowNodeRetry: vi.fn(),
handleWorkflowTextChunk: vi.fn(),
handleWorkflowTextReplace: vi.fn(),
handleWorkflowAgentLog: vi.fn(),
handleWorkflowPaused: vi.fn(),
handleWorkflowNodeHumanInputRequired: vi.fn(),
handleWorkflowNodeHumanInputFormFilled: vi.fn(),
handleWorkflowNodeHumanInputFormTimeout: vi.fn(),
}))
vi.mock('..', () => ({
useWorkflowStarted: () => ({ handleWorkflowStarted: handlers.handleWorkflowStarted }),
useWorkflowFinished: () => ({ handleWorkflowFinished: handlers.handleWorkflowFinished }),
useWorkflowFailed: () => ({ handleWorkflowFailed: handlers.handleWorkflowFailed }),
useWorkflowNodeStarted: () => ({ handleWorkflowNodeStarted: handlers.handleWorkflowNodeStarted }),
useWorkflowNodeFinished: () => ({ handleWorkflowNodeFinished: handlers.handleWorkflowNodeFinished }),
useWorkflowNodeIterationStarted: () => ({ handleWorkflowNodeIterationStarted: handlers.handleWorkflowNodeIterationStarted }),
useWorkflowNodeIterationNext: () => ({ handleWorkflowNodeIterationNext: handlers.handleWorkflowNodeIterationNext }),
useWorkflowNodeIterationFinished: () => ({ handleWorkflowNodeIterationFinished: handlers.handleWorkflowNodeIterationFinished }),
useWorkflowNodeLoopStarted: () => ({ handleWorkflowNodeLoopStarted: handlers.handleWorkflowNodeLoopStarted }),
useWorkflowNodeLoopNext: () => ({ handleWorkflowNodeLoopNext: handlers.handleWorkflowNodeLoopNext }),
useWorkflowNodeLoopFinished: () => ({ handleWorkflowNodeLoopFinished: handlers.handleWorkflowNodeLoopFinished }),
useWorkflowNodeRetry: () => ({ handleWorkflowNodeRetry: handlers.handleWorkflowNodeRetry }),
useWorkflowTextChunk: () => ({ handleWorkflowTextChunk: handlers.handleWorkflowTextChunk }),
useWorkflowTextReplace: () => ({ handleWorkflowTextReplace: handlers.handleWorkflowTextReplace }),
useWorkflowAgentLog: () => ({ handleWorkflowAgentLog: handlers.handleWorkflowAgentLog }),
useWorkflowPaused: () => ({ handleWorkflowPaused: handlers.handleWorkflowPaused }),
useWorkflowNodeHumanInputRequired: () => ({ handleWorkflowNodeHumanInputRequired: handlers.handleWorkflowNodeHumanInputRequired }),
useWorkflowNodeHumanInputFormFilled: () => ({ handleWorkflowNodeHumanInputFormFilled: handlers.handleWorkflowNodeHumanInputFormFilled }),
useWorkflowNodeHumanInputFormTimeout: () => ({ handleWorkflowNodeHumanInputFormTimeout: handlers.handleWorkflowNodeHumanInputFormTimeout }),
}))
describe('useWorkflowRunEvent', () => {
it('returns the composed handlers from all workflow event hooks', () => {
const { result } = renderHook(() => useWorkflowRunEvent())
expect(result.current).toEqual(handlers)
})
})

View File

@ -0,0 +1,56 @@
import { act, waitFor } from '@testing-library/react'
import { baseRunningData } from '../../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../../types'
import { useWorkflowStarted } from '../use-workflow-started'
import {
createStartedResponse,
getEdgeRuntimeState,
getNodeRuntimeState,
pausedRunningData,
renderRunEventHook,
} from './test-helpers'
describe('useWorkflowStarted', () => {
it('initializes workflow running data and resets nodes and edges', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
act(() => {
result.current.handleWorkflowStarted(createStartedResponse())
})
const state = store.getState().workflowRunningData!
expect(state.task_id).toBe('task-2')
expect(state.result.status).toBe(WorkflowRunningStatus.Running)
expect(state.resultText).toBe('')
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(true)
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBeUndefined()
expect(getEdgeRuntimeState(result.current.edges[0])._sourceRunningStatus).toBeUndefined()
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBeUndefined()
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBe(true)
})
})
it('resumes from Paused without resetting nodes or edges', () => {
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
result: pausedRunningData(),
}),
},
})
act(() => {
result.current.handleWorkflowStarted(createStartedResponse({
data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
}))
})
expect(store.getState().workflowRunningData!.result.status).toBe(WorkflowRunningStatus.Running)
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBeUndefined()
})
})

View File

@ -0,0 +1,19 @@
import type { TextChunkResponse } from '@/types/workflow'
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
import { useWorkflowTextChunk } from '../use-workflow-text-chunk'
describe('useWorkflowTextChunk', () => {
it('appends text and activates the result tab', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowTextChunk(), {
initialStoreState: {
workflowRunningData: baseRunningData({ resultText: 'Hello' }),
},
})
result.current.handleWorkflowTextChunk({ data: { text: ' World' } } as TextChunkResponse)
const state = store.getState().workflowRunningData!
expect(state.resultText).toBe('Hello World')
expect(state.resultTabActive).toBe(true)
})
})

View File

@ -0,0 +1,17 @@
import type { TextReplaceResponse } from '@/types/workflow'
import { baseRunningData, renderWorkflowHook } from '../../../__tests__/workflow-test-env'
import { useWorkflowTextReplace } from '../use-workflow-text-replace'
describe('useWorkflowTextReplace', () => {
it('replaces resultText', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowTextReplace(), {
initialStoreState: {
workflowRunningData: baseRunningData({ resultText: 'old text' }),
},
})
result.current.handleWorkflowTextReplace({ data: { text: 'new text' } } as TextReplaceResponse)
expect(store.getState().workflowRunningData!.resultText).toBe('new text')
})
})

View File

@ -0,0 +1,113 @@
import type { HistoryWorkflowData } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { fetchConversationMessages } from '@/service/debug'
import ChatRecord from '../index'
vi.mock('@/service/debug', () => ({
fetchConversationMessages: vi.fn(),
}))
const mockFetchConversationMessages = vi.mocked(fetchConversationMessages)
const historyWorkflowData: HistoryWorkflowData = {
id: 'run-1',
status: 'succeeded',
conversation_id: 'conversation-1',
finished_at: 1_700_000_000,
}
describe('ChatRecord', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
})
})
it('renders fetched chat history with the real chat shell and switches siblings', async () => {
const user = userEvent.setup()
mockFetchConversationMessages.mockResolvedValue({
data: [
{
id: 'msg-1',
query: 'Question 1',
answer: 'Answer 1',
metadata: {},
message_files: [],
},
{ id: 'msg-2', query: 'Question 2', answer: 'Answer 2', parent_message_id: 'msg-1', metadata: {}, message_files: [] },
{ id: 'msg-3', query: 'Question 3', answer: 'Answer 3', parent_message_id: 'msg-1', metadata: {}, message_files: [] },
],
} as never)
renderWorkflowComponent(<ChatRecord />, {
initialStoreState: { historyWorkflowData },
hooksStoreProps: {
handleLoadBackupDraft: vi.fn(),
},
})
await waitFor(() => {
expect(mockFetchConversationMessages).toHaveBeenCalledWith('app-1', 'conversation-1')
})
expect(await screen.findByText('Question 1')).toBeInTheDocument()
expect(screen.getByText('Answer 1')).toBeInTheDocument()
expect(screen.getByText('Question 3')).toBeInTheDocument()
expect(screen.getByText('Answer 3')).toBeInTheDocument()
expect(screen.queryByText('Question 2')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Previous' }))
expect(await screen.findByText('Question 2')).toBeInTheDocument()
expect(screen.getByText('Answer 2')).toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText('Question 3')).not.toBeInTheDocument()
})
})
it('closes the record panel and restores the backup draft', async () => {
const user = userEvent.setup()
const handleLoadBackupDraft = vi.fn()
mockFetchConversationMessages.mockResolvedValue({
data: [
{ id: 'msg-1', query: 'Question 1', answer: 'Answer 1', metadata: {}, message_files: [] },
],
} as never)
const { container, store } = renderWorkflowComponent(<ChatRecord />, {
initialStoreState: { historyWorkflowData },
hooksStoreProps: { handleLoadBackupDraft },
})
await screen.findByText('Question 1')
await user.click(container.querySelector('.h-6.w-6.cursor-pointer') as HTMLElement)
expect(handleLoadBackupDraft).toHaveBeenCalledTimes(1)
expect(store.getState().historyWorkflowData).toBeUndefined()
})
it('stops loading when conversation fetching fails', async () => {
mockFetchConversationMessages.mockRejectedValue(new Error('network error'))
const { container } = renderWorkflowComponent(<ChatRecord />, {
initialStoreState: { historyWorkflowData },
hooksStoreProps: {
handleLoadBackupDraft: vi.fn(),
},
})
await waitFor(() => {
expect(container).toHaveTextContent('TEST CHAT')
})
expect(screen.queryByText('Question 1')).not.toBeInTheDocument()
})
})

View File

@ -1,178 +0,0 @@
import type { ChatItemInTree } from '@/app/components/base/chat/types'
import type { HistoryWorkflowData } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import ChatRecord from '../index'
import UserInput from '../user-input'
const mockFetchConversationMessages = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
vi.mock('@/service/debug', () => ({
fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args),
}))
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getProcessedFilesFromResponse: (files: Array<{ id: string }>) => files.map(file => ({ ...file, processed: true })),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowRun: () => ({
handleLoadBackupDraft: mockHandleLoadBackupDraft,
}),
}))
vi.mock('@/app/components/base/chat/chat', () => ({
default: ({
chatList,
chatNode,
switchSibling,
}: {
chatList: ChatItemInTree[]
chatNode: React.ReactNode
switchSibling: (messageId: string) => void
}) => (
<div>
<button type="button" onClick={() => switchSibling('msg-2')}>
switch sibling
</button>
<div data-testid="chat-node">{chatNode}</div>
{chatList.map(item => (
<div key={item.id}>{`${item.content}:files-${item.message_files?.length ?? 0}`}</div>
))}
</div>
),
}))
const historyWorkflowData: HistoryWorkflowData = {
id: 'run-1',
status: 'succeeded',
conversation_id: 'conversation-1',
finished_at: 1_700_000_000,
}
describe('ChatRecord integration', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
})
})
it('should render fetched chat history and switch sibling threads', async () => {
const user = userEvent.setup()
mockFetchConversationMessages.mockResolvedValue({
data: [
{
id: 'msg-1',
query: 'Question 1',
answer: 'Answer 1',
metadata: {},
message_files: [
{ id: 'user-file-1', belongs_to: 'user' },
{ id: 'assistant-file-1', belongs_to: 'assistant' },
],
},
{ id: 'msg-2', query: 'Question 2', answer: 'Answer 2', parent_message_id: 'msg-1', metadata: {}, message_files: [] },
{ id: 'msg-3', query: 'Question 3', answer: 'Answer 3', parent_message_id: 'msg-1', metadata: {}, message_files: [] },
],
})
renderWorkflowComponent(<ChatRecord />, {
initialStoreState: {
historyWorkflowData,
},
})
await waitFor(() => {
expect(mockFetchConversationMessages).toHaveBeenCalledWith('app-1', 'conversation-1')
})
expect(await screen.findByText('Question 1:files-1')).toBeInTheDocument()
expect(screen.getByText('Answer 1:files-1')).toBeInTheDocument()
expect(screen.getByText('Question 3:files-0')).toBeInTheDocument()
expect(screen.getByText('Answer 3:files-0')).toBeInTheDocument()
expect(screen.queryByText('Question 2:files-0')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'switch sibling' }))
expect(await screen.findByText('Question 2:files-0')).toBeInTheDocument()
expect(screen.getByText('Answer 2:files-0')).toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText('Question 3:files-0')).not.toBeInTheDocument()
})
})
it('should close the record panel and restore the backup draft', async () => {
const user = userEvent.setup()
mockFetchConversationMessages.mockResolvedValue({
data: [
{ id: 'msg-1', query: 'Question 1', answer: 'Answer 1', metadata: {}, message_files: [] },
],
})
const { container, store } = renderWorkflowComponent(<ChatRecord />, {
initialStoreState: {
historyWorkflowData,
},
})
await screen.findByText('Question 1:files-0')
const closeButton = container.querySelector('.h-6.w-6.cursor-pointer') as HTMLElement
await user.click(closeButton)
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
expect(store.getState().historyWorkflowData).toBeUndefined()
})
it('should stop loading even when conversation fetch fails', async () => {
mockFetchConversationMessages.mockRejectedValue(new Error('network error'))
const { container } = renderWorkflowComponent(<ChatRecord />, {
initialStoreState: {
historyWorkflowData,
},
})
await waitFor(() => {
expect(container).toHaveTextContent('TEST CHAT')
})
expect(screen.queryByText('Question 1')).not.toBeInTheDocument()
})
it('should render no user-input block when the variable list is empty', () => {
const { container } = render(<UserInput />)
expect(container.firstChild).toBeNull()
})
it('should render provided user-input variables and toggle the panel body', async () => {
const user = userEvent.setup()
const { container } = render(
<UserInput
variables={[
{ variable: 'query' },
{ variable: 'locale' },
]}
initialExpanded={false}
/>,
)
const header = screen.getByText('WORKFLOW.PANEL.USERINPUTFIELD')
expect(container.querySelectorAll('.mb-2')).toHaveLength(0)
await user.click(header)
expect(container.querySelectorAll('.mb-2')).toHaveLength(2)
await user.click(header)
expect(container.querySelectorAll('.mb-2')).toHaveLength(0)
})
})

View File

@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UserInput from '../user-input'
describe('chat-record UserInput', () => {
it('returns null when there are no variables', () => {
const { container } = render(<UserInput />)
expect(container.firstChild).toBeNull()
})
it('toggles the variable list from the header', async () => {
const user = userEvent.setup()
const { container } = render(
<UserInput
variables={[
{ variable: 'query' },
{ variable: 'locale' },
]}
initialExpanded={false}
/>,
)
const header = screen.getByText('WORKFLOW.PANEL.USERINPUTFIELD')
expect(container.querySelectorAll('.mb-2')).toHaveLength(0)
await user.click(header)
expect(container.querySelectorAll('.mb-2')).toHaveLength(2)
await user.click(header)
expect(container.querySelectorAll('.mb-2')).toHaveLength(0)
})
})

View File

@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ArrayBoolList from '../array-bool-list'
describe('ArrayBoolList', () => {
it('toggles, appends, and removes boolean values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { container } = render(
<ArrayBoolList
list={[true]}
onChange={onChange}
/>,
)
await user.click(screen.getByText('False'))
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
await user.click(container.querySelector('button') as HTMLButtonElement)
expect(onChange).toHaveBeenNthCalledWith(1, [false])
expect(onChange).toHaveBeenNthCalledWith(2, [true, false])
expect(onChange).toHaveBeenNthCalledWith(3, [])
})
})

View File

@ -0,0 +1,43 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ArrayValueList from '../array-value-list'
describe('ArrayValueList', () => {
it('updates string items, appends a row, and removes an item', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { container } = render(
<ArrayValueList
isString
list={['alpha', 'beta']}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('alpha'), { target: { value: 'updated' } })
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
await user.click(container.querySelector('button') as HTMLButtonElement)
expect(onChange).toHaveBeenNthCalledWith(1, ['updated', 'beta'])
expect(onChange).toHaveBeenNthCalledWith(2, ['alpha', 'beta', undefined])
expect(onChange).toHaveBeenNthCalledWith(3, ['beta'])
})
it('coerces number inputs and appends an undefined slot', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ArrayValueList
isString={false}
list={[1]}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '7' } })
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
expect(onChange).toHaveBeenNthCalledWith(1, [7])
expect(onChange).toHaveBeenNthCalledWith(2, [1, undefined])
})
})

View File

@ -1,282 +0,0 @@
/* eslint-disable ts/no-explicit-any */
import type { ConversationVariable } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import ArrayBoolList from '../array-bool-list'
import ArrayValueList from '../array-value-list'
import VariableItem from '../variable-item'
import VariableModalTrigger from '../variable-modal-trigger'
import VariableTypeSelector from '../variable-type-select'
vi.mock('../variable-modal', () => ({
default: ({ chatVar, onSave, onClose }: any) => (
<div>
{chatVar?.name && <div>{chatVar.name}</div>}
<button type="button" onClick={() => onSave({ id: 'saved' })}>save-modal</button>
<button type="button" onClick={onClose}>close-modal</button>
</div>
),
}))
const createVariable = (overrides: Partial<ConversationVariable> = {}): ConversationVariable => ({
id: 'var-1',
name: 'conversation_var',
description: 'Conversation scoped variable',
value_type: ChatVarType.String,
value: '',
...overrides,
})
describe('chat-variable-panel components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The panel leaf components should support editing, selecting types, and opening the add-variable modal.
describe('Leaf interactions', () => {
it('should update string array items, add rows, and remove rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ArrayValueList
isString
list={['alpha', 'beta']}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('alpha'), { target: { value: 'updated' } })
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
await user.click(screen.getAllByRole('button')[0]!)
expect(onChange).toHaveBeenCalledTimes(3)
})
it('should coerce number array items and append undefined rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ArrayValueList
isString={false}
list={[1]}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '7' } })
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
expect(onChange).toHaveBeenNthCalledWith(1, [7])
expect(onChange).toHaveBeenNthCalledWith(2, [1, undefined])
})
it('should call edit and delete handlers from the variable item actions', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
const onDelete = vi.fn()
const { container } = render(
<VariableItem
item={createVariable()}
onEdit={onEdit}
onDelete={onDelete}
/>,
)
const card = container.firstElementChild as HTMLDivElement
const actions = container.querySelectorAll('.cursor-pointer')
fireEvent.mouseOver(actions[1] as Element)
expect(card.className).toContain('border-state-destructive-border')
fireEvent.mouseOut(actions[1] as Element)
expect(card.className).not.toContain('border-state-destructive-border')
const icons = container.querySelectorAll('svg')
await user.click(icons[1] as SVGElement)
await user.click(icons[2] as SVGElement)
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 'var-1' }))
expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'var-1' }))
})
it('should toggle the type selector and select a new value', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<VariableTypeSelector
value="string"
list={['string', 'number', 'boolean']}
onSelect={onSelect}
/>,
)
await user.click(screen.getByText('string'))
await user.click(screen.getByText('number'))
expect(onSelect).toHaveBeenCalledWith('number')
})
it('should dismiss the type selector through the real portal close flow', async () => {
const user = userEvent.setup()
render(
<VariableTypeSelector
value="string"
list={['string', 'number']}
onSelect={vi.fn()}
/>,
)
await user.click(screen.getByText('string'))
expect(screen.getByText('number')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByText('number')).not.toBeInTheDocument()
})
})
it('should open the in-cell selector from its trigger and keep the popup class', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<VariableTypeSelector
inCell
value="string"
list={['string', 'number']}
popupClassName="custom-popup"
onSelect={onSelect}
/>,
)
await user.click(screen.getAllByText('string')[0]!)
expect(screen.getByText('number').closest('.custom-popup')).not.toBeNull()
await user.click(screen.getAllByText('string')[1]!)
expect(onSelect).toHaveBeenCalledWith('string')
})
it('should update, add, and remove boolean array values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { container } = render(
<ArrayBoolList
list={[true]}
onChange={onChange}
/>,
)
await user.click(screen.getByText('False'))
expect(onChange).toHaveBeenNthCalledWith(1, [false])
await user.click(screen.getByText('workflow.chatVariable.modal.addArrayValue'))
expect(onChange).toHaveBeenNthCalledWith(2, [true, false])
const buttons = container.querySelectorAll('button')
await user.click(buttons[0] as HTMLButtonElement)
expect(onChange).toHaveBeenNthCalledWith(3, [])
})
it('should toggle the modal trigger without closing when it starts closed', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
const onClose = vi.fn()
render(
<VariableModalTrigger
open={false}
setOpen={setOpen}
showTip
onClose={onClose}
onSave={vi.fn()}
/>,
)
expect(screen.queryByText('save-modal')).not.toBeInTheDocument()
await user.click(screen.getByText('workflow.chatVariable.button'))
expect(setOpen).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
})
it('should open the modal trigger and close after saving', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
const onClose = vi.fn()
const onSave = vi.fn()
render(
<VariableModalTrigger
open
setOpen={setOpen}
showTip={false}
chatVar={createVariable()}
onClose={onClose}
onSave={onSave}
/>,
)
expect(screen.getByText('conversation_var')).toBeInTheDocument()
await user.click(screen.getByText('save-modal'))
await user.click(screen.getByText('close-modal'))
expect(onSave).toHaveBeenCalledWith({ id: 'saved' })
expect(onClose).toHaveBeenCalled()
expect(setOpen).toHaveBeenCalledWith(false)
})
it('should close the modal trigger when clicking the trigger while already open', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
const onClose = vi.fn()
render(
<VariableModalTrigger
open
setOpen={setOpen}
showTip={false}
chatVar={createVariable()}
onClose={onClose}
onSave={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'workflow.chatVariable.button' }))
expect(onClose).toHaveBeenCalledTimes(1)
expect(setOpen).toHaveBeenCalled()
})
it('should close the modal trigger when the portal dismisses', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const TriggerHarness = () => {
const [open, setOpen] = React.useState(true)
return (
<VariableModalTrigger
open={open}
setOpen={setOpen}
showTip={false}
chatVar={createVariable()}
onClose={onClose}
onSave={vi.fn()}
/>
)
}
render(<TriggerHarness />)
expect(screen.getByText('save-modal')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByText('save-modal')).not.toBeInTheDocument()
})
expect(onClose).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,43 @@
import type { ConversationVariable } from '@/app/components/workflow/types'
import { fireEvent, render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ChatVarType } from '../../type'
import VariableItem from '../variable-item'
const createVariable = (overrides: Partial<ConversationVariable> = {}): ConversationVariable => ({
id: 'var-1',
name: 'conversation_var',
description: 'Conversation scoped variable',
value_type: ChatVarType.String,
value: '',
...overrides,
})
describe('VariableItem', () => {
it('updates destructive state on hover and fires edit/delete actions', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
const onDelete = vi.fn()
const { container } = render(
<VariableItem
item={createVariable()}
onEdit={onEdit}
onDelete={onDelete}
/>,
)
const card = container.firstElementChild as HTMLDivElement
const actions = container.querySelectorAll('.cursor-pointer')
fireEvent.mouseOver(actions[1] as Element)
expect(card.className).toContain('border-state-destructive-border')
fireEvent.mouseOut(actions[1] as Element)
expect(card.className).not.toContain('border-state-destructive-border')
const icons = container.querySelectorAll('svg')
await user.click(icons[1] as SVGElement)
await user.click(icons[2] as SVGElement)
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 'var-1' }))
expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'var-1' }))
})
})

View File

@ -0,0 +1,108 @@
import type { ConversationVariable } from '@/app/components/workflow/types'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { ChatVarType } from '../../type'
import VariableModalTrigger from '../variable-modal-trigger'
vi.mock('uuid', () => ({
v4: () => 'generated-id',
}))
const createVariable = (overrides: Partial<ConversationVariable> = {}): ConversationVariable => ({
id: 'var-1',
name: 'conversation_var',
description: 'Conversation scoped variable',
value_type: ChatVarType.String,
value: '',
...overrides,
})
describe('VariableModalTrigger', () => {
it('opens from the trigger when initially closed', async () => {
const user = userEvent.setup()
const setOpen = vi.fn()
const onClose = vi.fn()
renderWorkflowComponent(
<VariableModalTrigger
open={false}
setOpen={setOpen}
showTip
onClose={onClose}
onSave={vi.fn()}
/>,
)
expect(screen.queryByPlaceholderText('workflow.chatVariable.modal.namePlaceholder')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.chatVariable.button' }))
expect(setOpen).toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
it('saves through the real modal and closes the trigger', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const onSave = vi.fn()
const setOpen = vi.fn()
renderWorkflowComponent(
<VariableModalTrigger
open
setOpen={setOpen}
showTip={false}
chatVar={createVariable()}
onClose={onClose}
onSave={onSave}
/>,
)
await user.clear(screen.getByDisplayValue('conversation_var'))
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'updated_var')
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), 'hello')
await user.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
id: 'var-1',
name: 'updated_var',
value: 'hello',
value_type: ChatVarType.String,
}))
expect(onClose).toHaveBeenCalled()
expect(setOpen).toHaveBeenCalledWith(false)
})
it('closes when the portal dismisses', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const TriggerHarness = () => {
const [open, setOpen] = React.useState(true)
return (
<VariableModalTrigger
open={open}
setOpen={setOpen}
showTip={false}
chatVar={createVariable()}
onClose={onClose}
onSave={vi.fn()}
/>
)
}
renderWorkflowComponent(<TriggerHarness />)
expect(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByPlaceholderText('workflow.chatVariable.modal.namePlaceholder')).not.toBeInTheDocument()
})
expect(onClose).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,59 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import VariableTypeSelector from '../variable-type-select'
describe('VariableTypeSelector', () => {
it('opens the selector and applies a new type', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<VariableTypeSelector
value="string"
list={['string', 'number', 'boolean']}
onSelect={onSelect}
/>,
)
await user.click(screen.getByText('string'))
await user.click(screen.getByText('number'))
expect(onSelect).toHaveBeenCalledWith('number')
})
it('dismisses the popup through the real portal flow', async () => {
const user = userEvent.setup()
render(
<VariableTypeSelector
value="string"
list={['string', 'number']}
onSelect={vi.fn()}
/>,
)
await user.click(screen.getByText('string'))
expect(screen.getByText('number')).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByText('number')).not.toBeInTheDocument()
})
})
it('keeps the custom popup class in in-cell mode', async () => {
const user = userEvent.setup()
render(
<VariableTypeSelector
inCell
value="string"
list={['string', 'number']}
popupClassName="custom-popup"
onSelect={vi.fn()}
/>,
)
await user.click(screen.getAllByText('string')[0] as HTMLElement)
expect(screen.getByText('number').closest('.custom-popup')).not.toBeNull()
})
})

View File

@ -0,0 +1,10 @@
import { render, screen } from '@testing-library/react'
import Empty from '../empty'
describe('debug-and-preview Empty', () => {
it('renders the preview placeholder', () => {
render(<Empty />)
expect(screen.getByText('workflow.common.previewPlaceholder')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,82 @@
import type { StartNodeType } from '../../../nodes/start/types'
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '../../../__tests__/workflow-test-env'
import { BlockEnum, InputVarType } from '../../../types'
import UserInput from '../user-input'
const runtimeNodes = vi.hoisted(() => [] as Array<{ data: StartNodeType }>)
vi.mock('reactflow', () => ({
useNodes: () => runtimeNodes,
}))
const createStartNodeData = (variables: StartNodeType['variables']): StartNodeType => ({
title: 'Start',
desc: '',
type: BlockEnum.Start,
variables,
})
describe('debug-and-preview UserInput', () => {
beforeEach(() => {
runtimeNodes.length = 0
})
it('returns null when no visible variables remain', () => {
runtimeNodes.push({
data: createStartNodeData([
{
type: InputVarType.textInput,
label: 'Hidden field',
variable: 'hidden_field',
required: false,
hide: true,
},
]),
})
const { container } = renderWorkflowComponent(<UserInput />, {
initialStoreState: {
showDebugAndPreviewPanel: false,
},
hooksStoreProps: {},
})
expect(container.firstChild).toBeNull()
})
it('renders start variables and writes updates back to the workflow store', () => {
runtimeNodes.push({
data: createStartNodeData([
{
type: InputVarType.textInput,
label: 'Query',
variable: 'query',
required: true,
},
{
type: InputVarType.textInput,
label: 'Hidden field',
variable: 'hidden_field',
required: false,
hide: true,
},
]),
})
const { store } = renderWorkflowComponent(<UserInput />, {
initialStoreState: {
showDebugAndPreviewPanel: true,
inputs: {},
},
hooksStoreProps: {},
})
fireEvent.change(screen.getByPlaceholderText('Query'), { target: { value: 'hello' } })
expect(screen.getByPlaceholderText('Hidden field')).toBeInTheDocument()
expect(store.getState().inputs).toEqual({
query: 'hello',
})
})
})

View File

@ -0,0 +1,108 @@
import type { ReactElement } from 'react'
import type { Shape } from '@/app/components/workflow/store/workflow'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import EnvItem from '../env-item'
const createEnv = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({
id: 'env-1',
name: 'api_key',
value: '[__HIDDEN__]',
value_type: 'secret',
description: 'secret description',
...overrides,
})
const renderWithProviders = (
ui: ReactElement,
options: {
storeState?: Partial<Shape>
} = {},
) => {
const store = createWorkflowStore({})
if (options.storeState)
store.setState(options.storeState)
const result = render(
<WorkflowContext value={store}>
{ui}
</WorkflowContext>,
)
return {
...result,
store,
}
}
describe('EnvItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders secret env items and triggers edit and delete actions', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
const onDelete = vi.fn()
const env = createEnv()
const { container } = renderWithProviders(
<EnvItem env={env} onEdit={onEdit} onDelete={onDelete} />,
{
storeState: {
envSecrets: {
[env.id]: 'masked-value',
},
},
},
)
expect(screen.getByText('api_key')).toBeInTheDocument()
expect(screen.getByText('Secret')).toBeInTheDocument()
expect(screen.getByText('masked-value')).toBeInTheDocument()
expect(screen.getByText('secret description')).toBeInTheDocument()
const actionWrappers = container.querySelectorAll('.cursor-pointer')
const editIcon = actionWrappers[0]?.querySelector('svg')
const deleteWrapper = actionWrappers[1] as HTMLElement
const deleteIcon = deleteWrapper.querySelector('svg')
fireEvent.mouseOver(deleteWrapper)
expect(container.firstElementChild).toHaveClass('border-state-destructive-border')
await user.click(editIcon as SVGElement)
await user.click(deleteIcon as SVGElement)
expect(onEdit).toHaveBeenCalledWith(env)
expect(onDelete).toHaveBeenCalledWith(env)
})
it('renders non-secret env values and clears destructive styling on mouse out', () => {
const env = createEnv({
id: 'env-plain',
name: 'public_value',
value: 'plain-text',
value_type: 'string',
description: '',
})
const { container } = renderWithProviders(
<EnvItem env={env} onEdit={vi.fn()} onDelete={vi.fn()} />,
)
expect(screen.getByText('public_value')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('plain-text')).toBeInTheDocument()
expect(screen.queryByText('secret description')).not.toBeInTheDocument()
const deleteWrapper = container.querySelectorAll('.cursor-pointer')[1] as HTMLElement
fireEvent.mouseOver(deleteWrapper)
expect(container.firstElementChild).toHaveClass('border-state-destructive-border')
fireEvent.mouseOut(deleteWrapper)
expect(container.firstElementChild).not.toHaveClass('border-state-destructive-border')
})
})

View File

@ -3,17 +3,10 @@ import type { Shape } from '@/app/components/workflow/store/workflow'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { toast } from '@/app/components/base/ui/toast'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import EnvItem from '../env-item'
import VariableModal from '../variable-modal'
import VariableTrigger from '../variable-trigger'
vi.mock('uuid', () => ({
v4: () => 'env-created',
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
@ -47,9 +40,9 @@ const renderWithProviders = (
store.setState(options.storeState)
const result = render(
<WorkflowContext.Provider value={store}>
<WorkflowContext value={store}>
{ui}
</WorkflowContext.Provider>,
</WorkflowContext>,
)
return {
@ -58,74 +51,12 @@ const renderWithProviders = (
}
}
describe('EnvPanel integration', () => {
describe('VariableModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render secret env items and trigger edit and delete actions', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
const onDelete = vi.fn()
const env = createEnv()
const { container } = renderWithProviders(
<EnvItem env={env} onEdit={onEdit} onDelete={onDelete} />,
{
storeState: {
envSecrets: {
[env.id]: 'masked-value',
},
},
},
)
expect(screen.getByText('api_key')).toBeInTheDocument()
expect(screen.getByText('Secret')).toBeInTheDocument()
expect(screen.getByText('masked-value')).toBeInTheDocument()
expect(screen.getByText('secret description')).toBeInTheDocument()
const actionWrappers = container.querySelectorAll('.cursor-pointer')
const editIcon = actionWrappers[0]?.querySelector('svg')
const deleteWrapper = actionWrappers[1] as HTMLElement
const deleteIcon = deleteWrapper.querySelector('svg')
fireEvent.mouseOver(deleteWrapper)
expect(container.firstElementChild).toHaveClass('border-state-destructive-border')
await user.click(editIcon as SVGElement)
await user.click(deleteIcon as SVGElement)
expect(onEdit).toHaveBeenCalledWith(env)
expect(onDelete).toHaveBeenCalledWith(env)
})
it('should render non-secret env values and clear destructive styling on mouse out', () => {
const env = createEnv({
id: 'env-plain',
name: 'public_value',
value: 'plain-text',
value_type: 'string',
description: '',
})
const { container } = renderWithProviders(
<EnvItem env={env} onEdit={vi.fn()} onDelete={vi.fn()} />,
)
expect(screen.getByText('public_value')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('plain-text')).toBeInTheDocument()
expect(screen.queryByText('secret description')).not.toBeInTheDocument()
const deleteWrapper = container.querySelectorAll('.cursor-pointer')[1] as HTMLElement
fireEvent.mouseOver(deleteWrapper)
expect(container.firstElementChild).toHaveClass('border-state-destructive-border')
fireEvent.mouseOut(deleteWrapper)
expect(container.firstElementChild).not.toHaveClass('border-state-destructive-border')
})
it('should create a secret environment variable and normalize spaces in its name', async () => {
it('creates a secret environment variable and normalizes spaces in its name', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const onClose = vi.fn()
@ -147,7 +78,7 @@ describe('EnvPanel integration', () => {
expect(screen.getByPlaceholderText('workflow.env.modal.namePlaceholder')).toHaveValue('my_secret')
expect(onSave).toHaveBeenCalledWith({
id: 'env-created',
id: expect.any(String),
name: 'my_secret',
value: 'top-secret',
value_type: 'secret',
@ -156,7 +87,7 @@ describe('EnvPanel integration', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should reject invalid and duplicate variable names', async () => {
it('rejects invalid and duplicate variable names', async () => {
const user = userEvent.setup()
renderWithProviders(
<VariableModal onClose={vi.fn()} onSave={vi.fn()} />,
@ -181,7 +112,7 @@ describe('EnvPanel integration', () => {
expect(mockToastError).toHaveBeenCalledWith('appDebug.varKeyError.keyAlreadyExists:{"key":"workflow.env.modal.name"}')
})
it('should load existing secret values and convert them to numbers when editing', async () => {
it('loads existing secret values and converts them to numbers when editing', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
@ -221,46 +152,4 @@ describe('EnvPanel integration', () => {
description: 'editable',
})
})
it('should open and close the variable trigger modal with the real portal flow', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const TriggerHarness = () => {
const [open, setOpen] = React.useState(false)
return (
<VariableTrigger
open={open}
setOpen={setOpen}
onClose={onClose}
onSave={vi.fn()}
/>
)
}
renderWithProviders(<TriggerHarness />)
const trigger = screen.getByRole('button', { name: 'workflow.env.envPanelButton' })
await user.click(trigger)
expect(screen.getByText('workflow.env.modal.title')).toBeInTheDocument()
await user.click(trigger)
expect(onClose).toHaveBeenCalledTimes(1)
expect(screen.queryByText('workflow.env.modal.title')).not.toBeInTheDocument()
await user.click(trigger)
expect(screen.getByText('workflow.env.modal.title')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalledTimes(2)
expect(screen.queryByText('workflow.env.modal.title')).not.toBeInTheDocument()
await user.click(trigger)
const closeIcon = document.querySelector('.h-6.w-6.cursor-pointer') as HTMLElement
await user.click(closeIcon)
expect(onClose).toHaveBeenCalledTimes(3)
expect(screen.queryByText('workflow.env.modal.title')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,131 @@
import type { ReactElement } from 'react'
import type { Shape } from '@/app/components/workflow/store/workflow'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import VariableTrigger from '../variable-trigger'
const createEnv = (overrides: Partial<EnvironmentVariable> = {}): EnvironmentVariable => ({
id: 'env-1',
name: 'api_key',
value: '[__HIDDEN__]',
value_type: 'secret',
description: 'secret description',
...overrides,
})
const renderWithProviders = (
ui: ReactElement,
options: {
storeState?: Partial<Shape>
} = {},
) => {
const store = createWorkflowStore({})
if (options.storeState)
store.setState(options.storeState)
const result = render(
<WorkflowContext value={store}>
{ui}
</WorkflowContext>,
)
return {
...result,
store,
}
}
describe('VariableTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('opens and closes the variable trigger modal through the real portal flow', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const TriggerHarness = () => {
const [open, setOpen] = React.useState(false)
return (
<VariableTrigger
open={open}
setOpen={setOpen}
onClose={onClose}
onSave={vi.fn()}
/>
)
}
renderWithProviders(<TriggerHarness />)
const trigger = screen.getByRole('button', { name: 'workflow.env.envPanelButton' })
await user.click(trigger)
expect(screen.getByText('workflow.env.modal.title')).toBeInTheDocument()
await user.click(trigger)
expect(onClose).toHaveBeenCalledTimes(1)
expect(screen.queryByText('workflow.env.modal.title')).not.toBeInTheDocument()
await user.click(trigger)
expect(screen.getByText('workflow.env.modal.title')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalledTimes(2)
expect(screen.queryByText('workflow.env.modal.title')).not.toBeInTheDocument()
})
it('submits the edited environment variable through the real modal', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const onSave = vi.fn()
const TriggerHarness = () => {
const [open, setOpen] = React.useState(false)
return (
<VariableTrigger
open={open}
setOpen={setOpen}
env={createEnv({
id: 'env-2',
name: 'counter',
value: '[__HIDDEN__]',
description: 'editable',
})}
onClose={onClose}
onSave={onSave}
/>
)
}
renderWithProviders(<TriggerHarness />, {
storeState: {
environmentVariables: [createEnv({ id: 'env-2', name: 'counter' })],
envSecrets: { 'env-2': '123' },
},
})
await user.click(screen.getByRole('button', { name: 'workflow.env.envPanelButton' }))
const valueInput = screen.getByPlaceholderText('workflow.env.modal.valuePlaceholder')
await user.clear(valueInput)
await user.type(valueInput, '456')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith({
id: 'env-2',
name: 'counter',
value: '456',
value_type: 'secret',
description: 'editable',
})
expect(onClose).toHaveBeenCalledTimes(1)
expect(screen.queryByText('workflow.env.modal.editTitle')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react'
import Item from '../item'
describe('GlobalVariablePanel Item', () => {
it('renders the global variable name, type, and description', () => {
render(
<Item
payload={{
name: 'timezone',
value_type: 'string',
description: 'Current timezone',
}}
/>,
)
expect(screen.getByText('sys.')).toBeInTheDocument()
expect(screen.getByText('timezone')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('Current timezone')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowVersionFilterOptions } from '../../../../types'
import FilterItem from '../filter-item'
describe('FilterItem', () => {
it('renders the label, fires selection, and shows the check mark when selected', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
const { container } = render(
<FilterItem
item={{
key: WorkflowVersionFilterOptions.onlyYours,
name: 'Only yours',
}}
isSelected
onClick={onClick}
/>,
)
await user.click(screen.getByText('Only yours'))
expect(onClick).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
expect(container.querySelector('svg')).not.toBeNull()
})
})

View File

@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import FilterSwitch from '../filter-switch'
describe('FilterSwitch', () => {
it('renders the switch label and toggles through the change handler', async () => {
const user = userEvent.setup()
const handleSwitch = vi.fn()
render(
<FilterSwitch
enabled={false}
handleSwitch={handleSwitch}
/>,
)
expect(screen.getByText('workflow.versionHistory.filter.onlyShowNamedVersions')).toBeInTheDocument()
await user.click(screen.getByRole('switch'))
expect(handleSwitch).toHaveBeenCalledWith(true)
})
})

View File

@ -0,0 +1,184 @@
import type { WorkflowRunDetailResponse } from '@/models/log'
import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { BlockEnum, NodeRunningStatus } from '../../types'
import RunPanel from '../index'
const {
mockFetchRunDetail,
mockFetchTracingList,
mockToastError,
} = vi.hoisted(() => ({
mockFetchRunDetail: vi.fn(),
mockFetchTracingList: vi.fn(),
mockToastError: vi.fn(),
}))
const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
vi.mock('@/service/log', () => ({
fetchRunDetail: (...args: unknown[]) => mockFetchRunDetail(...args),
fetchTracingList: (...args: unknown[]) => mockFetchTracingList(...args),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
}))
const createRunDetail = (overrides: Partial<WorkflowRunDetailResponse> = {}): WorkflowRunDetailResponse => ({
id: 'run-1',
version: '1',
graph: {
nodes: [],
edges: [],
},
inputs: '{"topic":"workflow"}',
inputs_truncated: false,
status: 'succeeded',
outputs: 'workflow output',
outputs_truncated: false,
elapsed_time: 1.25,
total_tokens: 24,
total_steps: 2,
created_by_role: 'account',
created_by_account: {
id: 'account-1',
name: 'Alice',
email: 'alice@example.com',
},
created_at: 1710000000,
finished_at: 1710000001,
...overrides,
})
const createTracingNode = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'node-1',
node_type: BlockEnum.Code,
title: 'Trace Node',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: NodeRunningStatus.Succeeded,
elapsed_time: 0.5,
execution_metadata: {
total_tokens: 12,
total_price: 0,
currency: 'USD',
},
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 1710000000,
created_by: {
id: 'account-1',
name: 'Alice',
email: 'alice@example.com',
},
finished_at: 1710000001,
...overrides,
})
describe('RunPanel', () => {
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get: () => 400,
})
})
beforeEach(() => {
vi.clearAllMocks()
mockFetchRunDetail.mockResolvedValue(createRunDetail())
mockFetchTracingList.mockResolvedValue({
data: [createTracingNode()],
} satisfies NodeTracingListResponse)
})
afterAll(() => {
if (originalClientHeightDescriptor)
Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeightDescriptor)
})
it('loads run detail and tracing data on mount, then renders the result tab', async () => {
const handleResult = vi.fn()
const runDetail = createRunDetail()
mockFetchRunDetail.mockResolvedValue(runDetail)
renderWorkflowComponent(
<RunPanel
runDetailUrl="/console/api/runs/run-1"
tracingListUrl="/console/api/runs/run-1/tracing"
getResultCallback={handleResult}
/>,
)
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/console/api/runs/run-1')
expect(mockFetchTracingList).toHaveBeenCalledWith({
url: '/console/api/runs/run-1/tracing',
})
expect(handleResult).toHaveBeenCalledWith(runDetail)
expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain('workflow output')
})
})
it('switches between detail, tracing, and result tabs with real child panels', async () => {
renderWorkflowComponent(
<RunPanel
activeTab="RESULT"
runDetailUrl="/console/api/runs/run-2"
tracingListUrl="/console/api/runs/run-2/tracing"
/>,
{
initialStoreState: {
isListening: true,
},
},
)
await waitFor(() => {
expect(screen.getAllByText('SUCCESS').length).toBeGreaterThan(0)
})
fireEvent.click(screen.getByText('runLog.tracing'))
await waitFor(() => {
expect(screen.getByText('Trace Node')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('runLog.result'))
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledTimes(2)
expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain('workflow output')
})
})
it('reports run-detail and tracing failures through toast.error', async () => {
mockFetchRunDetail.mockRejectedValueOnce(new Error('detail boom'))
mockFetchTracingList.mockRejectedValueOnce(new Error('tracing boom'))
renderWorkflowComponent(
<RunPanel
runDetailUrl="/console/api/runs/run-3"
tracingListUrl="/console/api/runs/run-3/tracing"
/>,
)
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('Error: detail boom')
expect(mockToastError).toHaveBeenCalledWith('Error: tracing boom')
})
})
})

View File

@ -0,0 +1,129 @@
import type { NodeTracing } from '@/types/workflow'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { BlockEnum, NodeRunningStatus } from '../../types'
import NodePanel from '../node'
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 Node',
inputs: undefined,
inputs_truncated: false,
process_data: undefined,
process_data_truncated: false,
outputs_truncated: false,
status: NodeRunningStatus.Succeeded,
elapsed_time: 1.25,
execution_metadata: {
total_tokens: 64,
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: 'Alice',
email: 'alice@example.com',
},
finished_at: 1,
...overrides,
})
describe('Run NodePanel', () => {
it('renders the running state in the header without the finished summary', () => {
render(
<NodePanel
nodeInfo={createNodeInfo({
status: NodeRunningStatus.Running,
})}
/>,
)
expect(screen.getByText('Running')).toBeInTheDocument()
expect(screen.queryByText('1.250 s')).not.toBeInTheDocument()
})
it('shows the stopped reason when the panel is expanded from tracing state', async () => {
render(
<NodePanel
nodeInfo={createNodeInfo({
expand: true,
status: NodeRunningStatus.Stopped,
})}
/>,
)
await waitFor(() => {
expect(screen.getByText(/Alice/)).toBeInTheDocument()
})
})
it('forwards iteration details through the real iteration trigger', async () => {
const handleShowIterationDetail = vi.fn()
const details = [[createNodeInfo({
id: 'iter-trace-1',
node_id: 'iter-node-1',
execution_metadata: {
total_tokens: 8,
total_price: 0,
currency: 'USD',
iteration_index: 0,
},
})]]
const iterDurationMap = { 0: 1.2 }
render(
<NodePanel
nodeInfo={createNodeInfo({
expand: true,
node_type: BlockEnum.Iteration,
details,
iterDurationMap,
})}
onShowIterationDetail={handleShowIterationDetail}
/>,
)
const trigger = await screen.findByRole('button')
fireEvent.click(trigger)
expect(handleShowIterationDetail).toHaveBeenCalledWith(details, iterDurationMap)
})
it('forwards retry details through the real retry trigger', async () => {
const handleShowRetryDetail = vi.fn()
const retryDetail = [
createNodeInfo({
id: 'retry-trace-1',
node_id: 'retry-node-1',
retry_index: 1,
status: NodeRunningStatus.Failed,
}),
]
render(
<NodePanel
nodeInfo={createNodeInfo({
expand: true,
retryDetail,
status: NodeRunningStatus.Failed,
})}
onShowRetryDetail={handleShowRetryDetail}
/>,
)
const trigger = await screen.findByRole('button')
fireEvent.click(trigger)
expect(handleShowRetryDetail).toHaveBeenCalledWith(retryDetail)
})
})

View File

@ -0,0 +1,87 @@
import type { AppContextValue } from '@/context/app-context'
import type { AgentLogItemWithChildren } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
AppContext,
initialLangGeniusVersionInfo,
initialWorkspaceInfo,
userProfilePlaceholder,
} from '@/context/app-context'
import AgentLogItem from '../agent-log-item'
const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
message_id: 'message-1',
label: 'Planner',
children: [],
status: 'succeeded',
node_execution_id: 'exec-1',
node_id: 'node-1',
data: { thought: 'inspect data' },
metadata: {
elapsed_time: 1.234,
},
...overrides,
})
const createAppContextValue = (): AppContextValue => {
let value!: AppContextValue
const base = {
userProfile: userProfilePlaceholder,
mutateUserProfile: vi.fn(),
currentWorkspace: {
...initialWorkspaceInfo,
id: 'workspace-1',
},
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: initialLangGeniusVersionInfo,
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
const useSelector: AppContextValue['useSelector'] = selector => selector(value)
value = {
...base,
useSelector,
}
return value
}
describe('AgentLogItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('expands to show action logs and data, then routes nested log clicks', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const child = createLogItem({ message_id: 'child', label: 'Tool Call' })
const item = createLogItem({
children: [child],
})
render(
<AppContext.Provider value={createAppContextValue()}>
<AgentLogItem
item={item}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>
</AppContext.Provider>,
)
expect(screen.getByText('Planner')).toBeInTheDocument()
expect(screen.getByText((_, node) => node?.textContent === '1.234s')).toBeInTheDocument()
await user.click(screen.getByText('Planner'))
expect(screen.getByRole('button', { name: /1 Action Logs/i })).toBeInTheDocument()
expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain('inspect data')
await user.click(screen.getByRole('button', { name: /1 Action Logs/i }))
expect(onShowAgentOrToolLog).toHaveBeenCalledWith(item)
})
})

View File

@ -0,0 +1,39 @@
import type { AgentLogItemWithChildren } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AgentLogNavMore from '../agent-log-nav-more'
const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
message_id: 'message-1',
label: 'Planner',
children: [],
status: 'succeeded',
node_execution_id: 'exec-1',
node_id: 'node-1',
data: {},
...overrides,
})
describe('AgentLogNavMore', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders nested options in the real menu and routes selection clicks', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const option = createLogItem({ message_id: 'mid', label: 'Intermediate Tool' })
render(
<AgentLogNavMore
options={[option]}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('Intermediate Tool'))
expect(onShowAgentOrToolLog).toHaveBeenCalledWith(option)
})
})

View File

@ -0,0 +1,48 @@
import type { AgentLogItemWithChildren } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AgentLogNav from '../agent-log-nav'
const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
message_id: 'message-1',
label: 'Planner',
children: [],
status: 'succeeded',
node_execution_id: 'exec-1',
node_id: 'node-1',
data: {},
...overrides,
})
describe('AgentLogNav', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('navigates back, opens intermediate entries, and shows the tail label', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const stack = [
createLogItem({ message_id: 'root', label: 'Strategy' }),
createLogItem({ message_id: 'mid', label: 'Tool A' }),
createLogItem({ message_id: 'tail', label: 'Tool B' }),
]
render(
<AgentLogNav
agentOrToolLogItemStack={stack}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
await user.click(screen.getByRole('button', { name: /^AGENT$/i }))
await user.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.strategy\.label$/ }))
await user.click(screen.getAllByRole('button')[2]!)
await user.click(screen.getByText('Tool A'))
expect(onShowAgentOrToolLog.mock.calls[0]).toHaveLength(0)
expect(onShowAgentOrToolLog).toHaveBeenNthCalledWith(2, stack[0])
expect(onShowAgentOrToolLog).toHaveBeenNthCalledWith(3, stack[1])
expect(screen.getByText('Tool B')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,83 @@
import type { AppContextValue } from '@/context/app-context'
import type { AgentLogItemWithChildren } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
AppContext,
initialLangGeniusVersionInfo,
initialWorkspaceInfo,
userProfilePlaceholder,
} from '@/context/app-context'
import AgentResultPanel from '../agent-result-panel'
const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
message_id: 'message-1',
label: 'Planner',
children: [],
status: 'succeeded',
node_execution_id: 'exec-1',
node_id: 'node-1',
data: {},
...overrides,
})
const createAppContextValue = (): AppContextValue => {
let value!: AppContextValue
const base = {
userProfile: userProfilePlaceholder,
mutateUserProfile: vi.fn(),
currentWorkspace: {
...initialWorkspaceInfo,
id: 'workspace-1',
},
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: initialLangGeniusVersionInfo,
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
const useSelector: AppContextValue['useSelector'] = selector => selector(value)
value = {
...base,
useSelector,
}
return value
}
describe('AgentResultPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the real child items, shows the circular warning, and opens nested action logs', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const grandchild = createLogItem({ message_id: 'grandchild', label: 'Tool Call' })
const child = createLogItem({
message_id: 'child',
label: 'Child Tool',
children: [grandchild],
})
const top = createLogItem({ message_id: 'top', label: 'Top', hasCircle: true })
render(
<AppContext.Provider value={createAppContextValue()}>
<AgentResultPanel
agentOrToolLogItemStack={[top]}
agentOrToolLogListMap={{ top: [child] }}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>
</AppContext.Provider>,
)
expect(screen.getByText('runLog.circularInvocationTip')).toBeInTheDocument()
await user.click(screen.getByText('Child Tool'))
await user.click(screen.getByRole('button', { name: /1 Action Logs/i }))
expect(onShowAgentOrToolLog).toHaveBeenCalledWith(child)
})
})

View File

@ -1,101 +0,0 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { AgentLogItemWithChildren } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AgentLogNav from '../agent-log-nav'
import AgentLogNavMore from '../agent-log-nav-more'
import AgentResultPanel from '../agent-result-panel'
vi.mock('../agent-log-item', () => ({
default: ({ item, onShowAgentOrToolLog }: any) => (
<button type="button" onClick={() => onShowAgentOrToolLog(item)}>
item-{item.label}
</button>
),
}))
const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
message_id: 'message-1',
label: 'Planner',
children: [],
status: 'succeeded',
node_execution_id: 'exec-1',
node_id: 'node-1',
data: {},
...overrides,
})
describe('agent-log leaf components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The navigation and result views should expose stack navigation and nested agent log entries.
describe('Navigation and Results', () => {
it('should navigate back, open intermediate entries, and show the tail label', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const stack = [
createLogItem({ message_id: 'root', label: 'Strategy' }),
createLogItem({ message_id: 'mid', label: 'Tool A' }),
createLogItem({ message_id: 'tail', label: 'Tool B' }),
]
render(
<AgentLogNav
agentOrToolLogItemStack={stack}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
await user.click(screen.getByRole('button', { name: /^AGENT$/i }))
await user.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.strategy\.label$/ }))
await user.click(screen.getAllByRole('button')[2]!)
await user.click(screen.getByText('Tool A'))
expect(onShowAgentOrToolLog.mock.calls[0]).toHaveLength(0)
expect(onShowAgentOrToolLog).toHaveBeenNthCalledWith(2, stack[0])
expect(onShowAgentOrToolLog).toHaveBeenNthCalledWith(3, stack[1])
expect(screen.getByText('Tool B')).toBeInTheDocument()
})
it('should render the more menu options as shortcuts to nested logs', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const option = createLogItem({ message_id: 'mid', label: 'Intermediate Tool' })
render(
<AgentLogNavMore
options={[option]}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('Intermediate Tool'))
expect(onShowAgentOrToolLog).toHaveBeenCalledWith(option)
})
it('should render result items and the circular invocation warning', async () => {
const user = userEvent.setup()
const onShowAgentOrToolLog = vi.fn()
const top = createLogItem({ message_id: 'top', label: 'Top', hasCircle: true })
const child = createLogItem({ message_id: 'child', label: 'Child Tool' })
render(
<AgentResultPanel
agentOrToolLogItemStack={[top]}
agentOrToolLogListMap={{ top: [child] }}
onShowAgentOrToolLog={onShowAgentOrToolLog}
/>,
)
expect(screen.getByText('runLog.circularInvocationTip')).toBeInTheDocument()
await user.click(screen.getByText('item-Child Tool'))
expect(onShowAgentOrToolLog).toHaveBeenCalledWith(child)
})
})
})

View File

@ -1,70 +0,0 @@
import type { IterationDurationMap, NodeTracing } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import IterationResultPanel from '../iteration-result-panel'
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
default: ({ list }: { list: NodeTracing[] }) => (
<div data-testid="tracing-panel">
{list.map(item => (
<div key={`${item.node_id}-${item.execution_metadata?.iteration_index}`}>{item.node_id}</div>
))}
</div>
),
}))
const createTracing = (
nodeId: string,
status: NodeRunningStatus,
iterationIndex: number,
parallelModeRunId?: string,
): NodeTracing => {
return {
node_id: nodeId,
status,
execution_metadata: {
iteration_index: iterationIndex,
parallel_mode_run_id: parallelModeRunId,
},
} as NodeTracing
}
describe('IterationResultPanel integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render failed, running, and completed iterations and toggle tracing details', async () => {
const user = userEvent.setup()
const onBack = vi.fn()
const list: NodeTracing[][] = [
[createTracing('failed-node', NodeRunningStatus.Failed, 0, 'iter-1')],
[createTracing('running-node', NodeRunningStatus.Running, 1, 'iter-2')],
[createTracing('done-node', NodeRunningStatus.Succeeded, 2, 'iter-3')],
]
const durationMap: IterationDurationMap = {
'iter-3': 0.001,
}
const { container } = render(
<IterationResultPanel
list={list}
onBack={onBack}
iterDurationMap={durationMap}
/>,
)
expect(screen.getByText('0.01s')).toBeInTheDocument()
await user.click(screen.getByText('workflow.singleRun.back'))
expect(onBack).toHaveBeenCalledTimes(1)
await user.click(screen.getByText((_, node) => node?.textContent === 'workflow.singleRun.iteration 3'))
expect(container.querySelectorAll('.opacity-100')).toHaveLength(1)
expect(screen.getByText('done-node')).toBeInTheDocument()
await user.click(screen.getByText((_, node) => node?.textContent === 'workflow.singleRun.iteration 3'))
expect(container.querySelectorAll('.opacity-100')).toHaveLength(0)
})
})

View File

@ -0,0 +1,78 @@
import type { IterationDurationMap, NodeTracing } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum, NodeRunningStatus } from '../../../types'
import IterationResultPanel from '../iteration-result-panel'
const createTrace = (id: string, overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id,
index: 0,
predecessor_node_id: '',
node_id: `node-${id}`,
node_type: BlockEnum.Code,
title: `Iteration Step ${id}`,
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: NodeRunningStatus.Succeeded,
error: '',
elapsed_time: 0.2,
execution_metadata: {
total_tokens: 0,
total_price: 0,
currency: 'USD',
iteration_index: 0,
parallel_mode_run_id: 'iter-1',
},
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 1710000000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
finished_at: 1710000001,
...overrides,
})
describe('IterationResultPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows iteration duration, toggles tracing details, and calls onBack', async () => {
const user = userEvent.setup()
const onBack = vi.fn()
const iterDurationMap: IterationDurationMap = { 'iter-1': 1.2 }
const { container } = render(
<IterationResultPanel
list={[[createTrace('1')]]}
onBack={onBack}
iterDurationMap={iterDurationMap}
/>,
)
expect(screen.getByText('1.20s')).toBeInTheDocument()
expect(container.querySelectorAll('.transition-all.duration-200.opacity-100')).toHaveLength(0)
await user.click(screen.getByText((_, node) => node?.textContent === 'workflow.singleRun.iteration 1'))
const expandArrow = container.querySelector('.transition-transform.duration-200')
if (!expandArrow)
throw new Error('Expected iteration expand arrow to be rendered')
expect(expandArrow).toHaveClass('rotate-90')
expect(container.querySelectorAll('.transition-all.duration-200.opacity-100')).toHaveLength(1)
await user.click(screen.getByText('workflow.singleRun.back'))
expect(onBack).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,80 @@
import type { LoopDurationMap, LoopVariableMap, NodeTracing } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum, NodeRunningStatus } from '../../../types'
import LoopResultPanel from '../loop-result-panel'
const createTrace = (id: string, overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id,
index: 0,
predecessor_node_id: '',
node_id: `node-${id}`,
node_type: BlockEnum.Code,
title: `Loop Step ${id}`,
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: NodeRunningStatus.Succeeded,
error: '',
elapsed_time: 0.2,
execution_metadata: {
total_tokens: 0,
total_price: 0,
currency: 'USD',
loop_index: 0,
},
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 1710000000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
finished_at: 1710000001,
...overrides,
})
describe('LoopResultPanel in loop-log', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows loop duration in the header, expands loop variables, and calls onBack', () => {
const onBack = vi.fn()
const loopDurationMap: LoopDurationMap = { 0: 1.2 }
const loopVariableMap: LoopVariableMap = { 0: { item: 'alpha' } }
const { container } = render(
<LoopResultPanel
list={[[createTrace('1')]]}
onBack={onBack}
loopDurationMap={loopDurationMap}
loopVariableMap={loopVariableMap}
/>,
)
expect(screen.getByText('1.20s')).toBeInTheDocument()
expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain('alpha')
const expandArrow = container.querySelector('.transition-transform.duration-200')
if (!expandArrow)
throw new Error('Expected loop expand arrow to be rendered')
expect(expandArrow).not.toHaveClass('rotate-90')
fireEvent.click(screen.getByText('workflow.singleRun.loop 1'))
expect(expandArrow).toHaveClass('rotate-90')
expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain('alpha')
expect(screen.getByText('Loop Step 1')).toBeInTheDocument()
fireEvent.click(screen.getByText('workflow.singleRun.back'))
expect(onBack).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,20 +1,9 @@
/* eslint-disable ts/no-explicit-any */
import type { NodeTracing } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '../../../types'
import { BlockEnum, NodeRunningStatus } from '../../../types'
import RetryResultPanel from '../retry-result-panel'
vi.mock('../../tracing-panel', () => ({
default: ({ list }: any) => (
<div>
{list.map((item: any) => (
<div key={item.id}>{item.title}</div>
))}
</div>
),
}))
const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-1',
index: 0,
@ -28,7 +17,7 @@ const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: 'succeeded',
status: NodeRunningStatus.Succeeded,
error: '',
elapsed_time: 0.1,
metadata: {
@ -44,6 +33,11 @@ const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
email: 'alice@example.com',
},
finished_at: 2,
execution_metadata: {
total_tokens: 0,
total_price: 0,
currency: 'USD',
},
...overrides,
})
@ -52,24 +46,22 @@ describe('RetryResultPanel', () => {
vi.clearAllMocks()
})
// The retry result panel should expose a back action and relabel each retry attempt in the tracing list.
describe('Rendering', () => {
it('should render retry titles and call onBack from the back header', async () => {
const user = userEvent.setup()
const onBack = vi.fn()
render(
<RetryResultPanel
list={[createTrace({ id: 'retry-1' }), createTrace({ id: 'retry-2' })]}
onBack={onBack}
/>,
)
it('renders retry titles through the real tracing panel and triggers the back action', async () => {
const user = userEvent.setup()
const onBack = vi.fn()
expect(screen.getByText('workflow.nodes.common.retry.retry 1')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.retry.retry 2')).toBeInTheDocument()
render(
<RetryResultPanel
list={[createTrace({ id: 'retry-1' }), createTrace({ id: 'retry-2' })]}
onBack={onBack}
/>,
)
await user.click(screen.getByText('workflow.singleRun.back'))
expect(screen.getByText('workflow.nodes.common.retry.retry 1')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.retry.retry 2')).toBeInTheDocument()
expect(onBack).toHaveBeenCalled()
})
await user.click(screen.getByText('workflow.singleRun.back'))
expect(onBack).toHaveBeenCalledTimes(1)
})
})