Merge main HEAD (segment 5) into sandboxed-agent-rebase

Resolve 83 conflicts: 10 backend, 62 frontend, 11 config/lock files.
Preserve sandbox/agent/collaboration features while adopting main's
UI refactorings (Dialog/AlertDialog/Popover), model provider updates,
and enterprise features.

Made-with: Cursor
This commit is contained in:
Novice
2026-03-23 14:20:06 +08:00
1671 changed files with 124822 additions and 22302 deletions

View File

@ -1,10 +1,17 @@
import { renderHook } from '@testing-library/react'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import type { Node } from '../../types'
import { act, waitFor } from '@testing-library/react'
import { useNodes } from 'reactflow'
import { createNode } from '../../__tests__/fixtures'
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import { useAutoGenerateWebhookUrl } from '../use-auto-generate-webhook-url'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
type WebhookFlowNode = Node & {
data: NonNullable<Node['data']> & {
webhook_url?: string
webhook_debug_url?: string
}
}
vi.mock('@/app/components/app/store', async () =>
(await import('../../__tests__/service-mock-factory')).createAppStoreMock({ appId: 'app-123' }))
@ -15,13 +22,29 @@ vi.mock('@/service/apps', () => ({
}))
describe('useAutoGenerateWebhookUrl', () => {
const createFlowNodes = (): WebhookFlowNode[] => [
createNode({
id: 'webhook-1',
data: { type: BlockEnum.TriggerWebhook, webhook_url: '' },
}) as WebhookFlowNode,
createNode({
id: 'code-1',
position: { x: 300, y: 0 },
data: { type: BlockEnum.Code },
}) as WebhookFlowNode,
]
const renderAutoGenerateWebhookUrlHook = () =>
renderWorkflowFlowHook(() => ({
autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(),
nodes: useNodes<WebhookFlowNode>(),
}), {
nodes: createFlowNodes(),
edges: [],
})
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
rfState.nodes = [
{ id: 'webhook-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.TriggerWebhook, webhook_url: '' } },
{ id: 'code-1', position: { x: 300, y: 0 }, data: { type: BlockEnum.Code } },
]
})
it('should fetch and set webhook URL for a webhook trigger node', async () => {
@ -30,38 +53,63 @@ describe('useAutoGenerateWebhookUrl', () => {
webhook_debug_url: 'https://example.com/webhook-debug',
})
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
const { result } = renderAutoGenerateWebhookUrlHook()
await act(async () => {
await result.current.autoGenerateWebhookUrl('webhook-1')
})
expect(mockFetchWebhookUrl).toHaveBeenCalledWith({ appId: 'app-123', nodeId: 'webhook-1' })
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const webhookNode = updatedNodes.find((n: { id: string }) => n.id === 'webhook-1')
expect(webhookNode.data.webhook_url).toBe('https://example.com/webhook')
expect(webhookNode.data.webhook_debug_url).toBe('https://example.com/webhook-debug')
await waitFor(() => {
const webhookNode = result.current.nodes.find(node => node.id === 'webhook-1') as WebhookFlowNode | undefined
expect(webhookNode?.data.webhook_url).toBe('https://example.com/webhook')
expect(webhookNode?.data.webhook_debug_url).toBe('https://example.com/webhook-debug')
})
})
it('should not fetch when node is not a webhook trigger', async () => {
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('code-1')
const { result } = renderAutoGenerateWebhookUrlHook()
await act(async () => {
await result.current.autoGenerateWebhookUrl('code-1')
})
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
expect(rfState.setNodes).not.toHaveBeenCalled()
const codeNode = result.current.nodes.find(node => node.id === 'code-1') as WebhookFlowNode | undefined
expect(codeNode?.data.webhook_url).toBeUndefined()
})
it('should not fetch when node does not exist', async () => {
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('nonexistent')
const { result } = renderAutoGenerateWebhookUrlHook()
await act(async () => {
await result.current.autoGenerateWebhookUrl('nonexistent')
})
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
})
it('should not fetch when webhook_url already exists', async () => {
rfState.nodes[0].data.webhook_url = 'https://existing.com/webhook'
const { result } = renderWorkflowFlowHook(() => ({
autoGenerateWebhookUrl: useAutoGenerateWebhookUrl(),
}), {
nodes: [
createNode({
id: 'webhook-1',
data: {
type: BlockEnum.TriggerWebhook,
webhook_url: 'https://existing.com/webhook',
},
}) as WebhookFlowNode,
],
edges: [],
})
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
await act(async () => {
await result.current.autoGenerateWebhookUrl('webhook-1')
})
expect(mockFetchWebhookUrl).not.toHaveBeenCalled()
})
@ -70,14 +118,18 @@ describe('useAutoGenerateWebhookUrl', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchWebhookUrl.mockRejectedValue(new Error('network error'))
const { result } = renderHook(() => useAutoGenerateWebhookUrl())
await result.current('webhook-1')
const { result } = renderAutoGenerateWebhookUrlHook()
await act(async () => {
await result.current.autoGenerateWebhookUrl('webhook-1')
})
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to auto-generate webhook URL:',
expect.any(Error),
)
expect(rfState.setNodes).not.toHaveBeenCalled()
const webhookNode = result.current.nodes.find(node => node.id === 'webhook-1') as WebhookFlowNode | undefined
expect(webhookNode?.data.webhook_url).toBe('')
consoleSpy.mockRestore()
})
})

View File

