mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 05:23:08 +00:00
test: add comprehensive Merge node integration tests for targetIndex preservation
Added 4 integration tests for Merge node (multi-input) to verify targetIndex preservation works correctly for incoming connections, complementing the sourceIndex tests for multi-output nodes. Tests verify against real n8n API: 1. Remove connection to Merge input 0 - Verifies input 1 stays at index 1 (not shifted to 0) - Tests targetIndex preservation for incoming connections 2. Remove middle connection to Merge (CRITICAL) - 3 inputs: remove input 1 - Verifies inputs 0 and 2 stay at original indices - Multi-input equivalent of Switch bug scenario 3. Replace source connection to Merge input - Remove Source1, add NewSource1 (both to input 0) - Verifies input 1 unchanged - Tests remove + add pattern for Merge inputs 4. Sequential operations on Merge inputs - Replace input 0, add input 2, remove input 1 - Verifies index integrity through complex operations - Tests empty array preservation at intermediate positions Key Finding: Our array index preservation fix works for BOTH: - Multi-output nodes (Switch/IF/Filter) - sourceIndex preservation - Multi-input nodes (Merge) - targetIndex preservation Coverage: - Total: 178 tests (158 unit + 20 integration) - All tests passing ✅ - Comprehensive regression protection for all multi-connection nodes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -107,19 +107,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- **Total Tests**: 174 tests passing (158 unit + 16 integration against real n8n API)
|
||||
- **Total Tests**: 178 tests passing (158 unit + 20 integration against real n8n API)
|
||||
- **Coverage**: 90.98% statements, 89.86% branches, 93.02% functions
|
||||
- **Quality**: Integration tests against real n8n API prevent regression
|
||||
- **New Tests**:
|
||||
- 21 tests for TypeError prevention (Issue #275)
|
||||
- 8 tests for rewireConnection operation
|
||||
- 8 tests for smart parameters
|
||||
- 16 integration tests against real n8n API:
|
||||
- Array index preservation for Switch node rewiring
|
||||
- 20 integration tests against real n8n API:
|
||||
- **Multi-output nodes (sourceIndex preservation)**:
|
||||
- Switch node rewiring with index preservation
|
||||
- IF node empty array preservation on removal
|
||||
- Switch node removing first case (production-breaking bug scenario)
|
||||
- Sequential operations on Switch node
|
||||
- Filter node connection rewiring
|
||||
- **Multi-input nodes (targetIndex preservation)**:
|
||||
- Merge node removing connection to input 0
|
||||
- Merge node removing middle connection (inputs 0, 2 preserved)
|
||||
- Merge node replacing source connections
|
||||
- Merge node sequential operations
|
||||
|
||||
### Technical Details
|
||||
|
||||
|
||||
@@ -1970,4 +1970,479 @@ describe('Integration: Smart Parameters with Real n8n API', () => {
|
||||
expect(fetchedWorkflow.connections.Filter.main[1][0].node).toBe('DiscardedHandler');
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// TEST 16-19: Merge Node - Multiple Inputs (targetIndex preservation)
|
||||
// ======================================================================
|
||||
describe('Merge Node - Multiple Inputs (targetIndex Preservation)', () => {
|
||||
it('should preserve targetIndex when removing connection to Merge input 0', async () => {
|
||||
// CRITICAL: Merge has multiple INPUTS (unlike Switch which has multiple outputs)
|
||||
// This tests that targetIndex preservation works for incoming connections
|
||||
// Bug would cause: Remove input 0 → input 1 shifts to input 0
|
||||
|
||||
const workflowName = createTestWorkflowName('Merge - Remove Input 0');
|
||||
|
||||
const workflow: Workflow = await client.createWorkflow({
|
||||
name: workflowName,
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Source1',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [200, -50],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a1', name: 'source', value: '1', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Source2',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [200, 50],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a2', name: 'source', value: '2', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Merge',
|
||||
type: 'n8n-nodes-base.merge',
|
||||
typeVersion: 3,
|
||||
position: [400, 0],
|
||||
parameters: {
|
||||
mode: 'append'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Output',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [600, 0],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Start: {
|
||||
main: [[{ node: 'Source1', type: 'main', index: 0 }]]
|
||||
},
|
||||
Source1: {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 0 }]] // to Merge input 0
|
||||
},
|
||||
Source2: {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 1 }]] // to Merge input 1
|
||||
},
|
||||
Merge: {
|
||||
main: [[{ node: 'Output', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(workflow.id).toBeTruthy();
|
||||
if (!workflow.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(workflow.id);
|
||||
|
||||
// Remove connection from Source1 to Merge (input 0)
|
||||
await handleUpdatePartialWorkflow({
|
||||
id: workflow.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'removeConnection',
|
||||
source: 'Source1',
|
||||
target: 'Merge'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const fetchedWorkflow = await client.getWorkflow(workflow.id);
|
||||
|
||||
// CRITICAL VERIFICATION: Source2 should STILL connect to Merge at targetIndex 1
|
||||
// Bug would cause it to shift to targetIndex 0
|
||||
expect(fetchedWorkflow.connections.Source2).toBeDefined();
|
||||
expect(fetchedWorkflow.connections.Source2.main).toBeDefined();
|
||||
expect(fetchedWorkflow.connections.Source2.main[0]).toBeDefined();
|
||||
expect(fetchedWorkflow.connections.Source2.main[0].length).toBe(1);
|
||||
expect(fetchedWorkflow.connections.Source2.main[0][0].node).toBe('Merge');
|
||||
expect(fetchedWorkflow.connections.Source2.main[0][0].index).toBe(1); // STILL index 1!
|
||||
|
||||
// Source1 should no longer connect to Merge
|
||||
expect(fetchedWorkflow.connections.Source1).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve targetIndex when removing middle connection to Merge', async () => {
|
||||
// MOST CRITICAL: Remove middle input, verify inputs 0 and 2 stay at their indices
|
||||
// This is the multi-input equivalent of the Switch node bug
|
||||
|
||||
const workflowName = createTestWorkflowName('Merge - Remove Middle Input');
|
||||
|
||||
const workflow: Workflow = await client.createWorkflow({
|
||||
name: workflowName,
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Source0',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [0, -100],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a0', name: 'source', value: '0', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Source1',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a1', name: 'source', value: '1', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Source2',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [0, 100],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a2', name: 'source', value: '2', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Merge',
|
||||
type: 'n8n-nodes-base.merge',
|
||||
typeVersion: 3,
|
||||
position: [200, 0],
|
||||
parameters: {
|
||||
mode: 'append'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Source0: {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 0 }]] // input 0
|
||||
},
|
||||
Source1: {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 1 }]] // input 1
|
||||
},
|
||||
Source2: {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 2 }]] // input 2
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(workflow.id).toBeTruthy();
|
||||
if (!workflow.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(workflow.id);
|
||||
|
||||
// Remove connection from Source1 to Merge (middle input)
|
||||
await handleUpdatePartialWorkflow({
|
||||
id: workflow.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'removeConnection',
|
||||
source: 'Source1',
|
||||
target: 'Merge'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const fetchedWorkflow = await client.getWorkflow(workflow.id);
|
||||
|
||||
// Source0 should STILL connect to Merge at targetIndex 0
|
||||
expect(fetchedWorkflow.connections.Source0).toBeDefined();
|
||||
expect(fetchedWorkflow.connections.Source0.main[0][0].node).toBe('Merge');
|
||||
expect(fetchedWorkflow.connections.Source0.main[0][0].index).toBe(0); // STILL 0!
|
||||
|
||||
// Source2 should STILL connect to Merge at targetIndex 2 (NOT shifted to 1!)
|
||||
expect(fetchedWorkflow.connections.Source2).toBeDefined();
|
||||
expect(fetchedWorkflow.connections.Source2.main[0][0].node).toBe('Merge');
|
||||
expect(fetchedWorkflow.connections.Source2.main[0][0].index).toBe(2); // STILL 2!
|
||||
|
||||
// Source1 should no longer connect to Merge
|
||||
expect(fetchedWorkflow.connections.Source1).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle replacing source connection to Merge input', async () => {
|
||||
// Test replacing which node connects to a Merge input
|
||||
// Use remove + add pattern (not rewireConnection which changes target, not source)
|
||||
|
||||
const workflowName = createTestWorkflowName('Merge - Replace Source');
|
||||
|
||||
const workflow: Workflow = await client.createWorkflow({
|
||||
name: workflowName,
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Source1',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [0, -50],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a1', name: 'source', value: '1', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Source2',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [0, 50],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a2', name: 'source', value: '2', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'NewSource1',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [0, -100],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a3', name: 'source', value: 'new1', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Merge',
|
||||
type: 'n8n-nodes-base.merge',
|
||||
typeVersion: 3,
|
||||
position: [200, 0],
|
||||
parameters: {
|
||||
mode: 'append'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Source1: {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 0 }]]
|
||||
},
|
||||
Source2: {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 1 }]]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(workflow.id).toBeTruthy();
|
||||
if (!workflow.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(workflow.id);
|
||||
|
||||
// Replace Source1 with NewSource1 (both to Merge input 0)
|
||||
// Use remove + add pattern
|
||||
await handleUpdatePartialWorkflow({
|
||||
id: workflow.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'removeConnection',
|
||||
source: 'Source1',
|
||||
target: 'Merge'
|
||||
},
|
||||
{
|
||||
type: 'addConnection',
|
||||
source: 'NewSource1',
|
||||
target: 'Merge',
|
||||
targetInput: 'main',
|
||||
targetIndex: 0
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const fetchedWorkflow = await client.getWorkflow(workflow.id);
|
||||
|
||||
// NewSource1 should now connect to Merge at input 0
|
||||
expect(fetchedWorkflow.connections.NewSource1).toBeDefined();
|
||||
expect(fetchedWorkflow.connections.NewSource1.main[0][0].node).toBe('Merge');
|
||||
expect(fetchedWorkflow.connections.NewSource1.main[0][0].index).toBe(0);
|
||||
|
||||
// Source2 should STILL connect to Merge at input 1 (unchanged)
|
||||
expect(fetchedWorkflow.connections.Source2).toBeDefined();
|
||||
expect(fetchedWorkflow.connections.Source2.main[0][0].node).toBe('Merge');
|
||||
expect(fetchedWorkflow.connections.Source2.main[0][0].index).toBe(1);
|
||||
|
||||
// Source1 should no longer connect to Merge
|
||||
expect(fetchedWorkflow.connections.Source1).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve indices through sequential operations on Merge inputs', async () => {
|
||||
// Complex scenario: Multiple operations on Merge inputs in sequence
|
||||
|
||||
const workflowName = createTestWorkflowName('Merge - Sequential Ops');
|
||||
|
||||
const workflow: Workflow = await client.createWorkflow({
|
||||
name: workflowName,
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Source1',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [0, -50],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a1', name: 'source', value: '1', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Source2',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [0, 50],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a2', name: 'source', value: '2', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'NewSource1',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [0, -100],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a3', name: 'source', value: 'new1', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Source3',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [0, 150],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{ id: 'a4', name: 'source', value: '3', type: 'string' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Merge',
|
||||
type: 'n8n-nodes-base.merge',
|
||||
typeVersion: 3,
|
||||
position: [200, 0],
|
||||
parameters: {
|
||||
mode: 'append'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Source1: {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 0 }]]
|
||||
},
|
||||
Source2: {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 1 }]]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(workflow.id).toBeTruthy();
|
||||
if (!workflow.id) throw new Error('Workflow ID is missing');
|
||||
context.trackWorkflow(workflow.id);
|
||||
|
||||
// Sequential operations:
|
||||
// 1. Replace input 0: Source1 → NewSource1 (remove + add)
|
||||
// 2. Add Source3 → Merge input 2
|
||||
// 3. Remove connection from Source2 (input 1)
|
||||
await handleUpdatePartialWorkflow({
|
||||
id: workflow.id,
|
||||
operations: [
|
||||
{
|
||||
type: 'removeConnection',
|
||||
source: 'Source1',
|
||||
target: 'Merge'
|
||||
},
|
||||
{
|
||||
type: 'addConnection',
|
||||
source: 'NewSource1',
|
||||
target: 'Merge',
|
||||
targetInput: 'main',
|
||||
targetIndex: 0
|
||||
},
|
||||
{
|
||||
type: 'addConnection',
|
||||
source: 'Source3',
|
||||
target: 'Merge',
|
||||
targetInput: 'main',
|
||||
targetIndex: 2
|
||||
},
|
||||
{
|
||||
type: 'removeConnection',
|
||||
source: 'Source2',
|
||||
target: 'Merge'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const fetchedWorkflow = await client.getWorkflow(workflow.id);
|
||||
|
||||
// NewSource1 should connect to Merge at input 0 (rewired)
|
||||
expect(fetchedWorkflow.connections.NewSource1).toBeDefined();
|
||||
expect(fetchedWorkflow.connections.NewSource1.main[0][0].node).toBe('Merge');
|
||||
expect(fetchedWorkflow.connections.NewSource1.main[0][0].index).toBe(0);
|
||||
|
||||
// Source2 removed, should not exist
|
||||
expect(fetchedWorkflow.connections.Source2).toBeUndefined();
|
||||
|
||||
// Source3 should connect to Merge at input 2 (NOT shifted to 1!)
|
||||
expect(fetchedWorkflow.connections.Source3).toBeDefined();
|
||||
expect(fetchedWorkflow.connections.Source3.main[0][0].node).toBe('Merge');
|
||||
expect(fetchedWorkflow.connections.Source3.main[0][0].index).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user