feat(validator): detect conditional branch fan-out & connection auto-fixes (#622)

* feat(auto-fixer): add 5 connection structure fix types

Add automatic repair for malformed workflow connections commonly
generated by AI models:
- connection-numeric-keys: "0","1" keys → main[0], main[1]
- connection-invalid-type: type:"0" → type:"main" (or parent key)
- connection-id-to-name: node ID refs → node name refs
- connection-duplicate-removal: dedup identical connection entries
- connection-input-index: out-of-bounds input index → clamped

Includes collision-safe ID-to-name renames, medium confidence on
merge conflicts and index clamping, shared CONNECTION_FIX_TYPES
constant, and 24 unit tests.

Concieved by Romuald Członkowski - www.aiadvisors.pl/en


* feat(validator): detect IF/Switch/Filter conditional branch fan-out misuse

Add CONDITIONAL_BRANCH_FANOUT warning when conditional nodes have all
connections on main[0] with higher outputs empty, indicating both
branches execute together instead of being split by condition.

Extract getShortNodeType() and getConditionalOutputInfo() helpers to
deduplicate conditional node detection logic.

Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
This commit is contained in:
Romuald Członkowski
2026-03-08 08:41:44 +01:00
committed by GitHub
parent 0918cd5425
commit 25b8a8145d
23 changed files with 2006 additions and 55 deletions

View File

@@ -0,0 +1,566 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WorkflowAutoFixer } from '@/services/workflow-auto-fixer';
import { NodeRepository } from '@/database/node-repository';
import type { WorkflowValidationResult } from '@/services/workflow-validator';
import type { Workflow, WorkflowNode } from '@/types/n8n-api';
vi.mock('@/database/node-repository');
vi.mock('@/services/node-similarity-service');
describe('WorkflowAutoFixer - Connection Fixes', () => {
let autoFixer: WorkflowAutoFixer;
let mockRepository: NodeRepository;
const createMockWorkflow = (
nodes: WorkflowNode[],
connections: any = {}
): Workflow => ({
id: 'test-workflow',
name: 'Test Workflow',
active: false,
nodes,
connections,
settings: {},
createdAt: '',
updatedAt: ''
});
const createMockNode = (id: string, name: string, type: string = 'n8n-nodes-base.noOp'): WorkflowNode => ({
id,
name,
type,
typeVersion: 1,
position: [0, 0],
parameters: {}
});
const emptyValidation: WorkflowValidationResult = {
valid: true,
errors: [],
warnings: [],
statistics: {
totalNodes: 0,
enabledNodes: 0,
triggerNodes: 0,
validConnections: 0,
invalidConnections: 0,
expressionsValidated: 0
},
suggestions: []
};
beforeEach(() => {
vi.clearAllMocks();
mockRepository = new NodeRepository({} as any);
vi.spyOn(mockRepository, 'getNodeVersions').mockReturnValue([]);
autoFixer = new WorkflowAutoFixer(mockRepository);
});
describe('Numeric Keys', () => {
it('should convert single numeric key to main[index]', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2')],
{
Node1: {
'0': [[{ node: 'Node2', type: 'main', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-numeric-keys');
expect(connFixes).toHaveLength(1);
expect(connFixes[0].before).toBe('0');
expect(connFixes[0].after).toBe('main[0]');
// Verify replaceConnections operation
const replaceOp = result.operations.find(op => op.type === 'replaceConnections');
expect(replaceOp).toBeDefined();
const connOp = replaceOp as any;
expect(connOp.connections.Node1['main']).toBeDefined();
expect(connOp.connections.Node1['0']).toBeUndefined();
});
it('should convert multiple numeric keys', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2'), createMockNode('id3', 'Node3')],
{
Node1: {
'0': [[{ node: 'Node2', type: 'main', index: 0 }]],
'1': [[{ node: 'Node3', type: 'main', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-numeric-keys');
expect(connFixes).toHaveLength(2);
});
it('should merge with existing main entries', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2'), createMockNode('id3', 'Node3')],
{
Node1: {
main: [[{ node: 'Node2', type: 'main', index: 0 }]],
'1': [[{ node: 'Node3', type: 'main', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const replaceOp = result.operations.find(op => op.type === 'replaceConnections') as any;
expect(replaceOp.connections.Node1['main']).toHaveLength(2);
expect(replaceOp.connections.Node1['main'][0]).toEqual([{ node: 'Node2', type: 'main', index: 0 }]);
expect(replaceOp.connections.Node1['main'][1]).toEqual([{ node: 'Node3', type: 'main', index: 0 }]);
});
it('should handle sparse numeric keys with gap filling', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2'), createMockNode('id3', 'Node3')],
{
Node1: {
'0': [[{ node: 'Node2', type: 'main', index: 0 }]],
'3': [[{ node: 'Node3', type: 'main', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const replaceOp = result.operations.find(op => op.type === 'replaceConnections') as any;
expect(replaceOp.connections.Node1['main']).toHaveLength(4);
expect(replaceOp.connections.Node1['main'][1]).toEqual([]);
expect(replaceOp.connections.Node1['main'][2]).toEqual([]);
});
});
describe('Invalid Type', () => {
it('should fix numeric type to "main"', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2')],
{
Node1: {
main: [[{ node: 'Node2', type: '0', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-invalid-type');
expect(connFixes).toHaveLength(1);
expect(connFixes[0].before).toBe('0');
expect(connFixes[0].after).toBe('main');
});
it('should use parent output key for AI connection types', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2')],
{
Node1: {
ai_tool: [[{ node: 'Node2', type: '0', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-invalid-type');
expect(connFixes).toHaveLength(1);
expect(connFixes[0].after).toBe('ai_tool');
});
});
describe('ID-to-Name', () => {
it('should replace source key when it matches a node ID', async () => {
const workflow = createMockWorkflow(
[createMockNode('abc-123', 'Node1'), createMockNode('def-456', 'Node2')],
{
'abc-123': {
main: [[{ node: 'Node2', type: 'main', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-id-to-name');
expect(connFixes).toHaveLength(1);
expect(connFixes[0].before).toBe('abc-123');
expect(connFixes[0].after).toBe('Node1');
const replaceOp = result.operations.find(op => op.type === 'replaceConnections') as any;
expect(replaceOp.connections['Node1']).toBeDefined();
expect(replaceOp.connections['abc-123']).toBeUndefined();
});
it('should replace target node value when it matches a node ID', async () => {
const workflow = createMockWorkflow(
[createMockNode('abc-123', 'Node1'), createMockNode('def-456', 'Node2')],
{
Node1: {
main: [[{ node: 'def-456', type: 'main', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-id-to-name');
expect(connFixes).toHaveLength(1);
expect(connFixes[0].before).toBe('def-456');
expect(connFixes[0].after).toBe('Node2');
});
it('should NOT fix when key matches both an ID and a name', async () => {
// Node with name that looks like an ID of another node
const workflow = createMockWorkflow(
[createMockNode('abc-123', 'abc-123'), createMockNode('def-456', 'Node2')],
{
'abc-123': {
main: [[{ node: 'Node2', type: 'main', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-id-to-name');
expect(connFixes).toHaveLength(0);
});
});
describe('Dedup', () => {
it('should remove exact duplicate connections', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2')],
{
Node1: {
main: [[
{ node: 'Node2', type: 'main', index: 0 },
{ node: 'Node2', type: 'main', index: 0 },
]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-duplicate-removal');
expect(connFixes).toHaveLength(1);
const replaceOp = result.operations.find(op => op.type === 'replaceConnections') as any;
expect(replaceOp.connections.Node1.main[0]).toHaveLength(1);
});
it('should keep near-duplicates with different index', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2')],
{
Node1: {
main: [[
{ node: 'Node2', type: 'main', index: 0 },
{ node: 'Node2', type: 'main', index: 1 },
]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-duplicate-removal');
expect(connFixes).toHaveLength(0);
});
});
describe('Input Index', () => {
it('should reset to 0 for single-input nodes', async () => {
const validation: WorkflowValidationResult = {
...emptyValidation,
errors: [{
type: 'error',
nodeName: 'Node2',
message: 'Input index 3 on node "Node2" exceeds its input count (1). Connection from "Node1" targets input 3, but this node has 1 main input(s) (indices 0-0).',
code: 'INPUT_INDEX_OUT_OF_BOUNDS'
}]
};
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2', 'n8n-nodes-base.httpRequest')],
{
Node1: {
main: [[{ node: 'Node2', type: 'main', index: 3 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, validation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-input-index');
expect(connFixes).toHaveLength(1);
expect(connFixes[0].before).toBe(3);
expect(connFixes[0].after).toBe(0);
expect(connFixes[0].confidence).toBe('medium');
});
it('should clamp for Merge nodes', async () => {
const validation: WorkflowValidationResult = {
...emptyValidation,
errors: [{
type: 'error',
nodeName: 'MergeNode',
message: 'Input index 5 on node "MergeNode" exceeds its input count (2). Connection from "Node1" targets input 5, but this node has 2 main input(s) (indices 0-1).',
code: 'INPUT_INDEX_OUT_OF_BOUNDS'
}]
};
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'MergeNode', 'n8n-nodes-base.merge')],
{
Node1: {
main: [[{ node: 'MergeNode', type: 'main', index: 5 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, validation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-input-index');
expect(connFixes).toHaveLength(1);
expect(connFixes[0].before).toBe(5);
expect(connFixes[0].after).toBe(1); // clamped to max valid index
});
it('should not fix valid indices', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2')],
{
Node1: {
main: [[{ node: 'Node2', type: 'main', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-input-index');
expect(connFixes).toHaveLength(0);
});
});
describe('Combined', () => {
it('should fix multiple issues in one workflow', async () => {
const workflow = createMockWorkflow(
[
createMockNode('id1', 'Node1'),
createMockNode('id2', 'Node2'),
createMockNode('id3', 'Node3')
],
{
Node1: {
'0': [[
{ node: 'Node2', type: '0', index: 0 },
{ node: 'Node2', type: '0', index: 0 }, // duplicate
]]
},
'id3': { // ID instead of name
main: [[{ node: 'Node2', type: 'main', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
expect(result.fixes.length).toBeGreaterThan(0);
expect(result.operations.find(op => op.type === 'replaceConnections')).toBeDefined();
// Should have numeric key, invalid type, dedup, and id-to-name fixes
const types = new Set(result.fixes.map(f => f.type));
expect(types.has('connection-numeric-keys')).toBe(true);
expect(types.has('connection-id-to-name')).toBe(true);
});
it('should be idempotent (no fixes on valid connections)', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2')],
{
Node1: {
main: [[{ node: 'Node2', type: 'main', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connectionFixTypes = [
'connection-numeric-keys',
'connection-invalid-type',
'connection-id-to-name',
'connection-duplicate-removal',
'connection-input-index'
];
const connFixes = result.fixes.filter(f => connectionFixTypes.includes(f.type));
expect(connFixes).toHaveLength(0);
expect(result.operations.find(op => op.type === 'replaceConnections')).toBeUndefined();
});
});
describe('Edge Cases', () => {
it('should handle empty connections', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1')],
{}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
expect(result.operations.find(op => op.type === 'replaceConnections')).toBeUndefined();
});
it('should respect fixTypes filtering', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2')],
{
Node1: {
'0': [[{ node: 'Node2', type: '0', index: 0 }]]
}
}
);
// Only allow numeric key fixes, not invalid type fixes
const result = await autoFixer.generateFixes(workflow, emptyValidation, [], {
fixTypes: ['connection-numeric-keys']
});
const numericFixes = result.fixes.filter(f => f.type === 'connection-numeric-keys');
const typeFixes = result.fixes.filter(f => f.type === 'connection-invalid-type');
expect(numericFixes.length).toBeGreaterThan(0);
expect(typeFixes).toHaveLength(0);
});
it('should filter replaceConnections from operations when confidence threshold filters all connection fixes', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2')],
{
Node1: {
main: [[{ node: 'Node2', type: 'main', index: 5 }]]
}
}
);
const validation: WorkflowValidationResult = {
...emptyValidation,
errors: [{
type: 'error',
nodeName: 'Node2',
message: 'Input index 5 on node "Node2" exceeds its input count (1). Connection from "Node1" targets input 5, but this node has 1 main input(s) (indices 0-0).',
code: 'INPUT_INDEX_OUT_OF_BOUNDS'
}]
};
// Input index fixes are medium confidence. Filter to high only.
const result = await autoFixer.generateFixes(workflow, validation, [], {
confidenceThreshold: 'high'
});
// Medium confidence fixes should be filtered out
const connFixes = result.fixes.filter(f => f.type === 'connection-input-index');
expect(connFixes).toHaveLength(0);
expect(result.operations.find(op => op.type === 'replaceConnections')).toBeUndefined();
});
it('should include connection issues in summary', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2')],
{
Node1: {
'0': [[{ node: 'Node2', type: 'main', index: 0 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
expect(result.summary).toContain('connection');
});
it('should handle non-existent target nodes gracefully', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1')],
{
Node1: {
'0': [[{ node: 'NonExistent', type: 'main', index: 0 }]]
}
}
);
// Should not throw
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
expect(result.fixes.some(f => f.type === 'connection-numeric-keys')).toBe(true);
});
it('should skip unparseable INPUT_INDEX_OUT_OF_BOUNDS errors gracefully', async () => {
const validation: WorkflowValidationResult = {
...emptyValidation,
errors: [{
type: 'error',
nodeName: 'Node2',
message: 'Something unexpected about input indices',
code: 'INPUT_INDEX_OUT_OF_BOUNDS'
}]
};
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2')],
{
Node1: {
main: [[{ node: 'Node2', type: 'main', index: 5 }]]
}
}
);
const result = await autoFixer.generateFixes(workflow, validation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-input-index');
expect(connFixes).toHaveLength(0);
});
it('should fix both source keys and target .node values as IDs in the same workflow', async () => {
const workflow = createMockWorkflow(
[
createMockNode('abc-123', 'Node1'),
createMockNode('def-456', 'Node2'),
createMockNode('ghi-789', 'Node3')
],
{
'abc-123': { // source key is ID
main: [[{ node: 'def-456', type: 'main', index: 0 }]] // target .node is also ID
},
Node2: {
main: [[{ node: 'ghi-789', type: 'main', index: 0 }]] // another target ID
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const connFixes = result.fixes.filter(f => f.type === 'connection-id-to-name');
// Should fix: source key abc-123 → Node1, target def-456 → Node2, target ghi-789 → Node3
expect(connFixes).toHaveLength(3);
const replaceOp = result.operations.find(op => op.type === 'replaceConnections') as any;
expect(replaceOp.connections['Node1']).toBeDefined();
expect(replaceOp.connections['abc-123']).toBeUndefined();
// Verify target .node values were also replaced
const node1Conns = replaceOp.connections['Node1'].main[0];
expect(node1Conns[0].node).toBe('Node2');
const node2Conns = replaceOp.connections['Node2'].main[0];
expect(node2Conns[0].node).toBe('Node3');
});
it('should lower confidence to medium when merging numeric key into non-empty main slot', async () => {
const workflow = createMockWorkflow(
[createMockNode('id1', 'Node1'), createMockNode('id2', 'Node2'), createMockNode('id3', 'Node3')],
{
Node1: {
main: [[{ node: 'Node2', type: 'main', index: 0 }]],
'0': [[{ node: 'Node3', type: 'main', index: 0 }]] // conflicts with existing main[0]
}
}
);
const result = await autoFixer.generateFixes(workflow, emptyValidation, []);
const numericFixes = result.fixes.filter(f => f.type === 'connection-numeric-keys');
expect(numericFixes).toHaveLength(1);
expect(numericFixes[0].confidence).toBe('medium');
expect(numericFixes[0].description).toContain('Merged');
});
});
});

View File

@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WorkflowValidator } from '@/services/workflow-validator';
import { NodeRepository } from '@/database/node-repository';
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
// Mock dependencies
vi.mock('@/database/node-repository');
vi.mock('@/services/enhanced-config-validator');
vi.mock('@/services/expression-validator');
vi.mock('@/utils/logger');
describe('WorkflowValidator - AI Sub-Node Main Connection Detection', () => {
let validator: WorkflowValidator;
let mockNodeRepository: NodeRepository;
beforeEach(() => {
vi.clearAllMocks();
mockNodeRepository = new NodeRepository({} as any) as any;
if (!mockNodeRepository.getAllNodes) {
mockNodeRepository.getAllNodes = vi.fn();
}
if (!mockNodeRepository.getNode) {
mockNodeRepository.getNode = vi.fn();
}
const nodeTypes: Record<string, any> = {
'nodes-base.manualTrigger': {
type: 'nodes-base.manualTrigger',
displayName: 'Manual Trigger',
package: 'n8n-nodes-base',
isTrigger: true,
outputs: ['main'],
properties: [],
},
'nodes-base.set': {
type: 'nodes-base.set',
displayName: 'Set',
package: 'n8n-nodes-base',
outputs: ['main'],
properties: [],
},
'nodes-langchain.lmChatGoogleGemini': {
type: 'nodes-langchain.lmChatGoogleGemini',
displayName: 'Google Gemini Chat Model',
package: '@n8n/n8n-nodes-langchain',
outputs: ['ai_languageModel'],
properties: [],
},
'nodes-langchain.memoryBufferWindow': {
type: 'nodes-langchain.memoryBufferWindow',
displayName: 'Window Buffer Memory',
package: '@n8n/n8n-nodes-langchain',
outputs: ['ai_memory'],
properties: [],
},
'nodes-langchain.embeddingsOpenAi': {
type: 'nodes-langchain.embeddingsOpenAi',
displayName: 'Embeddings OpenAI',
package: '@n8n/n8n-nodes-langchain',
outputs: ['ai_embedding'],
properties: [],
},
'nodes-langchain.agent': {
type: 'nodes-langchain.agent',
displayName: 'AI Agent',
package: '@n8n/n8n-nodes-langchain',
isAITool: true,
outputs: ['main'],
properties: [],
},
'nodes-langchain.openAi': {
type: 'nodes-langchain.openAi',
displayName: 'OpenAI',
package: '@n8n/n8n-nodes-langchain',
outputs: ['main'],
properties: [],
},
'nodes-langchain.textClassifier': {
type: 'nodes-langchain.textClassifier',
displayName: 'Text Classifier',
package: '@n8n/n8n-nodes-langchain',
outputs: ['={{}}'], // Dynamic expression-based outputs
properties: [],
},
'nodes-langchain.vectorStoreInMemory': {
type: 'nodes-langchain.vectorStoreInMemory',
displayName: 'In-Memory Vector Store',
package: '@n8n/n8n-nodes-langchain',
outputs: ['={{$parameter["mode"] === "retrieve" ? "main" : "ai_vectorStore"}}'],
properties: [],
},
};
vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => {
return nodeTypes[nodeType] || null;
});
vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes));
validator = new WorkflowValidator(
mockNodeRepository,
EnhancedConfigValidator as any
);
});
function makeWorkflow(sourceType: string, sourceName: string, connectionKey: string = 'main') {
return {
nodes: [
{ id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: sourceName, type: sourceType, position: [200, 0], parameters: {} },
{ id: '3', name: 'Set', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
],
connections: {
'Manual Trigger': {
main: [[{ node: sourceName, type: 'main', index: 0 }]]
},
[sourceName]: {
[connectionKey]: [[{ node: 'Set', type: connectionKey, index: 0 }]]
}
}
};
}
it('should flag LLM node (lmChatGoogleGemini) connected via main', async () => {
const workflow = makeWorkflow(
'n8n-nodes-langchain.lmChatGoogleGemini',
'Google Gemini'
);
const result = await validator.validateWorkflow(workflow as any);
const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
expect(error).toBeDefined();
expect(error!.message).toContain('ai_languageModel');
expect(error!.message).toContain('AI sub-node');
expect(error!.nodeName).toBe('Google Gemini');
});
it('should flag memory node (memoryBufferWindow) connected via main', async () => {
const workflow = makeWorkflow(
'n8n-nodes-langchain.memoryBufferWindow',
'Window Buffer Memory'
);
const result = await validator.validateWorkflow(workflow as any);
const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
expect(error).toBeDefined();
expect(error!.message).toContain('ai_memory');
});
it('should flag embeddings node connected via main', async () => {
const workflow = makeWorkflow(
'n8n-nodes-langchain.embeddingsOpenAi',
'Embeddings OpenAI'
);
const result = await validator.validateWorkflow(workflow as any);
const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
expect(error).toBeDefined();
expect(error!.message).toContain('ai_embedding');
});
it('should NOT flag regular langchain nodes (agent, openAi) connected via main', async () => {
const workflow1 = makeWorkflow('n8n-nodes-langchain.agent', 'AI Agent');
const workflow2 = makeWorkflow('n8n-nodes-langchain.openAi', 'OpenAI');
const result1 = await validator.validateWorkflow(workflow1 as any);
const result2 = await validator.validateWorkflow(workflow2 as any);
expect(result1.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
expect(result2.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
});
it('should NOT flag dynamic-output nodes (expression-based outputs)', async () => {
const workflow1 = makeWorkflow('n8n-nodes-langchain.textClassifier', 'Text Classifier');
const workflow2 = makeWorkflow('n8n-nodes-langchain.vectorStoreInMemory', 'Vector Store');
const result1 = await validator.validateWorkflow(workflow1 as any);
const result2 = await validator.validateWorkflow(workflow2 as any);
expect(result1.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
expect(result2.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
});
it('should NOT flag AI sub-node connected via correct AI type', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'AI Agent', type: 'n8n-nodes-langchain.agent', position: [200, 0], parameters: {} },
{ id: '3', name: 'Google Gemini', type: 'n8n-nodes-langchain.lmChatGoogleGemini', position: [200, 200], parameters: {} },
],
connections: {
'Manual Trigger': {
main: [[{ node: 'AI Agent', type: 'main', index: 0 }]]
},
'Google Gemini': {
ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
});
it('should NOT flag unknown/community nodes not in database', async () => {
const workflow = makeWorkflow('n8n-nodes-community.someNode', 'Community Node');
const result = await validator.validateWorkflow(workflow as any);
expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
});
});

View File

@@ -63,6 +63,20 @@ describe('WorkflowValidator - Connection Validation (#620)', () => {
outputs: ['main', 'main'],
properties: [],
},
'nodes-base.filter': {
type: 'nodes-base.filter',
displayName: 'Filter',
package: 'n8n-nodes-base',
outputs: ['main', 'main'],
properties: [],
},
'nodes-base.switch': {
type: 'nodes-base.switch',
displayName: 'Switch',
package: 'n8n-nodes-base',
outputs: ['main', 'main', 'main', 'main'],
properties: [],
},
'nodes-base.googleSheets': {
type: 'nodes-base.googleSheets',
displayName: 'Google Sheets',
@@ -715,4 +729,190 @@ describe('WorkflowValidator - Connection Validation (#620)', () => {
expect(orphanWarning!.message).toContain('not connected to any other nodes');
});
});
describe('Conditional branch fan-out detection (CONDITIONAL_BRANCH_FANOUT)', () => {
it('should warn when IF node has both branches in main[0]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
{ id: '3', name: 'TrueTarget', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'FalseTarget', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
'Route': {
main: [[{ node: 'TrueTarget', type: 'main', index: 0 }, { node: 'FalseTarget', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeDefined();
expect(warning!.nodeName).toBe('Route');
expect(warning!.message).toContain('2 connections on the "true" branch');
expect(warning!.message).toContain('"false" branch has no effect');
});
it('should not warn when IF node has correct true/false split', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
{ id: '3', name: 'TrueTarget', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'FalseTarget', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
'Route': {
main: [
[{ node: 'TrueTarget', type: 'main', index: 0 }],
[{ node: 'FalseTarget', type: 'main', index: 0 }]
]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeUndefined();
});
it('should not warn when IF has fan-out on main[0] AND connections on main[1]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
{ id: '3', name: 'TrueA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'TrueB', type: 'n8n-nodes-base.set', position: [400, 100], parameters: {} },
{ id: '5', name: 'FalseTarget', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
'Route': {
main: [
[{ node: 'TrueA', type: 'main', index: 0 }, { node: 'TrueB', type: 'main', index: 0 }],
[{ node: 'FalseTarget', type: 'main', index: 0 }]
]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeUndefined();
});
it('should warn when Switch node has all connections on main[0]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'MySwitch', type: 'n8n-nodes-base.switch', position: [200, 0], parameters: { rules: { values: [{ value: 'a' }, { value: 'b' }] } } },
{ id: '3', name: 'TargetA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'TargetB', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
{ id: '5', name: 'TargetC', type: 'n8n-nodes-base.set', position: [400, 400], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'MySwitch', type: 'main', index: 0 }]] },
'MySwitch': {
main: [[{ node: 'TargetA', type: 'main', index: 0 }, { node: 'TargetB', type: 'main', index: 0 }, { node: 'TargetC', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeDefined();
expect(warning!.nodeName).toBe('MySwitch');
expect(warning!.message).toContain('3 connections on output 0');
expect(warning!.message).toContain('other switch branches have no effect');
});
it('should not warn when Switch node has no rules parameter (indeterminate outputs)', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'MySwitch', type: 'n8n-nodes-base.switch', position: [200, 0], parameters: {} },
{ id: '3', name: 'TargetA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'TargetB', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'MySwitch', type: 'main', index: 0 }]] },
'MySwitch': {
main: [[{ node: 'TargetA', type: 'main', index: 0 }, { node: 'TargetB', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeUndefined();
});
it('should not warn when regular node has fan-out on main[0]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'MySet', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} },
{ id: '3', name: 'TargetA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'TargetB', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'MySet', type: 'main', index: 0 }]] },
'MySet': {
main: [[{ node: 'TargetA', type: 'main', index: 0 }, { node: 'TargetB', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeUndefined();
});
it('should not warn when IF has only 1 connection on main[0] with empty main[1]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
{ id: '3', name: 'TrueOnly', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
'Route': {
main: [[{ node: 'TrueOnly', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeUndefined();
});
it('should warn for Filter node with both branches in main[0]', async () => {
const workflow = {
nodes: [
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
{ id: '2', name: 'MyFilter', type: 'n8n-nodes-base.filter', position: [200, 0], parameters: {} },
{ id: '3', name: 'Matched', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
{ id: '4', name: 'Unmatched', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
],
connections: {
'Trigger': { main: [[{ node: 'MyFilter', type: 'main', index: 0 }]] },
'MyFilter': {
main: [[{ node: 'Matched', type: 'main', index: 0 }, { node: 'Unmatched', type: 'main', index: 0 }]]
}
}
};
const result = await validator.validateWorkflow(workflow as any);
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
expect(warning).toBeDefined();
expect(warning!.nodeName).toBe('MyFilter');
expect(warning!.message).toContain('"matched" branch');
expect(warning!.message).toContain('"unmatched" branch has no effect');
});
});
});