feat(tests): add comprehensive test suite for workflow utilities and node creation (#32841)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
Coding On Star
2026-03-02 18:24:01 +08:00
committed by GitHub
parent 707bf20c29
commit 9c33923985
19 changed files with 3606 additions and 69 deletions

View File

@ -0,0 +1,183 @@
import {
formatWorkflowRunIdentifier,
getKeyboardKeyCodeBySystem,
getKeyboardKeyNameBySystem,
isEventTargetInputArea,
isMac,
} from '../common'
describe('isMac', () => {
const originalNavigator = globalThis.navigator
afterEach(() => {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
writable: true,
configurable: true,
})
})
it('should return true when userAgent contains MAC', () => {
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' },
writable: true,
configurable: true,
})
expect(isMac()).toBe(true)
})
it('should return false when userAgent does not contain MAC', () => {
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
writable: true,
configurable: true,
})
expect(isMac()).toBe(false)
})
})
describe('getKeyboardKeyNameBySystem', () => {
const originalNavigator = globalThis.navigator
afterEach(() => {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
writable: true,
configurable: true,
})
})
function setMac() {
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Macintosh' },
writable: true,
configurable: true,
})
}
function setWindows() {
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Windows NT' },
writable: true,
configurable: true,
})
}
it('should map ctrl to ⌘ on Mac', () => {
setMac()
expect(getKeyboardKeyNameBySystem('ctrl')).toBe('⌘')
})
it('should map alt to ⌥ on Mac', () => {
setMac()
expect(getKeyboardKeyNameBySystem('alt')).toBe('⌥')
})
it('should map shift to ⇧ on Mac', () => {
setMac()
expect(getKeyboardKeyNameBySystem('shift')).toBe('⇧')
})
it('should return the original key for unmapped keys on Mac', () => {
setMac()
expect(getKeyboardKeyNameBySystem('enter')).toBe('enter')
})
it('should return the original key on non-Mac', () => {
setWindows()
expect(getKeyboardKeyNameBySystem('ctrl')).toBe('ctrl')
expect(getKeyboardKeyNameBySystem('alt')).toBe('alt')
})
})
describe('getKeyboardKeyCodeBySystem', () => {
const originalNavigator = globalThis.navigator
afterEach(() => {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
writable: true,
configurable: true,
})
})
it('should map ctrl to meta on Mac', () => {
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Macintosh' },
writable: true,
configurable: true,
})
expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('meta')
})
it('should return the original key on non-Mac', () => {
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Windows NT' },
writable: true,
configurable: true,
})
expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('ctrl')
})
it('should return the original key for unmapped keys on Mac', () => {
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Macintosh' },
writable: true,
configurable: true,
})
expect(getKeyboardKeyCodeBySystem('alt')).toBe('alt')
})
})
describe('isEventTargetInputArea', () => {
it('should return true for INPUT elements', () => {
const el = document.createElement('input')
expect(isEventTargetInputArea(el)).toBe(true)
})
it('should return true for TEXTAREA elements', () => {
const el = document.createElement('textarea')
expect(isEventTargetInputArea(el)).toBe(true)
})
it('should return true for contentEditable elements', () => {
const el = document.createElement('div')
el.contentEditable = 'true'
expect(isEventTargetInputArea(el)).toBe(true)
})
it('should return undefined for non-input elements', () => {
const el = document.createElement('div')
expect(isEventTargetInputArea(el)).toBeUndefined()
})
it('should return undefined for contentEditable=false elements', () => {
const el = document.createElement('div')
el.contentEditable = 'false'
expect(isEventTargetInputArea(el)).toBeUndefined()
})
})
describe('formatWorkflowRunIdentifier', () => {
it('should return fallback text when finishedAt is undefined', () => {
expect(formatWorkflowRunIdentifier()).toBe(' (Running)')
})
it('should return fallback text when finishedAt is 0', () => {
expect(formatWorkflowRunIdentifier(0)).toBe(' (Running)')
})
it('should capitalize custom fallback text', () => {
expect(formatWorkflowRunIdentifier(undefined, 'pending')).toBe(' (Pending)')
})
it('should format a valid timestamp', () => {
const timestamp = 1704067200 // 2024-01-01 00:00:00 UTC
const result = formatWorkflowRunIdentifier(timestamp)
expect(result).toMatch(/^ \(\d{2}:\d{2}:\d{2}( [AP]M)?\)$/)
})
it('should handle single-char fallback text', () => {
expect(formatWorkflowRunIdentifier(undefined, 'x')).toBe(' (X)')
})
})

View File

