fix(core): Throw on bare OutputSelector passed to .add()/.to() (#29736)

This commit is contained in:
Ricardo Espinoza 2026-05-06 09:33:30 -04:00 committed by GitHub
parent 04e9b258a8
commit 60a51229e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 100 additions and 1 deletions

View File

@ -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<string, string, unknown>;
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<string, string, unknown>;
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()', () => {

View File

@ -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
*/