@ -1,8 +1,12 @@
import type { CommonNodeType, Node } from '../../types'
import type { ChecklistItem } from '../use-checklist'
import { screen, waitFor } from '@testing-library/react'
import { createElement, Fragment } from 'react'
import { CollectionType } from '@/app/components/tools/types'
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { renderWorkflowComponent, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useStore } from '../../store'
import { BlockEnum } from '../../types'
import { useChecklist, useWorkflowRunValidation } from '../use-checklist'
@ -39,6 +43,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
type CheckValidFn = (data: CommonNodeType, t: unknown, extra?: unknown) => { errorMessage: string }
const mockNodesMap: Record<string, { checkValid: CheckValidFn, metaData: { isStart: boolean, isRequired: boolean } }> = {}
let mockModelProviders: Array<{ provider: string }> = []
let mockUsedVars: string[][] = []
const mockAvailableVarMap: Record<string, { availableVars: Array<{ nodeId: string, vars: Array<{ variable: string }> }> }> = {}
vi.mock('../use-nodes-meta-data', () => ({
useNodesMetaData: () => ({
@ -49,10 +56,10 @@ vi.mock('../use-nodes-meta-data', () => ({
vi.mock('../use-nodes-available-var-list', () => ({
default: (nodes: Node[]) => {
const map: Record<string, { availableVars: never[] }> = {}
const map: Record<string, { availableVars: Array<{ nodeId: string, vars: Array<{ variable: string }> }> }> = {}
if (nodes) {
for (const n of nodes)
map[n.id] = { availableVars: [] }
map[n.id] = mockAvailableVarMap[n.id] ?? { availableVars: [] }
}
return map
},
@ -60,7 +67,7 @@ vi.mock('../use-nodes-available-var-list', () => ({
}))
vi.mock('../../nodes/_base/components/variable/utils', () => ({
getNodeUsedVars: () => [],
getNodeUsedVars: () => mockUsedVars,
isSpecialVar: () => false,
}))
@ -90,6 +97,11 @@ vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en',
}))
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: (selector: (state: { modelProviders: Array<{ provider: string }> }) => unknown) =>
selector({ modelProviders: mockModelProviders }),
}))
// useWorkflowNodes reads from WorkflowContext (real store via renderWorkflowHook)
// ---------------------------------------------------------------------------
@ -124,6 +136,9 @@ beforeEach(() => {
resetReactFlowMockState()
resetFixtureCounters()
Object.keys(mockNodesMap).forEach(k => delete mockNodesMap[k])
Object.keys(mockAvailableVarMap).forEach(k => delete mockAvailableVarMap[k])
mockModelProviders = []
mockUsedVars = []
setupNodesMap()
})
@ -195,7 +210,7 @@ describe('useChecklist', () => {
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
expect(warning).toBeDefined()
expect(warning!.errorMessage).toBe('Model not configured')
expect(warning!.errorMessages).toContain('Model not configured')
})
it('should report missing start node in workflow mode', () => {
@ -217,7 +232,9 @@ describe('useChecklist', () => {
data: {
type: BlockEnum.Tool,
title: 'My Tool',
_pluginInstallLocked: true,
provider_type: CollectionType.builtIn,
provider_id: 'missing-provider',
plugin_unique_identifier: 'plugin/tool@0.0.1',
},
})
@ -233,6 +250,9 @@ describe('useChecklist', () => {
expect(warning).toBeDefined()
expect(warning!.canNavigate).toBe(false)
expect(warning!.disableGoTo).toBe(true)
expect(warning!.isPluginMissing).toBe(true)
expect(warning!.pluginUniqueIdentifier).toBe('plugin/tool@0.0.1')
expect(warning!.errorMessages).toContain('workflow.nodes.common.pluginNotInstalled')
})
it('should report required node types that are missing', () => {
@ -279,6 +299,112 @@ describe('useChecklist', () => {
const alienWarning = result.current.find((item: ChecklistItem) => item.id === 'alien')
expect(alienWarning).toBeUndefined()
})
it('should report configure model errors when an llm model provider plugin is missing', () => {
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const llmNode = createNode({
id: 'llm',
data: {
type: BlockEnum.LLM,
title: 'LLM',
model: {
provider: 'langgenius/openai/openai',
},
},
})
const edges = [
createEdge({ source: 'start', target: 'llm' }),
]
const { result } = renderWorkflowHook(
() => useChecklist([startNode, llmNode], edges),
)
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
expect(warning).toBeDefined()
expect(warning!.errorMessages).toContain('workflow.errorMsg.configureModel')
expect(warning!.canNavigate).toBe(true)
})
it('should accumulate validation and invalid variable errors for the same node', () => {
mockNodesMap[BlockEnum.LLM] = {
checkValid: () => ({ errorMessage: 'Model not configured' }),
metaData: { isStart: false, isRequired: false },
}
mockUsedVars = [['start', 'missingVar']]
mockAvailableVarMap.llm = {
availableVars: [
{
nodeId: 'start',
vars: [{ variable: 'existingVar' }],
},
],
}
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const llmNode = createNode({
id: 'llm',
data: {
type: BlockEnum.LLM,
title: 'LLM',
},
})
const edges = [
createEdge({ source: 'start', target: 'llm' }),
]
const { result } = renderWorkflowHook(
() => useChecklist([startNode, llmNode], edges),
)
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
expect(warning).toBeDefined()
expect(warning!.errorMessages).toEqual([
'Model not configured',
'workflow.errorMsg.invalidVariable',
])
})
it('should sync checklist items to the workflow store without render phase update warnings', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } })
function Operator() {
const checklistItems = useStore(state => state.checklistItems)
return createElement('div', { 'data-testid': 'checklist-count' }, checklistItems.length)
}
function WorkflowChecklist() {
useChecklist([startNode, codeNode], [])
return null
}
const { store } = renderWorkflowComponent(
createElement(
Fragment,
null,
createElement(Operator),
createElement(WorkflowChecklist),
),
)
await waitFor(() => {
expect(store.getState().checklistItems).toHaveLength(1)
})
expect(screen.getByTestId('checklist-count')).toHaveTextContent('1')
expect(errorSpy.mock.calls.some(call =>
call.some(arg => typeof arg === 'string' && arg.includes('Cannot update a component')),
)).toBe(false)
}
finally {
errorSpy.mockRestore()
}
})
})
// ---------------------------------------------------------------------------

View File

@ -1,10 +1,9 @@
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
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 { useEdgesInteractions } from '../use-edges-interactions'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
// useWorkflowHistory uses a debounced save — mock for synchronous assertions
const mockSaveStateToHistory = vi.fn()
vi.mock('../use-workflow-history', () => ({
@ -29,12 +28,67 @@ vi.mock('../../utils', () => ({
genNodeMetaData: vi.fn(({ type, sort }: { type: string, sort: number }) => ({ type, sort })),
}))
// useNodesSyncDraft is used REAL — via renderWorkflowHook + hooksStoreProps
function renderEdgesInteractions() {
type EdgeRuntimeState = {
_hovering?: boolean
_isBundled?: boolean
}
type NodeRuntimeState = {
selected?: boolean
_isBundled?: boolean
}
const getEdgeRuntimeState = (edge?: { data?: unknown }): EdgeRuntimeState =>
(edge?.data ?? {}) as EdgeRuntimeState
const getNodeRuntimeState = (node?: { data?: unknown }): NodeRuntimeState =>
(node?.data ?? {}) as NodeRuntimeState
function createFlowNodes() {
return [
createNode({ id: 'n1' }),
createNode({ id: 'n2', position: { x: 100, y: 0 } }),
]
}
function createFlowEdges() {
return [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
data: { _hovering: false },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false },
}),
]
}
function renderEdgesInteractions(options?: {
nodes?: ReturnType<typeof createFlowNodes>
edges?: ReturnType<typeof createFlowEdges>
initialStoreState?: Record<string, unknown>
}) {
const mockDoSync = vi.fn().mockResolvedValue(undefined)
const { nodes = createFlowNodes(), edges = createFlowEdges(), initialStoreState } = options ?? {}
return {
...renderWorkflowHook(() => useEdgesInteractions(), {
...renderWorkflowFlowHook(() => ({
...useEdgesInteractions(),
nodes: useNodes(),
edges: useEdges(),
}), {
nodes,
edges,
initialStoreState,
hooksStoreProps: { doSyncWorkflowDraft: mockDoSync },
reactFlowProps: { fitView: false },
}),
mockDoSync,
}
@ -43,73 +97,105 @@ function renderEdgesInteractions() {
describe('useEdgesInteractions', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
mockReadOnly = false
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
{ id: 'n2', position: { x: 100, y: 0 }, data: {} },
]
rfState.edges = [
{ id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false } },
{ id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false } },
]
})
it('handleEdgeEnter should set _hovering to true', () => {
it('handleEdgeEnter should set _hovering to true', async () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeEnter({} as never, rfState.edges[0] as never)
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated.find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(true)
expect(updated.find((e: { id: string }) => e.id === 'e2').data._hovering).toBe(false)
act(() => {
result.current.handleEdgeEnter({} as never, result.current.edges[0] as never)
})
await waitFor(() => {
expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e1'))._hovering).toBe(true)
expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e2'))._hovering).toBe(false)
})
})
it('handleEdgeLeave should set _hovering to false', () => {
rfState.edges[0].data._hovering = true
it('handleEdgeLeave should set _hovering to false', async () => {
const { result } = renderEdgesInteractions({
edges: createFlowEdges().map(edge =>
edge.id === 'e1'
? createEdge({ ...edge, data: { ...edge.data, _hovering: true } })
: edge,
),
})
act(() => {
result.current.handleEdgeLeave({} as never, result.current.edges[0] as never)
})
await waitFor(() => {
expect(getEdgeRuntimeState(result.current.edges.find(edge => edge.id === 'e1'))._hovering).toBe(false)
})
})
it('handleEdgesChange should update edge.selected for select changes', async () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeLeave({} as never, rfState.edges[0] as never)
expect(rfState.setEdges.mock.calls[0][0].find((e: { id: string }) => e.id === 'e1').data._hovering).toBe(false)
act(() => {
result.current.handleEdgesChange([
{ type: 'select', id: 'e1', selected: true },
{ type: 'select', id: 'e2', selected: false },
])
})
await waitFor(() => {
expect(result.current.edges.find(edge => edge.id === 'e1')?.selected).toBe(true)
expect(result.current.edges.find(edge => edge.id === 'e2')?.selected).toBe(false)
})
})
it('handleEdgesChange should update edge.selected for select changes', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgesChange([
{ type: 'select', id: 'e1', selected: true },
{ type: 'select', id: 'e2', selected: false },
])
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(true)
expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(false)
})
it('handleEdgeContextMenu should select the clicked edge and open edgeMenu', () => {
it('handleEdgeContextMenu should select the clicked edge and open edgeMenu', async () => {
const preventDefault = vi.fn()
const { result, store } = renderEdgesInteractions()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { selected: true, _isBundled: true }, selected: true } as typeof rfState.nodes[number] & { selected: boolean },
{ id: 'n2', position: { x: 100, y: 0 }, data: { _isBundled: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false, _isBundled: true } },
{ id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false, _isBundled: true } },
]
const { result, store } = renderEdgesInteractions({
nodes: [
createNode({
id: 'n1',
data: { selected: true, _isBundled: true },
selected: true,
}),
createNode({
id: 'n2',
position: { x: 100, y: 0 },
data: { _isBundled: true },
}),
],
edges: [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
data: { _hovering: false, _isBundled: true },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false, _isBundled: true },
}),
],
})
result.current.handleEdgeContextMenu({
preventDefault,
clientX: 320,
clientY: 180,
} as never, rfState.edges[1] as never)
act(() => {
result.current.handleEdgeContextMenu({
preventDefault,
clientX: 320,
clientY: 180,
} as never, result.current.edges[1] as never)
})
expect(preventDefault).toHaveBeenCalled()
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(false)
expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(true)
expect(updated.every((e: { data: { _isBundled?: boolean } }) => !e.data._isBundled)).toBe(true)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes.every((node: { data: { selected?: boolean, _isBundled?: boolean }, selected?: boolean }) => !node.data.selected && !node.selected && !node.data._isBundled)).toBe(true)
await waitFor(() => {
expect(result.current.edges.find(edge => edge.id === 'e1')?.selected).toBe(false)
expect(result.current.edges.find(edge => edge.id === 'e2')?.selected).toBe(true)
expect(result.current.edges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true)
expect(result.current.nodes.every(node => !getNodeRuntimeState(node).selected && !node.selected && !getNodeRuntimeState(node)._isBundled)).toBe(true)
})
expect(store.getState().edgeMenu).toEqual({
clientX: 320,
@ -121,70 +207,133 @@ describe('useEdgesInteractions', () => {
expect(store.getState().selectionMenu).toBeUndefined()
})
it('handleEdgeDelete should remove selected edge and trigger sync + history', () => {
;(rfState.edges[0] as Record<string, unknown>).selected = true
const { result, store } = renderEdgesInteractions()
store.setState({
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
it('handleEdgeDelete should remove selected edge and trigger sync + history', async () => {
const { result, store } = renderEdgesInteractions({
edges: [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
selected: true,
data: { _hovering: false },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false },
}),
],
initialStoreState: {
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
},
})
result.current.handleEdgeDelete()
act(() => {
result.current.handleEdgeDelete()
})
await waitFor(() => {
expect(result.current.edges).toHaveLength(1)
expect(result.current.edges[0]?.id).toBe('e2')
})
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated).toHaveLength(1)
expect(updated[0].id).toBe('e2')
expect(store.getState().edgeMenu).toBeUndefined()
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it('handleEdgeDelete should do nothing when no edge is selected', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeDelete()
expect(rfState.setEdges).not.toHaveBeenCalled()
})
it('handleEdgeDeleteById should remove the requested edge even when another edge is selected', () => {
;(rfState.edges[0] as Record<string, unknown>).selected = true
const { result, store } = renderEdgesInteractions()
store.setState({
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2' },
act(() => {
result.current.handleEdgeDelete()
})
result.current.handleEdgeDeleteById('e2')
expect(result.current.edges).toHaveLength(2)
})
it('handleEdgeDeleteById should remove the requested edge even when another edge is selected', async () => {
const { result, store } = renderEdgesInteractions({
edges: [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
selected: true,
data: { _hovering: false },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false },
}),
],
initialStoreState: {
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2' },
},
})
act(() => {
result.current.handleEdgeDeleteById('e2')
})
await waitFor(() => {
expect(result.current.edges).toHaveLength(1)
expect(result.current.edges[0]?.id).toBe('e1')
expect(result.current.edges[0]?.selected).toBe(true)
})
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated).toHaveLength(1)
expect(updated[0].id).toBe('e1')
expect(updated[0].selected).toBe(true)
expect(store.getState().edgeMenu).toBeUndefined()
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
})
it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', () => {
const { result, store } = renderEdgesInteractions()
store.setState({
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', async () => {
const { result, store } = renderEdgesInteractions({
initialStoreState: {
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
},
})
act(() => {
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
})
await waitFor(() => {
expect(result.current.edges).toHaveLength(1)
expect(result.current.edges[0]?.id).toBe('e2')
})
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated).toHaveLength(1)
expect(updated[0].id).toBe('e2')
expect(store.getState().edgeMenu).toBeUndefined()
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch')
})
it('handleEdgeSourceHandleChange should update sourceHandle and edge ID', () => {
rfState.edges = [
{ id: 'n1-old-handle-n2-target', source: 'n1', target: 'n2', sourceHandle: 'old-handle', targetHandle: 'target', data: {} } as typeof rfState.edges[0],
]
it('handleEdgeSourceHandleChange should update sourceHandle and edge ID', async () => {
const { result } = renderEdgesInteractions({
edges: [
createEdge({
id: 'n1-old-handle-n2-target',
source: 'n1',
target: 'n2',
sourceHandle: 'old-handle',
targetHandle: 'target',
data: {},
}),
],
})
const { result } = renderEdgesInteractions()
result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
act(() => {
result.current.handleEdgeSourceHandleChange('n1', 'old-handle', 'new-handle')
})
const updated = rfState.setEdges.mock.calls[0][0]
expect(updated[0].sourceHandle).toBe('new-handle')
expect(updated[0].id).toBe('n1-new-handle-n2-target')
await waitFor(() => {
expect(result.current.edges[0]?.sourceHandle).toBe('new-handle')
expect(result.current.edges[0]?.id).toBe('n1-new-handle-n2-target')
})
})
describe('read-only mode', () => {
@ -194,38 +343,75 @@ describe('useEdgesInteractions', () => {
it('handleEdgeEnter should do nothing', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeEnter({} as never, rfState.edges[0] as never)
expect(rfState.setEdges).not.toHaveBeenCalled()
act(() => {
result.current.handleEdgeEnter({} as never, result.current.edges[0] as never)
})
expect(getEdgeRuntimeState(result.current.edges[0])._hovering).toBe(false)
})
it('handleEdgeDelete should do nothing', () => {
;(rfState.edges[0] as Record<string, unknown>).selected = true
const { result } = renderEdgesInteractions()
result.current.handleEdgeDelete()
expect(rfState.setEdges).not.toHaveBeenCalled()
const { result } = renderEdgesInteractions({
edges: [
createEdge({
id: 'e1',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-a',
selected: true,
data: { _hovering: false },
}),
createEdge({
id: 'e2',
source: 'n1',
target: 'n2',
sourceHandle: 'branch-b',
data: { _hovering: false },
}),
],
})
act(() => {
result.current.handleEdgeDelete()
})
expect(result.current.edges).toHaveLength(2)
})
it('handleEdgeDeleteById should do nothing', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeDeleteById('e1')
expect(rfState.setEdges).not.toHaveBeenCalled()
act(() => {
result.current.handleEdgeDeleteById('e1')
})
expect(result.current.edges).toHaveLength(2)
})
it('handleEdgeContextMenu should do nothing', () => {
const { result, store } = renderEdgesInteractions()
result.current.handleEdgeContextMenu({
preventDefault: vi.fn(),
clientX: 200,
clientY: 120,
} as never, rfState.edges[0] as never)
expect(rfState.setEdges).not.toHaveBeenCalled()
act(() => {
result.current.handleEdgeContextMenu({
preventDefault: vi.fn(),
clientX: 200,
clientY: 120,
} as never, result.current.edges[0] as never)
})
expect(result.current.edges.every(edge => !edge.selected)).toBe(true)
expect(store.getState().edgeMenu).toBeUndefined()
})
it('handleEdgeDeleteByDeleteBranch should do nothing', () => {
const { result } = renderEdgesInteractions()
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
expect(rfState.setEdges).not.toHaveBeenCalled()
act(() => {
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
})
expect(result.current.edges).toHaveLength(2)
})
})
})

View File

@ -0,0 +1,253 @@
import type { CommonNodeType } from '../../types'
import { act } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import { useNodePluginInstallation } from '../use-node-plugin-installation'
const mockBuiltInTools = vi.fn()
const mockCustomTools = vi.fn()
const mockWorkflowTools = vi.fn()
const mockMcpTools = vi.fn()
const mockInvalidToolsByType = vi.fn()
const mockTriggerPlugins = vi.fn()
const mockInvalidateTriggers = vi.fn()
const mockInvalidDataSourceList = vi.fn()
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: (enabled: boolean) => mockBuiltInTools(enabled),
useAllCustomTools: (enabled: boolean) => mockCustomTools(enabled),
useAllWorkflowTools: (enabled: boolean) => mockWorkflowTools(enabled),
useAllMCPTools: (enabled: boolean) => mockMcpTools(enabled),
useInvalidToolsByType: (providerType?: string) => mockInvalidToolsByType(providerType),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: (enabled: boolean) => mockTriggerPlugins(enabled),
useInvalidateAllTriggerPlugins: () => mockInvalidateTriggers,
}))
vi.mock('@/service/use-pipeline', () => ({
useInvalidDataSourceList: () => mockInvalidDataSourceList,
}))
const makeToolNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.Tool,
title: 'Tool node',
desc: '',
provider_type: CollectionType.builtIn,
provider_id: 'search',
provider_name: 'search',
plugin_id: 'plugin-search',
plugin_unique_identifier: 'plugin-search@1.0.0',
...overrides,
}) as CommonNodeType
const makeTriggerNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.TriggerPlugin,
title: 'Trigger node',
desc: '',
provider_id: 'trigger-provider',
provider_name: 'trigger-provider',
plugin_id: 'trigger-plugin',
plugin_unique_identifier: 'trigger-plugin@1.0.0',
...overrides,
}) as CommonNodeType
const makeDataSourceNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.DataSource,
title: 'Data source node',
desc: '',
provider_name: 'knowledge-provider',
plugin_id: 'knowledge-plugin',
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
...overrides,
}) as CommonNodeType
const matchedTool = {
plugin_id: 'plugin-search',
provider: 'search',
name: 'search',
plugin_unique_identifier: 'plugin-search@1.0.0',
}
const matchedTriggerProvider = {
id: 'trigger-provider',
name: 'trigger-provider',
plugin_id: 'trigger-plugin',
}
const matchedDataSource = {
provider: 'knowledge-provider',
plugin_id: 'knowledge-plugin',
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
}
describe('useNodePluginInstallation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: false })
mockCustomTools.mockReturnValue({ data: undefined, isLoading: false })
mockWorkflowTools.mockReturnValue({ data: undefined, isLoading: false })
mockMcpTools.mockReturnValue({ data: undefined, isLoading: false })
mockInvalidToolsByType.mockReturnValue(undefined)
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: false })
mockInvalidateTriggers.mockReset()
mockInvalidDataSourceList.mockReset()
})
it('should return the noop installation state for non plugin-dependent nodes', () => {
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation({
type: BlockEnum.LLM,
title: 'LLM',
desc: '',
} as CommonNodeType),
)
expect(result.current).toEqual({
isChecking: false,
isMissing: false,
uniqueIdentifier: undefined,
canInstall: false,
onInstallSuccess: expect.any(Function),
shouldDim: false,
})
})
it('should report loading and invalidate built-in tools while the collection is resolving', () => {
const invalidateTools = vi.fn()
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: true })
mockInvalidToolsByType.mockReturnValue(invalidateTools)
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeToolNode()))
expect(mockBuiltInTools).toHaveBeenCalledWith(true)
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('plugin-search@1.0.0')
expect(result.current.canInstall).toBe(true)
expect(result.current.shouldDim).toBe(true)
act(() => {
result.current.onInstallSuccess()
})
expect(invalidateTools).toHaveBeenCalled()
})
it.each([
[CollectionType.custom, mockCustomTools],
[CollectionType.workflow, mockWorkflowTools],
[CollectionType.mcp, mockMcpTools],
])('should resolve matched %s tool collections without dimming', (providerType, hookMock) => {
hookMock.mockReturnValue({ data: [matchedTool], isLoading: false })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeToolNode({ provider_type: providerType })),
)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(false)
expect(result.current.shouldDim).toBe(false)
})
it('should keep unknown tool collection types installable without collection state', () => {
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeToolNode({
provider_type: 'unknown' as CollectionType,
plugin_unique_identifier: undefined,
plugin_id: undefined,
provider_id: 'legacy-provider',
})),
)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('legacy-provider')
expect(result.current.canInstall).toBe(false)
expect(result.current.shouldDim).toBe(false)
})
it('should flag missing trigger plugins and invalidate trigger data after installation', () => {
mockTriggerPlugins.mockReturnValue({ data: [matchedTriggerProvider], isLoading: false })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeTriggerNode({
provider_id: 'missing-trigger',
provider_name: 'missing-trigger',
plugin_id: 'missing-trigger',
})),
)
expect(mockTriggerPlugins).toHaveBeenCalledWith(true)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(true)
expect(result.current.shouldDim).toBe(true)
act(() => {
result.current.onInstallSuccess()
})
expect(mockInvalidateTriggers).toHaveBeenCalled()
})
it('should treat the trigger plugin list as still loading when it has not resolved yet', () => {
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: true })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeTriggerNode({ plugin_unique_identifier: undefined, plugin_id: 'trigger-plugin' })),
)
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('trigger-plugin')
expect(result.current.canInstall).toBe(false)
expect(result.current.shouldDim).toBe(true)
})
it('should track missing and matched data source providers based on workflow store state', () => {
const missingRender = renderWorkflowHook(
() => useNodePluginInstallation(makeDataSourceNode({
provider_name: 'missing-provider',
plugin_id: 'missing-plugin',
plugin_unique_identifier: 'missing-plugin@1.0.0',
})),
{
initialStoreState: {
dataSourceList: [matchedDataSource] as never,
},
},
)
expect(missingRender.result.current.isChecking).toBe(false)
expect(missingRender.result.current.isMissing).toBe(true)
expect(missingRender.result.current.shouldDim).toBe(true)
const matchedRender = renderWorkflowHook(
() => useNodePluginInstallation(makeDataSourceNode()),
{
initialStoreState: {
dataSourceList: [matchedDataSource] as never,
},
},
)
expect(matchedRender.result.current.isMissing).toBe(false)
expect(matchedRender.result.current.shouldDim).toBe(false)
act(() => {
matchedRender.result.current.onInstallSuccess()
})
expect(mockInvalidDataSourceList).toHaveBeenCalled()
})
it('should keep data sources in checking state before the list is loaded', () => {
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeDataSourceNode()))
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.shouldDim).toBe(true)
})
})

