From 95bb00257793dd836ae80cc545282dc4db526b54 Mon Sep 17 00:00:00 2001 From: czlonkowski <56956555+czlonkowski@users.noreply.github.com> Date: Mon, 6 Oct 2025 10:02:23 +0200 Subject: [PATCH] test: add comprehensive Merge node integration tests for targetIndex preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 20 +- .../workflows/smart-parameters.test.ts | 475 ++++++++++++++++++ 2 files changed, 488 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d960d..e4bd3a7 100644 --- a/CHANGELOG.md +++ b/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 - - 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 + - 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 diff --git a/tests/integration/n8n-api/workflows/smart-parameters.test.ts b/tests/integration/n8n-api/workflows/smart-parameters.test.ts index c38fdf4..042f234 100644 --- a/tests/integration/n8n-api/workflows/smart-parameters.test.ts +++ b/tests/integration/n8n-api/workflows/smart-parameters.test.ts @@ -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); + }); + }); });