mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
1234 lines
34 KiB
TypeScript
1234 lines
34 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|