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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 3606 additions and 69 deletions

View File

@ -0,0 +1,111 @@
import type { CommonEdgeType, CommonNodeType, Edge, Node } from '../types'
import { Position } from 'reactflow'
import { CUSTOM_NODE } from '../constants'
import { BlockEnum, NodeRunningStatus } from '../types'
let nodeIdCounter = 0
let edgeIdCounter = 0
export function resetFixtureCounters() {
nodeIdCounter = 0
edgeIdCounter = 0
}
export function createNode(
overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {},
): Node {
const id = overrides.id ?? `node-${++nodeIdCounter}`
const { data: dataOverrides, ...rest } = overrides
return {
id,
type: CUSTOM_NODE,
position: { x: 0, y: 0 },
targetPosition: Position.Left,
sourcePosition: Position.Right,
data: {
title: `Node ${id}`,
desc: '',
type: BlockEnum.Code,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
...dataOverrides,
} as CommonNodeType,
...rest,
} as Node
}
export function createStartNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node {
return createNode({
...overrides,
data: { type: BlockEnum.Start, title: 'Start', desc: '', ...overrides.data },
})
}
export function createTriggerNode(
triggerType: BlockEnum.TriggerSchedule | BlockEnum.TriggerWebhook | BlockEnum.TriggerPlugin = BlockEnum.TriggerWebhook,
overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {},
): Node {
return createNode({
...overrides,
data: { type: triggerType, title: `Trigger ${triggerType}`, desc: '', ...overrides.data },
})
}
export function createIterationNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node {
return createNode({
...overrides,
data: { type: BlockEnum.Iteration, title: 'Iteration', desc: '', ...overrides.data },
})
}
export function createLoopNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node {
return createNode({
...overrides,
data: { type: BlockEnum.Loop, title: 'Loop', desc: '', ...overrides.data },
})
}
export function createEdge(overrides: Omit<Partial<Edge>, 'data'> & { data?: Partial<CommonEdgeType> & Record<string, unknown> } = {}): Edge {
const { data: dataOverrides, ...rest } = overrides
return {
id: overrides.id ?? `edge-${overrides.source ?? 'src'}-${overrides.target ?? 'tgt'}-${++edgeIdCounter}`,
source: 'source-node',
target: 'target-node',
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.Code,
...dataOverrides,
} as CommonEdgeType,
...rest,
} as Edge
}
export function createLinearGraph(nodeCount: number): { nodes: Node[], edges: Edge[] } {
const nodes: Node[] = []
const edges: Edge[] = []
for (let i = 0; i < nodeCount; i++) {
const type = i === 0 ? BlockEnum.Start : BlockEnum.Code
nodes.push(createNode({
id: `n${i}`,
position: { x: i * 300, y: 0 },
data: { type, title: `Node ${i}`, desc: '' },
}))
if (i > 0) {
edges.push(createEdge({
id: `e-n${i - 1}-n${i}`,
source: `n${i - 1}`,
target: `n${i}`,
sourceHandle: 'source',
targetHandle: 'target',
data: {
sourceType: i === 1 ? BlockEnum.Start : BlockEnum.Code,
targetType: BlockEnum.Code,
},
}))
}
}
return { nodes, edges }
}
export { BlockEnum, NodeRunningStatus }

View File

