import { mock } from 'jest-mock-extended'; import { NodeConnectionTypes } from 'n8n-workflow'; import type { NodeConnectionType, INodeInputConfiguration, ExpressionString, INodeTypeDescription, } from 'n8n-workflow'; import type { SimpleWorkflow } from '@/types'; import { resolveConnections } from '@/validation/utils/resolve-connections'; import { evaluateConnections } from './connections'; const DEFAULT_VERSION = 1; describe('resolveInputConnections', () => { it('should return empty array for empty inputs', () => { const inputs: NodeConnectionType[] = []; const result = resolveConnections(inputs, {}, DEFAULT_VERSION); expect(result).toEqual([]); }); it('should return simple node connection types inputs as is', () => { const inputs = [ NodeConnectionTypes.Main, NodeConnectionTypes.AiDocument, NodeConnectionTypes.AiEmbedding, ]; const result = resolveConnections(inputs, {}, DEFAULT_VERSION); expect(result).toEqual(inputs); }); it('should return simple node input configurations as is', () => { const inputs = [ { type: NodeConnectionTypes.Main, displayName: 'Main', maxConnections: 1 }, { type: NodeConnectionTypes.AiDocument, displayName: 'Document', maxConnections: 1 }, ] satisfies INodeInputConfiguration[]; const result = resolveConnections(inputs, {}, DEFAULT_VERSION); expect(result).toEqual(inputs); }); it('should evaluate simple expression', () => { const inputs = `={{ ["${NodeConnectionTypes.Main}", "${NodeConnectionTypes.AiDocument}"] }}` as const; const result = resolveConnections(inputs, {}, DEFAULT_VERSION); expect(result).toEqual([NodeConnectionTypes.Main, NodeConnectionTypes.AiDocument]); }); it('should evaluate expression with parameters', () => { const inputs = `={{ ["${NodeConnectionTypes.Main}", $parameter["extraInput"]] }}` as const; const parameters = { extraInput: NodeConnectionTypes.AiDocument }; const result = resolveConnections(inputs, parameters, DEFAULT_VERSION); expect(result).toEqual([NodeConnectionTypes.Main, NodeConnectionTypes.AiDocument]); }); it('should evaluate complex expression with parameters', () => { const inputs = `={{ ((parameters) => { const mode = parameters?.mode; const useReranker = parameters?.useReranker; const inputs = [{ displayName: "Embedding", type: "${NodeConnectionTypes.AiEmbedding}", required: true, maxConnections: 1}] if (['load', 'retrieve', 'retrieve-as-tool'].includes(mode) && useReranker) { inputs.push({ displayName: "Reranker", type: "${NodeConnectionTypes.AiReranker}", required: true, maxConnections: 1}) } if (mode === 'retrieve-as-tool') { return inputs; } if (['insert', 'load', 'update'].includes(mode)) { inputs.push({ displayName: "", type: "${NodeConnectionTypes.Main}"}) } if (['insert'].includes(mode)) { inputs.push({ displayName: "Document", type: "${NodeConnectionTypes.AiDocument}", required: true, maxConnections: 1}) } return inputs })($parameter) }}` satisfies ExpressionString; const parameters = { mode: 'load', useReranker: true }; const result = resolveConnections(inputs, parameters, DEFAULT_VERSION); expect(result).toEqual([ { displayName: 'Embedding', type: NodeConnectionTypes.AiEmbedding, required: true, maxConnections: 1, }, { displayName: 'Reranker', type: NodeConnectionTypes.AiReranker, required: true, maxConnections: 1, }, { displayName: '', type: NodeConnectionTypes.Main }, ]); const parameters2 = { mode: 'retrieve-as-tool', useReranker: false }; const result2 = resolveConnections(inputs, parameters2, DEFAULT_VERSION); expect(result2).toEqual([ { displayName: 'Embedding', type: NodeConnectionTypes.AiEmbedding, required: true, maxConnections: 1, }, ]); }); }); describe('evaluateConnections', () => { const mockNodeTypes = mock([ { name: 'n8n-nodes-test.manualTrigger', displayName: 'Manual Trigger', group: ['trigger'], description: 'Starts the workflow manually', version: 1, inputs: [], outputs: [NodeConnectionTypes.Main], }, { name: 'n8n-nodes-test.code', displayName: 'Code', version: 1, inputs: [NodeConnectionTypes.Main], outputs: [NodeConnectionTypes.Main], }, { name: 'n8n-nodes-test.httpRequest', displayName: 'HTTP Request', version: 1, inputs: [NodeConnectionTypes.Main], outputs: [NodeConnectionTypes.Main], }, { name: 'n8n-nodes-test.openAi', displayName: 'OpenAI', version: 1, inputs: `={{(() => { return [{ type: "${NodeConnectionTypes.Main}" }, { type: "${NodeConnectionTypes.AiTool}", displayName: "Tools" }]; })()}}`, outputs: [NodeConnectionTypes.Main], }, { name: 'n8n-nodes-test.agent', displayName: 'AI Agent', version: [1, 2, 3], inputs: [ { type: NodeConnectionTypes.AiTool, required: true, maxConnections: -1 }, { type: NodeConnectionTypes.Main }, ], outputs: [NodeConnectionTypes.Main], }, { name: 'n8n-nodes-test.merge', displayName: 'Merge', version: 1, inputs: `={{ Array.from({ length: $parameter.numberInputs || 2 }, (_, i) => ({ type: "${NodeConnectionTypes.Main}", displayName: \`Input $\{i + 1}\` })) }}`, outputs: [NodeConnectionTypes.Main], }, { name: 'n8n-nodes-test.llmChain', displayName: 'LLM Chain', version: 1, inputs: [ { type: NodeConnectionTypes.Main }, { type: NodeConnectionTypes.AiLanguageModel, required: true, maxConnections: 1 }, { type: NodeConnectionTypes.AiMemory, required: false, maxConnections: 1 }, ], outputs: [NodeConnectionTypes.Main], }, { name: 'n8n-nodes-test.chatOpenAi', displayName: 'Chat OpenAI', version: 1, inputs: [], outputs: [NodeConnectionTypes.AiLanguageModel], }, { name: 'n8n-nodes-test.vectorStore', displayName: 'Vector Store', version: [1, 1.1, 1.2, 1.3], inputs: `={{ ((parameters) => { const mode = parameters?.mode; const inputs = [{ displayName: "Embedding", type: "${NodeConnectionTypes.AiEmbedding}", required: true, maxConnections: 1}] if (mode === 'retrieve-as-tool') { return inputs; } if (['insert', 'load', 'update'].includes(mode)) { inputs.push({ displayName: "", type: "${NodeConnectionTypes.Main}"}) } if (['insert'].includes(mode)) { inputs.push({ displayName: "Document", type: "${NodeConnectionTypes.AiDocument}", required: true, maxConnections: 1}) } return inputs })($parameter) }}`, outputs: `={{ (() => { const mode = $parameter.mode; if (mode === "retrieve-as-tool") { return [{ type: "${NodeConnectionTypes.AiTool}" }]; } return [{ type: "${NodeConnectionTypes.AiVectorStore}" }]; })() }}`, }, { name: 'n8n-nodes-test.embeddingsOpenAi', displayName: 'OpenAI Embeddings', version: [1, 1.1, 1.2], inputs: [], outputs: [NodeConnectionTypes.AiEmbedding], }, ]); describe('basic workflow connections validation', () => { it('should return no issues for a valid simple workflow', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-test.manualTrigger', parameters: {}, typeVersion: 1, position: [0, 0], }, { id: '2', name: 'Code Node', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [200, 0], }, { id: '3', name: 'HTTP Request', type: 'n8n-nodes-test.httpRequest', parameters: {}, typeVersion: 1, position: [400, 0], }, ], connections: { 'Manual Trigger': { main: [ [ { node: 'Code Node', type: 'main', index: 0, }, ], ], }, 'Code Node': { main: [ [ { node: 'HTTP Request', type: 'main', index: 0, }, ], ], }, }, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toEqual([]); }); it('should detect missing node type', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Unknown Node', type: 'n8n-nodes-test.unknown', parameters: {}, typeVersion: 1, position: [0, 0], }, ], connections: {}, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toContainEqual( expect.objectContaining({ description: 'Node type n8n-nodes-test.unknown not found for node Unknown Node', }), ); }); it('should detect node type with unregistered version', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Code Node', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 99, // Version that doesn't exist position: [0, 0], }, ], connections: {}, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toContainEqual( expect.objectContaining({ name: 'node-type-not-found', description: 'Node type n8n-nodes-test.code not found for node Code Node', }), ); }); it('should detect missing required inputs', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'LLM Chain', type: 'n8n-nodes-test.llmChain', parameters: {}, typeVersion: 1, position: [0, 0], }, ], connections: {}, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toContainEqual( expect.objectContaining({ description: 'Node LLM Chain (n8n-nodes-test.llmChain) is missing required input of type main', }), ); expect(violations).toContainEqual( expect.objectContaining({ description: 'Node LLM Chain (n8n-nodes-test.llmChain) is missing required input of type ai_languageModel', }), ); }); it('should detect unsupported connection types', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Chat OpenAI', type: 'n8n-nodes-test.chatOpenAi', parameters: {}, typeVersion: 1, position: [0, 0], }, { id: '2', name: 'Code Node', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [200, 0], }, ], connections: { 'Chat OpenAI': { ai_languageModel: [ [ { node: 'Code Node', type: 'ai_languageModel', index: 0, }, ], ], }, }, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toContainEqual( expect.objectContaining({ description: 'Node Code Node (n8n-nodes-test.code) received unsupported connection type ai_languageModel', }), ); }); it('should detect AI sub-node not connected to a root node', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Chat Model', type: 'n8n-nodes-test.chatOpenAi', parameters: {}, typeVersion: 1, position: [0, 0], }, ], connections: {}, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toContainEqual( expect.objectContaining({ description: expect.stringContaining( 'Sub-node Chat Model (n8n-nodes-test.chatOpenAi) provides ai_languageModel but is not connected to a root node.', ), }), ); }); it('should not report issues for nested sub-nodes properly connected to a root node', () => { const workflow = mock({ nodes: [ { id: '0', name: 'Manual Trigger', type: 'n8n-nodes-test.manualTrigger', parameters: {}, typeVersion: 1, position: [0, 0], }, { id: '1', parameters: { mode: 'retrieve-as-tool', }, name: 'Vector Store Retrieval', type: 'n8n-nodes-test.vectorStore', typeVersion: 1.3, position: [0, 0], }, { id: '2', parameters: {}, name: 'AI Agent', type: 'n8n-nodes-test.agent', typeVersion: 3, position: [0, 0], }, { id: '3', parameters: {}, name: 'OpenAI Embeddings', type: 'n8n-nodes-test.embeddingsOpenAi', typeVersion: 1.2, position: [0, 0], }, ], connections: { 'Manual Trigger': { main: [ [ { node: 'AI Agent', type: 'main', index: 0, }, ], ], }, 'Vector Store Retrieval': { ai_tool: [ [ { node: 'AI Agent', type: 'ai_tool', index: 0, }, ], ], }, 'OpenAI Embeddings': { ai_embedding: [ [ { node: 'Vector Store Retrieval', type: 'ai_embedding', index: 0, }, ], ], }, }, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toEqual([]); }); }); describe('dynamic input/output resolution', () => { it('should resolve dynamic inputs based on parameters', () => { // Use OpenAI node which has dynamic inputs that resolve to multiple input types const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-test.manualTrigger', parameters: {}, typeVersion: 1, position: [0, 0], }, { id: '2', name: 'OpenAI Node', type: 'n8n-nodes-test.openAi', parameters: {}, typeVersion: 1, position: [200, 0], }, ], connections: { 'Manual Trigger': { main: [ [ { node: 'OpenAI Node', type: 'main', index: 0, }, ], ], }, }, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); // Should not have any violations - the node's dynamic inputs should be resolved expect(violations).toEqual([]); }); it('should resolve vector store inputs based on mode', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Vector Store', type: 'n8n-nodes-test.vectorStore', parameters: { mode: 'retrieve' }, typeVersion: 1, position: [0, 0], }, ], connections: {}, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); // Should report missing required ai_embedding input expect(violations).toContainEqual( expect.objectContaining({ description: 'Node Vector Store (n8n-nodes-test.vectorStore) is missing required input of type ai_embedding', }), ); }); it('should resolve vector store outputs based on mode', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-test.manualTrigger', parameters: {}, typeVersion: 1, position: [0, 0], }, { id: '2', name: 'Vector Store', type: 'n8n-nodes-test.vectorStore', parameters: { mode: 'retrieve-as-tool' }, typeVersion: 1, position: [200, 0], }, { id: '3', name: 'OpenAI', type: 'n8n-nodes-test.openAi', parameters: {}, typeVersion: 1, position: [400, 0], }, { id: '4', name: 'Embeddings', type: 'n8n-nodes-test.embeddingsOpenAi', parameters: {}, typeVersion: 1, position: [600, 0], }, ], connections: { 'Manual Trigger': { main: [ [ { node: 'OpenAI', type: 'main', index: 0, }, ], ], }, 'Vector Store': { ai_tool: [ [ { node: 'OpenAI', type: 'ai_tool', index: 0, }, ], ], }, Embeddings: { ai_embedding: [ [ { node: 'Vector Store', type: 'ai_embedding', index: 0, }, ], ], }, }, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); // Should be valid - Vector Store outputs ai_tool when in retrieve-as-tool mode expect(violations).toEqual([]); }); }); describe('complex workflow scenarios', () => { it('should validate workflow with AI nodes', () => { const workflow = mock({ name: 'AI Workflow', nodes: [ { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-test.manualTrigger', parameters: {}, typeVersion: 1, position: [0, 0], }, { id: '2', name: 'Chat Model', type: 'n8n-nodes-test.chatOpenAi', parameters: {}, typeVersion: 1, position: [200, 0], }, { id: '3', name: 'LLM Chain', type: 'n8n-nodes-test.llmChain', parameters: {}, typeVersion: 1, position: [400, 0], }, { id: '4', name: 'Code', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [600, 0], }, ], connections: { 'Manual Trigger': { main: [ [ { node: 'LLM Chain', type: 'main', index: 0, }, ], ], }, 'Chat Model': { ai_languageModel: [ [ { node: 'LLM Chain', type: 'ai_languageModel', index: 0, }, ], ], }, 'LLM Chain': { main: [ [ { node: 'Code', type: 'main', index: 0, }, ], ], }, }, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toEqual([]); }); it('should handle workflows with no connections', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-test.manualTrigger', parameters: {}, typeVersion: 1, position: [0, 0], }, ], connections: undefined, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toEqual([]); }); it('should handle multiple connections to the same node', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-test.manualTrigger', parameters: {}, typeVersion: 1, position: [0, 100], }, { id: '2', name: 'Code1', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [200, 0], }, { id: '3', name: 'Code2', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [200, 200], }, { id: '4', name: 'Merge', type: 'n8n-nodes-test.merge', parameters: { numberInputs: 2 }, typeVersion: 1, position: [400, 100], }, ], connections: { 'Manual Trigger': { main: [ [ { node: 'Code1', type: 'main', index: 0, }, { node: 'Code2', type: 'main', index: 0, }, ], ], }, Code1: { main: [ [ { node: 'Merge', type: 'main', index: 0, }, ], ], }, Code2: { main: [ [ { node: 'Merge', type: 'main', index: 1, }, ], ], }, }, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toEqual([]); }); }); describe('dangling nodes validation', () => { it('should detect nodes with required main input but no connections', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-test.manualTrigger', parameters: {}, typeVersion: 1, position: [0, 0], }, { id: '2', name: 'Code1', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [200, 0], }, { id: '3', name: 'Code2', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [400, 0], }, ], connections: { 'Manual Trigger': { main: [ [ { node: 'Code1', type: 'main', index: 0, }, ], ], }, // Code1 is connected but Code2 is dangling }, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toContainEqual( expect.objectContaining({ description: 'Node Code2 (n8n-nodes-test.code) is missing required input of type main', }), ); }); it('should not report violations for trigger nodes without inputs', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Manual Trigger', type: 'n8n-nodes-test.manualTrigger', parameters: {}, typeVersion: 1, position: [0, 0], }, ], connections: {}, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); // Trigger nodes don't have inputs, so no violations expect(violations).toEqual([]); }); }); describe('merge node validation', () => { it('should report issue when merge node has only one input connection', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Code', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [0, 0], }, { id: '2', name: 'Merge Data', type: 'n8n-nodes-test.merge', parameters: { numberInputs: 2 }, typeVersion: 1, position: [200, 0], }, ], connections: { Code: { main: [ [ { node: 'Merge Data', type: 'main', index: 0, }, ], ], }, }, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toContainEqual( expect.objectContaining({ description: 'Merge node Merge Data has only 1 input connection(s). Merge nodes require at least 2 inputs to function properly.', }), ); }); it('should report issue when merge node inputs reuse the same index', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Code1', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [0, 0], }, { id: '2', name: 'Code2', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [0, 200], }, { id: '3', name: 'Code3', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [0, 400], }, { id: '4', name: 'Merge', type: 'n8n-nodes-test.merge', parameters: { numberInputs: 3 }, typeVersion: 1, position: [200, 200], }, ], connections: { Code1: { main: [ [ { node: 'Merge', type: 'main', index: 0, }, ], ], }, Code2: { main: [ [ { node: 'Merge', type: 'main', index: 0, }, ], ], }, Code3: { main: [ [ { node: 'Merge', type: 'main', index: 0, }, ], ], }, }, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); expect(violations).toContainEqual( expect.objectContaining({ description: 'Merge node Merge is missing connections for input(s) 2, 3.', }), ); }); it('should not report issue when merge node has 2 or more input connections', () => { const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Code1', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [0, 0], }, { id: '2', name: 'Code2', type: 'n8n-nodes-test.code', parameters: {}, typeVersion: 1, position: [0, 200], }, { id: '3', name: 'Merge', type: 'n8n-nodes-test.merge', parameters: { numberInputs: 2 }, typeVersion: 1, position: [200, 100], }, ], connections: { Code1: { main: [ [ { node: 'Merge', type: 'main', index: 0, }, ], ], }, Code2: { main: [ [ { node: 'Merge', type: 'main', index: 1, }, ], ], }, }, }); const { violations } = evaluateConnections(workflow, mockNodeTypes); // Should not contain merge node violations expect(violations).not.toContain( expect.stringMatching(/Merge node.*has only.*input connection/), ); }); }); describe('error handling', () => { it('should catch and report expression evaluation errors', () => { const nodeTypeWithBadExpression = mock({ name: 'n8n-nodes-test.badNode', displayName: 'Bad Node', inputs: '={{ invalidJavaScript( }}', outputs: ['main'], }); const workflow = mock({ name: 'Test Workflow', nodes: [ { id: '1', name: 'Bad Node', type: 'n8n-nodes-test.badNode', parameters: {}, typeVersion: 1, position: [0, 0], }, ], connections: {}, }); const { violations } = evaluateConnections(workflow, [ ...mockNodeTypes, nodeTypeWithBadExpression, ]); expect(violations).toContainEqual( expect.objectContaining({ description: expect.stringContaining('Failed to resolve connections'), }), ); }); }); });