From 62f4361f4653c453899c2ccc2ff7367418e38530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Fri, 15 Aug 2025 11:46:09 +0200 Subject: [PATCH] fix(editor): Revert changes from PRs #16059, #17959, #17759, and #17585 (#18382) --- cypress/e2e/14-mapping.cy.ts | 4 +- .../e2e/27-two-factor-authentication.cy.ts | 2 +- cypress/e2e/33-settings-personal.cy.ts | 2 +- cypress/pages/ndv.ts | 2 +- cypress/support/commands.ts | 2 +- .../workflow/src/errors/expression.error.ts | 19 - packages/workflow/src/workflow-data-proxy.ts | 122 +- packages/workflow/src/workflow.ts | 130 +- .../test/paired-item-path-detection.test.ts | 1233 ----------------- .../workflow/test/workflow-data-proxy.test.ts | 217 +-- packages/workflow/test/workflow.test.ts | 867 ------------ 11 files changed, 83 insertions(+), 2517 deletions(-) delete mode 100644 packages/workflow/test/paired-item-path-detection.test.ts diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts index 3af70bc270..b8d263941f 100644 --- a/cypress/e2e/14-mapping.cy.ts +++ b/cypress/e2e/14-mapping.cy.ts @@ -1,10 +1,10 @@ -import { WorkflowPage, NDV } from '../pages'; -import { getVisibleSelect } from '../utils'; import { MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, SCHEDULE_TRIGGER_NODE_NAME, } from './../constants'; +import { WorkflowPage, NDV } from '../pages'; +import { getVisibleSelect } from '../utils'; const workflowPage = new WorkflowPage(); const ndv = new NDV(); diff --git a/cypress/e2e/27-two-factor-authentication.cy.ts b/cypress/e2e/27-two-factor-authentication.cy.ts index 877345ccc1..2201e65285 100644 --- a/cypress/e2e/27-two-factor-authentication.cy.ts +++ b/cypress/e2e/27-two-factor-authentication.cy.ts @@ -1,11 +1,11 @@ import generateOTPToken from 'cypress-otp'; +import { MainSidebar } from './../pages/sidebar/main-sidebar'; import { INSTANCE_OWNER, INSTANCE_ADMIN, BACKEND_BASE_URL } from '../constants'; import { SigninPage } from '../pages'; import { MfaLoginPage } from '../pages/mfa-login'; import { successToast } from '../pages/notifications'; import { PersonalSettingsPage } from '../pages/settings-personal'; -import { MainSidebar } from './../pages/sidebar/main-sidebar'; const MFA_SECRET = 'KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD'; diff --git a/cypress/e2e/33-settings-personal.cy.ts b/cypress/e2e/33-settings-personal.cy.ts index 183655cbe8..6b5cc94687 100644 --- a/cypress/e2e/33-settings-personal.cy.ts +++ b/cypress/e2e/33-settings-personal.cy.ts @@ -35,7 +35,7 @@ describe('Personal Settings', () => { successToast().find('.el-notification__closeBtn').click(); }); }); - + // eslint-disable-next-line n8n-local-rules/no-skipped-tests it('not allow malicious values for personal data', () => { cy.visit('/settings/personal'); INVALID_NAMES.forEach((name) => { diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 5b672d83eb..caa2ead737 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -305,7 +305,7 @@ export class NDV extends BasePage { this.actions.typeIntoParameterInput(fieldName, invalidExpression ?? "{{ $('unknown')", { parseSpecialCharSequences: false, }); - this.actions.validateExpressionPreview(fieldName, 'No path back to node'); + this.actions.validateExpressionPreview(fieldName, "node doesn't exist"); }, openSettings: () => { this.getters.nodeSettingsTab().click(); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c5d939f6b6..361b0c0e06 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -149,7 +149,7 @@ Cypress.Commands.add('grantBrowserPermissions', (...permissions: string[]) => { }); Cypress.Commands.add('readClipboard', () => - cy.window().then(async (win) => await win.navigator.clipboard.readText()), + cy.window().then((win) => win.navigator.clipboard.readText()), ); Cypress.Commands.add('paste', { prevSubject: true }, (selector, pastePayload) => { diff --git a/packages/workflow/src/errors/expression.error.ts b/packages/workflow/src/errors/expression.error.ts index 9e97418e92..8b8bb6ef86 100644 --- a/packages/workflow/src/errors/expression.error.ts +++ b/packages/workflow/src/errors/expression.error.ts @@ -28,25 +28,6 @@ export interface ExpressionErrorOptions { /** * Class for instantiating an expression error */ -// Expression error constants -export const EXPRESSION_ERROR_MESSAGES = { - NODE_NOT_FOUND: 'Error finding the referenced node', - NODE_REFERENCE_TEMPLATE: - 'Make sure the node you referenced is spelled correctly and is a parent of this node', - NO_EXECUTION_DATA: 'No execution data available', -} as const; - -export const EXPRESSION_ERROR_TYPES = { - PAIRED_ITEM_NO_CONNECTION: 'paired_item_no_connection', -} as const; - -export const EXPRESSION_DESCRIPTION_KEYS = { - NODE_NOT_FOUND: 'nodeNotFound', - NO_NODE_EXECUTION_DATA: 'noNodeExecutionData', - PAIRED_ITEM_NO_CONNECTION: 'pairedItemNoConnection', - PAIRED_ITEM_NO_CONNECTION_CODE_NODE: 'pairedItemNoConnectionCodeNode', -} as const; - export class ExpressionError extends ExecutionBaseError { constructor(message: string, options?: ExpressionErrorOptions) { super(message, { cause: options?.cause, level: 'warning' }); diff --git a/packages/workflow/src/workflow-data-proxy.ts b/packages/workflow/src/workflow-data-proxy.ts index 8256a1b9cc..532dde41ca 100644 --- a/packages/workflow/src/workflow-data-proxy.ts +++ b/packages/workflow/src/workflow-data-proxy.ts @@ -8,13 +8,7 @@ import { DateTime, Duration, Interval, Settings } from 'luxon'; import { augmentArray, augmentObject } from './augment-object'; import { AGENT_LANGCHAIN_NODE_TYPE, SCRIPTING_NODE_TYPES } from './constants'; import { ApplicationError } from '@n8n/errors'; -import { - ExpressionError, - type ExpressionErrorOptions, - EXPRESSION_ERROR_MESSAGES, - EXPRESSION_ERROR_TYPES, - EXPRESSION_DESCRIPTION_KEYS, -} from './errors/expression.error'; +import { ExpressionError, type ExpressionErrorOptions } from './errors/expression.error'; import { getGlobalState } from './global-state'; import { NodeConnectionTypes } from './interfaces'; import type { @@ -396,13 +390,11 @@ export class WorkflowDataProxy { } if (!that.workflow.getNode(nodeName)) { - throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NODE_NOT_FOUND, { - messageTemplate: EXPRESSION_ERROR_MESSAGES.NODE_REFERENCE_TEMPLATE, + throw new ExpressionError("Referenced node doesn't exist", { runIndex: that.runIndex, itemIndex: that.itemIndex, nodeCause: nodeName, - descriptionKey: EXPRESSION_DESCRIPTION_KEYS.NODE_NOT_FOUND, - type: EXPRESSION_ERROR_TYPES.PAIRED_ITEM_NO_CONNECTION, + descriptionKey: 'nodeNotFound', }); } @@ -410,12 +402,11 @@ export class WorkflowDataProxy { !that.runExecutionData.resultData.runData.hasOwnProperty(nodeName) && !getPinDataIfManualExecution(that.workflow, nodeName, that.mode) ) { - throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NODE_NOT_FOUND, { - messageTemplate: EXPRESSION_ERROR_MESSAGES.NODE_REFERENCE_TEMPLATE, + throw new ExpressionError('Referenced node is unexecuted', { runIndex: that.runIndex, itemIndex: that.itemIndex, - type: EXPRESSION_ERROR_TYPES.PAIRED_ITEM_NO_CONNECTION, - descriptionKey: EXPRESSION_DESCRIPTION_KEYS.NO_NODE_EXECUTION_DATA, + type: 'no_node_execution_data', + descriptionKey: 'noNodeExecutionData', nodeCause: nodeName, }); } @@ -505,16 +496,11 @@ export class WorkflowDataProxy { name = name.toString(); if (!node) { - throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NODE_NOT_FOUND, { - messageTemplate: EXPRESSION_ERROR_MESSAGES.NODE_REFERENCE_TEMPLATE, - functionality: 'pairedItem', - descriptionKey: isScriptingNode(nodeName, that.workflow) - ? EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION_CODE_NODE - : EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION, - type: EXPRESSION_ERROR_TYPES.PAIRED_ITEM_NO_CONNECTION, - nodeCause: nodeName, + throw new ExpressionError("Referenced node doesn't exist", { runIndex: that.runIndex, itemIndex: that.itemIndex, + nodeCause: nodeName, + descriptionKey: 'nodeNotFound', }); } @@ -530,7 +516,7 @@ export class WorkflowDataProxy { if (executionData.length === 0) { if (that.workflow.getParentNodes(nodeName).length === 0) { - throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NO_EXECUTION_DATA, { + throw new ExpressionError('No execution data available', { messageTemplate: 'No execution data available to expression under ‘%%PARAMETER%%’', descriptionKey: 'noInputConnection', @@ -541,7 +527,7 @@ export class WorkflowDataProxy { }); } - throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NO_EXECUTION_DATA, { + throw new ExpressionError('No execution data available', { runIndex: that.runIndex, itemIndex: that.itemIndex, type: 'no_execution_data', @@ -707,16 +693,11 @@ export class WorkflowDataProxy { const nodeName = name.toString(); if (that.workflow.getNode(nodeName) === null) { - throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NODE_NOT_FOUND, { - messageTemplate: EXPRESSION_ERROR_MESSAGES.NODE_REFERENCE_TEMPLATE, - functionality: 'pairedItem', - descriptionKey: isScriptingNode(nodeName, that.workflow) - ? EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION_CODE_NODE - : EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION, - type: EXPRESSION_ERROR_TYPES.PAIRED_ITEM_NO_CONNECTION, - nodeCause: nodeName, + throw new ExpressionError("Referenced node doesn't exist", { runIndex: that.runIndex, itemIndex: that.itemIndex, + nodeCause: nodeName, + descriptionKey: 'nodeNotFound', }); } @@ -833,14 +814,14 @@ export class WorkflowDataProxy { }); }; - const createNodeReferenceError = (nodeCause: string) => { - return createExpressionError(EXPRESSION_ERROR_MESSAGES.NODE_NOT_FOUND, { - messageTemplate: EXPRESSION_ERROR_MESSAGES.NODE_REFERENCE_TEMPLATE, + const createNoConnectionError = (nodeCause: string) => { + return createExpressionError('Invalid expression', { + messageTemplate: 'No path back to referenced node', functionality: 'pairedItem', descriptionKey: isScriptingNode(nodeCause, that.workflow) - ? EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION_CODE_NODE - : EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION, - type: EXPRESSION_ERROR_TYPES.PAIRED_ITEM_NO_CONNECTION, + ? 'pairedItemNoConnectionCodeNode' + : 'pairedItemNoConnection', + type: 'paired_item_no_connection', moreInfoLink: true, nodeCause, }); @@ -1009,7 +990,7 @@ export class WorkflowDataProxy { const matchedItems = results.filter((result) => result.ok).map((result) => result.result); if (matchedItems.length === 0) { - if (sourceArray.length === 0) throw createNodeReferenceError(destinationNodeName); + if (sourceArray.length === 0) throw createNoConnectionError(destinationNodeName); throw createBranchNotFoundError(sourceData.previousNode, pairedItem.item, nodeBeforeLast); } @@ -1050,7 +1031,7 @@ export class WorkflowDataProxy { inputData?.[NodeConnectionTypes.AiTool]?.[0]?.[itemIndex].json; if (!placeholdersDataInputData) { - throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NO_EXECUTION_DATA, { + throw new ExpressionError('No execution data available', { runIndex, itemIndex, type: 'no_execution_data', @@ -1072,7 +1053,12 @@ export class WorkflowDataProxy { const referencedNode = that.workflow.getNode(nodeName); if (referencedNode === null) { - throw createNodeReferenceError(nodeName); + throw createExpressionError("Referenced node doesn't exist", { + runIndex: that.runIndex, + itemIndex: that.itemIndex, + nodeCause: nodeName, + descriptionKey: 'nodeNotFound', + }); } const ensureNodeExecutionData = () => { @@ -1080,38 +1066,16 @@ export class WorkflowDataProxy { !that?.runExecutionData?.resultData?.runData.hasOwnProperty(nodeName) && !getPinDataIfManualExecution(that.workflow, nodeName, that.mode) ) { - // Always show helpful "Execute node for preview" message - throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NO_EXECUTION_DATA, { - messageTemplate: `Execute node "${nodeName}" for preview`, - nodeCause: nodeName, + throw createExpressionError('Referenced node is unexecuted', { runIndex: that.runIndex, itemIndex: that.itemIndex, + type: 'no_node_execution_data', + descriptionKey: 'noNodeExecutionData', + nodeCause: nodeName, }); } }; - const ensureValidPath = () => { - // Check path before execution data - const referencedNode = that.workflow.getNode(nodeName); - if (!referencedNode) { - throw createNodeReferenceError(nodeName); - } - - const activeNode = that.workflow.getNode(that.activeNodeName); - let contextNode = that.contextNodeName; - if (activeNode) { - const parentMainInputNode = that.workflow.getParentMainInputNode(activeNode); - contextNode = parentMainInputNode?.name ?? contextNode; - } - - // For .first(), .last(), .all() methods, use unidirectional path checking - // (forward only) to maintain traditional paired item behavior - const hasForwardPath = that.workflow.getChildNodes(nodeName).includes(contextNode); - if (!hasForwardPath) { - throw createNodeReferenceError(nodeName); - } - }; - return new Proxy( {}, { @@ -1144,24 +1108,17 @@ export class WorkflowDataProxy { property === PAIRED_ITEM_METHOD.ITEM ) { // Before resolving the pairedItem make sure that the requested node comes in the - // graph before the current one or exists in the workflow - const referencedNode = that.workflow.getNode(nodeName); - if (!referencedNode) { - // Node doesn't exist in the workflow (could be trimmed manual execution) - throw createNodeReferenceError(nodeName); - } - + // graph before the current one const activeNode = that.workflow.getNode(that.activeNodeName); let contextNode = that.contextNodeName; if (activeNode) { const parentMainInputNode = that.workflow.getParentMainInputNode(activeNode); - contextNode = parentMainInputNode?.name ?? contextNode; + contextNode = parentMainInputNode.name ?? contextNode; } - - // Use bidirectional path checking to handle AI/tool nodes properly - if (!that.workflow.hasPath(nodeName, contextNode)) { - throw createNodeReferenceError(nodeName); + const parentNodes = that.workflow.getParentNodes(contextNode); + if (!parentNodes.includes(nodeName)) { + throw createNoConnectionError(nodeName); } ensureNodeExecutionData(); @@ -1242,7 +1199,6 @@ export class WorkflowDataProxy { } if (property === 'first') { - ensureValidPath(); ensureNodeExecutionData(); return (branchIndex?: number, runIndex?: number) => { branchIndex = @@ -1261,7 +1217,6 @@ export class WorkflowDataProxy { }; } if (property === 'last') { - ensureValidPath(); ensureNodeExecutionData(); return (branchIndex?: number, runIndex?: number) => { branchIndex = @@ -1283,7 +1238,6 @@ export class WorkflowDataProxy { }; } if (property === 'all') { - ensureValidPath(); ensureNodeExecutionData(); return (branchIndex?: number, runIndex?: number) => { branchIndex = @@ -1322,7 +1276,7 @@ export class WorkflowDataProxy { if (property === 'isProxy') return true; if (that.connectionInputData.length === 0) { - throw createExpressionError(EXPRESSION_ERROR_MESSAGES.NO_EXECUTION_DATA, { + throw createExpressionError('No execution data available', { runIndex: that.runIndex, itemIndex: that.itemIndex, type: 'no_execution_data', diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index 4ff469cc86..99a7404f35 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -36,6 +36,7 @@ import type { INodeConnection, IObservableObject, NodeParameterValueType, + INodeOutputConfiguration, NodeConnectionType, } from './interfaces'; import { NodeConnectionTypes } from './interfaces'; @@ -688,39 +689,40 @@ export class Workflow { return returnConns; } - getParentMainInputNode( - node: INode | null | undefined, - visitedNodes: Set = new Set(), - ): INode | null | undefined { - if (!node) return node; + getParentMainInputNode(node: INode): INode { + if (node) { + const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + const outputs = NodeHelpers.getNodeOutputs(this, node, nodeType.description); - // Prevent infinite recursion by tracking visited nodes - if (visitedNodes.has(node.name)) { - return node; - } - visitedNodes.add(node.name); - - const nodeConnections = this.connectionsBySourceNode[node.name]; - if (!nodeConnections) { - return node; - } - - // Get non-main connection types that this node connects TO (outgoing connections) - const nonMainConnectionTypes = Object.keys(nodeConnections).filter( - (type) => type !== NodeConnectionTypes.Main, - ); - - for (const connectionType of nonMainConnectionTypes) { - const connections = nodeConnections[connectionType] ?? []; - for (const connectionGroup of connections) { - for (const connection of connectionGroup ?? []) { - if (connection?.node) { - const returnNode = this.getNode(connection.node); - if (!returnNode) { - throw new ApplicationError(`Node "${connection.node}" not found`); - } - return this.getParentMainInputNode(returnNode, visitedNodes); + if ( + outputs.find( + (output) => + ((output as INodeOutputConfiguration)?.type ?? output) !== NodeConnectionTypes.Main, + ) + ) { + // Get the first node which is connected to a non-main output + const nonMainNodesConnected = outputs?.reduce((acc, outputName) => { + const parentNodes = this.getChildNodes( + node.name, + (outputName as INodeOutputConfiguration)?.type ?? outputName, + ); + if (parentNodes.length > 0) { + acc.push(...parentNodes); } + return acc; + }, [] as string[]); + + if (nonMainNodesConnected.length) { + const returnNode = this.getNode(nonMainNodesConnected[0]); + if (returnNode === null) { + // This should theoretically never happen as the node is connected + // but who knows and it makes TS happy + throw new ApplicationError(`Node "${nonMainNodesConnected[0]}" not found`); + } + + // The chain of non-main nodes is potentially not finished yet so + // keep on going + return this.getParentMainInputNode(returnNode); } } } @@ -929,70 +931,4 @@ export class Workflow { return result; } - - /** - * Checks if there's a bidirectional path between two nodes. - * This handles AI/tool nodes that have complex connection patterns - * where simple parent-child traversal doesn't work. - * - * @param fromNodeName The starting node name - * @param toNodeName The target node name - * @param maxDepth Maximum depth to search (default: 50) - * @returns true if there's a path between the nodes - */ - hasPath(fromNodeName: string, toNodeName: string, maxDepth = 50): boolean { - if (fromNodeName === toNodeName) return true; - - // Special case: If the source node has pinned data, consider it as having a valid path - // This is important for single node execution scenarios where pinned data creates virtual paths - if (this.getPinDataOfNode(fromNodeName)) { - return true; - } - - // Get connection types that actually exist in this workflow - // We need both source and destination connection types for bidirectional search - const connectionTypes = new Set(); - for (const nodeConnections of Object.values(this.connectionsBySourceNode).concat( - Object.values(this.connectionsByDestinationNode), - )) { - for (const type of Object.keys(nodeConnections)) { - connectionTypes.add(type as NodeConnectionType); - } - } - - const visited = new Set(); - const queue: Array<{ nodeName: string; depth: number }> = [ - { nodeName: fromNodeName, depth: 0 }, - ]; - - while (queue.length > 0) { - const { nodeName, depth } = queue.shift()!; - - if (depth > maxDepth) continue; - if (visited.has(nodeName)) continue; - if (nodeName === toNodeName) return true; - - visited.add(nodeName); - - for (const connectionType of connectionTypes) { - // Get children (forward direction) - const children = this.getChildNodes(nodeName, connectionType); - for (const childName of children) { - if (!visited.has(childName)) { - queue.push({ nodeName: childName, depth: depth + 1 }); - } - } - - // Get parents (backward direction) - const parents = this.getParentNodes(nodeName, connectionType); - for (const parentName of parents) { - if (!visited.has(parentName)) { - queue.push({ nodeName: parentName, depth: depth + 1 }); - } - } - } - } - - return false; - } } diff --git a/packages/workflow/test/paired-item-path-detection.test.ts b/packages/workflow/test/paired-item-path-detection.test.ts deleted file mode 100644 index a1e94fc5a4..0000000000 --- a/packages/workflow/test/paired-item-path-detection.test.ts +++ /dev/null @@ -1,1233 +0,0 @@ -import { NodeTypes } from './helpers'; -import { ExpressionError } from '../src/errors/expression.error'; -import type { IExecuteData, INode, IWorkflowBase, IRun, IConnections } from '../src/interfaces'; -import { NodeConnectionTypes } from '../src/interfaces'; -import { Workflow } from '../src/workflow'; -import { WorkflowDataProxy } from '../src/workflow-data-proxy'; - -describe('Paired Item Path Detection', () => { - /** - * Helper to create a minimal workflow for testing - */ - const createWorkflow = (nodes: INode[], connections: IConnections = {}): IWorkflowBase => ({ - id: '1', - name: 'test-workflow', - nodes, - connections, - active: false, - settings: {}, - isArchived: false, - updatedAt: new Date(), - createdAt: new Date(), - }); - - /** - * Helper to create a WorkflowDataProxy for testing - */ - const createProxy = ( - workflow: IWorkflowBase, - activeNodeName: string, - run?: IRun | null, - executeData?: IExecuteData, - ) => { - const wf = new Workflow({ - id: workflow.id, - name: workflow.name, - nodes: workflow.nodes, - connections: workflow.connections, - active: workflow.active, - nodeTypes: NodeTypes(), - settings: workflow.settings, - }); - - return new WorkflowDataProxy( - wf, - run?.data ?? null, - 0, // runIndex - 0, // itemIndex - activeNodeName, - [], // connectionInputData - {}, // siblingParameters - 'manual', // mode - {}, // additionalKeys - executeData, - ).getDataProxy(); - }; - - describe('AI/Tool Node Scenarios', () => { - test('should detect path in bidirectional AI/tool node setup', () => { - // Scenario: Code1 -> Vector Store <- Default Data Loader - const nodes: INode[] = [ - { - id: '1', - name: 'Code1', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'Vector Store', - type: 'n8n-nodes-langchain.vectorStore', - typeVersion: 1, - position: [300, 100], - parameters: {}, - }, - { - id: '3', - name: 'Default Data Loader', - type: 'n8n-nodes-langchain.documentDefaultDataLoader', - typeVersion: 1, - position: [100, 200], - parameters: {}, - }, - { - id: '4', - name: 'Code2', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [500, 100], - parameters: { - jsCode: '// Reference Code1 using $()\nreturn $("Code1").all();', - }, - }, - ]; - - const connections = { - Code1: { - [NodeConnectionTypes.Main]: [ - [{ node: 'Vector Store', type: NodeConnectionTypes.AiVectorStore, index: 0 }], - ], - }, - 'Default Data Loader': { - [NodeConnectionTypes.Main]: [ - [{ node: 'Vector Store', type: NodeConnectionTypes.AiDocument, index: 0 }], - ], - }, - 'Vector Store': { - [NodeConnectionTypes.Main]: [ - [{ node: 'Code2', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - - const workflow = createWorkflow(nodes, connections); - const wf = new Workflow({ - id: workflow.id, - name: workflow.name, - nodes: workflow.nodes, - connections: workflow.connections, - active: workflow.active, - nodeTypes: NodeTypes(), - settings: workflow.settings, - }); - - // Test bidirectional path detection - expect(wf.hasPath('Code1', 'Code2')).toBe(true); - expect(wf.hasPath('Default Data Loader', 'Code2')).toBe(true); - expect(wf.hasPath('Code1', 'Default Data Loader')).toBe(true); // Via Vector Store - - // Test that unconnected nodes return false - const unconnectedNode: INode = { - id: '5', - name: 'Unconnected', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [700, 100], - parameters: {}, - }; - const workflowWithUnconnected = createWorkflow([...nodes, unconnectedNode], connections); - const wfWithUnconnected = new Workflow({ - id: workflowWithUnconnected.id, - name: workflowWithUnconnected.name, - nodes: workflowWithUnconnected.nodes, - connections: workflowWithUnconnected.connections, - active: workflowWithUnconnected.active, - nodeTypes: NodeTypes(), - settings: workflowWithUnconnected.settings, - }); - - expect(wfWithUnconnected.hasPath('Code1', 'Unconnected')).toBe(false); - }); - - test('should handle complex AI tool connection patterns', () => { - // More complex AI scenario with multiple connection types - const nodes: INode[] = [ - { - id: '1', - name: 'Agent', - type: 'n8n-nodes-langchain.agent', - typeVersion: 1, - position: [300, 300], - parameters: {}, - }, - { - id: '2', - name: 'Tool1', - type: 'n8n-nodes-langchain.toolHttpRequest', - typeVersion: 1, - position: [100, 200], - parameters: {}, - }, - { - id: '3', - name: 'Tool2', - type: 'n8n-nodes-langchain.toolCalculator', - typeVersion: 1, - position: [100, 400], - parameters: {}, - }, - { - id: '4', - name: 'Memory', - type: 'n8n-nodes-langchain.memoryBufferMemory', - typeVersion: 1, - position: [200, 100], - parameters: {}, - }, - ]; - - const connections = { - Tool1: { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - Tool2: { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 1 }], - ], - }, - Memory: { - [NodeConnectionTypes.AiMemory]: [ - [{ node: 'Agent', type: NodeConnectionTypes.AiMemory, index: 0 }], - ], - }, - }; - - const workflow = createWorkflow(nodes, connections); - const wf = new Workflow({ - id: workflow.id, - name: workflow.name, - nodes: workflow.nodes, - connections: workflow.connections, - active: workflow.active, - nodeTypes: NodeTypes(), - settings: workflow.settings, - }); - - // Test all tools can reach the agent - expect(wf.hasPath('Tool1', 'Agent')).toBe(true); - expect(wf.hasPath('Tool2', 'Agent')).toBe(true); - expect(wf.hasPath('Memory', 'Agent')).toBe(true); - - // Test bidirectional paths - expect(wf.hasPath('Agent', 'Tool1')).toBe(true); - expect(wf.hasPath('Agent', 'Tool2')).toBe(true); - expect(wf.hasPath('Agent', 'Memory')).toBe(true); - - // Test indirect connections - expect(wf.hasPath('Tool1', 'Tool2')).toBe(true); // Via Agent - expect(wf.hasPath('Memory', 'Tool1')).toBe(true); // Via Agent - }); - }); - - describe('Manual Execution Node-Not-Found Scenarios', () => { - test('should throw "No path back to referenced node" when node does not exist in trimmed workflow', () => { - // Simulate manual execution scenario where node D is not in the trimmed workflow - const nodes: INode[] = [ - { - id: '1', - name: 'A', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'B', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [300, 100], - parameters: { - jsCode: 'return $("D").all(); // Reference missing node D', - }, - }, - { - id: '3', - name: 'C', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [500, 100], - parameters: {}, - }, - ]; - - const connections = { - A: { - [NodeConnectionTypes.Main]: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]], - }, - B: { - [NodeConnectionTypes.Main]: [[{ node: 'C', type: NodeConnectionTypes.Main, index: 0 }]], - }, - }; - - const workflow = createWorkflow(nodes, connections); - const proxy = createProxy(workflow, 'B'); - - // Should throw error when trying to access non-existent node D - expect(() => proxy.$('D')).toThrowError(ExpressionError); - expect(() => proxy.$('D')).toThrow(/Error finding the referenced node/); - }); - - test('should throw "No path back to referenced node" when node exists but has no path', () => { - // Node D exists but is not connected - const nodes: INode[] = [ - { - id: '1', - name: 'A', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'B', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [300, 100], - parameters: { - jsCode: 'return $("D").all(); // Reference unconnected node D', - }, - }, - { - id: '3', - name: 'C', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [500, 100], - parameters: {}, - }, - { - id: '4', - name: 'D', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [100, 300], - parameters: {}, - }, - ]; - - const connections = { - A: { - [NodeConnectionTypes.Main]: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]], - }, - B: { - [NodeConnectionTypes.Main]: [[{ node: 'C', type: NodeConnectionTypes.Main, index: 0 }]], - }, - // D is not connected - }; - - const workflow = createWorkflow(nodes, connections); - - // Create executeData to simulate a real execution context - const executeData: IExecuteData = { - data: { - main: [[]], - }, - node: nodes.find((n) => n.name === 'B')!, - source: { - main: [ - { - previousNode: 'A', - previousNodeOutput: 0, - previousNodeRun: 0, - }, - ], - }, - }; - - const proxy = createProxy(workflow, 'B', null, executeData); - - // Should throw error when trying to access paired item from unconnected node D - let error: ExpressionError | undefined; - try { - proxy.$('D').item; - } catch (e) { - error = e as ExpressionError; - } - - expect(error).toBeDefined(); - expect(error).toBeInstanceOf(ExpressionError); - expect(error!.context.type).toBe('paired_item_no_connection'); - expect(error!.context.descriptionKey).toBe('pairedItemNoConnectionCodeNode'); - }); - }); - - describe('Workflow.hasPath method', () => { - test('should handle self-reference', () => { - const nodes: INode[] = [ - { - id: '1', - name: 'A', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - ]; - - const workflow = createWorkflow(nodes, {}); - const wf = new Workflow({ - id: workflow.id, - name: workflow.name, - nodes: workflow.nodes, - connections: workflow.connections, - active: workflow.active, - nodeTypes: NodeTypes(), - settings: workflow.settings, - }); - - expect(wf.hasPath('A', 'A')).toBe(true); - }); - - test('should respect maximum depth limit', () => { - const nodes: INode[] = [ - { - id: '1', - name: 'A', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'B', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [300, 100], - parameters: {}, - }, - ]; - - const connections = { - A: { - [NodeConnectionTypes.Main]: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]], - }, - }; - - const workflow = createWorkflow(nodes, connections); - const wf = new Workflow({ - id: workflow.id, - name: workflow.name, - nodes: workflow.nodes, - connections: workflow.connections, - active: workflow.active, - nodeTypes: NodeTypes(), - settings: workflow.settings, - }); - - // Should find path with sufficient depth - expect(wf.hasPath('A', 'B', 10)).toBe(true); - - // Should not find path with insufficient depth - expect(wf.hasPath('A', 'B', 0)).toBe(false); - }); - - test('should handle cycles without infinite loops', () => { - const nodes: INode[] = [ - { - id: '1', - name: 'A', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'B', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [300, 100], - parameters: {}, - }, - { - id: '3', - name: 'C', - type: 'n8n-nodes-base.code', - typeVersion: 1, - position: [500, 100], - parameters: {}, - }, - ]; - - // Create a cycle: A -> B -> C -> A - const connections = { - A: { - [NodeConnectionTypes.Main]: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]], - }, - B: { - [NodeConnectionTypes.Main]: [[{ node: 'C', type: NodeConnectionTypes.Main, index: 0 }]], - }, - C: { - [NodeConnectionTypes.Main]: [[{ node: 'A', type: NodeConnectionTypes.Main, index: 0 }]], - }, - }; - - const workflow = createWorkflow(nodes, connections); - const wf = new Workflow({ - id: workflow.id, - name: workflow.name, - nodes: workflow.nodes, - connections: workflow.connections, - active: workflow.active, - nodeTypes: NodeTypes(), - settings: workflow.settings, - }); - - // Should handle cycles correctly - expect(wf.hasPath('A', 'C')).toBe(true); - expect(wf.hasPath('B', 'A')).toBe(true); - expect(wf.hasPath('C', 'B')).toBe(true); - }); - }); - - describe('Actual workflow', () => { - test('should show correct error message for disconnected nodes', () => { - // Recreate the exact scenario from the user's workflow - const nodes: INode[] = [ - { - id: 'afc0fc26-d521-4464-9f90-3327559bd4a6', - name: 'On form submission', - type: 'n8n-nodes-base.formTrigger', - typeVersion: 2.2, - position: [0, 0], - parameters: { - formTitle: 'Submit BBS application', - }, - }, - { - id: 'c5861385-d513-4d74-8fe3-e5acbe08a90a', - name: 'Code', - type: 'n8n-nodes-base.code', - typeVersion: 2, - position: [288, 432], - parameters: { - jsCode: "\nreturn $('On form submission').all();", - }, - }, - { - id: '523b019b-e456-4784-a50a-18558c858c3b', - name: "When clicking 'Test workflow'", - type: 'n8n-nodes-base.manualTrigger', - typeVersion: 1, - position: [0, 288], - parameters: {}, - }, - { - id: '3057aebb-d87a-4142-8354-f298e41ab919', - name: 'Edit Fields', - type: 'n8n-nodes-base.set', - typeVersion: 3.4, - position: [288, 128], - parameters: { - assignments: { - assignments: [ - { - id: '9c260756-a7ce-41ba-ad9b-0eb1ceeaf02b', - name: 'test', - value: "={{ $('On form submission').item.json }}", - type: 'string', - }, - ], - }, - }, - }, - ]; - - const connections = { - 'On form submission': { - [NodeConnectionTypes.Main]: [[]], - }, - "When clicking 'Test workflow'": { - [NodeConnectionTypes.Main]: [ - [ - { node: 'Code', type: NodeConnectionTypes.Main, index: 0 }, - { node: 'Edit Fields', type: NodeConnectionTypes.Main, index: 0 }, - ], - ], - }, - }; - - const workflow = createWorkflow(nodes, connections); - const proxy = createProxy(workflow, 'Code'); - - // Should throw the correct error when trying to access disconnected node - let error: ExpressionError | undefined; - try { - proxy.$('On form submission').all(); - } catch (e) { - error = e as ExpressionError; - } - - expect(error).toBeDefined(); - expect(error).toBeInstanceOf(ExpressionError); - expect(error!.context.type).toBe('paired_item_no_connection'); - expect(error!.context.descriptionKey).toBe('pairedItemNoConnection'); - expect(error!.message).toBe('Error finding the referenced node'); - expect(error!.context.messageTemplate).toBe( - 'Make sure the node you referenced is spelled correctly and is a parent of this node', - ); - }); - - test('should also show correct error for Edit Fields node', () => { - // Test the Edit Fields node as well - const nodes: INode[] = [ - { - id: 'afc0fc26-d521-4464-9f90-3327559bd4a6', - name: 'On form submission', - type: 'n8n-nodes-base.formTrigger', - typeVersion: 2.2, - position: [0, 0], - parameters: { - formTitle: 'Submit BBS application', - }, - }, - { - id: 'c5861385-d513-4d74-8fe3-e5acbe08a90a', - name: 'Code', - type: 'n8n-nodes-base.code', - typeVersion: 2, - position: [288, 432], - parameters: { - jsCode: "\nreturn $('On form submission').all();", - }, - }, - { - id: '523b019b-e456-4784-a50a-18558c858c3b', - name: "When clicking 'Test workflow'", - type: 'n8n-nodes-base.manualTrigger', - typeVersion: 1, - position: [0, 288], - parameters: {}, - }, - { - id: '3057aebb-d87a-4142-8354-f298e41ab919', - name: 'Edit Fields', - type: 'n8n-nodes-base.set', - typeVersion: 3.4, - position: [288, 128], - parameters: { - assignments: { - assignments: [ - { - id: '9c260756-a7ce-41ba-ad9b-0eb1ceeaf02b', - name: 'test', - value: "={{ $('On form submission').item.json }}", - type: 'string', - }, - ], - }, - }, - }, - ]; - - const connections = { - 'On form submission': { - [NodeConnectionTypes.Main]: [[]], - }, - "When clicking 'Test workflow'": { - [NodeConnectionTypes.Main]: [ - [ - { node: 'Code', type: NodeConnectionTypes.Main, index: 0 }, - { node: 'Edit Fields', type: NodeConnectionTypes.Main, index: 0 }, - ], - ], - }, - }; - - const workflow = createWorkflow(nodes, connections); - const proxy = createProxy(workflow, 'Edit Fields'); - - // Should throw the correct error when trying to access disconnected node - let error: ExpressionError | undefined; - try { - proxy.$('On form submission').item; - } catch (e) { - error = e as ExpressionError; - } - - expect(error).toBeDefined(); - expect(error).toBeInstanceOf(ExpressionError); - expect(error!.context.type).toBe('paired_item_no_connection'); - expect(error!.context.descriptionKey).toBe('pairedItemNoConnection'); - expect(error!.message).toBe('Error finding the referenced node'); - expect(error!.context.messageTemplate).toBe( - 'Make sure the node you referenced is spelled correctly and is a parent of this node', - ); - }); - - test('should show correct error in runtime execution context', () => { - // Test with execution data to simulate real runtime - const nodes: INode[] = [ - { - id: 'afc0fc26-d521-4464-9f90-3327559bd4a6', - name: 'On form submission', - type: 'n8n-nodes-base.formTrigger', - typeVersion: 2.2, - position: [0, 0], - parameters: { - formTitle: 'Submit BBS application', - }, - }, - { - id: 'c5861385-d513-4d74-8fe3-e5acbe08a90a', - name: 'Code', - type: 'n8n-nodes-base.code', - typeVersion: 2, - position: [288, 432], - parameters: { - jsCode: "\nreturn $('On form submission').all();", - }, - }, - { - id: '523b019b-e456-4784-a50a-18558c858c3b', - name: "When clicking 'Test workflow'", - type: 'n8n-nodes-base.manualTrigger', - typeVersion: 1, - position: [0, 288], - parameters: {}, - }, - ]; - - const connections = { - 'On form submission': { - [NodeConnectionTypes.Main]: [[]], - }, - "When clicking 'Test workflow'": { - [NodeConnectionTypes.Main]: [ - [{ node: 'Code', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - - const workflow = createWorkflow(nodes, connections); - - // Create execution data to simulate real workflow execution - const executeData: IExecuteData = { - data: { - main: [[]], - }, - node: nodes.find((n) => n.name === 'Code')!, - source: { - main: [ - { - previousNode: "When clicking 'Test workflow'", - previousNodeOutput: 0, - previousNodeRun: 0, - }, - ], - }, - }; - - const proxy = createProxy(workflow, 'Code', null, executeData); - - // Should throw the correct error when trying to access disconnected node during execution - let error: ExpressionError | undefined; - try { - proxy.$('On form submission').all(); - } catch (e) { - error = e as ExpressionError; - } - - expect(error).toBeDefined(); - expect(error).toBeInstanceOf(ExpressionError); - expect(error!.context.type).toBe('paired_item_no_connection'); - expect(error!.message).toBe('Error finding the referenced node'); - expect(error!.context.messageTemplate).toBe( - 'Make sure the node you referenced is spelled correctly and is a parent of this node', - ); - }); - }); - - describe('AI/Tool Node Path Detection Fix', () => { - test('should properly detect paths in complex Telegram workflow scenario', () => { - // Recreate the exact workflow structure from the reported issue - const nodes: INode[] = [ - { - id: 'cb00be8d-004b-4d3d-986e-60386516c67a', - name: 'Telegram Trigger', - type: 'n8n-nodes-base.telegramTrigger', - typeVersion: 1.2, - position: [0, 0], - parameters: { - updates: ['message'], - additionalFields: { download: true }, - }, - }, - { - id: 'c5bf285a-d6a5-4767-b369-a48ef504a38e', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 2.1, - position: [208, 0], - parameters: { - promptType: 'define', - text: '={{ $json.message.text }}', - options: {}, - }, - }, - { - id: 'eab6fbe5-1998-46f8-9804-8f04147f9624', - name: 'Anthropic Chat Model', - type: '@n8n/n8n-nodes-langchain.lmChatAnthropic', - typeVersion: 1.3, - position: [32, 368], - parameters: { - model: { - __rl: true, - mode: 'list', - value: 'claude-sonnet-4-20250514', - }, - options: {}, - }, - }, - { - id: '1923ef8d-d459-4d4b-a6bb-0317ab54c2be', - name: 'Zep', - type: '@n8n/n8n-nodes-langchain.memoryZep', - typeVersion: 1.3, - position: [192, 384], - parameters: { - sessionIdType: 'customKey', - sessionKey: "={{ $('Telegram Trigger').item.json.message.chat.id }}", - }, - }, - { - id: '8015fce3-73e2-443b-8dfa-26e6effdc596', - name: 'AI Agent Tool', - type: '@n8n/n8n-nodes-langchain.agentTool', - typeVersion: 2.2, - position: [368, 208], - parameters: { - toolDescription: - 'AI Agent that can call get emails from Gmail and create drafts in Gmail', - text: "={{ $fromAI('Prompt__User_Message_', ``, 'string') }}", - options: {}, - }, - }, - { - id: '201385a6-8adb-4946-92d5-8d46267750b5', - name: 'Send a text message', - type: 'n8n-nodes-base.telegram', - typeVersion: 1.2, - position: [576, 0], - parameters: { - chatId: "={{ $('Telegram Trigger').item.json.message.chat.id }}", - text: '={{ $json.output }}', - additionalFields: { appendAttribution: false }, - }, - }, - ]; - - const connections = { - 'Telegram Trigger': { - [NodeConnectionTypes.Main]: [ - [{ node: 'AI Agent', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - 'AI Agent': { - [NodeConnectionTypes.Main]: [ - [{ node: 'Send a text message', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - 'Anthropic Chat Model': { - [NodeConnectionTypes.AiLanguageModel]: [ - [ - { node: 'AI Agent', type: NodeConnectionTypes.AiLanguageModel, index: 0 }, - { node: 'AI Agent Tool', type: NodeConnectionTypes.AiLanguageModel, index: 0 }, - ], - ], - }, - Zep: { - [NodeConnectionTypes.AiMemory]: [ - [ - { node: 'AI Agent', type: NodeConnectionTypes.AiMemory, index: 0 }, - { node: 'AI Agent Tool', type: NodeConnectionTypes.AiMemory, index: 0 }, - ], - ], - }, - 'AI Agent Tool': { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'AI Agent', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - }; - - const workflow = createWorkflow(nodes, connections); - const wf = new Workflow({ - id: workflow.id, - name: workflow.name, - nodes: workflow.nodes, - connections: workflow.connections, - active: workflow.active, - nodeTypes: NodeTypes(), - settings: workflow.settings, - }); - - // Test the key path detections that were failing before the fix - expect(wf.hasPath('Telegram Trigger', 'Zep')).toBe(true); - expect(wf.hasPath('Telegram Trigger', 'AI Agent')).toBe(true); - expect(wf.hasPath('Telegram Trigger', 'Send a text message')).toBe(true); - expect(wf.hasPath('Telegram Trigger', 'AI Agent Tool')).toBe(true); - expect(wf.hasPath('Telegram Trigger', 'Anthropic Chat Model')).toBe(true); - - // Test reverse paths (bidirectional) - expect(wf.hasPath('Zep', 'Telegram Trigger')).toBe(true); - expect(wf.hasPath('AI Agent', 'Telegram Trigger')).toBe(true); - expect(wf.hasPath('Send a text message', 'Telegram Trigger')).toBe(true); - expect(wf.hasPath('AI Agent Tool', 'AI Agent')).toBe(true); - expect(wf.hasPath('Anthropic Chat Model', 'AI Agent')).toBe(true); - - // Test getParentMainInputNode for AI/tool nodes - const zepNode = wf.getNode('Zep'); - const zepParent = wf.getParentMainInputNode(zepNode); - expect(zepParent?.name).toBe('AI Agent'); - - const toolNode = wf.getNode('AI Agent Tool'); - const toolParent = wf.getParentMainInputNode(toolNode); - expect(toolParent?.name).toBe('AI Agent'); - - const modelNode = wf.getNode('Anthropic Chat Model'); - const modelParent = wf.getParentMainInputNode(modelNode); - expect(modelParent?.name).toBe('AI Agent'); - }); - - test('should handle getParentMainInputNode with cycle detection', () => { - // Create a scenario where AI/tool nodes could create cycles - const nodes: INode[] = [ - { - id: '1', - name: 'Agent1', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 2.1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'Agent2', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 2.1, - position: [300, 100], - parameters: {}, - }, - { - id: '3', - name: 'Tool1', - type: '@n8n/n8n-nodes-langchain.toolCalculator', - typeVersion: 1, - position: [200, 200], - parameters: {}, - }, - ]; - - // Create connections that could form a cycle - const connections = { - Agent1: { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'Agent2', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - Agent2: { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'Tool1', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - Tool1: { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'Agent1', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - }; - - const workflow = createWorkflow(nodes, connections); - const wf = new Workflow({ - id: workflow.id, - name: workflow.name, - nodes: workflow.nodes, - connections: workflow.connections, - active: workflow.active, - nodeTypes: NodeTypes(), - settings: workflow.settings, - }); - - // This should not cause infinite recursion due to cycle detection - const agent1Node = wf.getNode('Agent1'); - const agent1Parent = wf.getParentMainInputNode(agent1Node); - expect(agent1Parent?.name).toBe('Agent1'); // Returns self due to cycle detection - - const tool1Node = wf.getNode('Tool1'); - const tool1Parent = wf.getParentMainInputNode(tool1Node); - expect(tool1Parent?.name).toBe('Tool1'); // Returns self due to cycle detection - }); - - test('should correctly identify parent main input nodes in complex AI scenarios', () => { - // Test complex scenario with multiple connection types - const nodes: INode[] = [ - { - id: '1', - name: 'Trigger', - type: 'n8n-nodes-base.manualTrigger', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - { - id: '2', - name: 'MainAgent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 2.1, - position: [200, 0], - parameters: {}, - }, - { - id: '3', - name: 'Memory', - type: '@n8n/n8n-nodes-langchain.memoryBufferMemory', - typeVersion: 1, - position: [100, 200], - parameters: {}, - }, - { - id: '4', - name: 'ChatModel', - type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', - typeVersion: 1, - position: [300, 200], - parameters: {}, - }, - ]; - - const connections = { - Trigger: { - [NodeConnectionTypes.Main]: [ - [{ node: 'MainAgent', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - Memory: { - [NodeConnectionTypes.AiMemory]: [ - [{ node: 'MainAgent', type: NodeConnectionTypes.AiMemory, index: 0 }], - ], - }, - ChatModel: { - [NodeConnectionTypes.AiLanguageModel]: [ - [{ node: 'MainAgent', type: NodeConnectionTypes.AiLanguageModel, index: 0 }], - ], - }, - }; - - const workflow = createWorkflow(nodes, connections); - const wf = new Workflow({ - id: workflow.id, - name: workflow.name, - nodes: workflow.nodes, - connections: workflow.connections, - active: workflow.active, - nodeTypes: NodeTypes(), - settings: workflow.settings, - }); - - // Memory node should trace back to MainAgent as its parent main input - const memoryNode = wf.getNode('Memory'); - const memoryParent = wf.getParentMainInputNode(memoryNode); - expect(memoryParent?.name).toBe('MainAgent'); - - // ChatModel node should trace back to MainAgent as its parent main input - const chatModelNode = wf.getNode('ChatModel'); - const chatModelParent = wf.getParentMainInputNode(chatModelNode); - expect(chatModelParent?.name).toBe('MainAgent'); - - // MainAgent should trace back to itself (no further parent main input) - const mainAgentNode = wf.getNode('MainAgent'); - const mainAgentParent = wf.getParentMainInputNode(mainAgentNode); - expect(mainAgentParent?.name).toBe('MainAgent'); - }); - - test('should handle workflow with multiple AI connections', () => { - const nodes: INode[] = [ - { - id: '85056f63-f461-4b64-a8ca-807b019b30da', - name: 'Telegram Trigger', - type: 'n8n-nodes-base.telegramTrigger', - typeVersion: 1.2, - position: [-272, 16], - parameters: {}, - }, - { - id: '9670cb60-8926-40d0-bcba-efab28b477ee', - name: 'AI Agent', - type: '@n8n/n8n-nodes-langchain.agent', - typeVersion: 2.1, - position: [1056, 0], - parameters: {}, - }, - { - id: '7fe1aa70-3418-4ad7-940d-af0b7f9b6fb2', - name: 'Anthropic Chat Model', - type: '@n8n/n8n-nodes-langchain.lmChatAnthropic', - typeVersion: 1.3, - position: [1072, 224], - parameters: {}, - }, - { - id: 'e7820a46-6f6e-48b2-8dfe-744d3515af79', - name: 'Zep', - type: '@n8n/n8n-nodes-langchain.memoryZep', - typeVersion: 1.3, - position: [1200, 224], - parameters: {}, - }, - { - id: 'f259429d-3a70-4746-945e-c8056160408c', - name: 'Send a text message', - type: 'n8n-nodes-base.telegram', - typeVersion: 1.2, - position: [1696, 0], - parameters: {}, - }, - { - id: '92756ecf-546f-454c-9423-ae273f07a2f2', - name: 'AI Agent Tool', - type: '@n8n/n8n-nodes-langchain.agentTool', - typeVersion: 2.2, - position: [1536, 272], - parameters: {}, - }, - { - id: '193c4482-8477-4a72-9bdb-cc8dc46fe34c', - name: 'Anthropic Chat Model1', - type: '@n8n/n8n-nodes-langchain.lmChatAnthropic', - typeVersion: 1.3, - position: [1424, 480], - parameters: {}, - }, - { - id: '141264f6-dcfa-4a50-9212-a4fbc76fead6', - name: 'Switch', - type: 'n8n-nodes-base.switch', - typeVersion: 3.2, - position: [160, 16], - parameters: {}, - }, - { - id: 'b9498c03-f517-43e8-830b-7b694be1199f', - name: 'Edit Fields', - type: 'n8n-nodes-base.set', - typeVersion: 3.4, - position: [368, 112], - parameters: {}, - }, - { - id: '564bfeaf-85a6-46f7-bc00-b6ed4e305c8f', - name: 'Typing ...', - type: 'n8n-nodes-base.telegram', - typeVersion: 1.2, - position: [-64, 16], - parameters: {}, - }, - ]; - - const connections = { - 'Telegram Trigger': { - [NodeConnectionTypes.Main]: [ - [{ node: 'Typing ...', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - 'AI Agent': { - [NodeConnectionTypes.Main]: [ - [{ node: 'Send a text message', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - 'Anthropic Chat Model': { - [NodeConnectionTypes.AiLanguageModel]: [ - [{ node: 'AI Agent', type: NodeConnectionTypes.AiLanguageModel, index: 0 }], - ], - }, - Zep: { - [NodeConnectionTypes.AiMemory]: [ - [{ node: 'AI Agent', type: NodeConnectionTypes.AiMemory, index: 0 }], - ], - }, - 'Anthropic Chat Model1': { - [NodeConnectionTypes.AiLanguageModel]: [ - [{ node: 'AI Agent Tool', type: NodeConnectionTypes.AiLanguageModel, index: 0 }], - ], - }, - 'AI Agent Tool': { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'AI Agent', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - Switch: { - [NodeConnectionTypes.Main]: [ - [{ node: 'Edit Fields', type: NodeConnectionTypes.Main, index: 1 }], - ], - }, - 'Edit Fields': { - [NodeConnectionTypes.Main]: [ - [{ node: 'AI Agent', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - 'Typing ...': { - [NodeConnectionTypes.Main]: [ - [{ node: 'Switch', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - - const workflow = createWorkflow(nodes, connections); - const wf = new Workflow({ - id: workflow.id, - name: workflow.name, - nodes: workflow.nodes, - connections: workflow.connections, - active: workflow.active, - nodeTypes: NodeTypes(), - settings: workflow.settings, - }); - - // Test bidirectional path detection for the complex workflow - // Main flow: Telegram Trigger -> Typing ... -> Switch -> Edit Fields -> AI Agent -> Send a text message - expect(wf.hasPath('Telegram Trigger', 'Send a text message')).toBe(true); - expect(wf.hasPath('Typing ...', 'AI Agent')).toBe(true); - expect(wf.hasPath('Switch', 'AI Agent')).toBe(true); - expect(wf.hasPath('Edit Fields', 'AI Agent')).toBe(true); - - // Test AI connections that should be reachable via bidirectional path detection - expect(wf.hasPath('Zep', 'Send a text message')).toBe(true); // ai_memory -> AI Agent -> Send a text message - expect(wf.hasPath('Anthropic Chat Model', 'Send a text message')).toBe(true); // ai_languageModel -> AI Agent -> Send a text message - expect(wf.hasPath('AI Agent Tool', 'Send a text message')).toBe(true); // ai_tool -> AI Agent -> Send a text message - expect(wf.hasPath('Anthropic Chat Model1', 'Send a text message')).toBe(true); // ai_languageModel -> AI Agent Tool -> AI Agent -> Send a text message - - // Test WorkflowDataProxy access from 'Send a text message' to all other nodes - const proxy = createProxy(workflow, 'Send a text message'); - - // These should all work without throwing path detection errors - expect(() => proxy.$('Telegram Trigger')).not.toThrow(); - expect(() => proxy.$('Typing ...')).not.toThrow(); - expect(() => proxy.$('Switch')).not.toThrow(); - expect(() => proxy.$('Edit Fields')).not.toThrow(); - expect(() => proxy.$('AI Agent')).not.toThrow(); - expect(() => proxy.$('Zep')).not.toThrow(); - expect(() => proxy.$('Anthropic Chat Model')).not.toThrow(); - expect(() => proxy.$('AI Agent Tool')).not.toThrow(); - expect(() => proxy.$('Anthropic Chat Model1')).not.toThrow(); - }); - }); -}); diff --git a/packages/workflow/test/workflow-data-proxy.test.ts b/packages/workflow/test/workflow-data-proxy.test.ts index f3a8b3bc34..c436565309 100644 --- a/packages/workflow/test/workflow-data-proxy.test.ts +++ b/packages/workflow/test/workflow-data-proxy.test.ts @@ -235,7 +235,7 @@ describe('WorkflowDataProxy', () => { } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; - expect(exprError.message).toEqual('Error finding the referenced node'); + expect(exprError.message).toEqual("Referenced node doesn't exist"); } }); @@ -246,7 +246,7 @@ describe('WorkflowDataProxy', () => { } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; - expect(exprError.message).toEqual('Error finding the referenced node'); + expect(exprError.message).toEqual('Invalid expression'); expect(exprError.context.type).toEqual('paired_item_no_connection'); } }); @@ -262,8 +262,8 @@ describe('WorkflowDataProxy', () => { } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; - expect(exprError.message).toEqual('Error finding the referenced node'); - expect(exprError.context.type).toEqual('paired_item_no_connection'); + expect(exprError.message).toEqual('Referenced node is unexecuted'); + expect(exprError.context.type).toEqual('no_node_execution_data'); } }); @@ -286,10 +286,8 @@ describe('WorkflowDataProxy', () => { } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; - expect(exprError.message).toEqual('No execution data available'); - expect(exprError.context.messageTemplate).toEqual( - 'Execute node "Impossible if" for preview', - ); + expect(exprError.message).toEqual('Referenced node is unexecuted'); + expect(exprError.context.type).toEqual('no_node_execution_data'); } }); @@ -828,207 +826,4 @@ describe('WorkflowDataProxy', () => { expect(proxy.$('Set main variable').item.json.main_variable).toEqual(2); }); }); - - describe('Improved error messages for missing execution data', () => { - test('should show helpful error message when accessing node without execution data', () => { - // Create a simple workflow with two connected nodes - const workflow: IWorkflowBase = { - id: '1', - name: 'test-workflow', - nodes: [ - { - id: '1', - name: 'Telegram Trigger', - type: 'n8n-nodes-base.telegramTrigger', - typeVersion: 1.2, - position: [0, 0], - parameters: {}, - }, - { - id: '2', - name: 'Send a text message', - type: 'n8n-nodes-base.telegram', - typeVersion: 1.2, - position: [576, 0], - parameters: { - chatId: "={{ $('Telegram Trigger').item.json.message.chat.id }}", - text: 'Test message', - }, - }, - ], - connections: { - 'Telegram Trigger': { - main: [[{ node: 'Send a text message', type: NodeConnectionTypes.Main, index: 0 }]], - }, - }, - active: false, - isArchived: false, - createdAt: new Date(), - updatedAt: new Date(), - }; - - // Create run data without execution data for Telegram Trigger - const run = { - data: { - resultData: { - runData: {}, // Empty - no nodes have executed - }, - }, - mode: 'manual' as const, - startedAt: new Date(), - status: 'success' as const, - }; - - const proxy = getProxyFromFixture(workflow, run, 'Send a text message'); - - // Should throw helpful error when trying to access Telegram Trigger data - let error: ExpressionError | undefined; - try { - proxy.$('Telegram Trigger').item; - } catch (e) { - error = e as ExpressionError; - } - - expect(error).toBeDefined(); - expect(error).toBeInstanceOf(ExpressionError); - expect(error!.message).toBe('No execution data available'); - expect(error!.context.messageTemplate).toBe('Execute node "Telegram Trigger" for preview'); - expect(error!.context.nodeCause).toBe('Telegram Trigger'); - }); - - test('should show helpful error message for different node names', () => { - const workflow: IWorkflowBase = { - id: '1', - name: 'test-workflow', - nodes: [ - { - id: '1', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - { - id: '2', - name: 'Process Data', - type: 'n8n-nodes-base.code', - typeVersion: 2, - position: [300, 0], - parameters: { - jsCode: "return $('HTTP Request').all();", - }, - }, - ], - connections: { - 'HTTP Request': { - main: [[{ node: 'Process Data', type: NodeConnectionTypes.Main, index: 0 }]], - }, - }, - active: false, - isArchived: false, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const run = { - data: { - resultData: { - runData: {}, // Empty - no nodes have executed - }, - }, - mode: 'manual' as const, - startedAt: new Date(), - status: 'success' as const, - }; - - const proxy = getProxyFromFixture(workflow, run, 'Process Data'); - - let error: ExpressionError | undefined; - try { - proxy.$('HTTP Request').item; - } catch (e) { - error = e as ExpressionError; - } - - expect(error).toBeDefined(); - expect(error!.context.messageTemplate).toBe('Execute node "HTTP Request" for preview'); - expect(error!.context.nodeCause).toBe('HTTP Request'); - }); - - test('should use improved error for first(), last(), and all() methods', () => { - const workflow: IWorkflowBase = { - id: '1', - name: 'test-workflow', - nodes: [ - { - id: '1', - name: 'Start Node', - type: 'n8n-nodes-base.manualTrigger', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - { - id: '2', - name: 'End Node', - type: 'n8n-nodes-base.noOp', - typeVersion: 1, - position: [300, 0], - parameters: {}, - }, - ], - connections: { - 'Start Node': { - main: [[{ node: 'End Node', type: NodeConnectionTypes.Main, index: 0 }]], - }, - }, - active: false, - isArchived: false, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const run = { - data: { - resultData: { - runData: {}, // Empty - no nodes have executed - }, - }, - mode: 'manual' as const, - startedAt: new Date(), - status: 'success' as const, - }; - - const proxy = getProxyFromFixture(workflow, run, 'End Node'); - - // Test first() method - let error: ExpressionError | undefined; - try { - proxy.$('Start Node').first(); - } catch (e) { - error = e as ExpressionError; - } - expect(error).toBeDefined(); - expect(error!.context.messageTemplate).toBe('Execute node "Start Node" for preview'); - - // Test last() method - try { - proxy.$('Start Node').last(); - } catch (e) { - error = e as ExpressionError; - } - expect(error).toBeDefined(); - expect(error!.context.messageTemplate).toBe('Execute node "Start Node" for preview'); - - // Test all() method - try { - proxy.$('Start Node').all(); - } catch (e) { - error = e as ExpressionError; - } - expect(error).toBeDefined(); - expect(error!.context.messageTemplate).toBe('Execute node "Start Node" for preview'); - }); - }); }); diff --git a/packages/workflow/test/workflow.test.ts b/packages/workflow/test/workflow.test.ts index d79a8c1dfc..ab6da32b7e 100644 --- a/packages/workflow/test/workflow.test.ts +++ b/packages/workflow/test/workflow.test.ts @@ -11,7 +11,6 @@ import type { INode, INodeExecutionData, INodeParameters, - IPinData, IRunExecutionData, NodeParameterValueType, } from '../src/interfaces'; @@ -2363,479 +2362,6 @@ describe('Workflow', () => { const result = WORKFLOW_WITH_LOOPS.getParentMainInputNode(set1Node); expect(result).toBe(set1Node); }); - - describe('nodes with only main outputs', () => { - test('should return the same node when it only has main outputs', () => { - const nodes: INode[] = [ - { - id: '1', - name: 'SimpleNode', - type: 'test.set', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'TargetNode', - type: 'test.set', - typeVersion: 1, - position: [200, 100], - parameters: {}, - }, - ]; - - const connections = { - SimpleNode: { - [NodeConnectionTypes.Main]: [ - [{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - - const workflow = new Workflow({ - id: 'test', - nodes, - connections, - active: false, - nodeTypes, - }); - - const simpleNode = workflow.getNode('SimpleNode')!; - const result = workflow.getParentMainInputNode(simpleNode); - - expect(result).toBe(simpleNode); - expect(result!.name).toBe('SimpleNode'); - }); - - test('should return the same node when it has no connections', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: '1', - name: 'IsolatedNode', - type: 'test.set', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - ], - connections: {}, - active: false, - nodeTypes, - }); - - const isolatedNode = workflow.getNode('IsolatedNode')!; - const result = workflow.getParentMainInputNode(isolatedNode); - - expect(result).toBe(isolatedNode); - expect(result!.name).toBe('IsolatedNode'); - }); - }); - - describe('nodes with non-main outputs (AI/Tool connections)', () => { - test('should follow AI tool connection to find main input node', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: '1', - name: 'ToolNode', - type: 'test.set', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'AgentNode', - type: 'test.set', - typeVersion: 1, - position: [200, 100], - parameters: {}, - }, - ], - connections: { - ToolNode: { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'AgentNode', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - }, - active: false, - nodeTypes, - }); - - const toolNode = workflow.getNode('ToolNode')!; - const result = workflow.getParentMainInputNode(toolNode); - - expect(result!.name).toBe('AgentNode'); - }); - - test('should follow AI memory connection to find main input node', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: '1', - name: 'MemoryNode', - type: 'test.set', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'ChatNode', - type: 'test.set', - typeVersion: 1, - position: [200, 100], - parameters: {}, - }, - ], - connections: { - MemoryNode: { - [NodeConnectionTypes.AiMemory]: [ - [{ node: 'ChatNode', type: NodeConnectionTypes.AiMemory, index: 0 }], - ], - }, - }, - active: false, - nodeTypes, - }); - - const memoryNode = workflow.getNode('MemoryNode')!; - const result = workflow.getParentMainInputNode(memoryNode); - - expect(result!.name).toBe('ChatNode'); - }); - - test('should handle mixed main and non-main outputs', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: '1', - name: 'MixedNode', - type: 'test.set', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'MainTarget', - type: 'test.set', - typeVersion: 1, - position: [200, 100], - parameters: {}, - }, - { - id: '3', - name: 'ToolTarget', - type: 'test.set', - typeVersion: 1, - position: [200, 200], - parameters: {}, - }, - ], - connections: { - MixedNode: { - [NodeConnectionTypes.Main]: [ - [{ node: 'MainTarget', type: NodeConnectionTypes.Main, index: 0 }], - ], - [NodeConnectionTypes.AiTool]: [ - [{ node: 'ToolTarget', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - }, - active: false, - nodeTypes, - }); - - const mixedNode = workflow.getNode('MixedNode')!; - const result = workflow.getParentMainInputNode(mixedNode); - - // Should follow the first non-main connection (AiTool) - expect(result!.name).toBe('ToolTarget'); - }); - }); - - describe('chain traversal scenarios', () => { - test('should follow a chain of AI connections until reaching main input node', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: '1', - name: 'StartTool', - type: 'test.set', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'MiddleTool', - type: 'test.set', - typeVersion: 1, - position: [200, 100], - parameters: {}, - }, - { - id: '3', - name: 'FinalAgent', - type: 'test.set', - typeVersion: 1, - position: [300, 100], - parameters: {}, - }, - ], - connections: { - StartTool: { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'MiddleTool', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - MiddleTool: { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'FinalAgent', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - }, - active: false, - nodeTypes, - }); - - const startTool = workflow.getNode('StartTool')!; - const result = workflow.getParentMainInputNode(startTool); - - expect(result!.name).toBe('FinalAgent'); - }); - - test('should handle chain that ends with a node having only main outputs', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: '1', - name: 'ToolNode', - type: 'test.set', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'IntermediateNode', - type: 'test.set', - typeVersion: 1, - position: [200, 100], - parameters: {}, - }, - { - id: '3', - name: 'EndNode', - type: 'test.set', - typeVersion: 1, - position: [300, 100], - parameters: {}, - }, - ], - connections: { - ToolNode: { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'IntermediateNode', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - IntermediateNode: { - [NodeConnectionTypes.Main]: [ - [{ node: 'EndNode', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }, - active: false, - nodeTypes, - }); - - const toolNode = workflow.getNode('ToolNode')!; - const result = workflow.getParentMainInputNode(toolNode); - - expect(result!.name).toBe('IntermediateNode'); - }); - - test('should handle complex multi-branch AI connections', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: '1', - name: 'MultiTool', - type: 'test.set', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - { - id: '2', - name: 'Agent1', - type: 'test.set', - typeVersion: 1, - position: [200, 50], - parameters: {}, - }, - { - id: '3', - name: 'Agent2', - type: 'test.set', - typeVersion: 1, - position: [200, 150], - parameters: {}, - }, - ], - connections: { - MultiTool: { - [NodeConnectionTypes.AiTool]: [ - [ - { node: 'Agent1', type: NodeConnectionTypes.AiTool, index: 0 }, - { node: 'Agent2', type: NodeConnectionTypes.AiTool, index: 0 }, - ], - ], - }, - }, - active: false, - nodeTypes, - }); - - const multiTool = workflow.getNode('MultiTool')!; - const result = workflow.getParentMainInputNode(multiTool); - - // Should follow the first connection in the array - expect(result!.name).toBe('Agent1'); - }); - }); - - describe('edge cases', () => { - test('should handle null node input', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [], - connections: {}, - active: false, - nodeTypes, - }); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const result = workflow.getParentMainInputNode(null as any); - expect(result).toBeNull(); - }); - - test('should handle undefined node input', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [], - connections: {}, - active: false, - nodeTypes, - }); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const result = workflow.getParentMainInputNode(undefined as any); - expect(result).toBeUndefined(); - }); - - test('should throw error when connected node does not exist in workflow', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: '1', - name: 'ToolNode', - type: 'test.set', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - ], - connections: { - ToolNode: { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'NonExistentNode', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - }, - active: false, - nodeTypes, - }); - - const toolNode = workflow.getNode('ToolNode')!; - - expect(() => { - workflow.getParentMainInputNode(toolNode); - }).toThrow('Node "NonExistentNode" not found'); - }); - - test('should handle empty connection arrays', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: '1', - name: 'EmptyConnectionNode', - type: 'test.set', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - ], - connections: { - EmptyConnectionNode: { - [NodeConnectionTypes.AiTool]: [ - [], // Empty connection array - ], - }, - }, - active: false, - nodeTypes, - }); - - const emptyConnectionNode = workflow.getNode('EmptyConnectionNode')!; - const result = workflow.getParentMainInputNode(emptyConnectionNode); - - expect(result).toBe(emptyConnectionNode); - }); - - test('should handle null connections in connection array', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: '1', - name: 'NullConnectionNode', - type: 'test.set', - typeVersion: 1, - position: [100, 100], - parameters: {}, - }, - ], - connections: { - NullConnectionNode: { - [NodeConnectionTypes.AiTool]: [ - [{ node: '', type: NodeConnectionTypes.AiTool, index: 0 }], // Connection with empty node name - ], - }, - }, - active: false, - nodeTypes, - }); - - const nullConnectionNode = workflow.getNode('NullConnectionNode')!; - const result = workflow.getParentMainInputNode(nullConnectionNode); - - expect(result).toBe(nullConnectionNode); - }); - }); }); describe('getNodeConnectionIndexes', () => { @@ -3243,397 +2769,4 @@ describe('Workflow', () => { expect(result).toEqual([]); }); }); - - describe('hasPath method', () => { - test('should return true for self-reference', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: 'Node1', - name: 'Node1', - type: 'test.set', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - ], - connections: {}, - active: false, - nodeTypes, - }); - - expect(workflow.hasPath('Node1', 'Node1')).toBe(true); - }); - - test('should return false when nodes are not connected', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: 'Node1', - name: 'Node1', - type: 'test.set', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - { - id: 'Node2', - name: 'Node2', - type: 'test.set', - typeVersion: 1, - position: [100, 0], - parameters: {}, - }, - ], - connections: {}, - active: false, - nodeTypes, - }); - - expect(workflow.hasPath('Node1', 'Node2')).toBe(false); - }); - - test('should return true for directly connected nodes', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: 'Node1', - name: 'Node1', - type: 'test.set', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - { - id: 'Node2', - name: 'Node2', - type: 'test.set', - typeVersion: 1, - position: [100, 0], - parameters: {}, - }, - ], - connections: { - Node1: { - [NodeConnectionTypes.Main]: [ - [{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }, - active: false, - nodeTypes, - }); - - expect(workflow.hasPath('Node1', 'Node2')).toBe(true); - expect(workflow.hasPath('Node2', 'Node1')).toBe(true); - }); - - test('should respect maximum depth limit', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: 'Node1', - name: 'Node1', - type: 'test.set', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - { - id: 'Node2', - name: 'Node2', - type: 'test.set', - typeVersion: 1, - position: [100, 0], - parameters: {}, - }, - ], - connections: { - Node1: { - [NodeConnectionTypes.Main]: [ - [{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }, - active: false, - nodeTypes, - }); - - // Should find path with sufficient depth - expect(workflow.hasPath('Node1', 'Node2', 5)).toBe(true); - expect(workflow.hasPath('Node1', 'Node2', 1)).toBe(true); - - // Should not find path with insufficient depth - expect(workflow.hasPath('Node1', 'Node2', 0)).toBe(false); - }); - - test('should handle AI connection types', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: 'Agent', - name: 'Agent', - type: 'test.ai.agent', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - { - id: 'Tool1', - name: 'Tool1', - type: 'test.ai.tool', - typeVersion: 1, - position: [100, 0], - parameters: {}, - }, - { - id: 'Memory', - name: 'Memory', - type: 'test.ai.memory', - typeVersion: 1, - position: [200, 0], - parameters: {}, - }, - ], - connections: { - Tool1: { - [NodeConnectionTypes.AiTool]: [ - [{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - Memory: { - [NodeConnectionTypes.AiMemory]: [ - [{ node: 'Agent', type: NodeConnectionTypes.AiMemory, index: 0 }], - ], - }, - }, - active: false, - nodeTypes, - }); - - expect(workflow.hasPath('Tool1', 'Agent')).toBe(true); - expect(workflow.hasPath('Memory', 'Agent')).toBe(true); - expect(workflow.hasPath('Tool1', 'Memory')).toBe(true); - }); - - test('should handle complex paths with multiple connection types', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: 'Start', - name: 'Start', - type: 'test.start', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - { - id: 'VectorStore', - name: 'VectorStore', - type: 'test.vectorstore', - typeVersion: 1, - position: [100, 0], - parameters: {}, - }, - { - id: 'Document', - name: 'Document', - type: 'test.document', - typeVersion: 1, - position: [200, 0], - parameters: {}, - }, - { - id: 'End', - name: 'End', - type: 'test.end', - typeVersion: 1, - position: [300, 0], - parameters: {}, - }, - ], - connections: { - Start: { - [NodeConnectionTypes.Main]: [ - [{ node: 'VectorStore', type: NodeConnectionTypes.AiVectorStore, index: 0 }], - ], - }, - Document: { - [NodeConnectionTypes.Main]: [ - [{ node: 'VectorStore', type: NodeConnectionTypes.AiDocument, index: 0 }], - ], - }, - VectorStore: { - [NodeConnectionTypes.Main]: [ - [{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }, - active: false, - nodeTypes, - }); - - expect(workflow.hasPath('Start', 'End')).toBe(true); - expect(workflow.hasPath('Document', 'End')).toBe(true); - expect(workflow.hasPath('Start', 'Document')).toBe(true); - }); - - test('should handle cyclic graphs without infinite loops', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: 'Node1', - name: 'Node1', - type: 'test.set', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - { - id: 'Node2', - name: 'Node2', - type: 'test.set', - typeVersion: 1, - position: [100, 0], - parameters: {}, - }, - { - id: 'Node3', - name: 'Node3', - type: 'test.set', - typeVersion: 1, - position: [200, 0], - parameters: {}, - }, - ], - connections: { - Node1: { - [NodeConnectionTypes.Main]: [ - [{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - Node2: { - [NodeConnectionTypes.Main]: [ - [{ node: 'Node3', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - Node3: { - [NodeConnectionTypes.Main]: [ - [{ node: 'Node1', type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }, - active: false, - nodeTypes, - }); - - expect(workflow.hasPath('Node1', 'Node3')).toBe(true); - expect(workflow.hasPath('Node2', 'Node1')).toBe(true); - expect(workflow.hasPath('Node3', 'Node2')).toBe(true); - }); - - test('should handle empty workflow', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [], - connections: {}, - active: false, - nodeTypes, - }); - - expect(workflow.hasPath('NonExistent1', 'NonExistent2')).toBe(false); - }); - - test('should handle nodes with no outgoing connections', () => { - const workflow = new Workflow({ - id: 'test', - nodes: [ - { - id: 'Node1', - name: 'Node1', - type: 'test.set', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - { - id: 'Node2', - name: 'Node2', - type: 'test.set', - typeVersion: 1, - position: [100, 0], - parameters: {}, - }, - ], - connections: { - Node1: { - [NodeConnectionTypes.Main]: [[]], - }, - }, - active: false, - nodeTypes, - }); - - expect(workflow.hasPath('Node1', 'Node2')).toBe(false); - expect(workflow.hasPath('Node2', 'Node1')).toBe(false); - }); - - it('should return true when source node has pinned data (virtual path)', () => { - const nodes: INode[] = [ - { - id: '1', - name: 'Trigger', - type: 'n8n-nodes-base.executeWorkflowTrigger', - typeVersion: 1, - position: [0, 0], - parameters: {}, - }, - { - id: '2', - name: 'EditFields', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [200, 0], - parameters: {}, - }, - ]; - - const connections: IConnections = { - Trigger: { - main: [[{ node: 'EditFields', type: 'main', index: 0 }]], - }, - }; - - const pinData: IPinData = { - Trigger: [ - { - json: { - name: 'Test item', - value: 123, - }, - }, - ], - }; - - const workflow = new Workflow({ - nodes, - connections, - active: false, - nodeTypes, - pinData, - }); - - // Should return true because Trigger has pinned data, creating a virtual path - expect(workflow.hasPath('Trigger', 'EditFields')).toBe(true); - // Should also work for self-reference with pinned data - expect(workflow.hasPath('Trigger', 'Trigger')).toBe(true); - }); - }); });