View File

@ -1,58 +1,52 @@
import type * as React from 'react'
import type { Node, OnSelectionChangeParams } from 'reactflow'
import type { MockEdge, MockNode } from '../../__tests__/reactflow-mock-state'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import type { OnSelectionChangeParams } from 'reactflow'
import { act, waitFor } from '@testing-library/react'
import { useEdges, useNodes, useStoreApi } from 'reactflow'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
import { useSelectionInteractions } from '../use-selection-interactions'
const rfStoreExtra = vi.hoisted(() => ({
userSelectionRect: null as { x: number, y: number, width: number, height: number } | null,
userSelectionActive: false,
resetSelectedElements: vi.fn(),
setState: vi.fn(),
}))
type BundledState = {
_isBundled?: boolean
}
vi.mock('reactflow', async () => {
const mod = await import('../../__tests__/reactflow-mock-state')
const base = mod.createReactFlowModuleMock()
return {
...base,
useStoreApi: vi.fn(() => ({
getState: () => ({
getNodes: () => mod.rfState.nodes,
setNodes: mod.rfState.setNodes,
edges: mod.rfState.edges,
setEdges: mod.rfState.setEdges,
transform: mod.rfState.transform,
userSelectionRect: rfStoreExtra.userSelectionRect,
userSelectionActive: rfStoreExtra.userSelectionActive,
resetSelectedElements: rfStoreExtra.resetSelectedElements,
}),
setState: rfStoreExtra.setState,
subscribe: vi.fn().mockReturnValue(vi.fn()),
})),
}
})
const getBundledState = (item?: { data?: unknown }): BundledState =>
(item?.data ?? {}) as BundledState
function createFlowNodes() {
return [
createNode({ id: 'n1', data: { _isBundled: true } }),
createNode({ id: 'n2', position: { x: 100, y: 100 }, data: { _isBundled: true } }),
createNode({ id: 'n3', position: { x: 200, y: 200 }, data: {} }),
]
}
function createFlowEdges() {
return [
createEdge({ id: 'e1', source: 'n1', target: 'n2', data: { _isBundled: true } }),
createEdge({ id: 'e2', source: 'n2', target: 'n3', data: {} }),
]
}
function renderSelectionInteractions(initialStoreState?: Record<string, unknown>) {
return renderWorkflowFlowHook(() => ({
...useSelectionInteractions(),
nodes: useNodes(),
edges: useEdges(),
reactFlowStore: useStoreApi(),
}), {
nodes: createFlowNodes(),
edges: createFlowEdges(),
reactFlowProps: { fitView: false },
initialStoreState,
})
}
describe('useSelectionInteractions', () => {
let container: HTMLDivElement
beforeEach(() => {
resetReactFlowMockState()
rfStoreExtra.userSelectionRect = null
rfStoreExtra.userSelectionActive = false
rfStoreExtra.resetSelectedElements = vi.fn()
rfStoreExtra.setState.mockReset()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _isBundled: true } },
{ id: 'n2', position: { x: 100, y: 100 }, data: { _isBundled: true } },
{ id: 'n3', position: { x: 200, y: 200 }, data: {} },
]
rfState.edges = [
{ id: 'e1', source: 'n1', target: 'n2', data: { _isBundled: true } },
{ id: 'e2', source: 'n2', target: 'n3', data: {} },
]
vi.clearAllMocks()
container = document.createElement('div')
container.id = 'workflow-container'
@ -73,110 +67,137 @@ describe('useSelectionInteractions', () => {
container.remove()
})
it('handleSelectionStart should clear _isBundled from all nodes and edges', () => {
const { result } = renderWorkflowHook(() => useSelectionInteractions())
it('handleSelectionStart should clear _isBundled from all nodes and edges', async () => {
const { result } = renderSelectionInteractions()
result.current.handleSelectionStart()
act(() => {
result.current.handleSelectionStart()
})
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true)
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true)
await waitFor(() => {
expect(result.current.nodes.every(node => !getBundledState(node)._isBundled)).toBe(true)
expect(result.current.edges.every(edge => !getBundledState(edge)._isBundled)).toBe(true)
})
})
it('handleSelectionChange should mark selected nodes as bundled', () => {
rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 }
it('handleSelectionChange should mark selected nodes as bundled', async () => {
const { result } = renderSelectionInteractions()
const { result } = renderWorkflowHook(() => useSelectionInteractions())
act(() => {
result.current.reactFlowStore.setState({
userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
} as never)
})
result.current.handleSelectionChange({
nodes: [{ id: 'n1' }, { id: 'n3' }],
edges: [],
} as unknown as OnSelectionChangeParams)
act(() => {
result.current.handleSelectionChange({
nodes: [{ id: 'n1' }, { id: 'n3' }],
edges: [],
} as unknown as OnSelectionChangeParams)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.find(n => n.id === 'n1')!.data._isBundled).toBe(true)
expect(updatedNodes.find(n => n.id === 'n2')!.data._isBundled).toBe(false)
expect(updatedNodes.find(n => n.id === 'n3')!.data._isBundled).toBe(true)
await waitFor(() => {
expect(getBundledState(result.current.nodes.find(node => node.id === 'n1'))._isBundled).toBe(true)
expect(getBundledState(result.current.nodes.find(node => node.id === 'n2'))._isBundled).toBe(false)
expect(getBundledState(result.current.nodes.find(node => node.id === 'n3'))._isBundled).toBe(true)
})
})
it('handleSelectionChange should mark selected edges', () => {
rfStoreExtra.userSelectionRect = { x: 0, y: 0, width: 100, height: 100 }
it('handleSelectionChange should mark selected edges', async () => {
const { result } = renderSelectionInteractions()
const { result } = renderWorkflowHook(() => useSelectionInteractions())
act(() => {
result.current.reactFlowStore.setState({
userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
} as never)
})
result.current.handleSelectionChange({
nodes: [],
edges: [{ id: 'e1' }],
} as unknown as OnSelectionChangeParams)
act(() => {
result.current.handleSelectionChange({
nodes: [],
edges: [{ id: 'e1' }],
} as unknown as OnSelectionChangeParams)
})
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
expect(updatedEdges.find(e => e.id === 'e1')!.data._isBundled).toBe(true)
expect(updatedEdges.find(e => e.id === 'e2')!.data._isBundled).toBe(false)
await waitFor(() => {
expect(getBundledState(result.current.edges.find(edge => edge.id === 'e1'))._isBundled).toBe(true)
expect(getBundledState(result.current.edges.find(edge => edge.id === 'e2'))._isBundled).toBe(false)
})
})
it('handleSelectionDrag should sync node positions', () => {
const { result, store } = renderWorkflowHook(() => useSelectionInteractions())
it('handleSelectionDrag should sync node positions', async () => {
const { result, store } = renderSelectionInteractions()
const draggedNodes = [
{ id: 'n1', position: { x: 50, y: 60 }, data: {} },
] as unknown as Node[]
] as never
result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes)
act(() => {
result.current.handleSelectionDrag({} as unknown as React.MouseEvent, draggedNodes)
})
expect(store.getState().nodeAnimation).toBe(false)
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.find(n => n.id === 'n1')!.position).toEqual({ x: 50, y: 60 })
expect(updatedNodes.find(n => n.id === 'n2')!.position).toEqual({ x: 100, y: 100 })
await waitFor(() => {
expect(result.current.nodes.find(node => node.id === 'n1')?.position).toEqual({ x: 50, y: 60 })
expect(result.current.nodes.find(node => node.id === 'n2')?.position).toEqual({ x: 100, y: 100 })
})
})
it('handleSelectionCancel should clear all selection state', () => {
const { result } = renderWorkflowHook(() => useSelectionInteractions())
it('handleSelectionCancel should clear all selection state', async () => {
const { result } = renderSelectionInteractions()
result.current.handleSelectionCancel()
expect(rfStoreExtra.setState).toHaveBeenCalledWith({
userSelectionRect: null,
userSelectionActive: true,
act(() => {
result.current.reactFlowStore.setState({
userSelectionRect: { x: 0, y: 0, width: 100, height: 100 },
userSelectionActive: false,
} as never)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0] as MockNode[]
expect(updatedNodes.every(n => !n.data._isBundled)).toBe(true)
act(() => {
result.current.handleSelectionCancel()
})
const updatedEdges = rfState.setEdges.mock.calls[0][0] as MockEdge[]
expect(updatedEdges.every(e => !e.data._isBundled)).toBe(true)
expect(result.current.reactFlowStore.getState().userSelectionRect).toBeNull()
expect(result.current.reactFlowStore.getState().userSelectionActive).toBe(true)
await waitFor(() => {
expect(result.current.nodes.every(node => !getBundledState(node)._isBundled)).toBe(true)
expect(result.current.edges.every(edge => !getBundledState(edge)._isBundled)).toBe(true)
})
})
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), {
initialStoreState: {
nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
panelMenu: { top: 30, left: 40 },
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
},
const { result, store } = renderSelectionInteractions({
nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
panelMenu: { top: 30, left: 40 },
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
})
const wrongTarget = document.createElement('div')
wrongTarget.classList.add('some-other-class')
result.current.handleSelectionContextMenu({
target: wrongTarget,
preventDefault: vi.fn(),
clientX: 300,
clientY: 200,
} as unknown as React.MouseEvent)
act(() => {
result.current.handleSelectionContextMenu({
target: wrongTarget,
preventDefault: vi.fn(),
clientX: 300,
clientY: 200,
} as unknown as React.MouseEvent)
})
expect(store.getState().selectionMenu).toBeUndefined()
const correctTarget = document.createElement('div')
correctTarget.classList.add('react-flow__nodesselection-rect')
result.current.handleSelectionContextMenu({
target: correctTarget,
preventDefault: vi.fn(),
clientX: 300,
clientY: 200,
} as unknown as React.MouseEvent)
act(() => {
result.current.handleSelectionContextMenu({
target: correctTarget,
preventDefault: vi.fn(),
clientX: 300,
clientY: 200,
} as unknown as React.MouseEvent)
})
expect(store.getState().selectionMenu).toEqual({
top: 150,
@ -188,11 +209,13 @@ describe('useSelectionInteractions', () => {
})
it('handleSelectionContextmenuCancel should clear selectionMenu', () => {
const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), {
initialStoreState: { selectionMenu: { top: 50, left: 60 } },
const { result, store } = renderSelectionInteractions({
selectionMenu: { top: 50, left: 60 },
})
result.current.handleSelectionContextmenuCancel()
act(() => {
result.current.handleSelectionContextmenuCancel()
})
expect(store.getState().selectionMenu).toBeUndefined()
})

View File

@ -1,130 +1,209 @@
import { renderHook } from '@testing-library/react'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
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'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
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', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.edges = [
{ id: 'e1', source: 'a', target: 'b', data: { _sourceRunningStatus: 'running', _targetRunningStatus: 'running', _waitingRun: true } },
{ id: 'e2', source: 'b', target: 'c', data: { _sourceRunningStatus: 'succeeded', _targetRunningStatus: undefined, _waitingRun: false } },
]
})
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 } = renderHook(() => useEdgesInteractionsWithoutSync())
const { result } = renderEdgesInteractionsHook()
result.current.handleEdgeCancelRunningStatus()
act(() => {
result.current.handleEdgeCancelRunningStatus()
})
expect(rfState.setEdges).toHaveBeenCalledOnce()
const updated = rfState.setEdges.mock.calls[0][0]
for (const edge of updated) {
expect(edge.data._sourceRunningStatus).toBeUndefined()
expect(edge.data._targetRunningStatus).toBeUndefined()
expect(edge.data._waitingRun).toBe(false)
}
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 originalData = { ...rfState.edges[0].data }
const { result } = renderHook(() => useEdgesInteractionsWithoutSync())
const edges = createFlowEdges()
const originalData = { ...getEdgeRuntimeState(edges[0]) }
const { result } = renderWorkflowFlowHook(() => ({
...useEdgesInteractionsWithoutSync(),
edges: useEdges(),
}), {
nodes: createFlowNodes(),
edges,
})
result.current.handleEdgeCancelRunningStatus()
act(() => {
result.current.handleEdgeCancelRunningStatus()
})
expect(rfState.edges[0].data._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
expect(getEdgeRuntimeState(edges[0])._sourceRunningStatus).toBe(originalData._sourceRunningStatus)
})
})
describe('useNodesInteractionsWithoutSync', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running, _waitingRun: true } },
{ id: 'n2', position: { x: 100, y: 0 }, data: { _runningStatus: NodeRunningStatus.Succeeded, _waitingRun: false } },
{ id: 'n3', position: { x: 200, y: 0 }, data: { _runningStatus: NodeRunningStatus.Failed, _waitingRun: true } },
]
})
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', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
it('should clear _runningStatus and _waitingRun on all nodes', async () => {
const { result } = renderNodesInteractionsHook()
result.current.handleNodeCancelRunningStatus()
act(() => {
result.current.handleNodeCancelRunningStatus()
})
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updated = rfState.setNodes.mock.calls[0][0]
for (const node of updated) {
expect(node.data._runningStatus).toBeUndefined()
expect(node.data._waitingRun).toBe(false)
}
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', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
it('should clear _runningStatus only for Succeeded nodes', async () => {
const { result } = renderNodesInteractionsHook()
result.current.handleCancelAllNodeSuccessStatus()
act(() => {
result.current.handleCancelAllNodeSuccessStatus()
})
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updated = rfState.setNodes.mock.calls[0][0]
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
const n2 = updated.find((n: { id: string }) => n.id === 'n2')
const n3 = updated.find((n: { id: string }) => n.id === 'n3')
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(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n2.data._runningStatus).toBeUndefined()
expect(n3.data._runningStatus).toBe(NodeRunningStatus.Failed)
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
expect(getNodeRuntimeState(n2)._runningStatus).toBeUndefined()
expect(getNodeRuntimeState(n3)._runningStatus).toBe(NodeRunningStatus.Failed)
})
})
it('should not modify _waitingRun', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
it('should not modify _waitingRun', async () => {
const { result } = renderNodesInteractionsHook()
result.current.handleCancelAllNodeSuccessStatus()
act(() => {
result.current.handleCancelAllNodeSuccessStatus()
})
const updated = rfState.setNodes.mock.calls[0][0]
expect(updated.find((n: { id: string }) => n.id === 'n1').data._waitingRun).toBe(true)
expect(updated.find((n: { id: string }) => n.id === 'n3').data._waitingRun).toBe(true)
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', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
it('should clear _runningStatus and _waitingRun for the specified Succeeded node', async () => {
const { result } = renderNodesInteractionsHook()
result.current.handleCancelNodeSuccessStatus('n2')
act(() => {
result.current.handleCancelNodeSuccessStatus('n2')
})
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updated = rfState.setNodes.mock.calls[0][0]
const n2 = updated.find((n: { id: string }) => n.id === 'n2')
expect(n2.data._runningStatus).toBeUndefined()
expect(n2.data._waitingRun).toBe(false)
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', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
it('should not modify nodes that are not Succeeded', async () => {
const { result } = renderNodesInteractionsHook()
result.current.handleCancelNodeSuccessStatus('n1')
act(() => {
result.current.handleCancelNodeSuccessStatus('n1')
})
const updated = rfState.setNodes.mock.calls[0][0]
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._waitingRun).toBe(true)
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', () => {
const { result } = renderHook(() => useNodesInteractionsWithoutSync())
it('should not modify other nodes', async () => {
const { result } = renderNodesInteractionsHook()
result.current.handleCancelNodeSuccessStatus('n2')
act(() => {
result.current.handleCancelNodeSuccessStatus('n2')
})
const updated = rfState.setNodes.mock.calls[0][0]
const n1 = updated.find((n: { id: string }) => n.id === 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
await waitFor(() => {
const n1 = result.current.nodes.find(node => node.id === 'n1')
expect(getNodeRuntimeState(n1)._runningStatus).toBe(NodeRunningStatus.Running)
})
})
})
})

View File

@ -7,8 +7,10 @@ import type {
NodeFinishedResponse,
WorkflowStartedResponse,
} from '@/types/workflow'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
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'
@ -19,44 +21,100 @@ import { useWorkflowNodeLoopNext } from '../use-workflow-run-event/use-workflow-
import { useWorkflowNodeRetry } from '../use-workflow-run-event/use-workflow-node-retry'
import { useWorkflowStarted } from '../use-workflow-run-event/use-workflow-started'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
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', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _waitingRun: false } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should initialize workflow running data and reset nodes/edges', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowStarted(), {
it('should initialize workflow running data and reset nodes/edges', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 1000 },
} as WorkflowStartedResponse)
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('')
expect(rfState.setNodes).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._waitingRun).toBe(true)
expect(rfState.setEdges).toHaveBeenCalledOnce()
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 } = renderWorkflowHook(() => useWorkflowStarted(), {
const { result, store } = renderRunEventHook(() => useWorkflowStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
result: { status: WorkflowRunningStatus.Paused } as WorkflowRunningData['result'],
@ -64,30 +122,28 @@ describe('useWorkflowStarted', () => {
},
})
result.current.handleWorkflowStarted({
task_id: 'task-2',
data: { id: 'run-2', workflow_id: 'wf-1', created_at: 2000 },
} as WorkflowStartedResponse)
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(rfState.setNodes).not.toHaveBeenCalled()
expect(rfState.setEdges).not.toHaveBeenCalled()
expect(getNodeRuntimeState(result.current.nodes[0])._waitingRun).toBe(false)
expect(getEdgeRuntimeState(result.current.edges[0])._waitingRun).toBeUndefined()
})
})
describe('useWorkflowNodeFinished', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing and node running status', () => {
const { result, store } = renderWorkflowHook(() => 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 }],
@ -95,20 +151,29 @@ describe('useWorkflowNodeFinished', () => {
},
})
result.current.handleWorkflowNodeFinished({
data: { id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as NodeFinishedResponse)
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)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(rfState.setEdges).toHaveBeenCalledOnce()
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', () => {
const { result } = renderWorkflowHook(() => useWorkflowNodeFinished(), {
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 }],
@ -116,83 +181,75 @@ describe('useWorkflowNodeFinished', () => {
},
})
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)
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)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningBranchId).toBe('branch-a')
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningBranchId).toBe('branch-a')
})
})
})
describe('useWorkflowNodeRetry', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
]
})
it('should push retry data to tracing and update _retryIndex', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeRetry(), {
it('should push retry data to tracing and update _retryIndex', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeRetry(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeRetry({
data: { node_id: 'n1', retry_index: 2 },
} as NodeFinishedResponse)
act(() => {
result.current.handleWorkflowNodeRetry({
data: { node_id: 'n1', retry_index: 2 },
} as NodeFinishedResponse)
})
expect(store.getState().workflowRunningData!.tracing).toHaveLength(1)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._retryIndex).toBe(2)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._retryIndex).toBe(2)
})
})
})
describe('useWorkflowNodeIterationNext', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
]
})
it('should set _iterationIndex and increment iterTimes', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeIterationNext(), {
it('should set _iterationIndex and increment iterTimes', async () => {
const { result, store } = renderRunEventHook(() => useWorkflowNodeIterationNext(), {
initialStoreState: {
workflowRunningData: baseRunningData(),
iterTimes: 3,
},
})
result.current.handleWorkflowNodeIterationNext({
data: { node_id: 'n1' },
} as IterationNextResponse)
act(() => {
result.current.handleWorkflowNodeIterationNext({
data: { node_id: 'n1' },
} as IterationNextResponse)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._iterationIndex).toBe(3)
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._iterationIndex).toBe(3)
})
expect(store.getState().iterTimes).toBe(4)
})
})
describe('useWorkflowNodeIterationFinished', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing, reset iterTimes, update node status and edges', () => {
const { result, store } = renderWorkflowHook(() => 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 }],
@ -201,56 +258,60 @@ describe('useWorkflowNodeIterationFinished', () => {
},
})
result.current.handleWorkflowNodeIterationFinished({
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as IterationFinishedResponse)
act(() => {
result.current.handleWorkflowNodeIterationFinished({
data: { id: 'iter-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as IterationFinishedResponse)
})
expect(store.getState().iterTimes).toBe(DEFAULT_ITER_TIMES)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(rfState.setEdges).toHaveBeenCalledOnce()
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
})
})
})
describe('useWorkflowNodeLoopNext', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
{ id: 'n2', position: { x: 300, y: 0 }, parentId: 'n1', data: { _waitingRun: false } },
]
})
it('should set _loopIndex and reset child nodes to waiting', () => {
const { result } = renderWorkflowHook(() => 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() },
})
result.current.handleWorkflowNodeLoopNext({
data: { node_id: 'n1', index: 5 },
} as LoopNextResponse)
act(() => {
result.current.handleWorkflowNodeLoopNext({
data: { node_id: 'n1', index: 5 },
} as LoopNextResponse)
})
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(updatedNodes[0].data._loopIndex).toBe(5)
expect(updatedNodes[1].data._waitingRun).toBe(true)
expect(updatedNodes[1].data._runningStatus).toBe(NodeRunningStatus.Waiting)
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', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should update tracing, node status and edges', () => {
const { result, store } = renderWorkflowHook(() => 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 }],
@ -258,12 +319,18 @@ describe('useWorkflowNodeLoopFinished', () => {
},
})
result.current.handleWorkflowNodeLoopFinished({
data: { id: 'loop-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
} as LoopFinishedResponse)
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)
expect(rfState.setEdges).toHaveBeenCalledOnce()
await waitFor(() => {
expect(getNodeRuntimeState(result.current.nodes[0])._runningStatus).toBe(NodeRunningStatus.Succeeded)
expect(getEdgeRuntimeState(result.current.edges[0])._targetRunningStatus).toBe(NodeRunningStatus.Succeeded)
})
})
})

