## 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>
568 lines
17 KiB
TypeScript
568 lines
17 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { NodeRepository } from '@/database/node-repository';
|
|
import { DatabaseAdapter } from '@/database/database-adapter';
|
|
import { ParsedNode } from '@/parsers/node-parser';
|
|
|
|
describe('NodeRepository - Outputs Handling', () => {
|
|
let repository: NodeRepository;
|
|
let mockDb: DatabaseAdapter;
|
|
let mockStatement: any;
|
|
|
|
beforeEach(() => {
|
|
mockStatement = {
|
|
run: vi.fn(),
|
|
get: vi.fn(),
|
|
all: vi.fn()
|
|
};
|
|
|
|
mockDb = {
|
|
prepare: vi.fn().mockReturnValue(mockStatement),
|
|
transaction: vi.fn(),
|
|
exec: vi.fn(),
|
|
close: vi.fn(),
|
|
pragma: vi.fn()
|
|
} as any;
|
|
|
|
repository = new NodeRepository(mockDb);
|
|
});
|
|
|
|
describe('saveNode with outputs', () => {
|
|
it('should save node with outputs and outputNames correctly', () => {
|
|
const outputs = [
|
|
{ displayName: 'Done', description: 'Final results when loop completes' },
|
|
{ displayName: 'Loop', description: 'Current batch data during iteration' }
|
|
];
|
|
const outputNames = ['done', 'loop'];
|
|
|
|
const node: ParsedNode = {
|
|
style: 'programmatic',
|
|
nodeType: 'nodes-base.splitInBatches',
|
|
displayName: 'Split In Batches',
|
|
description: 'Split data into batches',
|
|
category: 'transform',
|
|
properties: [],
|
|
credentials: [],
|
|
isAITool: false,
|
|
isTrigger: false,
|
|
isWebhook: false,
|
|
operations: [],
|
|
version: '3',
|
|
isVersioned: false,
|
|
packageName: 'n8n-nodes-base',
|
|
outputs,
|
|
outputNames
|
|
};
|
|
|
|
repository.saveNode(node);
|
|
|
|
expect(mockDb.prepare).toHaveBeenCalledWith(`
|
|
INSERT OR REPLACE INTO nodes (
|
|
node_type, package_name, display_name, description,
|
|
category, development_style, is_ai_tool, is_trigger,
|
|
is_webhook, is_versioned, version, documentation,
|
|
properties_schema, operations, credentials_required,
|
|
outputs, output_names
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
expect(mockStatement.run).toHaveBeenCalledWith(
|
|
'nodes-base.splitInBatches',
|
|
'n8n-nodes-base',
|
|
'Split In Batches',
|
|
'Split data into batches',
|
|
'transform',
|
|
'programmatic',
|
|
0, // false
|
|
0, // false
|
|
0, // false
|
|
0, // false
|
|
'3',
|
|
null, // documentation
|
|
JSON.stringify([], null, 2), // properties
|
|
JSON.stringify([], null, 2), // operations
|
|
JSON.stringify([], null, 2), // credentials
|
|
JSON.stringify(outputs, null, 2), // outputs
|
|
JSON.stringify(outputNames, null, 2) // output_names
|
|
);
|
|
});
|
|
|
|
it('should save node with only outputs (no outputNames)', () => {
|
|
const outputs = [
|
|
{ displayName: 'True', description: 'Items that match condition' },
|
|
{ displayName: 'False', description: 'Items that do not match condition' }
|
|
];
|
|
|
|
const node: ParsedNode = {
|
|
style: 'programmatic',
|
|
nodeType: 'nodes-base.if',
|
|
displayName: 'IF',
|
|
description: 'Route items based on conditions',
|
|
category: 'transform',
|
|
properties: [],
|
|
credentials: [],
|
|
isAITool: false,
|
|
isTrigger: false,
|
|
isWebhook: false,
|
|
operations: [],
|
|
version: '2',
|
|
isVersioned: false,
|
|
packageName: 'n8n-nodes-base',
|
|
outputs
|
|
// no outputNames
|
|
};
|
|
|
|
repository.saveNode(node);
|
|
|
|
const callArgs = mockStatement.run.mock.calls[0];
|
|
expect(callArgs[15]).toBe(JSON.stringify(outputs, null, 2)); // outputs
|
|
expect(callArgs[16]).toBe(null); // output_names should be null
|
|
});
|
|
|
|
it('should save node with only outputNames (no outputs)', () => {
|
|
const outputNames = ['main', 'error'];
|
|
|
|
const node: ParsedNode = {
|
|
style: 'programmatic',
|
|
nodeType: 'nodes-base.customNode',
|
|
displayName: 'Custom Node',
|
|
description: 'Custom node with output names only',
|
|
category: 'transform',
|
|
properties: [],
|
|
credentials: [],
|
|
isAITool: false,
|
|
isTrigger: false,
|
|
isWebhook: false,
|
|
operations: [],
|
|
version: '1',
|
|
isVersioned: false,
|
|
packageName: 'n8n-nodes-base',
|
|
outputNames
|
|
// no outputs
|
|
};
|
|
|
|
repository.saveNode(node);
|
|
|
|
const callArgs = mockStatement.run.mock.calls[0];
|
|
expect(callArgs[15]).toBe(null); // outputs should be null
|
|
expect(callArgs[16]).toBe(JSON.stringify(outputNames, null, 2)); // output_names
|
|
});
|
|
|
|
it('should save node without outputs or outputNames', () => {
|
|
const node: ParsedNode = {
|
|
style: 'programmatic',
|
|
nodeType: 'nodes-base.httpRequest',
|
|
displayName: 'HTTP Request',
|
|
description: 'Make HTTP requests',
|
|
category: 'input',
|
|
properties: [],
|
|
credentials: [],
|
|
isAITool: false,
|
|
isTrigger: false,
|
|
isWebhook: false,
|
|
operations: [],
|
|
version: '4',
|
|
isVersioned: false,
|
|
packageName: 'n8n-nodes-base'
|
|
// no outputs or outputNames
|
|
};
|
|
|
|
repository.saveNode(node);
|
|
|
|
const callArgs = mockStatement.run.mock.calls[0];
|
|
expect(callArgs[15]).toBe(null); // outputs should be null
|
|
expect(callArgs[16]).toBe(null); // output_names should be null
|
|
});
|
|
|
|
it('should handle empty outputs and outputNames arrays', () => {
|
|
const node: ParsedNode = {
|
|
style: 'programmatic',
|
|
nodeType: 'nodes-base.emptyNode',
|
|
displayName: 'Empty Node',
|
|
description: 'Node with empty outputs',
|
|
category: 'misc',
|
|
properties: [],
|
|
credentials: [],
|
|
isAITool: false,
|
|
isTrigger: false,
|
|
isWebhook: false,
|
|
operations: [],
|
|
version: '1',
|
|
isVersioned: false,
|
|
packageName: 'n8n-nodes-base',
|
|
outputs: [],
|
|
outputNames: []
|
|
};
|
|
|
|
repository.saveNode(node);
|
|
|
|
const callArgs = mockStatement.run.mock.calls[0];
|
|
expect(callArgs[15]).toBe(JSON.stringify([], null, 2)); // outputs
|
|
expect(callArgs[16]).toBe(JSON.stringify([], null, 2)); // output_names
|
|
});
|
|
});
|
|
|
|
describe('getNode with outputs', () => {
|
|
it('should retrieve node with outputs and outputNames correctly', () => {
|
|
const outputs = [
|
|
{ displayName: 'Done', description: 'Final results when loop completes' },
|
|
{ displayName: 'Loop', description: 'Current batch data during iteration' }
|
|
];
|
|
const outputNames = ['done', 'loop'];
|
|
|
|
const mockRow = {
|
|
node_type: 'nodes-base.splitInBatches',
|
|
display_name: 'Split In Batches',
|
|
description: 'Split data into batches',
|
|
category: 'transform',
|
|
development_style: 'programmatic',
|
|
package_name: 'n8n-nodes-base',
|
|
is_ai_tool: 0,
|
|
is_trigger: 0,
|
|
is_webhook: 0,
|
|
is_versioned: 0,
|
|
version: '3',
|
|
properties_schema: JSON.stringify([]),
|
|
operations: JSON.stringify([]),
|
|
credentials_required: JSON.stringify([]),
|
|
documentation: null,
|
|
outputs: JSON.stringify(outputs),
|
|
output_names: JSON.stringify(outputNames)
|
|
};
|
|
|
|
mockStatement.get.mockReturnValue(mockRow);
|
|
|
|
const result = repository.getNode('nodes-base.splitInBatches');
|
|
|
|
expect(result).toEqual({
|
|
nodeType: 'nodes-base.splitInBatches',
|
|
displayName: 'Split In Batches',
|
|
description: 'Split data into batches',
|
|
category: 'transform',
|
|
developmentStyle: 'programmatic',
|
|
package: 'n8n-nodes-base',
|
|
isAITool: false,
|
|
isTrigger: false,
|
|
isWebhook: false,
|
|
isVersioned: false,
|
|
version: '3',
|
|
properties: [],
|
|
operations: [],
|
|
credentials: [],
|
|
hasDocumentation: false,
|
|
outputs,
|
|
outputNames
|
|
});
|
|
});
|
|
|
|
it('should retrieve node with only outputs (null outputNames)', () => {
|
|
const outputs = [
|
|
{ displayName: 'True', description: 'Items that match condition' }
|
|
];
|
|
|
|
const mockRow = {
|
|
node_type: 'nodes-base.if',
|
|
display_name: 'IF',
|
|
description: 'Route items',
|
|
category: 'transform',
|
|
development_style: 'programmatic',
|
|
package_name: 'n8n-nodes-base',
|
|
is_ai_tool: 0,
|
|
is_trigger: 0,
|
|
is_webhook: 0,
|
|
is_versioned: 0,
|
|
version: '2',
|
|
properties_schema: JSON.stringify([]),
|
|
operations: JSON.stringify([]),
|
|
credentials_required: JSON.stringify([]),
|
|
documentation: null,
|
|
outputs: JSON.stringify(outputs),
|
|
output_names: null
|
|
};
|
|
|
|
mockStatement.get.mockReturnValue(mockRow);
|
|
|
|
const result = repository.getNode('nodes-base.if');
|
|
|
|
expect(result.outputs).toEqual(outputs);
|
|
expect(result.outputNames).toBe(null);
|
|
});
|
|
|
|
it('should retrieve node with only outputNames (null outputs)', () => {
|
|
const outputNames = ['main'];
|
|
|
|
const mockRow = {
|
|
node_type: 'nodes-base.customNode',
|
|
display_name: 'Custom Node',
|
|
description: 'Custom node',
|
|
category: 'misc',
|
|
development_style: 'programmatic',
|
|
package_name: 'n8n-nodes-base',
|
|
is_ai_tool: 0,
|
|
is_trigger: 0,
|
|
is_webhook: 0,
|
|
is_versioned: 0,
|
|
version: '1',
|
|
properties_schema: JSON.stringify([]),
|
|
operations: JSON.stringify([]),
|
|
credentials_required: JSON.stringify([]),
|
|
documentation: null,
|
|
outputs: null,
|
|
output_names: JSON.stringify(outputNames)
|
|
};
|
|
|
|
mockStatement.get.mockReturnValue(mockRow);
|
|
|
|
const result = repository.getNode('nodes-base.customNode');
|
|
|
|
expect(result.outputs).toBe(null);
|
|
expect(result.outputNames).toEqual(outputNames);
|
|
});
|
|
|
|
it('should retrieve node without outputs or outputNames', () => {
|
|
const mockRow = {
|
|
node_type: 'nodes-base.httpRequest',
|
|
display_name: 'HTTP Request',
|
|
description: 'Make HTTP requests',
|
|
category: 'input',
|
|
development_style: 'programmatic',
|
|
package_name: 'n8n-nodes-base',
|
|
is_ai_tool: 0,
|
|
is_trigger: 0,
|
|
is_webhook: 0,
|
|
is_versioned: 0,
|
|
version: '4',
|
|
properties_schema: JSON.stringify([]),
|
|
operations: JSON.stringify([]),
|
|
credentials_required: JSON.stringify([]),
|
|
documentation: null,
|
|
outputs: null,
|
|
output_names: null
|
|
};
|
|
|
|
mockStatement.get.mockReturnValue(mockRow);
|
|
|
|
const result = repository.getNode('nodes-base.httpRequest');
|
|
|
|
expect(result.outputs).toBe(null);
|
|
expect(result.outputNames).toBe(null);
|
|
});
|
|
|
|
it('should handle malformed JSON gracefully', () => {
|
|
const mockRow = {
|
|
node_type: 'nodes-base.malformed',
|
|
display_name: 'Malformed Node',
|
|
description: 'Node with malformed JSON',
|
|
category: 'misc',
|
|
development_style: 'programmatic',
|
|
package_name: 'n8n-nodes-base',
|
|
is_ai_tool: 0,
|
|
is_trigger: 0,
|
|
is_webhook: 0,
|
|
is_versioned: 0,
|
|
version: '1',
|
|
properties_schema: JSON.stringify([]),
|
|
operations: JSON.stringify([]),
|
|
credentials_required: JSON.stringify([]),
|
|
documentation: null,
|
|
outputs: '{invalid json}',
|
|
output_names: '[invalid, json'
|
|
};
|
|
|
|
mockStatement.get.mockReturnValue(mockRow);
|
|
|
|
const result = repository.getNode('nodes-base.malformed');
|
|
|
|
// Should use default values when JSON parsing fails
|
|
expect(result.outputs).toBe(null);
|
|
expect(result.outputNames).toBe(null);
|
|
});
|
|
|
|
it('should return null for non-existent node', () => {
|
|
mockStatement.get.mockReturnValue(null);
|
|
|
|
const result = repository.getNode('nodes-base.nonExistent');
|
|
|
|
expect(result).toBe(null);
|
|
});
|
|
|
|
it('should handle SplitInBatches counterintuitive output order correctly', () => {
|
|
// Test that the output order is preserved: done=0, loop=1
|
|
const outputs = [
|
|
{ displayName: 'Done', description: 'Final results when loop completes', index: 0 },
|
|
{ displayName: 'Loop', description: 'Current batch data during iteration', index: 1 }
|
|
];
|
|
const outputNames = ['done', 'loop'];
|
|
|
|
const mockRow = {
|
|
node_type: 'nodes-base.splitInBatches',
|
|
display_name: 'Split In Batches',
|
|
description: 'Split data into batches',
|
|
category: 'transform',
|
|
development_style: 'programmatic',
|
|
package_name: 'n8n-nodes-base',
|
|
is_ai_tool: 0,
|
|
is_trigger: 0,
|
|
is_webhook: 0,
|
|
is_versioned: 0,
|
|
version: '3',
|
|
properties_schema: JSON.stringify([]),
|
|
operations: JSON.stringify([]),
|
|
credentials_required: JSON.stringify([]),
|
|
documentation: null,
|
|
outputs: JSON.stringify(outputs),
|
|
output_names: JSON.stringify(outputNames)
|
|
};
|
|
|
|
mockStatement.get.mockReturnValue(mockRow);
|
|
|
|
const result = repository.getNode('nodes-base.splitInBatches');
|
|
|
|
// Verify order is preserved
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('parseNodeRow with outputs', () => {
|
|
it('should parse node row with outputs correctly using parseNodeRow', () => {
|
|
const outputs = [{ displayName: 'Output' }];
|
|
const outputNames = ['main'];
|
|
|
|
const mockRow = {
|
|
node_type: 'nodes-base.test',
|
|
display_name: 'Test',
|
|
description: 'Test node',
|
|
category: 'misc',
|
|
development_style: 'programmatic',
|
|
package_name: 'n8n-nodes-base',
|
|
is_ai_tool: 0,
|
|
is_trigger: 0,
|
|
is_webhook: 0,
|
|
is_versioned: 0,
|
|
version: '1',
|
|
properties_schema: JSON.stringify([]),
|
|
operations: JSON.stringify([]),
|
|
credentials_required: JSON.stringify([]),
|
|
documentation: null,
|
|
outputs: JSON.stringify(outputs),
|
|
output_names: JSON.stringify(outputNames)
|
|
};
|
|
|
|
mockStatement.all.mockReturnValue([mockRow]);
|
|
|
|
const results = repository.getAllNodes(1);
|
|
|
|
expect(results[0].outputs).toEqual(outputs);
|
|
expect(results[0].outputNames).toEqual(outputNames);
|
|
});
|
|
|
|
it('should handle empty string as null for outputs', () => {
|
|
const mockRow = {
|
|
node_type: 'nodes-base.empty',
|
|
display_name: 'Empty',
|
|
description: 'Empty node',
|
|
category: 'misc',
|
|
development_style: 'programmatic',
|
|
package_name: 'n8n-nodes-base',
|
|
is_ai_tool: 0,
|
|
is_trigger: 0,
|
|
is_webhook: 0,
|
|
is_versioned: 0,
|
|
version: '1',
|
|
properties_schema: JSON.stringify([]),
|
|
operations: JSON.stringify([]),
|
|
credentials_required: JSON.stringify([]),
|
|
documentation: null,
|
|
outputs: '', // empty string
|
|
output_names: '' // empty string
|
|
};
|
|
|
|
mockStatement.all.mockReturnValue([mockRow]);
|
|
|
|
const results = repository.getAllNodes(1);
|
|
|
|
// Empty strings should be treated as null since they fail JSON parsing
|
|
expect(results[0].outputs).toBe(null);
|
|
expect(results[0].outputNames).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('complex output structures', () => {
|
|
it('should handle complex output objects with metadata', () => {
|
|
const complexOutputs = [
|
|
{
|
|
displayName: 'Done',
|
|
name: 'done',
|
|
type: 'main',
|
|
hint: 'Receives the final data after all batches have been processed',
|
|
description: 'Final results when loop completes',
|
|
index: 0
|
|
},
|
|
{
|
|
displayName: 'Loop',
|
|
name: 'loop',
|
|
type: 'main',
|
|
hint: 'Receives the current batch data during each iteration',
|
|
description: 'Current batch data during iteration',
|
|
index: 1
|
|
}
|
|
];
|
|
|
|
const node: ParsedNode = {
|
|
style: 'programmatic',
|
|
nodeType: 'nodes-base.splitInBatches',
|
|
displayName: 'Split In Batches',
|
|
description: 'Split data into batches',
|
|
category: 'transform',
|
|
properties: [],
|
|
credentials: [],
|
|
isAITool: false,
|
|
isTrigger: false,
|
|
isWebhook: false,
|
|
operations: [],
|
|
version: '3',
|
|
isVersioned: false,
|
|
packageName: 'n8n-nodes-base',
|
|
outputs: complexOutputs,
|
|
outputNames: ['done', 'loop']
|
|
};
|
|
|
|
repository.saveNode(node);
|
|
|
|
// Simulate retrieval
|
|
const mockRow = {
|
|
node_type: 'nodes-base.splitInBatches',
|
|
display_name: 'Split In Batches',
|
|
description: 'Split data into batches',
|
|
category: 'transform',
|
|
development_style: 'programmatic',
|
|
package_name: 'n8n-nodes-base',
|
|
is_ai_tool: 0,
|
|
is_trigger: 0,
|
|
is_webhook: 0,
|
|
is_versioned: 0,
|
|
version: '3',
|
|
properties_schema: JSON.stringify([]),
|
|
operations: JSON.stringify([]),
|
|
credentials_required: JSON.stringify([]),
|
|
documentation: null,
|
|
outputs: JSON.stringify(complexOutputs),
|
|
output_names: JSON.stringify(['done', 'loop'])
|
|
};
|
|
|
|
mockStatement.get.mockReturnValue(mockRow);
|
|
|
|
const result = repository.getNode('nodes-base.splitInBatches');
|
|
|
|
expect(result.outputs).toEqual(complexOutputs);
|
|
expect(result.outputs[0]).toMatchObject({
|
|
displayName: 'Done',
|
|
name: 'done',
|
|
type: 'main',
|
|
hint: 'Receives the final data after all batches have been processed'
|
|
});
|
|
});
|
|
});
|
|
}); |