@ -0,0 +1,116 @@
import type { DataSourceNodeType } from '../../nodes/data-source/types'
import type { ToolWithProvider } from '../../types'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '../../types'
import { getDataSourceCheckParams } from '../data-source'
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
toolParametersToFormSchemas: vi.fn((params: Array<Record<string, unknown>>) =>
params.map(p => ({
variable: p.name,
label: p.label || { en_US: p.name },
type: p.type || 'string',
required: p.required ?? false,
form: p.form ?? 'llm',
hide: p.hide ?? false,
}))),
}))
function createDataSourceData(overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType {
return {
title: 'DataSource',
desc: '',
type: BlockEnum.DataSource,
plugin_id: 'plugin-ds-1',
provider_type: CollectionType.builtIn,
datasource_name: 'mysql_query',
datasource_parameters: {},
datasource_configurations: {},
...overrides,
} as DataSourceNodeType
}
function createDataSourceCollection(overrides: Partial<ToolWithProvider> = {}): ToolWithProvider {
return {
id: 'ds-collection',
plugin_id: 'plugin-ds-1',
name: 'MySQL',
tools: [
{
name: 'mysql_query',
parameters: [
{ name: 'query', label: { en_US: 'SQL Query', zh_Hans: 'SQL 查询' }, type: 'string', required: true },
{ name: 'limit', label: { en_US: 'Limit' }, type: 'number', required: false, hide: true },
],
},
],
allow_delete: true,
is_authorized: false,
...overrides,
} as unknown as ToolWithProvider
}
describe('getDataSourceCheckParams', () => {
it('should extract input schema from matching data source', () => {
const result = getDataSourceCheckParams(
createDataSourceData(),
[createDataSourceCollection()],
'en_US',
)
expect(result.dataSourceInputsSchema).toEqual([
{ label: 'SQL Query', variable: 'query', type: 'string', required: true, hide: false },
{ label: 'Limit', variable: 'limit', type: 'number', required: false, hide: true },
])
})
it('should mark notAuthed for builtin datasource without authorization', () => {
const result = getDataSourceCheckParams(
createDataSourceData(),
[createDataSourceCollection()],
'en_US',
)
expect(result.notAuthed).toBe(true)
})
it('should mark as authed when is_authorized is true', () => {
const result = getDataSourceCheckParams(
createDataSourceData(),
[createDataSourceCollection({ is_authorized: true })],
'en_US',
)
expect(result.notAuthed).toBe(false)
})
it('should return empty schemas when data source is not found', () => {
const result = getDataSourceCheckParams(
createDataSourceData({ plugin_id: 'non-existent' }),
[createDataSourceCollection()],
'en_US',
)
expect(result.dataSourceInputsSchema).toEqual([])
})
it('should return empty schemas when datasource item is not found', () => {
const result = getDataSourceCheckParams(
createDataSourceData({ datasource_name: 'non_existent_ds' }),
[createDataSourceCollection()],
'en_US',
)
expect(result.dataSourceInputsSchema).toEqual([])
})
it('should include language in result', () => {
const result = getDataSourceCheckParams(
createDataSourceData(),
[createDataSourceCollection()],
'zh_Hans',
)
expect(result.language).toBe('zh_Hans')
})
})

View File

@ -0,0 +1,48 @@
import { VarInInspectType } from '@/types/workflow'
import { VarType } from '../../types'
import { outputToVarInInspect } from '../debug'
describe('outputToVarInInspect', () => {
it('should create a VarInInspect object with correct fields', () => {
const result = outputToVarInInspect({
nodeId: 'node-1',
name: 'output',
value: 'hello world',
})
expect(result).toMatchObject({
type: VarInInspectType.node,
name: 'output',
description: '',
selector: ['node-1', 'output'],
value_type: VarType.string,
value: 'hello world',
edited: false,
visible: true,
is_truncated: false,
full_content: { size_bytes: 0, download_url: '' },
})
expect(result.id).toBeDefined()
})
it('should handle different value types', () => {
const result = outputToVarInInspect({
nodeId: 'n2',
name: 'count',
value: 42,
})
expect(result.value).toBe(42)
expect(result.selector).toEqual(['n2', 'count'])
})
it('should handle null value', () => {
const result = outputToVarInInspect({
nodeId: 'n3',
name: 'empty',
value: null,
})
expect(result.value).toBeNull()
})
})

View File

@ -0,0 +1,33 @@
import { NodeRunningStatus } from '../../types'
import { getEdgeColor } from '../edge'
describe('getEdgeColor', () => {
it('should return success color when status is Succeeded', () => {
expect(getEdgeColor(NodeRunningStatus.Succeeded)).toBe('var(--color-workflow-link-line-success-handle)')
})
it('should return error color when status is Failed', () => {
expect(getEdgeColor(NodeRunningStatus.Failed)).toBe('var(--color-workflow-link-line-error-handle)')
})
it('should return failure color when status is Exception', () => {
expect(getEdgeColor(NodeRunningStatus.Exception)).toBe('var(--color-workflow-link-line-failure-handle)')
})
it('should return default running color when status is Running and not fail branch', () => {
expect(getEdgeColor(NodeRunningStatus.Running)).toBe('var(--color-workflow-link-line-handle)')
})
it('should return failure color when status is Running and is fail branch', () => {
expect(getEdgeColor(NodeRunningStatus.Running, true)).toBe('var(--color-workflow-link-line-failure-handle)')
})
it('should return normal color when status is undefined', () => {
expect(getEdgeColor()).toBe('var(--color-workflow-link-line-normal)')
})
it('should return normal color for other statuses', () => {
expect(getEdgeColor(NodeRunningStatus.Waiting)).toBe('var(--color-workflow-link-line-normal)')
expect(getEdgeColor(NodeRunningStatus.NotStart)).toBe('var(--color-workflow-link-line-normal)')
})
})

View File

@ -0,0 +1,665 @@
import type { CommonEdgeType, CommonNodeType, Edge, Node } from '../../types'
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
import { CUSTOM_NODE, NODE_LAYOUT_HORIZONTAL_PADDING } from '../../constants'
import { CUSTOM_ITERATION_START_NODE } from '../../nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '../../nodes/loop-start/constants'
import { BlockEnum } from '../../types'
type ElkChild = Record<string, unknown> & { id: string, width?: number, height?: number, x?: number, y?: number, children?: ElkChild[], ports?: Array<{ id: string }>, layoutOptions?: Record<string, string> }
type ElkGraph = Record<string, unknown> & { id: string, children?: ElkChild[], edges?: Array<Record<string, unknown>> }
let layoutCallArgs: ElkGraph | null = null
let mockReturnOverride: ((graph: ElkGraph) => ElkGraph) | null = null
vi.mock('elkjs/lib/elk.bundled.js', () => {
return {
default: class MockELK {
async layout(graph: ElkGraph) {
layoutCallArgs = graph
if (mockReturnOverride)
return mockReturnOverride(graph)
const children = (graph.children || []).map((child: ElkChild, i: number) => ({
...child,
x: 100 + i * 300,
y: 50 + i * 100,
width: child.width || 244,
height: child.height || 100,
}))
return { ...graph, children }
}
},
}
})
const { getLayoutByDagre, getLayoutForChildNodes } = await import('../elk-layout')
function makeWorkflowNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node {
return createNode({
type: CUSTOM_NODE,
...overrides,
})
}
function makeWorkflowEdge(overrides: Omit<Partial<Edge>, 'data'> & { data?: Partial<CommonEdgeType> & Record<string, unknown> } = {}): Edge {
return createEdge(overrides)
}
beforeEach(() => {
resetFixtureCounters()
layoutCallArgs = null
mockReturnOverride = null
})
describe('getLayoutByDagre', () => {
it('should return layout for simple linear graph', async () => {
const nodes = [
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [makeWorkflowEdge({ source: 'a', target: 'b' })]
const result = await getLayoutByDagre(nodes, edges)
expect(result.nodes.size).toBe(2)
expect(result.nodes.has('a')).toBe(true)
expect(result.nodes.has('b')).toBe(true)
expect(result.bounds.minX).toBe(0)
expect(result.bounds.minY).toBe(0)
})
it('should filter out nodes with parentId', async () => {
const nodes = [
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
makeWorkflowNode({ id: 'child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'a' }),
]
const result = await getLayoutByDagre(nodes, [])
expect(result.nodes.size).toBe(1)
expect(result.nodes.has('child')).toBe(false)
})
it('should filter out non-CUSTOM_NODE type nodes', async () => {
const nodes = [
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
makeWorkflowNode({ id: 'iter-start', type: CUSTOM_ITERATION_START_NODE, data: { type: BlockEnum.IterationStart, title: '', desc: '' } }),
]
const result = await getLayoutByDagre(nodes, [])
expect(result.nodes.size).toBe(1)
})
it('should filter out iteration/loop internal edges', async () => {
const nodes = [
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ source: 'a', target: 'b', data: { isInIteration: true, iteration_id: 'iter-1' } }),
]
await getLayoutByDagre(nodes, edges)
expect(layoutCallArgs!.edges).toHaveLength(0)
})
it('should use default dimensions when node has no width/height', async () => {
const node = makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
Reflect.deleteProperty(node, 'width')
Reflect.deleteProperty(node, 'height')
const result = await getLayoutByDagre([node], [])
expect(result.nodes.size).toBe(1)
const info = result.nodes.get('a')!
expect(info.width).toBe(244)
expect(info.height).toBe(100)
})
it('should build ports for IfElse nodes with multiple branches', async () => {
const nodes = [
makeWorkflowNode({
id: 'if-1',
data: {
type: BlockEnum.IfElse,
title: '',
desc: '',
cases: [{ case_id: 'case-1', logical_operator: 'and', conditions: [] }],
},
}),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b', sourceHandle: 'case-1' }),
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
]
await getLayoutByDagre(nodes, edges)
const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifElkNode.ports).toHaveLength(2)
expect(ifElkNode.layoutOptions!['elk.portConstraints']).toBe('FIXED_ORDER')
})
it('should use normal node for IfElse with single branch', async () => {
const nodes = [
makeWorkflowNode({
id: 'if-1',
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'case-1' }] },
}),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [makeWorkflowEdge({ source: 'if-1', target: 'b', sourceHandle: 'case-1' })]
await getLayoutByDagre(nodes, edges)
const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifElkNode.ports).toBeUndefined()
})
it('should build ports for HumanInput nodes with multiple branches', async () => {
const nodes = [
makeWorkflowNode({
id: 'hi-1',
data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'action-1' }, { id: 'action-2' }] },
}),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ id: 'e1', source: 'hi-1', target: 'b', sourceHandle: 'action-1' }),
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }),
]
await getLayoutByDagre(nodes, edges)
const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiElkNode.ports).toHaveLength(2)
})
it('should use normal node for HumanInput with single branch', async () => {
const nodes = [
makeWorkflowNode({
id: 'hi-1',
data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'action-1' }] },
}),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [makeWorkflowEdge({ source: 'hi-1', target: 'b', sourceHandle: 'action-1' })]
await getLayoutByDagre(nodes, edges)
const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiElkNode.ports).toBeUndefined()
})
it('should normalise bounds so minX and minY start at 0', async () => {
const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })]
const result = await getLayoutByDagre(nodes, [])
expect(result.bounds.minX).toBe(0)
expect(result.bounds.minY).toBe(0)
})
it('should return empty layout when no nodes match filter', async () => {
const result = await getLayoutByDagre([], [])
expect(result.nodes.size).toBe(0)
expect(result.bounds).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 })
})
it('should sort IfElse edges with false (ELSE) last', async () => {
const nodes = [
makeWorkflowNode({
id: 'if-1',
data: {
type: BlockEnum.IfElse,
title: '',
desc: '',
cases: [
{ case_id: 'case-a', logical_operator: 'and', conditions: [] },
{ case_id: 'case-b', logical_operator: 'and', conditions: [] },
],
},
}),
makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'y', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'z', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ id: 'e-else', source: 'if-1', target: 'z', sourceHandle: 'false' }),
makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'x', sourceHandle: 'case-a' }),
makeWorkflowEdge({ id: 'e-b', source: 'if-1', target: 'y', sourceHandle: 'case-b' }),
]
await getLayoutByDagre(nodes, edges)
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
const portIds = ifNode.ports!.map((p: { id: string }) => p.id)
expect(portIds[portIds.length - 1]).toContain('false')
})
it('should sort HumanInput edges with __timeout last', async () => {
const nodes = [
makeWorkflowNode({
id: 'hi-1',
data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'a1' }, { id: 'a2' }] },
}),
makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'y', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'z', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ id: 'e-timeout', source: 'hi-1', target: 'z', sourceHandle: '__timeout' }),
makeWorkflowEdge({ id: 'e-a1', source: 'hi-1', target: 'x', sourceHandle: 'a1' }),
makeWorkflowEdge({ id: 'e-a2', source: 'hi-1', target: 'y', sourceHandle: 'a2' }),
]
await getLayoutByDagre(nodes, edges)
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
const portIds = hiNode.ports!.map((p: { id: string }) => p.id)
expect(portIds[portIds.length - 1]).toContain('__timeout')
})
it('should assign sourcePort to edges from IfElse nodes with ports', async () => {
const nodes = [
makeWorkflowNode({
id: 'if-1',
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'case-1' }] },
}),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b', sourceHandle: 'case-1' }),
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
]
await getLayoutByDagre(nodes, edges)
const portEdges = layoutCallArgs!.edges!.filter((e: Record<string, unknown>) => e.sourcePort)
expect(portEdges.length).toBeGreaterThan(0)
})
it('should handle edges without sourceHandle for ports (use index)', async () => {
const nodes = [
makeWorkflowNode({
id: 'if-1',
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [] },
}),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const e1 = makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b' })
const e2 = makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c' })
Reflect.deleteProperty(e1, 'sourceHandle')
Reflect.deleteProperty(e2, 'sourceHandle')
const result = await getLayoutByDagre(nodes, [e1, e2])
expect(result.nodes.size).toBeGreaterThan(0)
})
it('should handle collectLayout with null x/y/width/height values', async () => {
mockReturnOverride = (graph: ElkGraph) => ({
...graph,
children: (graph.children || []).map((child: ElkChild) => ({
id: child.id,
})),
})
const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })]
const result = await getLayoutByDagre(nodes, [])
const info = result.nodes.get('a')!
expect(info.x).toBe(0)
expect(info.y).toBe(0)
expect(info.width).toBe(244)
expect(info.height).toBe(100)
})
it('should parse layer index from layoutOptions', async () => {
mockReturnOverride = (graph: ElkGraph) => ({
...graph,
children: (graph.children || []).map((child: ElkChild, i: number) => ({
...child,
x: i * 300,
y: 0,
width: 244,
height: 100,
layoutOptions: {
'org.eclipse.elk.layered.layerIndex': String(i),
},
})),
})
const nodes = [
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutByDagre(nodes, [])
expect(result.nodes.get('a')!.layer).toBe(0)
expect(result.nodes.get('b')!.layer).toBe(1)
})
it('should handle collectLayout with nested children', async () => {
mockReturnOverride = (graph: ElkGraph) => ({
...graph,
children: [
{
id: 'parent-node',
x: 0,
y: 0,
width: 500,
height: 400,
children: [
{ id: 'nested-1', x: 10, y: 10, width: 200, height: 100 },
{ id: 'nested-2', x: 10, y: 120, width: 200, height: 100 },
],
},
],
})
const nodes = [
makeWorkflowNode({ id: 'parent-node', data: { type: BlockEnum.Start, title: '', desc: '' } }),
makeWorkflowNode({ id: 'nested-1', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'nested-2', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutByDagre(nodes, [])
expect(result.nodes.has('nested-1')).toBe(true)
expect(result.nodes.has('nested-2')).toBe(true)
})
it('should handle collectLayout with predicate filtering some children', async () => {
mockReturnOverride = (graph: ElkGraph) => ({
...graph,
children: [
{ id: 'visible', x: 0, y: 0, width: 200, height: 100 },
{ id: 'also-visible', x: 300, y: 0, width: 200, height: 100 },
],
})
const nodes = [
makeWorkflowNode({ id: 'visible', data: { type: BlockEnum.Start, title: '', desc: '' } }),
makeWorkflowNode({ id: 'also-visible', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutByDagre(nodes, [])
expect(result.nodes.size).toBe(2)
})
it('should sort IfElse edges where case not found in cases array', async () => {
const nodes = [
makeWorkflowNode({
id: 'if-1',
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'known-case' }] },
}),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b', sourceHandle: 'unknown-case' }),
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'other-unknown' }),
]
await getLayoutByDagre(nodes, edges)
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifNode.ports).toHaveLength(2)
})
it('should sort HumanInput edges where action not found in user_actions', async () => {
const nodes = [
makeWorkflowNode({
id: 'hi-1',
data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [{ id: 'known-action' }] },
}),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ id: 'e1', source: 'hi-1', target: 'b', sourceHandle: 'unknown-action' }),
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: 'another-unknown' }),
]
await getLayoutByDagre(nodes, edges)
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiNode.ports).toHaveLength(2)
})
it('should handle IfElse edges without handles (no sourceHandle)', async () => {
const nodes = [
makeWorkflowNode({
id: 'if-1',
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [] },
}),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const e1 = makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b' })
const e2 = makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c' })
Reflect.deleteProperty(e1, 'sourceHandle')
Reflect.deleteProperty(e2, 'sourceHandle')
await getLayoutByDagre(nodes, [e1, e2])
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifNode.ports).toHaveLength(2)
})
it('should handle HumanInput edges without handles', async () => {
const nodes = [
makeWorkflowNode({
id: 'hi-1',
data: { type: BlockEnum.HumanInput, title: '', desc: '', user_actions: [] },
}),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const e1 = makeWorkflowEdge({ id: 'e1', source: 'hi-1', target: 'b' })
const e2 = makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c' })
Reflect.deleteProperty(e1, 'sourceHandle')
Reflect.deleteProperty(e2, 'sourceHandle')
await getLayoutByDagre(nodes, [e1, e2])
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiNode.ports).toHaveLength(2)
})
it('should handle IfElse with no cases property', async () => {
const nodes = [
makeWorkflowNode({ id: 'if-1', data: { type: BlockEnum.IfElse, title: '', desc: '' } }),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ id: 'e1', source: 'if-1', target: 'b', sourceHandle: 'true' }),
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
]
await getLayoutByDagre(nodes, edges)
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifNode.ports).toHaveLength(2)
})
it('should handle HumanInput with no user_actions property', async () => {
const nodes = [
makeWorkflowNode({ id: 'hi-1', data: { type: BlockEnum.HumanInput, title: '', desc: '' } }),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ id: 'e1', source: 'hi-1', target: 'b', sourceHandle: 'action-1' }),
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }),
]
await getLayoutByDagre(nodes, edges)
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiNode.ports).toHaveLength(2)
})
it('should filter loop internal edges', async () => {
const nodes = [
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ source: 'x', target: 'y', data: { isInLoop: true, loop_id: 'loop-1' } }),
]
await getLayoutByDagre(nodes, edges)
expect(layoutCallArgs!.edges).toHaveLength(0)
})
})
describe('getLayoutForChildNodes', () => {
it('should return null when no child nodes exist', async () => {
const nodes = [
makeWorkflowNode({ id: 'parent', data: { type: BlockEnum.Iteration, title: '', desc: '' } }),
]
const result = await getLayoutForChildNodes('parent', nodes, [])
expect(result).toBeNull()
})
it('should layout child nodes of an iteration', async () => {
const nodes = [
makeWorkflowNode({ id: 'parent', data: { type: BlockEnum.Iteration, title: '', desc: '' } }),
makeWorkflowNode({
id: 'iter-start',
type: CUSTOM_ITERATION_START_NODE,
parentId: 'parent',
data: { type: BlockEnum.IterationStart, title: '', desc: '' },
}),
makeWorkflowNode({ id: 'child-1', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ source: 'iter-start', target: 'child-1', data: { isInIteration: true, iteration_id: 'parent' } }),
]
const result = await getLayoutForChildNodes('parent', nodes, edges)
expect(result).not.toBeNull()
expect(result!.nodes.size).toBe(2)
expect(result!.bounds.minX).toBe(0)
})
it('should layout child nodes of a loop', async () => {
const nodes = [
makeWorkflowNode({ id: 'loop-p', data: { type: BlockEnum.Loop, title: '', desc: '' } }),
makeWorkflowNode({
id: 'loop-start',
type: CUSTOM_LOOP_START_NODE,
parentId: 'loop-p',
data: { type: BlockEnum.LoopStart, title: '', desc: '' },
}),
makeWorkflowNode({ id: 'loop-child', parentId: 'loop-p', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ source: 'loop-start', target: 'loop-child', data: { isInLoop: true, loop_id: 'loop-p' } }),
]
const result = await getLayoutForChildNodes('loop-p', nodes, edges)
expect(result).not.toBeNull()
expect(result!.nodes.size).toBe(2)
})
it('should only include edges belonging to the parent iteration', async () => {
const nodes = [
makeWorkflowNode({ id: 'child-a', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'child-b', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
makeWorkflowEdge({ source: 'child-a', target: 'child-b', data: { isInIteration: true, iteration_id: 'parent' } }),
makeWorkflowEdge({ source: 'x', target: 'y', data: { isInIteration: true, iteration_id: 'other-parent' } }),
]
await getLayoutForChildNodes('parent', nodes, edges)
expect(layoutCallArgs!.edges).toHaveLength(1)
})
it('should adjust start node position when x exceeds horizontal padding', async () => {
mockReturnOverride = (graph: ElkGraph) => ({
...graph,
children: (graph.children || []).map((child: ElkChild, i: number) => ({
...child,
x: 200 + i * 300,
y: 50,
width: 244,
height: 100,
})),
})
const nodes = [
makeWorkflowNode({
id: 'start',
type: CUSTOM_ITERATION_START_NODE,
parentId: 'parent',
data: { type: BlockEnum.IterationStart, title: '', desc: '' },
}),
makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutForChildNodes('parent', nodes, [])
expect(result).not.toBeNull()
const startInfo = result!.nodes.get('start')!
expect(startInfo.x).toBeLessThanOrEqual(NODE_LAYOUT_HORIZONTAL_PADDING / 1.5 + 1)
})
it('should not shift when start node x is already within padding', async () => {
mockReturnOverride = (graph: ElkGraph) => ({
...graph,
children: (graph.children || []).map((child: ElkChild, i: number) => ({
...child,
x: 10 + i * 300,
y: 50,
width: 244,
height: 100,
})),
})
const nodes = [
makeWorkflowNode({
id: 'start',
type: CUSTOM_ITERATION_START_NODE,
parentId: 'parent',
data: { type: BlockEnum.IterationStart, title: '', desc: '' },
}),
makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutForChildNodes('parent', nodes, [])
expect(result).not.toBeNull()
})
it('should handle child nodes identified by data type LoopStart', async () => {
const nodes = [
makeWorkflowNode({ id: 'ls', parentId: 'parent', data: { type: BlockEnum.LoopStart, title: '', desc: '' } }),
makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutForChildNodes('parent', nodes, [])
expect(result).not.toBeNull()
expect(result!.nodes.size).toBe(2)
})
it('should handle child nodes identified by data type IterationStart', async () => {
const nodes = [
makeWorkflowNode({ id: 'is', parentId: 'parent', data: { type: BlockEnum.IterationStart, title: '', desc: '' } }),
makeWorkflowNode({ id: 'child', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutForChildNodes('parent', nodes, [])
expect(result).not.toBeNull()
expect(result!.nodes.size).toBe(2)
})
it('should handle no start node in child layout', async () => {
const nodes = [
makeWorkflowNode({ id: 'c1', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'c2', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutForChildNodes('parent', nodes, [])
expect(result).not.toBeNull()
expect(result!.nodes.size).toBe(2)
})
it('should return original layout when bounds are not finite', async () => {
mockReturnOverride = (graph: ElkGraph) => ({
...graph,
children: [],
})
const nodes = [
makeWorkflowNode({ id: 'c1', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = await getLayoutForChildNodes('parent', nodes, [])
expect(result).not.toBeNull()
expect(result!.bounds).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 })
})
})

View File

@ -0,0 +1,70 @@
import { BlockClassificationEnum } from '../../block-selector/types'
import { BlockEnum } from '../../types'
import { genNodeMetaData } from '../gen-node-meta-data'
describe('genNodeMetaData', () => {
it('should generate metadata with all required fields', () => {
const result = genNodeMetaData({
sort: 1,
type: BlockEnum.LLM,
title: 'LLM Node',
})
expect(result).toEqual({
classification: BlockClassificationEnum.Default,
sort: 1,
type: BlockEnum.LLM,
title: 'LLM Node',
author: 'Dify',
helpLinkUri: BlockEnum.LLM,
isRequired: false,
isUndeletable: false,
isStart: false,
isSingleton: false,
isTypeFixed: false,
})
})
it('should use custom values when provided', () => {
const result = genNodeMetaData({
classification: BlockClassificationEnum.Logic,
sort: 5,
type: BlockEnum.Start,
title: 'Start',
author: 'Custom',
helpLinkUri: 'code',
isRequired: true,
isUndeletable: true,
isStart: true,
isSingleton: true,
isTypeFixed: true,
})
expect(result.classification).toBe(BlockClassificationEnum.Logic)
expect(result.author).toBe('Custom')
expect(result.helpLinkUri).toBe('code')
expect(result.isRequired).toBe(true)
expect(result.isUndeletable).toBe(true)
expect(result.isStart).toBe(true)
expect(result.isSingleton).toBe(true)
expect(result.isTypeFixed).toBe(true)
})
it('should default title to empty string', () => {
const result = genNodeMetaData({
sort: 0,
type: BlockEnum.Code,
})
expect(result.title).toBe('')
})
it('should fall back helpLinkUri to type when not provided', () => {
const result = genNodeMetaData({
sort: 0,
type: BlockEnum.HttpRequest,
})
expect(result.helpLinkUri).toBe(BlockEnum.HttpRequest)
})
})

View File

@ -0,0 +1,161 @@
import {
scrollToWorkflowNode,
selectWorkflowNode,
setupNodeSelectionListener,
setupScrollToNodeListener,
} from '../node-navigation'
describe('selectWorkflowNode', () => {
it('should dispatch workflow:select-node event with correct detail', () => {
const handler = vi.fn()
document.addEventListener('workflow:select-node', handler)
selectWorkflowNode('node-1', true)
expect(handler).toHaveBeenCalledTimes(1)
const event = handler.mock.calls[0][0] as CustomEvent
expect(event.detail).toEqual({ nodeId: 'node-1', focus: true })
document.removeEventListener('workflow:select-node', handler)
})
it('should default focus to false', () => {
const handler = vi.fn()
document.addEventListener('workflow:select-node', handler)
selectWorkflowNode('node-2')
const event = handler.mock.calls[0][0] as CustomEvent
expect(event.detail.focus).toBe(false)
document.removeEventListener('workflow:select-node', handler)
})
})
describe('scrollToWorkflowNode', () => {
it('should dispatch workflow:scroll-to-node event', () => {
const handler = vi.fn()
document.addEventListener('workflow:scroll-to-node', handler)
scrollToWorkflowNode('node-5')
expect(handler).toHaveBeenCalledTimes(1)
const event = handler.mock.calls[0][0] as CustomEvent
expect(event.detail).toEqual({ nodeId: 'node-5' })
document.removeEventListener('workflow:scroll-to-node', handler)
})
})
describe('setupNodeSelectionListener', () => {
it('should call handleNodeSelect when event is dispatched', () => {
const handleNodeSelect = vi.fn()
const cleanup = setupNodeSelectionListener(handleNodeSelect)
selectWorkflowNode('node-10')
expect(handleNodeSelect).toHaveBeenCalledWith('node-10')
cleanup()
})
it('should also scroll to node when focus is true', () => {
vi.useFakeTimers()
const handleNodeSelect = vi.fn()
const scrollHandler = vi.fn()
document.addEventListener('workflow:scroll-to-node', scrollHandler)
const cleanup = setupNodeSelectionListener(handleNodeSelect)
selectWorkflowNode('node-11', true)
expect(handleNodeSelect).toHaveBeenCalledWith('node-11')
vi.advanceTimersByTime(150)
expect(scrollHandler).toHaveBeenCalledTimes(1)
cleanup()
document.removeEventListener('workflow:scroll-to-node', scrollHandler)
vi.useRealTimers()
})
it('should not call handler after cleanup', () => {
const handleNodeSelect = vi.fn()
const cleanup = setupNodeSelectionListener(handleNodeSelect)
cleanup()
selectWorkflowNode('node-12')
expect(handleNodeSelect).not.toHaveBeenCalled()
})
it('should ignore events with empty nodeId', () => {
const handleNodeSelect = vi.fn()
const cleanup = setupNodeSelectionListener(handleNodeSelect)
const event = new CustomEvent('workflow:select-node', {
detail: { nodeId: '', focus: false },
})
document.dispatchEvent(event)
expect(handleNodeSelect).not.toHaveBeenCalled()
cleanup()
})
})
describe('setupScrollToNodeListener', () => {
it('should call reactflow.setCenter when scroll event targets an existing node', () => {
const nodes = [{ id: 'n1', position: { x: 100, y: 200 } }]
const reactflow = { setCenter: vi.fn() }
const cleanup = setupScrollToNodeListener(nodes, reactflow)
scrollToWorkflowNode('n1')
expect(reactflow.setCenter).toHaveBeenCalledTimes(1)
const [targetX, targetY, options] = reactflow.setCenter.mock.calls[0]
expect(targetX).toBeGreaterThan(100)
expect(targetY).toBeGreaterThan(200)
expect(options).toEqual({ zoom: 1, duration: 800 })
cleanup()
})
it('should not call setCenter when node is not found', () => {
const nodes = [{ id: 'n1', position: { x: 0, y: 0 } }]
const reactflow = { setCenter: vi.fn() }
const cleanup = setupScrollToNodeListener(nodes, reactflow)
scrollToWorkflowNode('non-existent')
expect(reactflow.setCenter).not.toHaveBeenCalled()
cleanup()
})
it('should not react after cleanup', () => {
const nodes = [{ id: 'n1', position: { x: 0, y: 0 } }]
const reactflow = { setCenter: vi.fn() }
const cleanup = setupScrollToNodeListener(nodes, reactflow)
cleanup()
scrollToWorkflowNode('n1')
expect(reactflow.setCenter).not.toHaveBeenCalled()
})
it('should ignore events with empty nodeId', () => {
const nodes = [{ id: 'n1', position: { x: 0, y: 0 } }]
const reactflow = { setCenter: vi.fn() }
const cleanup = setupScrollToNodeListener(nodes, reactflow)
const event = new CustomEvent('workflow:scroll-to-node', {
detail: { nodeId: '' },
})
document.dispatchEvent(event)
expect(reactflow.setCenter).not.toHaveBeenCalled()
cleanup()
})
})

View File

@ -0,0 +1,219 @@
import type { IterationNodeType } from '../../nodes/iteration/types'
import type { LoopNodeType } from '../../nodes/loop/types'
import type { CommonNodeType, Node } from '../../types'
import { CUSTOM_NODE, ITERATION_CHILDREN_Z_INDEX, ITERATION_NODE_Z_INDEX, LOOP_CHILDREN_Z_INDEX, LOOP_NODE_Z_INDEX } from '../../constants'
import { CUSTOM_ITERATION_START_NODE } from '../../nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '../../nodes/loop-start/constants'
import { CUSTOM_SIMPLE_NODE } from '../../simple-node/constants'
import { BlockEnum } from '../../types'
import {
generateNewNode,
genNewNodeTitleFromOld,
getIterationStartNode,
getLoopStartNode,
getNestedNodePosition,
getNodeCustomTypeByNodeDataType,
getTopLeftNodePosition,
hasRetryNode,
} from '../node'
describe('generateNewNode', () => {
it('should create a basic node with default CUSTOM_NODE type', () => {
const { newNode } = generateNewNode({
data: { title: 'Test', desc: '', type: BlockEnum.Code } as CommonNodeType,
position: { x: 100, y: 200 },
})
expect(newNode.type).toBe(CUSTOM_NODE)
expect(newNode.position).toEqual({ x: 100, y: 200 })
expect(newNode.data.title).toBe('Test')
expect(newNode.id).toBeDefined()
})
it('should use provided id when given', () => {
const { newNode } = generateNewNode({
id: 'custom-id',
data: { title: 'Test', desc: '', type: BlockEnum.Code } as CommonNodeType,
position: { x: 0, y: 0 },
})
expect(newNode.id).toBe('custom-id')
})
it('should set ITERATION_NODE_Z_INDEX for iteration nodes', () => {
const { newNode } = generateNewNode({
data: { title: 'Iter', desc: '', type: BlockEnum.Iteration } as CommonNodeType,
position: { x: 0, y: 0 },
})
expect(newNode.zIndex).toBe(ITERATION_NODE_Z_INDEX)
})
it('should set LOOP_NODE_Z_INDEX for loop nodes', () => {
const { newNode } = generateNewNode({
data: { title: 'Loop', desc: '', type: BlockEnum.Loop } as CommonNodeType,
position: { x: 0, y: 0 },
})
expect(newNode.zIndex).toBe(LOOP_NODE_Z_INDEX)
})
it('should create an iteration start node for iteration type', () => {
const { newNode, newIterationStartNode } = generateNewNode({
id: 'iter-1',
data: { title: 'Iter', desc: '', type: BlockEnum.Iteration } as CommonNodeType,
position: { x: 0, y: 0 },
})
expect(newIterationStartNode).toBeDefined()
expect(newIterationStartNode!.id).toBe('iter-1start')
expect(newIterationStartNode!.data.type).toBe(BlockEnum.IterationStart)
expect((newNode.data as IterationNodeType).start_node_id).toBe('iter-1start')
expect((newNode.data as CommonNodeType)._children).toEqual([
{ nodeId: 'iter-1start', nodeType: BlockEnum.IterationStart },
])
})
it('should create a loop start node for loop type', () => {
const { newNode, newLoopStartNode } = generateNewNode({
id: 'loop-1',
data: { title: 'Loop', desc: '', type: BlockEnum.Loop } as CommonNodeType,
position: { x: 0, y: 0 },
})
expect(newLoopStartNode).toBeDefined()
expect(newLoopStartNode!.id).toBe('loop-1start')
expect(newLoopStartNode!.data.type).toBe(BlockEnum.LoopStart)
expect((newNode.data as LoopNodeType).start_node_id).toBe('loop-1start')
expect((newNode.data as CommonNodeType)._children).toEqual([
{ nodeId: 'loop-1start', nodeType: BlockEnum.LoopStart },
])
})
it('should not create child start nodes for regular types', () => {
const result = generateNewNode({
data: { title: 'Code', desc: '', type: BlockEnum.Code } as CommonNodeType,
position: { x: 0, y: 0 },
})
expect(result.newIterationStartNode).toBeUndefined()
expect(result.newLoopStartNode).toBeUndefined()
})
})
describe('getIterationStartNode', () => {
it('should create a properly configured iteration start node', () => {
const node = getIterationStartNode('parent-iter')
expect(node.id).toBe('parent-iterstart')
expect(node.type).toBe(CUSTOM_ITERATION_START_NODE)
expect(node.data.type).toBe(BlockEnum.IterationStart)
expect(node.data.isInIteration).toBe(true)
expect(node.parentId).toBe('parent-iter')
expect(node.selectable).toBe(false)
expect(node.draggable).toBe(false)
expect(node.zIndex).toBe(ITERATION_CHILDREN_Z_INDEX)
expect(node.position).toEqual({ x: 24, y: 68 })
})
})
describe('getLoopStartNode', () => {
it('should create a properly configured loop start node', () => {
const node = getLoopStartNode('parent-loop')
expect(node.id).toBe('parent-loopstart')
expect(node.type).toBe(CUSTOM_LOOP_START_NODE)
expect(node.data.type).toBe(BlockEnum.LoopStart)
expect(node.data.isInLoop).toBe(true)
expect(node.parentId).toBe('parent-loop')
expect(node.selectable).toBe(false)
expect(node.draggable).toBe(false)
expect(node.zIndex).toBe(LOOP_CHILDREN_Z_INDEX)
expect(node.position).toEqual({ x: 24, y: 68 })
})
})
describe('genNewNodeTitleFromOld', () => {
it('should append (1) to a title without a counter', () => {
expect(genNewNodeTitleFromOld('LLM')).toBe('LLM (1)')
})
it('should increment existing counter', () => {
expect(genNewNodeTitleFromOld('LLM (1)')).toBe('LLM (2)')
expect(genNewNodeTitleFromOld('LLM (99)')).toBe('LLM (100)')
})
it('should handle titles with spaces around counter', () => {
expect(genNewNodeTitleFromOld('My Node (3)')).toBe('My Node (4)')
})
it('should handle titles that happen to contain parentheses in the name', () => {
expect(genNewNodeTitleFromOld('Node (special) name')).toBe('Node (special) name (1)')
})
})
describe('getTopLeftNodePosition', () => {
it('should return the minimum x and y from nodes', () => {
const nodes = [
{ position: { x: 100, y: 50 } },
{ position: { x: 20, y: 200 } },
{ position: { x: 50, y: 10 } },
] as Node[]
expect(getTopLeftNodePosition(nodes)).toEqual({ x: 20, y: 10 })
})
it('should handle a single node', () => {
const nodes = [{ position: { x: 42, y: 99 } }] as Node[]
expect(getTopLeftNodePosition(nodes)).toEqual({ x: 42, y: 99 })
})
it('should handle negative positions', () => {
const nodes = [
{ position: { x: -10, y: -20 } },
{ position: { x: 5, y: -30 } },
] as Node[]
expect(getTopLeftNodePosition(nodes)).toEqual({ x: -10, y: -30 })
})
})
describe('getNestedNodePosition', () => {
it('should compute relative position of child to parent', () => {
const node = { position: { x: 150, y: 200 } } as Node
const parent = { position: { x: 100, y: 80 } } as Node
expect(getNestedNodePosition(node, parent)).toEqual({ x: 50, y: 120 })
})
})
describe('hasRetryNode', () => {
it.each([BlockEnum.LLM, BlockEnum.Tool, BlockEnum.HttpRequest, BlockEnum.Code])(
'should return true for %s',
(nodeType) => {
expect(hasRetryNode(nodeType)).toBe(true)
},
)
it.each([BlockEnum.Start, BlockEnum.End, BlockEnum.IfElse, BlockEnum.Iteration])(
'should return false for %s',
(nodeType) => {
expect(hasRetryNode(nodeType)).toBe(false)
},
)
it('should return false when nodeType is undefined', () => {
expect(hasRetryNode()).toBe(false)
})
})
describe('getNodeCustomTypeByNodeDataType', () => {
it('should return CUSTOM_SIMPLE_NODE for LoopEnd', () => {
expect(getNodeCustomTypeByNodeDataType(BlockEnum.LoopEnd)).toBe(CUSTOM_SIMPLE_NODE)
})
it('should return undefined for other types', () => {
expect(getNodeCustomTypeByNodeDataType(BlockEnum.Code)).toBeUndefined()
expect(getNodeCustomTypeByNodeDataType(BlockEnum.LLM)).toBeUndefined()
})
})

View File

@ -0,0 +1,191 @@
import type { ToolNodeType } from '../../nodes/tool/types'
import type { ToolWithProvider } from '../../types'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '../../types'
import { CHUNK_TYPE_MAP, getToolCheckParams, wrapStructuredVarItem } from '../tool'
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
toolParametersToFormSchemas: vi.fn((params: Array<Record<string, unknown>>) =>
params.map(p => ({
variable: p.name,
label: p.label || { en_US: p.name },
type: p.type || 'string',
required: p.required ?? false,
form: p.form ?? 'llm',
}))),
}))
vi.mock('@/utils', () => ({
canFindTool: vi.fn((collectionId: string, providerId: string) => collectionId === providerId),
}))
function createToolData(overrides: Partial<ToolNodeType> = {}): ToolNodeType {
return {
title: 'Tool',
desc: '',
type: BlockEnum.Tool,
provider_id: 'builtin-search',
provider_type: CollectionType.builtIn,
tool_name: 'google_search',
tool_parameters: {},
tool_configurations: {},
...overrides,
} as ToolNodeType
}
function createToolCollection(overrides: Partial<ToolWithProvider> = {}): ToolWithProvider {
return {
id: 'builtin-search',
name: 'Search',
tools: [
{
name: 'google_search',
parameters: [
{ name: 'query', label: { en_US: 'Query', zh_Hans: '查询' }, type: 'string', required: true, form: 'llm' },
{ name: 'api_key', label: { en_US: 'API Key' }, type: 'string', required: true, form: 'credential' },
],
},
],
allow_delete: true,
is_team_authorization: false,
...overrides,
} as unknown as ToolWithProvider
}
describe('getToolCheckParams', () => {
it('should separate llm inputs from settings', () => {
const result = getToolCheckParams(
createToolData(),
[createToolCollection()],
[],
[],
'en_US',
)
expect(result.toolInputsSchema).toEqual([
{ label: 'Query', variable: 'query', type: 'string', required: true },
])
expect(result.toolSettingSchema).toHaveLength(1)
expect(result.toolSettingSchema[0].variable).toBe('api_key')
})
it('should mark notAuthed for builtin tools without team auth', () => {
const result = getToolCheckParams(
createToolData(),
[createToolCollection()],
[],
[],
'en_US',
)
expect(result.notAuthed).toBe(true)
})
it('should mark authed when is_team_authorization is true', () => {
const result = getToolCheckParams(
createToolData(),
[createToolCollection({ is_team_authorization: true })],
[],
[],
'en_US',
)
expect(result.notAuthed).toBe(false)
})
it('should use custom tools when provider_type is custom', () => {
const customTool = createToolCollection({ id: 'custom-tool' })
const result = getToolCheckParams(
createToolData({ provider_id: 'custom-tool', provider_type: CollectionType.custom }),
[],
[customTool],
[],
'en_US',
)
expect(result.toolInputsSchema).toHaveLength(1)
})
it('should return empty schemas when tool is not found', () => {
const result = getToolCheckParams(
createToolData({ provider_id: 'non-existent' }),
[],
[],
[],
'en_US',
)
expect(result.toolInputsSchema).toEqual([])
expect(result.toolSettingSchema).toEqual([])
})
it('should include language in result', () => {
const result = getToolCheckParams(createToolData(), [createToolCollection()], [], [], 'zh_Hans')
expect(result.language).toBe('zh_Hans')
})
it('should use workflowTools when provider_type is workflow', () => {
const workflowTool = createToolCollection({ id: 'wf-tool' })
const result = getToolCheckParams(
createToolData({ provider_id: 'wf-tool', provider_type: CollectionType.workflow }),
[],
[],
[workflowTool],
'en_US',
)
expect(result.toolInputsSchema).toHaveLength(1)
})
it('should fallback to en_US label when language key is missing', () => {
const tool = createToolCollection({
tools: [
{
name: 'google_search',
parameters: [
{ name: 'query', label: { en_US: 'Query' }, type: 'string', required: true, form: 'llm' },
],
},
],
} as Partial<ToolWithProvider>)
const result = getToolCheckParams(
createToolData(),
[tool],
[],
[],
'ja_JP',
)
expect(result.toolInputsSchema[0].label).toBe('Query')
})
})
describe('CHUNK_TYPE_MAP', () => {
it('should contain all expected chunk type mappings', () => {
expect(CHUNK_TYPE_MAP).toEqual({
general_chunks: 'GeneralStructureChunk',
parent_child_chunks: 'ParentChildStructureChunk',
qa_chunks: 'QAStructureChunk',
})
})
})
describe('wrapStructuredVarItem', () => {
it('should wrap an output item into StructuredOutput format', () => {
const outputItem = {
name: 'result',
value: { type: 'string', description: 'test' },
}
const result = wrapStructuredVarItem(outputItem, 'json_schema')
expect(result.schema.type).toBe('object')
expect(result.schema.additionalProperties).toBe(false)
expect(result.schema.properties.result).toEqual({
type: 'string',
description: 'test',
schemaType: 'json_schema',
})
})
})

View File

@ -0,0 +1,132 @@
import type { TriggerWithProvider } from '../../block-selector/types'
import type { PluginTriggerNodeType } from '../../nodes/trigger-plugin/types'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '../../types'
import { getTriggerCheckParams } from '../trigger'
function createTriggerData(overrides: Partial<PluginTriggerNodeType> = {}): PluginTriggerNodeType {
return {
title: 'Trigger',
desc: '',
type: BlockEnum.TriggerPlugin,
provider_id: 'provider-1',
provider_type: CollectionType.builtIn,
provider_name: 'my-provider',
event_name: 'on_message',
event_label: 'On Message',
event_parameters: {},
event_configurations: {},
output_schema: {},
...overrides,
} as PluginTriggerNodeType
}
function createTriggerProvider(overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider {
return {
id: 'provider-1',
name: 'my-provider',
plugin_id: 'plugin-1',
events: [
{
name: 'on_message',
label: { en_US: 'On Message', zh_Hans: '收到消息' },
parameters: [
{
name: 'channel',
label: { en_US: 'Channel', zh_Hans: '频道' },
required: true,
},
{
name: 'filter',
label: { en_US: 'Filter' },
required: false,
},
],
},
],
...overrides,
} as unknown as TriggerWithProvider
}
describe('getTriggerCheckParams', () => {
it('should return empty schema when triggerProviders is undefined', () => {
const result = getTriggerCheckParams(createTriggerData(), undefined, 'en_US')
expect(result).toEqual({
triggerInputsSchema: [],
isReadyForCheckValid: false,
})
})
it('should match provider by name and extract parameters', () => {
const result = getTriggerCheckParams(
createTriggerData(),
[createTriggerProvider()],
'en_US',
)
expect(result.isReadyForCheckValid).toBe(true)
expect(result.triggerInputsSchema).toEqual([
{ variable: 'channel', label: 'Channel', required: true },
{ variable: 'filter', label: 'Filter', required: false },
])
})
it('should use the requested language for labels', () => {
const result = getTriggerCheckParams(
createTriggerData(),
[createTriggerProvider()],
'zh_Hans',
)
expect(result.triggerInputsSchema[0].label).toBe('频道')
})
it('should fall back to en_US when language label is missing', () => {
const result = getTriggerCheckParams(
createTriggerData(),
[createTriggerProvider()],
'ja_JP',
)
expect(result.triggerInputsSchema[0].label).toBe('Channel')
})
it('should fall back to parameter name when no labels exist', () => {
const provider = createTriggerProvider({
events: [{
name: 'on_message',
label: { en_US: 'On Message' },
parameters: [{ name: 'raw_param' }],
}],
} as Partial<TriggerWithProvider>)
const result = getTriggerCheckParams(createTriggerData(), [provider], 'en_US')
expect(result.triggerInputsSchema[0].label).toBe('raw_param')
})
it('should match provider by provider_id', () => {
const trigger = createTriggerData({ provider_name: 'different-name', provider_id: 'provider-1' })
const provider = createTriggerProvider({ name: 'other-name', id: 'provider-1' })
const result = getTriggerCheckParams(trigger, [provider], 'en_US')
expect(result.isReadyForCheckValid).toBe(true)
})
it('should match provider by plugin_id', () => {
const trigger = createTriggerData({ provider_name: 'x', provider_id: 'plugin-1' })
const provider = createTriggerProvider({ name: 'y', id: 'z', plugin_id: 'plugin-1' })
const result = getTriggerCheckParams(trigger, [provider], 'en_US')
expect(result.isReadyForCheckValid).toBe(true)
})
it('should return empty schema when event is not found', () => {
const trigger = createTriggerData({ event_name: 'non_existent_event' })
const result = getTriggerCheckParams(trigger, [createTriggerProvider()], 'en_US')
expect(result.triggerInputsSchema).toEqual([])
expect(result.isReadyForCheckValid).toBe(true)
})
})

View File

@ -0,0 +1,55 @@
import { BlockEnum } from '../../types'
import { isExceptionVariable, variableTransformer } from '../variable'
describe('variableTransformer', () => {
describe('string → array (template to selector)', () => {
it('should parse a simple template variable', () => {
expect(variableTransformer('{{#node1.output#}}')).toEqual(['node1', 'output'])
})
it('should parse a deeply nested path', () => {
expect(variableTransformer('{{#node1.data.items.0.name#}}')).toEqual(['node1', 'data', 'items', '0', 'name'])
})
it('should handle a single-segment path', () => {
expect(variableTransformer('{{#value#}}')).toEqual(['value'])
})
})
describe('array → string (selector to template)', () => {
it('should join an array into a template variable', () => {
expect(variableTransformer(['node1', 'output'])).toBe('{{#node1.output#}}')
})
it('should join a single-element array', () => {
expect(variableTransformer(['value'])).toBe('{{#value#}}')
})
})
})
describe('isExceptionVariable', () => {
const errorHandleTypes = [BlockEnum.LLM, BlockEnum.Tool, BlockEnum.HttpRequest, BlockEnum.Code, BlockEnum.Agent]
it.each(errorHandleTypes)('should return true for error_message with %s node type', (nodeType) => {
expect(isExceptionVariable('error_message', nodeType)).toBe(true)
})
it.each(errorHandleTypes)('should return true for error_type with %s node type', (nodeType) => {
expect(isExceptionVariable('error_type', nodeType)).toBe(true)
})
it('should return false for error_message with non-error-handle node types', () => {
expect(isExceptionVariable('error_message', BlockEnum.Start)).toBe(false)
expect(isExceptionVariable('error_message', BlockEnum.End)).toBe(false)
expect(isExceptionVariable('error_message', BlockEnum.IfElse)).toBe(false)
})
it('should return false for normal variables with error-handle node types', () => {
expect(isExceptionVariable('output', BlockEnum.LLM)).toBe(false)
expect(isExceptionVariable('text', BlockEnum.Tool)).toBe(false)
})
it('should return false when nodeType is undefined', () => {
expect(isExceptionVariable('error_message')).toBe(false)
})
})

View File

@ -0,0 +1,89 @@
import { createNode, createStartNode, createTriggerNode, resetFixtureCounters } from '../../__tests__/fixtures'
import { BlockEnum } from '../../types'
import { getWorkflowEntryNode, isTriggerWorkflow, isWorkflowEntryNode } from '../workflow-entry'
beforeEach(() => {
resetFixtureCounters()
})
describe('getWorkflowEntryNode', () => {
it('should return the trigger node when present', () => {
const nodes = [
createStartNode({ id: 'start' }),
createTriggerNode(BlockEnum.TriggerWebhook, { id: 'trigger' }),
createNode({ id: 'code', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const entry = getWorkflowEntryNode(nodes)
expect(entry?.id).toBe('trigger')
})
it('should return the start node when no trigger node exists', () => {
const nodes = [
createStartNode({ id: 'start' }),
createNode({ id: 'code', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const entry = getWorkflowEntryNode(nodes)
expect(entry?.id).toBe('start')
})
it('should return undefined when no entry node exists', () => {
const nodes = [
createNode({ id: 'code', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
expect(getWorkflowEntryNode(nodes)).toBeUndefined()
})
it('should prefer trigger node over start node', () => {
const nodes = [
createStartNode({ id: 'start' }),
createTriggerNode(BlockEnum.TriggerSchedule, { id: 'schedule' }),
]
const entry = getWorkflowEntryNode(nodes)
expect(entry?.id).toBe('schedule')
})
})
describe('isWorkflowEntryNode', () => {
it('should return true for Start', () => {
expect(isWorkflowEntryNode(BlockEnum.Start)).toBe(true)
})
it.each([BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin])(
'should return true for %s',
(type) => {
expect(isWorkflowEntryNode(type)).toBe(true)
},
)
it('should return false for non-entry types', () => {
expect(isWorkflowEntryNode(BlockEnum.Code)).toBe(false)
expect(isWorkflowEntryNode(BlockEnum.LLM)).toBe(false)
expect(isWorkflowEntryNode(BlockEnum.End)).toBe(false)
})
})
describe('isTriggerWorkflow', () => {
it('should return true when nodes contain a trigger node', () => {
const nodes = [
createStartNode(),
createTriggerNode(BlockEnum.TriggerWebhook),
]
expect(isTriggerWorkflow(nodes)).toBe(true)
})
it('should return false when no trigger nodes exist', () => {
const nodes = [
createStartNode(),
createNode({ data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
expect(isTriggerWorkflow(nodes)).toBe(false)
})
it('should return false for empty nodes', () => {
expect(isTriggerWorkflow([])).toBe(false)
})
})

View File

@ -0,0 +1,742 @@
import type { IfElseNodeType } from '../../nodes/if-else/types'
import type { IterationNodeType } from '../../nodes/iteration/types'
import type { KnowledgeRetrievalNodeType } from '../../nodes/knowledge-retrieval/types'
import type { LLMNodeType } from '../../nodes/llm/types'
import type { LoopNodeType } from '../../nodes/loop/types'
import type { ParameterExtractorNodeType } from '../../nodes/parameter-extractor/types'
import type { ToolNodeType } from '../../nodes/tool/types'
import type {
Edge,
Node,
} from '@/app/components/workflow/types'
import { CUSTOM_NODE, DEFAULT_RETRY_INTERVAL, DEFAULT_RETRY_MAX } from '@/app/components/workflow/constants'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { BlockEnum, ErrorHandleMode } from '@/app/components/workflow/types'
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
import { initialEdges, initialNodes, preprocessNodesAndEdges } from '../workflow-init'
vi.mock('reactflow', async (importOriginal) => {
const actual = await importOriginal<typeof import('reactflow')>()
return {
...actual,
getConnectedEdges: vi.fn((_nodes: Node[], edges: Edge[]) => {
const node = _nodes[0]
return edges.filter(e => e.source === node.id || e.target === node.id)
}),
}
})
vi.mock('@/utils', () => ({
correctModelProvider: vi.fn((p: string) => p ? `corrected/${p}` : ''),
}))
vi.mock('@/app/components/workflow/nodes/if-else/utils', () => ({
branchNameCorrect: vi.fn((branches: Array<Record<string, unknown>>) => branches.map((b: Record<string, unknown>, i: number) => ({
...b,
name: b.id === 'false' ? 'ELSE' : branches.length === 2 ? 'IF' : `CASE ${i + 1}`,
}))),
}))
beforeEach(() => {
resetFixtureCounters()
vi.clearAllMocks()
})
describe('preprocessNodesAndEdges', () => {
it('should return origin nodes and edges when no iteration/loop nodes exist', () => {
const nodes = [createNode({ data: { type: BlockEnum.Code, title: '', desc: '' } })]
const result = preprocessNodesAndEdges(nodes, [])
expect(result).toEqual({ nodes, edges: [] })
})
it('should add iteration start node when iteration has no start_node_id', () => {
const nodes = [
createNode({ id: 'iter-1', data: { type: BlockEnum.Iteration, title: '', desc: '' } }),
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.IterationStart)
expect(startNodes).toHaveLength(1)
expect(startNodes[0].parentId).toBe('iter-1')
})
it('should add iteration start node when iteration has start_node_id but node type does not match', () => {
const nodes = [
createNode({
id: 'iter-1',
data: { type: BlockEnum.Iteration, title: '', desc: '', start_node_id: 'some-node' },
}),
createNode({ id: 'some-node', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.IterationStart)
expect(startNodes).toHaveLength(1)
})
it('should not add iteration start node when one already exists with correct type', () => {
const nodes = [
createNode({
id: 'iter-1',
data: { type: BlockEnum.Iteration, title: '', desc: '', start_node_id: 'iter-start' },
}),
createNode({
id: 'iter-start',
type: CUSTOM_ITERATION_START_NODE,
data: { type: BlockEnum.IterationStart, title: '', desc: '' },
}),
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
expect(result.nodes).toEqual(nodes)
})
it('should add loop start node when loop has no start_node_id', () => {
const nodes = [
createNode({ id: 'loop-1', data: { type: BlockEnum.Loop, title: '', desc: '' } }),
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.LoopStart)
expect(startNodes).toHaveLength(1)
})
it('should add loop start node when loop has start_node_id but type does not match', () => {
const nodes = [
createNode({
id: 'loop-1',
data: { type: BlockEnum.Loop, title: '', desc: '', start_node_id: 'some-node' },
}),
createNode({ id: 'some-node', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
const startNodes = result.nodes.filter(n => n.data.type === BlockEnum.LoopStart)
expect(startNodes).toHaveLength(1)
})
it('should not add loop start node when one already exists with correct type', () => {
const nodes = [
createNode({
id: 'loop-1',
data: { type: BlockEnum.Loop, title: '', desc: '', start_node_id: 'loop-start' },
}),
createNode({
id: 'loop-start',
type: CUSTOM_LOOP_START_NODE,
data: { type: BlockEnum.LoopStart, title: '', desc: '' },
}),
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
expect(result.nodes).toEqual(nodes)
})
it('should create edges linking new start nodes to existing start nodes', () => {
const nodes = [
createNode({
id: 'iter-1',
data: { type: BlockEnum.Iteration, title: '', desc: '', start_node_id: 'child-1' },
}),
createNode({
id: 'child-1',
parentId: 'iter-1',
data: { type: BlockEnum.Code, title: '', desc: '' },
}),
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
const newEdges = result.edges
expect(newEdges).toHaveLength(1)
expect(newEdges[0].target).toBe('child-1')
expect(newEdges[0].data!.sourceType).toBe(BlockEnum.IterationStart)
expect(newEdges[0].data!.isInIteration).toBe(true)
})
it('should create edges for loop nodes with start_node_id', () => {
const nodes = [
createNode({
id: 'loop-1',
data: { type: BlockEnum.Loop, title: '', desc: '', start_node_id: 'child-1' },
}),
createNode({
id: 'child-1',
parentId: 'loop-1',
data: { type: BlockEnum.Code, title: '', desc: '' },
}),
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
const newEdges = result.edges
expect(newEdges).toHaveLength(1)
expect(newEdges[0].target).toBe('child-1')
expect(newEdges[0].data!.isInLoop).toBe(true)
})
it('should update start_node_id on iteration and loop nodes', () => {
const nodes = [
createNode({
id: 'iter-1',
data: { type: BlockEnum.Iteration, title: '', desc: '' },
}),
createNode({
id: 'loop-1',
data: { type: BlockEnum.Loop, title: '', desc: '' },
}),
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
const iterNode = result.nodes.find(n => n.id === 'iter-1')
const loopNode = result.nodes.find(n => n.id === 'loop-1')
expect((iterNode!.data as IterationNodeType).start_node_id).toBeTruthy()
expect((loopNode!.data as LoopNodeType).start_node_id).toBeTruthy()
})
})
describe('initialNodes', () => {
it('should set positions when first node has no position', () => {
const nodes = [
createNode({ id: 'n1', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'n2', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
nodes.forEach(n => Reflect.deleteProperty(n, 'position'))
const result = initialNodes(nodes, [])
expect(result[0].position).toBeDefined()
expect(result[1].position).toBeDefined()
expect(result[1].position.x).toBeGreaterThan(result[0].position.x)
})
it('should set type to CUSTOM_NODE when type is missing', () => {
const nodes = [
createNode({ id: 'n1', data: { type: BlockEnum.Start, title: '', desc: '' } }),
]
Reflect.deleteProperty(nodes[0], 'type')
const result = initialNodes(nodes, [])
expect(result[0].type).toBe(CUSTOM_NODE)
})
it('should set connected source and target handle ids', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'a', target: 'b', sourceHandle: 'source', targetHandle: 'target' }),
]
const result = initialNodes(nodes, edges)
expect(result[0].data._connectedSourceHandleIds).toContain('source')
expect(result[1].data._connectedTargetHandleIds).toContain('target')
})
it('should handle IfElse node with cases', () => {
const nodes = [
createNode({
id: 'if-1',
data: {
type: BlockEnum.IfElse,
title: '',
desc: '',
cases: [
{ case_id: 'case-1', logical_operator: 'and', conditions: [] },
],
},
}),
]
const result = initialNodes(nodes, [])
expect(result[0].data._targetBranches).toBeDefined()
expect(result[0].data._targetBranches).toHaveLength(2)
})
it('should migrate legacy IfElse node without cases to cases format', () => {
const nodes = [
createNode({
id: 'if-1',
data: {
type: BlockEnum.IfElse,
title: '',
desc: '',
logical_operator: 'and',
conditions: [{ id: 'c1', value: 'test' }],
cases: undefined,
},
}),
]
const result = initialNodes(nodes, [])
const data = result[0].data as IfElseNodeType
expect(data.cases).toHaveLength(1)
expect(data.cases[0].case_id).toBe('true')
})
it('should delete legacy conditions/logical_operator when cases exist', () => {
const nodes = [
createNode({
id: 'if-1',
data: {
type: BlockEnum.IfElse,
title: '',
desc: '',
logical_operator: 'and',
conditions: [{ id: 'c1', value: 'test' }],
cases: [
{ case_id: 'true', logical_operator: 'and', conditions: [{ id: 'c1', value: 'test' }] },
],
},
}),
]
const result = initialNodes(nodes, [])
const data = result[0].data as IfElseNodeType
expect(data.conditions).toBeUndefined()
expect(data.logical_operator).toBeUndefined()
})
it('should set _targetBranches for QuestionClassifier nodes', () => {
const nodes = [
createNode({
id: 'qc-1',
data: {
type: BlockEnum.QuestionClassifier,
title: '',
desc: '',
classes: [{ id: 'cls-1', name: 'Class 1' }],
model: { provider: 'openai' },
},
}),
]
const result = initialNodes(nodes, [])
expect(result[0].data._targetBranches).toHaveLength(1)
})
it('should set iteration node defaults', () => {
const nodes = [
createNode({
id: 'iter-1',
data: {
type: BlockEnum.Iteration,
title: '',
desc: '',
},
}),
]
const result = initialNodes(nodes, [])
const iterNode = result.find(n => n.id === 'iter-1')!
const data = iterNode.data as IterationNodeType
expect(data.is_parallel).toBe(false)
expect(data.parallel_nums).toBe(10)
expect(data.error_handle_mode).toBe(ErrorHandleMode.Terminated)
expect(data._children).toBeDefined()
})
it('should set loop node defaults', () => {
const nodes = [
createNode({
id: 'loop-1',
data: {
type: BlockEnum.Loop,
title: '',
desc: '',
},
}),
]
const result = initialNodes(nodes, [])
const loopNode = result.find(n => n.id === 'loop-1')!
const data = loopNode.data as LoopNodeType
expect(data.error_handle_mode).toBe(ErrorHandleMode.Terminated)
expect(data._children).toBeDefined()
})
it('should populate _children for iteration nodes with child nodes', () => {
const nodes = [
createNode({
id: 'iter-1',
data: { type: BlockEnum.Iteration, title: '', desc: '' },
}),
createNode({
id: 'child-1',
parentId: 'iter-1',
data: { type: BlockEnum.Code, title: '', desc: '' },
}),
]
const result = initialNodes(nodes, [])
const iterNode = result.find(n => n.id === 'iter-1')!
const data = iterNode.data as IterationNodeType
expect(data._children).toEqual(
expect.arrayContaining([
expect.objectContaining({ nodeId: 'child-1', nodeType: BlockEnum.Code }),
]),
)
})
it('should correct model provider for LLM nodes', () => {
const nodes = [
createNode({
id: 'llm-1',
data: {
type: BlockEnum.LLM,
title: '',
desc: '',
model: { provider: 'openai' },
},
}),
]
const result = initialNodes(nodes, [])
expect((result[0].data as LLMNodeType).model.provider).toBe('corrected/openai')
})
it('should correct model provider for KnowledgeRetrieval reranking_model', () => {
const nodes = [
createNode({
id: 'kr-1',
data: {
type: BlockEnum.KnowledgeRetrieval,
title: '',
desc: '',
multiple_retrieval_config: {
reranking_model: { provider: 'cohere' },
},
},
}),
]
const result = initialNodes(nodes, [])
expect((result[0].data as KnowledgeRetrievalNodeType).multiple_retrieval_config!.reranking_model!.provider).toBe('corrected/cohere')
})
it('should correct model provider for ParameterExtractor nodes', () => {
const nodes = [
createNode({
id: 'pe-1',
data: {
type: BlockEnum.ParameterExtractor,
title: '',
desc: '',
model: { provider: 'anthropic' },
},
}),
]
const result = initialNodes(nodes, [])
expect((result[0].data as ParameterExtractorNodeType).model.provider).toBe('corrected/anthropic')
})
it('should add default retry_config for HttpRequest nodes', () => {
const nodes = [
createNode({
id: 'http-1',
data: {
type: BlockEnum.HttpRequest,
title: '',
desc: '',
},
}),
]
const result = initialNodes(nodes, [])
expect(result[0].data.retry_config).toEqual({
retry_enabled: true,
max_retries: DEFAULT_RETRY_MAX,
retry_interval: DEFAULT_RETRY_INTERVAL,
})
})
it('should not overwrite existing retry_config for HttpRequest nodes', () => {
const existingConfig = { retry_enabled: false, max_retries: 1, retry_interval: 50 }
const nodes = [
createNode({
id: 'http-1',
data: {
type: BlockEnum.HttpRequest,
title: '',
desc: '',
retry_config: existingConfig,
},
}),
]
const result = initialNodes(nodes, [])
expect(result[0].data.retry_config).toEqual(existingConfig)
})
it('should migrate legacy Tool node configurations', () => {
const nodes = [
createNode({
id: 'tool-1',
data: {
type: BlockEnum.Tool,
title: '',
desc: '',
tool_configurations: {
api_key: 'secret-key',
nested: { type: 'constant', value: 'already-migrated' },
},
},
}),
]
const result = initialNodes(nodes, [])
const data = result[0].data as ToolNodeType
expect(data.tool_node_version).toBe('2')
expect(data.tool_configurations.api_key).toEqual({
type: 'constant',
value: 'secret-key',
})
expect(data.tool_configurations.nested).toEqual({
type: 'constant',
value: 'already-migrated',
})
})
it('should not migrate Tool node when version already exists', () => {
const nodes = [
createNode({
id: 'tool-1',
data: {
type: BlockEnum.Tool,
title: '',
desc: '',
version: '1',
tool_configurations: { key: 'val' },
},
}),
]
const result = initialNodes(nodes, [])
const data = result[0].data as ToolNodeType
expect(data.tool_configurations).toEqual({ key: 'val' })
})
it('should not migrate Tool node when tool_node_version already exists', () => {
const nodes = [
createNode({
id: 'tool-1',
data: {
type: BlockEnum.Tool,
title: '',
desc: '',
tool_node_version: '2',
tool_configurations: { key: 'val' },
},
}),
]
const result = initialNodes(nodes, [])
const data = result[0].data as ToolNodeType
expect(data.tool_configurations).toEqual({ key: 'val' })
})
it('should handle Tool node with null configuration value', () => {
const nodes = [
createNode({
id: 'tool-1',
data: {
type: BlockEnum.Tool,
title: '',
desc: '',
tool_configurations: { key: null },
},
}),
]
const result = initialNodes(nodes, [])
const data = result[0].data as ToolNodeType
expect(data.tool_configurations.key).toEqual({ type: 'constant', value: null })
})
it('should handle Tool node with empty tool_configurations', () => {
const nodes = [
createNode({
id: 'tool-1',
data: {
type: BlockEnum.Tool,
title: '',
desc: '',
tool_configurations: {},
},
}),
]
const result = initialNodes(nodes, [])
const data = result[0].data as ToolNodeType
expect(data.tool_node_version).toBe('2')
})
})
describe('initialEdges', () => {
it('should set edge type to custom', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [createEdge({ source: 'a', target: 'b' })]
const result = initialEdges(edges, nodes)
expect(result[0].type).toBe('custom')
})
it('should set default sourceHandle and targetHandle', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edge = createEdge({ source: 'a', target: 'b' })
Reflect.deleteProperty(edge, 'sourceHandle')
Reflect.deleteProperty(edge, 'targetHandle')
const result = initialEdges([edge], nodes)
expect(result[0].sourceHandle).toBe('source')
expect(result[0].targetHandle).toBe('target')
})
it('should set sourceType and targetType from nodes', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [createEdge({ source: 'a', target: 'b' })]
Reflect.deleteProperty(edges[0].data!, 'sourceType')
Reflect.deleteProperty(edges[0].data!, 'targetType')
const result = initialEdges(edges, nodes)
expect(result[0].data!.sourceType).toBe(BlockEnum.Start)
expect(result[0].data!.targetType).toBe(BlockEnum.Code)
})
it('should set _connectedNodeIsSelected when a node is selected', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '', selected: true } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [createEdge({ source: 'a', target: 'b' })]
const result = initialEdges(edges, nodes)
expect(result[0].data!._connectedNodeIsSelected).toBe(true)
})
it('should filter cycle edges', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
createNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'a', target: 'b' }),
createEdge({ source: 'b', target: 'c' }),
createEdge({ source: 'c', target: 'b' }),
]
const result = initialEdges(edges, nodes)
const hasCycleEdge = result.some(
e => (e.source === 'b' && e.target === 'c') || (e.source === 'c' && e.target === 'b'),
)
const hasABEdge = result.some(
e => e.source === 'a' && e.target === 'b',
)
expect(hasCycleEdge).toBe(false)
// In this specific graph, getCycleEdges treats all nodes remaining in the DFS stack (a, b, c)
// as part of the cycle, so a→b is also filtered. This assertion documents that behaviour.
expect(hasABEdge).toBe(false)
})
it('should keep non-cycle edges intact', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [createEdge({ source: 'a', target: 'b' })]
const result = initialEdges(edges, nodes)
expect(result).toHaveLength(1)
expect(result[0].source).toBe('a')
expect(result[0].target).toBe('b')
})
it('should handle empty edges', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
]
const result = initialEdges([], nodes)
expect(result).toHaveLength(0)
})
it('should handle edges where source/target node is missing from nodesMap', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
]
const edges = [createEdge({ source: 'a', target: 'missing' })]
const result = initialEdges(edges, nodes)
expect(result).toHaveLength(1)
})
it('should set _connectedNodeIsSelected for edge target matching selected node', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '', selected: true } }),
]
const edges = [createEdge({ source: 'a', target: 'b' })]
const result = initialEdges(edges, nodes)
expect(result[0].data!._connectedNodeIsSelected).toBe(true)
})
it('should not set default sourceHandle when sourceHandle already exists', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [createEdge({ source: 'a', target: 'b', sourceHandle: 'custom-src', targetHandle: 'custom-tgt' })]
const result = initialEdges(edges, nodes)
expect(result[0].sourceHandle).toBe('custom-src')
expect(result[0].targetHandle).toBe('custom-tgt')
})
it('should handle graph with edges referencing nodes not in the node list', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'a', target: 'b' }),
createEdge({ source: 'unknown-src', target: 'unknown-tgt' }),
]
const result = initialEdges(edges, nodes)
expect(result.length).toBeGreaterThanOrEqual(1)
})
it('should handle self-referencing cycle', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'a', target: 'b' }),
createEdge({ source: 'b', target: 'b' }),
]
const result = initialEdges(edges, nodes)
const selfLoop = result.find(e => e.source === 'b' && e.target === 'b')
expect(selfLoop).toBeUndefined()
})
it('should handle complex cycle with multiple nodes', () => {
const nodes = [
createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
createNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
createNode({ id: 'd', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'a', target: 'b' }),
createEdge({ source: 'b', target: 'c' }),
createEdge({ source: 'c', target: 'd' }),
createEdge({ source: 'd', target: 'b' }),
]
const result = initialEdges(edges, nodes)
expect(result.length).toBeLessThan(edges.length)
})
})

View File

@ -0,0 +1,423 @@
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
import { BlockEnum } from '../../types'
import {
canRunBySingle,
changeNodesAndEdgesId,
getNodesConnectedSourceOrTargetHandleIdsMap,
getValidTreeNodes,
hasErrorHandleNode,
isSupportCustomRunForm,
} from '../workflow'
beforeEach(() => {
resetFixtureCounters()
})
describe('canRunBySingle', () => {
const runnableTypes = [
BlockEnum.LLM,
BlockEnum.KnowledgeRetrieval,
BlockEnum.Code,
BlockEnum.TemplateTransform,
BlockEnum.QuestionClassifier,
BlockEnum.HttpRequest,
BlockEnum.Tool,
BlockEnum.ParameterExtractor,
BlockEnum.Iteration,
BlockEnum.Agent,
BlockEnum.DocExtractor,
BlockEnum.Loop,
BlockEnum.Start,
BlockEnum.IfElse,
BlockEnum.VariableAggregator,
BlockEnum.Assigner,
BlockEnum.HumanInput,
BlockEnum.DataSource,
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]
it.each(runnableTypes)('should return true for %s when not a child node', (type) => {
expect(canRunBySingle(type, false)).toBe(true)
})
it('should return false for Assigner when it is a child node', () => {
expect(canRunBySingle(BlockEnum.Assigner, true)).toBe(false)
})
it('should return true for LLM even as a child node', () => {
expect(canRunBySingle(BlockEnum.LLM, true)).toBe(true)
})
it('should return false for End node', () => {
expect(canRunBySingle(BlockEnum.End, false)).toBe(false)
})
it('should return false for Answer node', () => {
expect(canRunBySingle(BlockEnum.Answer, false)).toBe(false)
})
})
describe('isSupportCustomRunForm', () => {
it('should return true for DataSource', () => {
expect(isSupportCustomRunForm(BlockEnum.DataSource)).toBe(true)
})
it('should return false for other types', () => {
expect(isSupportCustomRunForm(BlockEnum.LLM)).toBe(false)
expect(isSupportCustomRunForm(BlockEnum.Code)).toBe(false)
})
})
describe('hasErrorHandleNode', () => {
it.each([BlockEnum.LLM, BlockEnum.Tool, BlockEnum.HttpRequest, BlockEnum.Code, BlockEnum.Agent])(
'should return true for %s',
(type) => {
expect(hasErrorHandleNode(type)).toBe(true)
},
)
it('should return false for non-error-handle types', () => {
expect(hasErrorHandleNode(BlockEnum.Start)).toBe(false)
expect(hasErrorHandleNode(BlockEnum.Iteration)).toBe(false)
})
it('should return false when undefined', () => {
expect(hasErrorHandleNode()).toBe(false)
})
})
describe('getNodesConnectedSourceOrTargetHandleIdsMap', () => {
it('should add handle ids when type is add', () => {
const node1 = createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
const node2 = createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } })
const edge = createEdge({
source: 'a',
target: 'b',
sourceHandle: 'src-handle',
targetHandle: 'tgt-handle',
})
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
[{ type: 'add', edge }],
[node1, node2],
)
expect(result.a._connectedSourceHandleIds).toContain('src-handle')
expect(result.b._connectedTargetHandleIds).toContain('tgt-handle')
})
it('should remove handle ids when type is remove', () => {
const node1 = createNode({
id: 'a',
data: { type: BlockEnum.Start, title: '', desc: '', _connectedSourceHandleIds: ['src-handle'] },
})
const node2 = createNode({
id: 'b',
data: { type: BlockEnum.Code, title: '', desc: '', _connectedTargetHandleIds: ['tgt-handle'] },
})
const edge = createEdge({
source: 'a',
target: 'b',
sourceHandle: 'src-handle',
targetHandle: 'tgt-handle',
})
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
[{ type: 'remove', edge }],
[node1, node2],
)
expect(result.a._connectedSourceHandleIds).not.toContain('src-handle')
expect(result.b._connectedTargetHandleIds).not.toContain('tgt-handle')
})
it('should use default handle ids when sourceHandle/targetHandle are missing', () => {
const node1 = createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
const node2 = createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } })
const edge = createEdge({ source: 'a', target: 'b' })
Reflect.deleteProperty(edge, 'sourceHandle')
Reflect.deleteProperty(edge, 'targetHandle')
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
[{ type: 'add', edge }],
[node1, node2],
)
expect(result.a._connectedSourceHandleIds).toContain('source')
expect(result.b._connectedTargetHandleIds).toContain('target')
})
it('should skip when source node is not found', () => {
const node2 = createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } })
const edge = createEdge({ source: 'missing', target: 'b', sourceHandle: 'src' })
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
[{ type: 'add', edge }],
[node2],
)
expect(result.missing).toBeUndefined()
expect(result.b._connectedTargetHandleIds).toBeDefined()
})
it('should skip when target node is not found', () => {
const node1 = createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
const edge = createEdge({ source: 'a', target: 'missing', targetHandle: 'tgt' })
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
[{ type: 'add', edge }],
[node1],
)
expect(result.a._connectedSourceHandleIds).toBeDefined()
expect(result.missing).toBeUndefined()
})
it('should reuse existing map entry for same node across multiple changes', () => {
const node1 = createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
const node2 = createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } })
const node3 = createNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } })
const edge1 = createEdge({ source: 'a', target: 'b', sourceHandle: 'h1' })
const edge2 = createEdge({ source: 'a', target: 'c', sourceHandle: 'h2' })
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
[{ type: 'add', edge: edge1 }, { type: 'add', edge: edge2 }],
[node1, node2, node3],
)
expect(result.a._connectedSourceHandleIds).toContain('h1')
expect(result.a._connectedSourceHandleIds).toContain('h2')
})
it('should fallback to empty arrays when node data has no handle id arrays', () => {
const node1 = createNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })
const node2 = createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } })
Reflect.deleteProperty(node1.data, '_connectedSourceHandleIds')
Reflect.deleteProperty(node1.data, '_connectedTargetHandleIds')
Reflect.deleteProperty(node2.data, '_connectedSourceHandleIds')
Reflect.deleteProperty(node2.data, '_connectedTargetHandleIds')
const edge = createEdge({ source: 'a', target: 'b', sourceHandle: 'h1', targetHandle: 'h2' })
const result = getNodesConnectedSourceOrTargetHandleIdsMap(
[{ type: 'add', edge }],
[node1, node2],
)
expect(result.a._connectedSourceHandleIds).toContain('h1')
expect(result.b._connectedTargetHandleIds).toContain('h2')
})
})
describe('getValidTreeNodes', () => {
it('should return empty when there are no start/trigger nodes', () => {
const nodes = [
createNode({ id: 'n1', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const result = getValidTreeNodes(nodes, [])
expect(result.validNodes).toEqual([])
expect(result.maxDepth).toBe(0)
})
it('should traverse a linear graph from Start', () => {
const nodes = [
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'llm', data: { type: BlockEnum.LLM, title: '', desc: '' } }),
createNode({ id: 'end', data: { type: BlockEnum.End, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'start', target: 'llm' }),
createEdge({ source: 'llm', target: 'end' }),
]
const result = getValidTreeNodes(nodes, edges)
expect(result.validNodes.map(n => n.id)).toEqual(['start', 'llm', 'end'])
expect(result.maxDepth).toBe(3)
})
it('should traverse from trigger nodes', () => {
const nodes = [
createNode({ id: 'trigger', data: { type: BlockEnum.TriggerWebhook, title: '', desc: '' } }),
createNode({ id: 'code', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'trigger', target: 'code' }),
]
const result = getValidTreeNodes(nodes, edges)
expect(result.validNodes.map(n => n.id)).toContain('trigger')
expect(result.validNodes.map(n => n.id)).toContain('code')
})
it('should include iteration children as valid nodes', () => {
const nodes = [
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'iter', data: { type: BlockEnum.Iteration, title: '', desc: '' } }),
createNode({ id: 'child1', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'iter' }),
]
const edges = [
createEdge({ source: 'start', target: 'iter' }),
]
const result = getValidTreeNodes(nodes, edges)
expect(result.validNodes.map(n => n.id)).toContain('child1')
})
it('should include loop children when loop has outgoers', () => {
const nodes = [
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'loop', data: { type: BlockEnum.Loop, title: '', desc: '' } }),
createNode({ id: 'loop-child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'loop' }),
createNode({ id: 'end', data: { type: BlockEnum.End, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'start', target: 'loop' }),
createEdge({ source: 'loop', target: 'end' }),
]
const result = getValidTreeNodes(nodes, edges)
expect(result.validNodes.map(n => n.id)).toContain('loop-child')
})
it('should include loop children as valid nodes when loop is a leaf', () => {
const nodes = [
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'loop', data: { type: BlockEnum.Loop, title: '', desc: '' } }),
createNode({ id: 'loop-child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'loop' }),
]
const edges = [
createEdge({ source: 'start', target: 'loop' }),
]
const result = getValidTreeNodes(nodes, edges)
expect(result.validNodes.map(n => n.id)).toContain('loop-child')
})
it('should handle cycles without infinite loop', () => {
const nodes = [
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'a', data: { type: BlockEnum.Code, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'start', target: 'a' }),
createEdge({ source: 'a', target: 'b' }),
createEdge({ source: 'b', target: 'a' }),
]
const result = getValidTreeNodes(nodes, edges)
expect(result.validNodes).toHaveLength(3)
})
it('should exclude disconnected nodes', () => {
const nodes = [
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'connected', data: { type: BlockEnum.Code, title: '', desc: '' } }),
createNode({ id: 'isolated', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'start', target: 'connected' }),
]
const result = getValidTreeNodes(nodes, edges)
expect(result.validNodes.map(n => n.id)).not.toContain('isolated')
})
it('should handle multiple start nodes without double-traversal', () => {
const nodes = [
createNode({ id: 'start1', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'trigger', data: { type: BlockEnum.TriggerSchedule, title: '', desc: '' } }),
createNode({ id: 'shared', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'start1', target: 'shared' }),
createEdge({ source: 'trigger', target: 'shared' }),
]
const result = getValidTreeNodes(nodes, edges)
expect(result.validNodes.map(n => n.id)).toContain('start1')
expect(result.validNodes.map(n => n.id)).toContain('trigger')
expect(result.validNodes.map(n => n.id)).toContain('shared')
})
it('should not increase maxDepth when visiting nodes at same or lower depth', () => {
const nodes = [
createNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'a', data: { type: BlockEnum.Code, title: '', desc: '' } }),
createNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'start', target: 'a' }),
createEdge({ source: 'start', target: 'b' }),
]
const result = getValidTreeNodes(nodes, edges)
expect(result.maxDepth).toBe(2)
})
it('should traverse from all trigger types', () => {
const nodes = [
createNode({ id: 'ts', data: { type: BlockEnum.TriggerSchedule, title: '', desc: '' } }),
createNode({ id: 'tp', data: { type: BlockEnum.TriggerPlugin, title: '', desc: '' } }),
createNode({ id: 'code1', data: { type: BlockEnum.Code, title: '', desc: '' } }),
createNode({ id: 'code2', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'ts', target: 'code1' }),
createEdge({ source: 'tp', target: 'code2' }),
]
const result = getValidTreeNodes(nodes, edges)
expect(result.validNodes).toHaveLength(4)
})
it('should skip start nodes already visited by a previous start node traversal', () => {
const nodes = [
createNode({ id: 'start1', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'start2', data: { type: BlockEnum.TriggerWebhook, title: '', desc: '' } }),
createNode({ id: 'shared', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'start1', target: 'start2' }),
createEdge({ source: 'start2', target: 'shared' }),
]
const result = getValidTreeNodes(nodes, edges)
expect(result.validNodes.map(n => n.id)).toContain('start1')
expect(result.validNodes.map(n => n.id)).toContain('start2')
expect(result.validNodes.map(n => n.id)).toContain('shared')
})
})
describe('changeNodesAndEdgesId', () => {
it('should replace all node and edge ids with new uuids', () => {
const nodes = [
createNode({ id: 'old-1', data: { type: BlockEnum.Start, title: '', desc: '' } }),
createNode({ id: 'old-2', data: { type: BlockEnum.Code, title: '', desc: '' } }),
]
const edges = [
createEdge({ source: 'old-1', target: 'old-2' }),
]
const [newNodes, newEdges] = changeNodesAndEdgesId(nodes, edges)
expect(newNodes[0].id).not.toBe('old-1')
expect(newNodes[1].id).not.toBe('old-2')
expect(newEdges[0].source).toBe(newNodes[0].id)
expect(newEdges[0].target).toBe(newNodes[1].id)
})
it('should generate unique ids for all nodes', () => {
const nodes = [
createNode({ id: 'a' }),
createNode({ id: 'b' }),
createNode({ id: 'c' }),
]
const [newNodes] = changeNodesAndEdgesId(nodes, [])
const ids = new Set(newNodes.map(n => n.id))
expect(ids.size).toBe(3)
})
})

View File

@ -1,69 +0,0 @@
import type {
Node,
} from '@/app/components/workflow/types'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { BlockEnum } from '@/app/components/workflow/types'
import { preprocessNodesAndEdges } from './workflow-init'
describe('preprocessNodesAndEdges', () => {
it('process nodes without iteration node or loop node should return origin nodes and edges.', () => {
const nodes = [
{
data: {
type: BlockEnum.Code,
},
},
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
expect(result).toEqual({
nodes,
edges: [],
})
})
it('process nodes with iteration node should return nodes with iteration start node', () => {
const nodes = [
{
id: 'iteration',
data: {
type: BlockEnum.Iteration,
},
},
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
expect(result.nodes).toEqual(
expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
type: BlockEnum.IterationStart,
}),
}),
]),
)
})
it('process nodes with iteration node start should return origin', () => {
const nodes = [
{
data: {
type: BlockEnum.Iteration,
start_node_id: 'iterationStart',
},
},
{
id: 'iterationStart',
type: CUSTOM_ITERATION_START_NODE,
data: {
type: BlockEnum.IterationStart,
},
},
]
const result = preprocessNodesAndEdges(nodes as Node[], [])
expect(result).toEqual({
nodes,
edges: [],
})
})
})