mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
test: add unit tests for workflow components and stores
This commit is contained in:
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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'])
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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, [])
|
||||
})
|
||||
})
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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' }))
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
184
web/app/components/workflow/run/__tests__/index.spec.tsx
Normal file
184
web/app/components/workflow/run/__tests__/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
129
web/app/components/workflow/run/__tests__/node.spec.tsx
Normal file
129
web/app/components/workflow/run/__tests__/node.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user