@ -0,0 +1,59 @@
import { noop } from 'es-toolkit'
/**
* Default hooks store state.
* All function fields default to noop / vi.fn() stubs.
* Use `createHooksStoreState(overrides)` to get a customised state object.
*/
export function createHooksStoreState(overrides: Record<string, unknown> = {}) {
return {
refreshAll: noop,
// draft sync
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
syncWorkflowDraftWhenPageClose: noop,
handleRefreshWorkflowDraft: noop,
handleBackupDraft: noop,
handleLoadBackupDraft: noop,
handleRestoreFromPublishedWorkflow: noop,
// run
handleRun: noop,
handleStopRun: noop,
handleStartWorkflowRun: noop,
handleWorkflowStartRunInWorkflow: noop,
handleWorkflowStartRunInChatflow: noop,
handleWorkflowTriggerScheduleRunInWorkflow: noop,
handleWorkflowTriggerWebhookRunInWorkflow: noop,
handleWorkflowTriggerPluginRunInWorkflow: noop,
handleWorkflowRunAllTriggersInWorkflow: noop,
// meta
availableNodesMetaData: undefined,
configsMap: undefined,
// export / DSL
exportCheck: vi.fn().mockResolvedValue(undefined),
handleExportDSL: vi.fn().mockResolvedValue(undefined),
getWorkflowRunAndTraceUrl: vi.fn().mockReturnValue({ runUrl: '', traceUrl: '' }),
// inspect vars
fetchInspectVars: vi.fn().mockResolvedValue(undefined),
hasNodeInspectVars: vi.fn().mockReturnValue(false),
hasSetInspectVar: vi.fn().mockReturnValue(false),
fetchInspectVarValue: vi.fn().mockResolvedValue(undefined),
editInspectVarValue: vi.fn().mockResolvedValue(undefined),
renameInspectVarName: vi.fn().mockResolvedValue(undefined),
appendNodeInspectVars: noop,
deleteInspectVar: vi.fn().mockResolvedValue(undefined),
deleteNodeInspectorVars: vi.fn().mockResolvedValue(undefined),
deleteAllInspectorVars: vi.fn().mockResolvedValue(undefined),
isInspectVarEdited: vi.fn().mockReturnValue(false),
resetToLastRunVar: vi.fn().mockResolvedValue(undefined),
invalidateSysVarValues: noop,
resetConversationVar: vi.fn().mockResolvedValue(undefined),
invalidateConversationVarValues: noop,
...overrides,
}
}

View File

@ -0,0 +1,110 @@
/**
* ReactFlow mock factory for workflow tests.
*
* Usage add this to the top of any test file that imports reactflow:
*
* vi.mock('reactflow', async () => (await import('../__tests__/mock-reactflow')).createReactFlowMock())
*
* Or for more control:
*
* vi.mock('reactflow', async () => {
* const base = (await import('../__tests__/mock-reactflow')).createReactFlowMock()
* return { ...base, useReactFlow: () => ({ ...base.useReactFlow(), fitView: vi.fn() }) }
* })
*/
import * as React from 'react'
export function createReactFlowMock(overrides: Record<string, unknown> = {}) {
const noopComponent: React.FC<{ children?: React.ReactNode }> = ({ children }) =>
React.createElement('div', { 'data-testid': 'reactflow-mock' }, children)
noopComponent.displayName = 'ReactFlowMock'
const backgroundComponent: React.FC = () => null
backgroundComponent.displayName = 'BackgroundMock'
return {
// re-export the real Position enum
Position: { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' },
MarkerType: { Arrow: 'arrow', ArrowClosed: 'arrowclosed' },
ConnectionMode: { Strict: 'strict', Loose: 'loose' },
ConnectionLineType: { Bezier: 'default', Straight: 'straight', Step: 'step', SmoothStep: 'smoothstep' },
// components
default: noopComponent,
ReactFlow: noopComponent,
ReactFlowProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
Background: backgroundComponent,
MiniMap: backgroundComponent,
Controls: backgroundComponent,
Handle: (props: Record<string, unknown>) => React.createElement('div', { 'data-testid': 'handle', ...props }),
BaseEdge: (props: Record<string, unknown>) => React.createElement('path', props),
EdgeLabelRenderer: ({ children }: { children?: React.ReactNode }) =>
React.createElement('div', null, children),
// hooks
useReactFlow: () => ({
setCenter: vi.fn(),
fitView: vi.fn(),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
zoomTo: vi.fn(),
getNodes: vi.fn().mockReturnValue([]),
getEdges: vi.fn().mockReturnValue([]),
getNode: vi.fn(),
setNodes: vi.fn(),
setEdges: vi.fn(),
addNodes: vi.fn(),
addEdges: vi.fn(),
deleteElements: vi.fn(),
getViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }),
setViewport: vi.fn(),
screenToFlowPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos),
flowToScreenPosition: vi.fn().mockImplementation((pos: { x: number, y: number }) => pos),
toObject: vi.fn().mockReturnValue({ nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }),
viewportInitialized: true,
}),
useStoreApi: () => ({
getState: vi.fn().mockReturnValue({
nodeInternals: new Map(),
edges: [],
transform: [0, 0, 1],
d3Selection: null,
d3Zoom: null,
}),
setState: vi.fn(),
subscribe: vi.fn().mockReturnValue(vi.fn()),
}),
useNodesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
useEdgesState: vi.fn((initial: unknown[] = []) => [initial, vi.fn(), vi.fn()]),
useStore: vi.fn().mockReturnValue(null),
useNodes: vi.fn().mockReturnValue([]),
useEdges: vi.fn().mockReturnValue([]),
useViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }),
useOnSelectionChange: vi.fn(),
useKeyPress: vi.fn().mockReturnValue(false),
useUpdateNodeInternals: vi.fn().mockReturnValue(vi.fn()),
useOnViewportChange: vi.fn(),
useNodeId: vi.fn().mockReturnValue(null),
// utils
getOutgoers: vi.fn().mockReturnValue([]),
getIncomers: vi.fn().mockReturnValue([]),
getConnectedEdges: vi.fn().mockReturnValue([]),
isNode: vi.fn().mockReturnValue(true),
isEdge: vi.fn().mockReturnValue(false),
addEdge: vi.fn().mockImplementation((_edge: unknown, edges: unknown[]) => edges),
applyNodeChanges: vi.fn().mockImplementation((_changes: unknown[], nodes: unknown[]) => nodes),
applyEdgeChanges: vi.fn().mockImplementation((_changes: unknown[], edges: unknown[]) => edges),
getBezierPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
getSmoothStepPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
getStraightPath: vi.fn().mockReturnValue(['M 0 0', 0, 0]),
internalsSymbol: Symbol('internals'),
...overrides,
}
}

