From 60a51229e0db92a00788eb12586ea6376276645d Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Wed, 6 May 2026 09:33:30 -0400 Subject: [PATCH] fix(core): Throw on bare OutputSelector passed to .add()/.to() (#29736) --- .../workflow-sdk/src/workflow-builder.test.ts | 76 +++++++++++++++++++ .../@n8n/workflow-sdk/src/workflow-builder.ts | 25 +++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder.test.ts b/packages/@n8n/workflow-sdk/src/workflow-builder.test.ts index 25860915f8f..2d71e901a58 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder.test.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder.test.ts @@ -296,6 +296,82 @@ describe('Workflow Builder', () => { 'Cannot call .input() on the workflow builder', ); }); + + it('should throw a clear error when an OutputSelector is passed to .add()', () => { + const t = trigger({ type: 'n8n-nodes-base.webhookTrigger', version: 1, config: {} }); + const source = node({ + type: 'n8n-nodes-base.if', + version: 2, + config: { name: 'Branch' }, + }); + const target = node({ + type: 'n8n-nodes-base.set', + version: 3, + config: { name: 'Set' }, + }); + + const wf = workflow('test-id', 'Test Workflow').add(t); + const misuse = source.output(0) as unknown as NodeInstance; + expect(() => wf.add(misuse).to(target)).toThrow(TypeError); + expect(() => wf.add(misuse).to(target)).toThrow(/Cannot pass an OutputSelector to \.add\(\)/); + expect(() => wf.add(misuse).to(target)).toThrow(/Branch\.output\(0\)\.to\(/); + }); + + it('should throw a clear error when an OutputSelector is passed to .to()', () => { + const t = trigger({ type: 'n8n-nodes-base.webhookTrigger', version: 1, config: {} }); + const source = node({ + type: 'n8n-nodes-base.if', + version: 2, + config: { name: 'Branch' }, + }); + + const wf = workflow('test-id', 'Test Workflow').add(t); + const misuse = source.output(1) as unknown as NodeInstance; + expect(() => wf.to(misuse)).toThrow(/Cannot pass an OutputSelector to \.to\(\)/); + }); + + it('should throw a clear error when an OutputSelector is inside an array passed to .add()', () => { + const t = trigger({ type: 'n8n-nodes-base.webhookTrigger', version: 1, config: {} }); + const source = node({ + type: 'n8n-nodes-base.if', + version: 2, + config: { name: 'Branch' }, + }); + const sibling = node({ + type: 'n8n-nodes-base.set', + version: 3, + config: { name: 'Sibling' }, + }); + + const wf = workflow('test-id', 'Test Workflow').add(t); + const misuse = [sibling, source.output(0)] as unknown as NodeInstance< + string, + string, + unknown + >; + expect(() => wf.add(misuse)).toThrow(/Cannot pass an OutputSelector to \.add\(\)/); + }); + + it('should accept node.output(n).to(target) inside .add() — the documented form', () => { + const t = trigger({ type: 'n8n-nodes-base.webhookTrigger', version: 1, config: {} }); + const source = node({ + type: 'n8n-nodes-base.if', + version: 2, + config: { name: 'Branch' }, + }); + const target = node({ + type: 'n8n-nodes-base.set', + version: 3, + config: { name: 'Set' }, + }); + + const wf = workflow('test-id', 'Test Workflow').add(t).add(source.output(0).to(target)); + const json = wf.toJSON(); + const branchConns = json.connections[source.name]?.main[0]; + expect(branchConns).toBeDefined(); + expect(branchConns).toHaveLength(1); + expect(branchConns?.[0]?.node).toBe(target.name); + }); }); describe('.to()', () => { diff --git a/packages/@n8n/workflow-sdk/src/workflow-builder.ts b/packages/@n8n/workflow-sdk/src/workflow-builder.ts index 76dd775184e..2c4c6ed8c7f 100644 --- a/packages/@n8n/workflow-sdk/src/workflow-builder.ts +++ b/packages/@n8n/workflow-sdk/src/workflow-builder.ts @@ -16,7 +16,11 @@ import { isNodeChain } from './types/base'; import type { ValidationOptions, ValidationResult, ValidationErrorCode } from './validation/index'; import { ValidationError, ValidationWarning } from './validation/index'; import { resolveTargetNodeName as resolveTargetNodeNameUtil } from './workflow-builder/connection-utils'; -import { isInputTarget, cloneNodeWithId } from './workflow-builder/node-builders/node-builder'; +import { + isInputTarget, + isOutputSelector, + cloneNodeWithId, +} from './workflow-builder/node-builders/node-builder'; import { shouldGeneratePinData } from './workflow-builder/pin-data-utils'; import { registerDefaultPlugins } from './workflow-builder/plugins/defaults'; import { pluginRegistry, type PluginRegistry } from './workflow-builder/plugins/registry'; @@ -171,10 +175,13 @@ class WorkflowBuilderImpl implements WorkflowBuilder { } add(node: unknown): WorkflowBuilder { + assertNotOutputSelector(node, 'add'); + // Handle plain array (fan-out) // This adds all targets without creating a primary connection if (Array.isArray(node)) { for (const target of node) { + assertNotOutputSelector(target, 'add'); if (isInputTarget(target)) { // InputTarget - add the target node const inputTargetNode = target.node; @@ -263,6 +270,8 @@ class WorkflowBuilderImpl implements WorkflowBuilder { } to(nodeOrComposite: unknown): WorkflowBuilder { + assertNotOutputSelector(nodeOrComposite, 'to'); + // Handle InputTarget (e.g., mergeNode.input(0)) if (isInputTarget(nodeOrComposite)) { const actualNode = nodeOrComposite.node; @@ -900,6 +909,8 @@ class WorkflowBuilderImpl implements WorkflowBuilder { return; } + assertNotOutputSelector(node, 'to'); + // Use addBranchToGraph to handle NodeChains properly // This returns the head node name for connection const headNodeName = this.addBranchToGraph( @@ -1162,6 +1173,18 @@ class WorkflowBuilderImpl implements WorkflowBuilder { } } +function assertNotOutputSelector(value: unknown, method: 'add' | 'to'): void { + if (!isOutputSelector(value)) return; + const sourceName = value.node.name; + throw new TypeError( + `Cannot pass an OutputSelector to .${method}(). ` + + `${sourceName}.output(${value.outputIndex}) by itself does not connect anything; ` + + 'chain `.to(target)` on the selector first to produce a connection. ' + + `Example: .add(${sourceName}.output(${value.outputIndex}).to(targetNode)) ` + + `— not .add(${sourceName}.output(${value.outputIndex})).to(targetNode).`, + ); +} + /** * Helper to check if options is a WorkflowBuilderOptions object */