View File

@ -4,8 +4,10 @@ import type {
LoopStartedResponse,
NodeStartedResponse,
} from '@/types/workflow'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
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'
@ -13,67 +15,145 @@ import { useWorkflowNodeIterationStarted } from '../use-workflow-run-event/use-w
import { useWorkflowNodeLoopStarted } from '../use-workflow-run-event/use-workflow-node-loop-started'
import { useWorkflowNodeStarted } from '../use-workflow-run-event/use-workflow-node-started'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
function findNodeById(nodes: Array<{ id: string, data: Record<string, unknown> }>, id: string) {
return nodes.find(n => n.id === id)!
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 }
describe('useWorkflowNodeStarted', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
{ id: 'n2', position: { x: 400, y: 50 }, width: 200, height: 80, parentId: 'n1', data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
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 },
}),
]
}
it('should push to tracing, set node running, and adjust viewport for root node', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
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() },
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
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)
expect(rfState.setViewport).toHaveBeenCalledOnce()
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 updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._waitingRun).toBe(false)
expect(rfState.setEdges).toHaveBeenCalledOnce()
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)', () => {
const { result } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
it('should not adjust viewport for child node (has parentId)', async () => {
const { result } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: { workflowRunningData: baseRunningData() },
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n2' } } as NodeStartedResponse,
containerParams,
)
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n2' } } as NodeStartedResponse,
containerParams,
)
})
expect(rfState.setViewport).not.toHaveBeenCalled()
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 } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
@ -84,10 +164,12 @@ describe('useWorkflowNodeStarted', () => {
},
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
act(() => {
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
})
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(2)
@ -96,92 +178,80 @@ describe('useWorkflowNodeStarted', () => {
})
describe('useWorkflowNodeIterationStarted', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should push to tracing, reset iterTimes, set viewport, and update node with _iterationLength', () => {
const { result, store } = renderWorkflowHook(() => 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,
},
})
result.current.handleWorkflowNodeIterationStarted(
{ data: { node_id: 'n1', metadata: { iterator_length: 10 } } } as IterationStartedResponse,
containerParams,
)
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)
expect(rfState.setViewport).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._iterationLength).toBe(10)
expect(n1.data._waitingRun).toBe(false)
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(200)
expect(transform[1]).toBe(310)
expect(transform[2]).toBe(1)
expect(rfState.setEdges).toHaveBeenCalledOnce()
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', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n0', position: { x: 0, y: 0 }, width: 200, height: 80, data: { _runningStatus: NodeRunningStatus.Succeeded } },
{ id: 'n1', position: { x: 100, y: 50 }, width: 200, height: 80, data: { _waitingRun: true } },
]
rfState.edges = [
{ id: 'e1', source: 'n0', target: 'n1', data: {} },
]
})
it('should push to tracing, set viewport, and update node with _loopLength', () => {
const { result, store } = renderWorkflowHook(() => 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() },
})
result.current.handleWorkflowNodeLoopStarted(
{ data: { node_id: 'n1', metadata: { loop_length: 5 } } } as LoopStartedResponse,
containerParams,
)
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)
expect(rfState.setViewport).toHaveBeenCalledOnce()
const updatedNodes = rfState.setNodes.mock.calls[0][0]
const n1 = findNodeById(updatedNodes, 'n1')
expect(n1.data._runningStatus).toBe(NodeRunningStatus.Running)
expect(n1.data._loopLength).toBe(5)
expect(n1.data._waitingRun).toBe(false)
await waitFor(() => {
const transform = result.current.reactFlowStore.getState().transform
expect(transform[0]).toBe(200)
expect(transform[1]).toBe(310)
expect(transform[2]).toBe(1)
expect(rfState.setEdges).toHaveBeenCalledOnce()
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', () => {
beforeEach(() => {
resetReactFlowMockState()
rfState.nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
{ id: 'n2', position: { x: 300, y: 0 }, data: { _runningStatus: NodeRunningStatus.Running } },
]
})
it('should create humanInputFormDataList and set tracing/node to Paused', () => {
const { result, store } = renderWorkflowHook(() => 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 }],
@ -189,21 +259,29 @@ describe('useWorkflowNodeHumanInputRequired', () => {
},
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'f1', node_title: 'Node 1', form_content: 'content' },
} as HumanInputRequiredResponse)
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)
const updatedNodes = rfState.setNodes.mock.calls[0][0]
expect(findNodeById(updatedNodes, 'n1').data._runningStatus).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 } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
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 }],
@ -214,9 +292,11 @@ describe('useWorkflowNodeHumanInputRequired', () => {
},
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n1', form_id: 'new', node_title: 'Node 1', form_content: 'new' },
} as HumanInputRequiredResponse)
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)
@ -224,7 +304,12 @@ describe('useWorkflowNodeHumanInputRequired', () => {
})
it('should append new form entry for different node_id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeHumanInputRequired(), {
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 }],
@ -235,9 +320,11 @@ describe('useWorkflowNodeHumanInputRequired', () => {
},
})
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
} as HumanInputRequiredResponse)
act(() => {
result.current.handleWorkflowNodeHumanInputRequired({
data: { node_id: 'n2', form_id: 'f2', node_title: 'Node 2', form_content: 'content2' },
} as HumanInputRequiredResponse)
})
expect(store.getState().workflowRunningData!.humanInputFormDataList).toHaveLength(2)
})

