Files
n8n-mcp/tests/unit/services/workflow-auto-fixer-connections.test.ts
Romuald Członkowski 25b8a8145d 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
2026-03-08 08:41:44 +01:00

567 lines
20 KiB
TypeScript

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');
});
});
});