fix: resolve SplitInBatches output confusion for AI assistants (#97)
## Problem AI assistants were consistently connecting SplitInBatches node outputs backwards because: - Output index 0 = "done" (runs after loop completes) - Output index 1 = "loop" (processes items inside loop) This counterintuitive ordering caused incorrect workflow connections. ## Solution Enhanced the n8n-mcp system to expose and clarify output information: ### Database & Schema - Added `outputs` and `output_names` columns to nodes table - Updated NodeRepository to store/retrieve output information ### Node Parsing - Enhanced NodeParser to extract outputs and outputNames from nodes - Properly handles versioned nodes like SplitInBatchesV3 ### MCP Server - Modified getNodeInfo to return detailed output descriptions - Added connection guidance for each output - Special handling for loop nodes (SplitInBatches, IF, Switch) ### Documentation - Enhanced DocsMapper to inject critical output guidance - Added warnings about counterintuitive output ordering - Provides correct connection patterns for loop nodes ### Workflow Validation - Added validateSplitInBatchesConnection method - Detects reversed connections and provides specific errors - Added checkForLoopBack with depth limit to prevent stack overflow - Smart heuristics to identify likely connection mistakes ## Testing - Created comprehensive test suite (81 tests) - Unit tests for all modified components - Edge case handling for malformed data - Performance testing with large workflows ## Impact AI assistants will now: - See explicit output indices and names (e.g., "Output 0: done") - Receive clear connection guidance - Get validation errors when connections are reversed - Have enhanced documentation explaining the correct pattern Fixes #97 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
469
tests/unit/parsers/node-parser-outputs.test.ts
Normal file
469
tests/unit/parsers/node-parser-outputs.test.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NodeParser } from '@/parsers/node-parser';
|
||||
import { PropertyExtractor } from '@/parsers/property-extractor';
|
||||
|
||||
// Mock PropertyExtractor
|
||||
vi.mock('@/parsers/property-extractor');
|
||||
|
||||
describe('NodeParser - Output Extraction', () => {
|
||||
let parser: NodeParser;
|
||||
let mockPropertyExtractor: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockPropertyExtractor = {
|
||||
extractProperties: vi.fn().mockReturnValue([]),
|
||||
extractCredentials: vi.fn().mockReturnValue([]),
|
||||
detectAIToolCapability: vi.fn().mockReturnValue(false),
|
||||
extractOperations: vi.fn().mockReturnValue([])
|
||||
};
|
||||
|
||||
(PropertyExtractor as any).mockImplementation(() => mockPropertyExtractor);
|
||||
|
||||
parser = new NodeParser();
|
||||
});
|
||||
|
||||
describe('extractOutputs method', () => {
|
||||
it('should extract outputs array from base description', () => {
|
||||
const outputs = [
|
||||
{ displayName: 'Done', description: 'Final results when loop completes' },
|
||||
{ displayName: 'Loop', description: 'Current batch data during iteration' }
|
||||
];
|
||||
|
||||
const nodeDescription = {
|
||||
name: 'splitInBatches',
|
||||
displayName: 'Split In Batches',
|
||||
outputs
|
||||
};
|
||||
|
||||
const NodeClass = class {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(outputs);
|
||||
expect(result.outputNames).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should extract outputNames array from base description', () => {
|
||||
const outputNames = ['done', 'loop'];
|
||||
|
||||
const nodeDescription = {
|
||||
name: 'splitInBatches',
|
||||
displayName: 'Split In Batches',
|
||||
outputNames
|
||||
};
|
||||
|
||||
const NodeClass = class {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputNames).toEqual(outputNames);
|
||||
expect(result.outputs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should extract both outputs and outputNames when both are present', () => {
|
||||
const outputs = [
|
||||
{ displayName: 'Done', description: 'Final results when loop completes' },
|
||||
{ displayName: 'Loop', description: 'Current batch data during iteration' }
|
||||
];
|
||||
const outputNames = ['done', 'loop'];
|
||||
|
||||
const nodeDescription = {
|
||||
name: 'splitInBatches',
|
||||
displayName: 'Split In Batches',
|
||||
outputs,
|
||||
outputNames
|
||||
};
|
||||
|
||||
const NodeClass = class {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(outputs);
|
||||
expect(result.outputNames).toEqual(outputNames);
|
||||
});
|
||||
|
||||
it('should convert single output to array format', () => {
|
||||
const singleOutput = { displayName: 'Output', description: 'Single output' };
|
||||
|
||||
const nodeDescription = {
|
||||
name: 'singleOutputNode',
|
||||
displayName: 'Single Output Node',
|
||||
outputs: singleOutput
|
||||
};
|
||||
|
||||
const NodeClass = class {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual([singleOutput]);
|
||||
});
|
||||
|
||||
it('should convert single outputName to array format', () => {
|
||||
const nodeDescription = {
|
||||
name: 'singleOutputNode',
|
||||
displayName: 'Single Output Node',
|
||||
outputNames: 'main'
|
||||
};
|
||||
|
||||
const NodeClass = class {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputNames).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('should extract outputs from versioned node when not in base description', () => {
|
||||
const versionedOutputs = [
|
||||
{ displayName: 'True', description: 'Items that match condition' },
|
||||
{ displayName: 'False', description: 'Items that do not match condition' }
|
||||
];
|
||||
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'if',
|
||||
displayName: 'IF'
|
||||
// No outputs in base description
|
||||
};
|
||||
|
||||
nodeVersions = {
|
||||
1: {
|
||||
description: {
|
||||
outputs: versionedOutputs
|
||||
}
|
||||
},
|
||||
2: {
|
||||
description: {
|
||||
outputs: versionedOutputs,
|
||||
outputNames: ['true', 'false']
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
// Should get outputs from latest version (2)
|
||||
expect(result.outputs).toEqual(versionedOutputs);
|
||||
expect(result.outputNames).toEqual(['true', 'false']);
|
||||
});
|
||||
|
||||
it('should handle node instantiation failure gracefully', () => {
|
||||
const NodeClass = class {
|
||||
// Static description that can be accessed when instantiation fails
|
||||
static description = {
|
||||
name: 'problematic',
|
||||
displayName: 'Problematic Node'
|
||||
};
|
||||
|
||||
constructor() {
|
||||
throw new Error('Cannot instantiate');
|
||||
}
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toBeUndefined();
|
||||
expect(result.outputNames).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return empty result when no outputs found anywhere', () => {
|
||||
const nodeDescription = {
|
||||
name: 'noOutputs',
|
||||
displayName: 'No Outputs Node'
|
||||
// No outputs or outputNames
|
||||
};
|
||||
|
||||
const NodeClass = class {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toBeUndefined();
|
||||
expect(result.outputNames).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle complex versioned node structure', () => {
|
||||
const NodeClass = class VersionedNodeType {
|
||||
baseDescription = {
|
||||
name: 'complexVersioned',
|
||||
displayName: 'Complex Versioned Node',
|
||||
defaultVersion: 3
|
||||
};
|
||||
|
||||
nodeVersions = {
|
||||
1: {
|
||||
description: {
|
||||
outputs: [{ displayName: 'V1 Output' }]
|
||||
}
|
||||
},
|
||||
2: {
|
||||
description: {
|
||||
outputs: [
|
||||
{ displayName: 'V2 Output 1' },
|
||||
{ displayName: 'V2 Output 2' }
|
||||
]
|
||||
}
|
||||
},
|
||||
3: {
|
||||
description: {
|
||||
outputs: [
|
||||
{ displayName: 'V3 True', description: 'True branch' },
|
||||
{ displayName: 'V3 False', description: 'False branch' }
|
||||
],
|
||||
outputNames: ['true', 'false']
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
// Should use latest version (3)
|
||||
expect(result.outputs).toEqual([
|
||||
{ displayName: 'V3 True', description: 'True branch' },
|
||||
{ displayName: 'V3 False', description: 'False branch' }
|
||||
]);
|
||||
expect(result.outputNames).toEqual(['true', 'false']);
|
||||
});
|
||||
|
||||
it('should prefer base description outputs over versioned when both exist', () => {
|
||||
const baseOutputs = [{ displayName: 'Base Output' }];
|
||||
const versionedOutputs = [{ displayName: 'Versioned Output' }];
|
||||
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'preferBase',
|
||||
displayName: 'Prefer Base',
|
||||
outputs: baseOutputs
|
||||
};
|
||||
|
||||
nodeVersions = {
|
||||
1: {
|
||||
description: {
|
||||
outputs: versionedOutputs
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(baseOutputs);
|
||||
});
|
||||
|
||||
it('should handle IF node with typical output structure', () => {
|
||||
const ifOutputs = [
|
||||
{ displayName: 'True', description: 'Items that match the condition' },
|
||||
{ displayName: 'False', description: 'Items that do not match the condition' }
|
||||
];
|
||||
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'if',
|
||||
displayName: 'IF',
|
||||
outputs: ifOutputs,
|
||||
outputNames: ['true', 'false']
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(ifOutputs);
|
||||
expect(result.outputNames).toEqual(['true', 'false']);
|
||||
});
|
||||
|
||||
it('should handle SplitInBatches node with counterintuitive output structure', () => {
|
||||
const splitInBatchesOutputs = [
|
||||
{ displayName: 'Done', description: 'Final results when loop completes' },
|
||||
{ displayName: 'Loop', description: 'Current batch data during iteration' }
|
||||
];
|
||||
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'splitInBatches',
|
||||
displayName: 'Split In Batches',
|
||||
outputs: splitInBatchesOutputs,
|
||||
outputNames: ['done', 'loop']
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(splitInBatchesOutputs);
|
||||
expect(result.outputNames).toEqual(['done', 'loop']);
|
||||
|
||||
// Verify the counterintuitive order: done=0, loop=1
|
||||
expect(result.outputs[0].displayName).toBe('Done');
|
||||
expect(result.outputs[1].displayName).toBe('Loop');
|
||||
expect(result.outputNames[0]).toBe('done');
|
||||
expect(result.outputNames[1]).toBe('loop');
|
||||
});
|
||||
|
||||
it('should handle Switch node with multiple outputs', () => {
|
||||
const switchOutputs = [
|
||||
{ displayName: 'Output 1', description: 'First branch' },
|
||||
{ displayName: 'Output 2', description: 'Second branch' },
|
||||
{ displayName: 'Output 3', description: 'Third branch' },
|
||||
{ displayName: 'Fallback', description: 'Default branch when no conditions match' }
|
||||
];
|
||||
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'switch',
|
||||
displayName: 'Switch',
|
||||
outputs: switchOutputs,
|
||||
outputNames: ['0', '1', '2', 'fallback']
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(switchOutputs);
|
||||
expect(result.outputNames).toEqual(['0', '1', '2', 'fallback']);
|
||||
});
|
||||
|
||||
it('should handle empty outputs array', () => {
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'emptyOutputs',
|
||||
displayName: 'Empty Outputs',
|
||||
outputs: [],
|
||||
outputNames: []
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual([]);
|
||||
expect(result.outputNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle mismatched outputs and outputNames arrays', () => {
|
||||
const outputs = [
|
||||
{ displayName: 'Output 1' },
|
||||
{ displayName: 'Output 2' }
|
||||
];
|
||||
const outputNames = ['first', 'second', 'third']; // One extra
|
||||
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'mismatched',
|
||||
displayName: 'Mismatched Arrays',
|
||||
outputs,
|
||||
outputNames
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(outputs);
|
||||
expect(result.outputNames).toEqual(outputNames);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world node structures', () => {
|
||||
it('should handle actual n8n SplitInBatches node structure', () => {
|
||||
// This mimics the actual structure from n8n-nodes-base
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'splitInBatches',
|
||||
displayName: 'Split In Batches',
|
||||
description: 'Split data into batches and iterate over each batch',
|
||||
icon: 'fa:th-large',
|
||||
group: ['transform'],
|
||||
version: 3,
|
||||
outputs: [
|
||||
{
|
||||
displayName: 'Done',
|
||||
name: 'done',
|
||||
type: 'main',
|
||||
hint: 'Receives the final data after all batches have been processed'
|
||||
},
|
||||
{
|
||||
displayName: 'Loop',
|
||||
name: 'loop',
|
||||
type: 'main',
|
||||
hint: 'Receives the current batch data during each iteration'
|
||||
}
|
||||
],
|
||||
outputNames: ['done', 'loop']
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toHaveLength(2);
|
||||
expect(result.outputs[0].displayName).toBe('Done');
|
||||
expect(result.outputs[1].displayName).toBe('Loop');
|
||||
expect(result.outputNames).toEqual(['done', 'loop']);
|
||||
});
|
||||
|
||||
it('should handle actual n8n IF node structure', () => {
|
||||
// This mimics the actual structure from n8n-nodes-base
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'if',
|
||||
displayName: 'IF',
|
||||
description: 'Route items to different outputs based on conditions',
|
||||
icon: 'fa:map-signs',
|
||||
group: ['transform'],
|
||||
version: 2,
|
||||
outputs: [
|
||||
{
|
||||
displayName: 'True',
|
||||
name: 'true',
|
||||
type: 'main',
|
||||
hint: 'Items that match the condition'
|
||||
},
|
||||
{
|
||||
displayName: 'False',
|
||||
name: 'false',
|
||||
type: 'main',
|
||||
hint: 'Items that do not match the condition'
|
||||
}
|
||||
],
|
||||
outputNames: ['true', 'false']
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toHaveLength(2);
|
||||
expect(result.outputs[0].displayName).toBe('True');
|
||||
expect(result.outputs[1].displayName).toBe('False');
|
||||
expect(result.outputNames).toEqual(['true', 'false']);
|
||||
});
|
||||
|
||||
it('should handle single-output nodes like HTTP Request', () => {
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
description: 'Make HTTP requests',
|
||||
icon: 'fa:at',
|
||||
group: ['input'],
|
||||
version: 4
|
||||
// No outputs specified - single main output implied
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toBeUndefined();
|
||||
expect(result.outputNames).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user