View File

@ -1,6 +1,6 @@
import { act, renderHook } from '@testing-library/react'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { baseRunningData, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { createNode } from '../../__tests__/fixtures'
import { baseRunningData, renderWorkflowFlowHook, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { WorkflowRunningStatus } from '../../types'
import {
useIsChatMode,
@ -10,9 +10,6 @@ import {
useWorkflowReadOnly,
} from '../use-workflow'
vi.mock('reactflow', async () =>
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
let mockAppMode = 'workflow'
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { mode: string } }) => unknown) => selector({ appDetail: { mode: mockAppMode } }),
@ -20,7 +17,6 @@ vi.mock('@/app/components/app/store', () => ({
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
mockAppMode = 'workflow'
})
@ -158,37 +154,50 @@ describe('useNodesReadOnly', () => {
// ---------------------------------------------------------------------------
describe('useIsNodeInIteration', () => {
beforeEach(() => {
rfState.nodes = [
{ id: 'iter-1', position: { x: 0, y: 0 }, data: { type: 'iteration' } },
{ id: 'child-1', position: { x: 10, y: 0 }, parentId: 'iter-1', data: {} },
{ id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} },
{ id: 'outside-1', position: { x: 100, y: 0 }, data: {} },
]
})
const createIterationNodes = () => [
createNode({ id: 'iter-1' }),
createNode({ id: 'child-1', parentId: 'iter-1' }),
createNode({ id: 'grandchild-1', parentId: 'child-1' }),
createNode({ id: 'outside-1' }),
]
it('should return true when node is a direct child of the iteration', () => {
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
nodes: createIterationNodes(),
edges: [],
})
expect(result.current.isNodeInIteration('child-1')).toBe(true)
})
it('should return false for a grandchild (only checks direct parentId)', () => {
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
nodes: createIterationNodes(),
edges: [],
})
expect(result.current.isNodeInIteration('grandchild-1')).toBe(false)
})
it('should return false when node is outside the iteration', () => {
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
nodes: createIterationNodes(),
edges: [],
})
expect(result.current.isNodeInIteration('outside-1')).toBe(false)
})
it('should return false when node does not exist', () => {
const { result } = renderHook(() => useIsNodeInIteration('iter-1'))
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('iter-1'), {
nodes: createIterationNodes(),
edges: [],
})
expect(result.current.isNodeInIteration('nonexistent')).toBe(false)
})
it('should return false when iteration id has no children', () => {
const { result } = renderHook(() => useIsNodeInIteration('no-such-iter'))
const { result } = renderWorkflowFlowHook(() => useIsNodeInIteration('no-such-iter'), {
nodes: createIterationNodes(),
edges: [],
})
expect(result.current.isNodeInIteration('child-1')).toBe(false)
})
})
@ -198,37 +207,50 @@ describe('useIsNodeInIteration', () => {
// ---------------------------------------------------------------------------
describe('useIsNodeInLoop', () => {
beforeEach(() => {
rfState.nodes = [
{ id: 'loop-1', position: { x: 0, y: 0 }, data: { type: 'loop' } },
{ id: 'child-1', position: { x: 10, y: 0 }, parentId: 'loop-1', data: {} },
{ id: 'grandchild-1', position: { x: 20, y: 0 }, parentId: 'child-1', data: {} },
{ id: 'outside-1', position: { x: 100, y: 0 }, data: {} },
]
})
const createLoopNodes = () => [
createNode({ id: 'loop-1' }),
createNode({ id: 'child-1', parentId: 'loop-1' }),
createNode({ id: 'grandchild-1', parentId: 'child-1' }),
createNode({ id: 'outside-1' }),
]
it('should return true when node is a direct child of the loop', () => {
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
nodes: createLoopNodes(),
edges: [],
})
expect(result.current.isNodeInLoop('child-1')).toBe(true)
})
it('should return false for a grandchild (only checks direct parentId)', () => {
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
nodes: createLoopNodes(),
edges: [],
})
expect(result.current.isNodeInLoop('grandchild-1')).toBe(false)
})
it('should return false when node is outside the loop', () => {
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
nodes: createLoopNodes(),
edges: [],
})
expect(result.current.isNodeInLoop('outside-1')).toBe(false)
})
it('should return false when node does not exist', () => {
const { result } = renderHook(() => useIsNodeInLoop('loop-1'))
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('loop-1'), {
nodes: createLoopNodes(),
edges: [],
})
expect(result.current.isNodeInLoop('nonexistent')).toBe(false)
})
it('should return false when loop id has no children', () => {
const { result } = renderHook(() => useIsNodeInLoop('no-such-loop'))
const { result } = renderWorkflowFlowHook(() => useIsNodeInLoop('no-such-loop'), {
nodes: createLoopNodes(),
edges: [],
})
expect(result.current.isNodeInLoop('child-1')).toBe(false)
})
})