diff --git a/web/app/components/workflow/__tests__/fixtures.ts b/web/app/components/workflow/__tests__/fixtures.ts new file mode 100644 index 0000000000..50a42ebe3d --- /dev/null +++ b/web/app/components/workflow/__tests__/fixtures.ts @@ -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, 'data'> & { data?: Partial & Record } = {}, +): 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, 'data'> & { data?: Partial & Record } = {}): 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, 'data'> & { data?: Partial & Record } = {}, +): Node { + return createNode({ + ...overrides, + data: { type: triggerType, title: `Trigger ${triggerType}`, desc: '', ...overrides.data }, + }) +} + +export function createIterationNode(overrides: Omit, 'data'> & { data?: Partial & Record } = {}): Node { + return createNode({ + ...overrides, + data: { type: BlockEnum.Iteration, title: 'Iteration', desc: '', ...overrides.data }, + }) +} + +export function createLoopNode(overrides: Omit, 'data'> & { data?: Partial & Record } = {}): Node { + return createNode({ + ...overrides, + data: { type: BlockEnum.Loop, title: 'Loop', desc: '', ...overrides.data }, + }) +} + +export function createEdge(overrides: Omit, 'data'> & { data?: Partial & Record } = {}): 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 } diff --git a/web/app/components/workflow/__tests__/mock-hooks-store.ts b/web/app/components/workflow/__tests__/mock-hooks-store.ts new file mode 100644 index 0000000000..9363b31c35 --- /dev/null +++ b/web/app/components/workflow/__tests__/mock-hooks-store.ts @@ -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 = {}) { + 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, + } +} diff --git a/web/app/components/workflow/__tests__/mock-reactflow.ts b/web/app/components/workflow/__tests__/mock-reactflow.ts new file mode 100644 index 0000000000..168713de4c --- /dev/null +++ b/web/app/components/workflow/__tests__/mock-reactflow.ts @@ -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 = {}) { + 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) => React.createElement('div', { 'data-testid': 'handle', ...props }), + BaseEdge: (props: Record) => 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, + } +} diff --git a/web/app/components/workflow/__tests__/mock-workflow-store.ts b/web/app/components/workflow/__tests__/mock-workflow-store.ts new file mode 100644 index 0000000000..112384c4f6 --- /dev/null +++ b/web/app/components/workflow/__tests__/mock-workflow-store.ts @@ -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 = {}) { + 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, + } +} diff --git a/web/app/components/workflow/utils/__tests__/common.spec.ts b/web/app/components/workflow/utils/__tests__/common.spec.ts new file mode 100644 index 0000000000..8c84a21d09 --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/common.spec.ts @@ -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)') + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/data-source.spec.ts b/web/app/components/workflow/utils/__tests__/data-source.spec.ts new file mode 100644 index 0000000000..2de5b7f717 --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/data-source.spec.ts @@ -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>) => + 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 { + 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 { + 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') + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/debug.spec.ts b/web/app/components/workflow/utils/__tests__/debug.spec.ts new file mode 100644 index 0000000000..4439428e09 --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/debug.spec.ts @@ -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() + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/edge.spec.ts b/web/app/components/workflow/utils/__tests__/edge.spec.ts new file mode 100644 index 0000000000..e5067d1866 --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/edge.spec.ts @@ -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)') + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts b/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts new file mode 100644 index 0000000000..662b380f5d --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts @@ -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 & { id: string, width?: number, height?: number, x?: number, y?: number, children?: ElkChild[], ports?: Array<{ id: string }>, layoutOptions?: Record } +type ElkGraph = Record & { id: string, children?: ElkChild[], edges?: Array> } + +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, 'data'> & { data?: Partial & Record } = {}): Node { + return createNode({ + type: CUSTOM_NODE, + ...overrides, + }) +} + +function makeWorkflowEdge(overrides: Omit, 'data'> & { data?: Partial & Record } = {}): 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) => 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 }) + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/gen-node-meta-data.spec.ts b/web/app/components/workflow/utils/__tests__/gen-node-meta-data.spec.ts new file mode 100644 index 0000000000..86203c76b1 --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/gen-node-meta-data.spec.ts @@ -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) + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/node-navigation.spec.ts b/web/app/components/workflow/utils/__tests__/node-navigation.spec.ts new file mode 100644 index 0000000000..8ccdff0604 --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/node-navigation.spec.ts @@ -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() + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/node.spec.ts b/web/app/components/workflow/utils/__tests__/node.spec.ts new file mode 100644 index 0000000000..19f3a1614a --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/node.spec.ts @@ -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() + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/tool.spec.ts b/web/app/components/workflow/utils/__tests__/tool.spec.ts new file mode 100644 index 0000000000..baa61d8a4e --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/tool.spec.ts @@ -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>) => + 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 { + 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 { + 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) + + 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', + }) + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/trigger.spec.ts b/web/app/components/workflow/utils/__tests__/trigger.spec.ts new file mode 100644 index 0000000000..b74126d69f --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/trigger.spec.ts @@ -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 { + 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 { + 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) + + 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) + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/variable.spec.ts b/web/app/components/workflow/utils/__tests__/variable.spec.ts new file mode 100644 index 0000000000..065e2187ac --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/variable.spec.ts @@ -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) + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/workflow-entry.spec.ts b/web/app/components/workflow/utils/__tests__/workflow-entry.spec.ts new file mode 100644 index 0000000000..5a2a3d8e47 --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/workflow-entry.spec.ts @@ -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) + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/workflow-init.spec.ts b/web/app/components/workflow/utils/__tests__/workflow-init.spec.ts new file mode 100644 index 0000000000..15aa2a933d --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/workflow-init.spec.ts @@ -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() + 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>) => branches.map((b: Record, 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) + }) +}) diff --git a/web/app/components/workflow/utils/__tests__/workflow.spec.ts b/web/app/components/workflow/utils/__tests__/workflow.spec.ts new file mode 100644 index 0000000000..165b4d5ee6 --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/workflow.spec.ts @@ -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) + }) +}) diff --git a/web/app/components/workflow/utils/workflow-init.spec.ts b/web/app/components/workflow/utils/workflow-init.spec.ts deleted file mode 100644 index 8dfcbeb30d..0000000000 --- a/web/app/components/workflow/utils/workflow-init.spec.ts +++ /dev/null @@ -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: [], - }) - }) -})