View File

@ -0,0 +1,199 @@
import type { ControlMode, Node } from '../types'
import { noop } from 'es-toolkit'
import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../constants'
/**
* Default workflow store state covering all slices.
* Use `createWorkflowStoreState(overrides)` to get a state object
* that can be injected via `useWorkflowStore.setState(...)` or
* used as the return value of a mocked `useStore` selector.
*/
export function createWorkflowStoreState(overrides: Record<string, unknown> = {}) {
return {
// --- workflow-slice ---
workflowRunningData: undefined,
isListening: false,
listeningTriggerType: null,
listeningTriggerNodeId: null,
listeningTriggerNodeIds: [],
listeningTriggerIsAll: false,
clipboardElements: [] as Node[],
selection: null,
bundleNodeSize: null,
controlMode: 'pointer' as ControlMode,
mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
showConfirm: undefined,
controlPromptEditorRerenderKey: 0,
showImportDSLModal: false,
fileUploadConfig: undefined,
// --- node-slice ---
showSingleRunPanel: false,
nodeAnimation: false,
candidateNode: undefined,
nodeMenu: undefined,
showAssignVariablePopup: undefined,
hoveringAssignVariableGroupId: undefined,
connectingNodePayload: undefined,
enteringNodePayload: undefined,
iterTimes: DEFAULT_ITER_TIMES,
loopTimes: DEFAULT_LOOP_TIMES,
iterParallelLogMap: new Map(),
pendingSingleRun: undefined,
// --- panel-slice ---
panelWidth: 420,
showFeaturesPanel: false,
showWorkflowVersionHistoryPanel: false,
showInputsPanel: false,
showDebugAndPreviewPanel: false,
panelMenu: undefined,
selectionMenu: undefined,
showVariableInspectPanel: false,
initShowLastRunTab: false,
// --- help-line-slice ---
helpLineHorizontal: undefined,
helpLineVertical: undefined,
// --- history-slice ---
historyWorkflowData: undefined,
showRunHistory: false,
versionHistory: [],
// --- chat-variable-slice ---
showChatVariablePanel: false,
showGlobalVariablePanel: false,
conversationVariables: [],
// --- env-variable-slice ---
showEnvPanel: false,
environmentVariables: [],
envSecrets: {},
// --- form-slice ---
inputs: {},
files: [],
// --- tool-slice ---
toolPublished: false,
lastPublishedHasUserInput: false,
buildInTools: undefined,
customTools: undefined,
workflowTools: undefined,
mcpTools: undefined,
// --- version-slice ---
draftUpdatedAt: 0,
publishedAt: 0,
currentVersion: null,
isRestoring: false,
// --- workflow-draft-slice ---
backupDraft: undefined,
syncWorkflowDraftHash: '',
isSyncingWorkflowDraft: false,
isWorkflowDataLoaded: false,
nodes: [] as Node[],
// --- inspect-vars-slice ---
currentFocusNodeId: null,
nodesWithInspectVars: [],
conversationVars: [],
// --- layout-slice ---
workflowCanvasWidth: undefined,
workflowCanvasHeight: undefined,
rightPanelWidth: undefined,
nodePanelWidth: 420,
previewPanelWidth: 420,
otherPanelWidth: 420,
bottomPanelWidth: 0,
bottomPanelHeight: 0,
variableInspectPanelHeight: 300,
maximizeCanvas: false,
// --- setters (all default to noop, override as needed) ---
setWorkflowRunningData: noop,
setIsListening: noop,
setListeningTriggerType: noop,
setListeningTriggerNodeId: noop,
setListeningTriggerNodeIds: noop,
setListeningTriggerIsAll: noop,
setClipboardElements: noop,
setSelection: noop,
setBundleNodeSize: noop,
setControlMode: noop,
setMousePosition: noop,
setShowConfirm: noop,
setControlPromptEditorRerenderKey: noop,
setShowImportDSLModal: noop,
setFileUploadConfig: noop,
setShowSingleRunPanel: noop,
setNodeAnimation: noop,
setCandidateNode: noop,
setNodeMenu: noop,
setShowAssignVariablePopup: noop,
setHoveringAssignVariableGroupId: noop,
setConnectingNodePayload: noop,
setEnteringNodePayload: noop,
setIterTimes: noop,
setLoopTimes: noop,
setIterParallelLogMap: noop,
setPendingSingleRun: noop,
setShowFeaturesPanel: noop,
setShowWorkflowVersionHistoryPanel: noop,
setShowInputsPanel: noop,
setShowDebugAndPreviewPanel: noop,
setPanelMenu: noop,
setSelectionMenu: noop,
setShowVariableInspectPanel: noop,
setInitShowLastRunTab: noop,
setHelpLineHorizontal: noop,
setHelpLineVertical: noop,
setHistoryWorkflowData: noop,
setShowRunHistory: noop,
setVersionHistory: noop,
setShowChatVariablePanel: noop,
setShowGlobalVariablePanel: noop,
setConversationVariables: noop,
setShowEnvPanel: noop,
setEnvironmentVariables: noop,
setEnvSecrets: noop,
setInputs: noop,
setFiles: noop,
setToolPublished: noop,
setLastPublishedHasUserInput: noop,
setDraftUpdatedAt: noop,
setPublishedAt: noop,
setCurrentVersion: noop,
setIsRestoring: noop,
setBackupDraft: noop,
setSyncWorkflowDraftHash: noop,
setIsSyncingWorkflowDraft: noop,
setIsWorkflowDataLoaded: noop,
setNodes: noop,
flushPendingSync: noop,
setCurrentFocusNodeId: noop,
setNodesWithInspectVars: noop,
setNodeInspectVars: noop,
deleteAllInspectVars: noop,
deleteNodeInspectVars: noop,
setInspectVarValue: noop,
resetToLastRunVar: noop,
renameInspectVarName: noop,
deleteInspectVar: noop,
setWorkflowCanvasWidth: noop,
setWorkflowCanvasHeight: noop,
setRightPanelWidth: noop,
setNodePanelWidth: noop,
setPreviewPanelWidth: noop,
setOtherPanelWidth: noop,
setBottomPanelWidth: noop,
setBottomPanelHeight: noop,
setVariableInspectPanelHeight: noop,
setMaximizeCanvas: noop,
...overrides,
}
}

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: [],
})
})
})