mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
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:
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user