Merge branch 'feat/workflow-sdk-fetch-1000-workflows' of github.com:n8n-io/n8n into feat/fix-more-issues

This commit is contained in:
Mutasem Aldmour
2026-02-24 14:51:21 +01:00
4 changed files with 431 additions and 3 deletions
@@ -889,4 +889,235 @@ export default workflow('test', 'Test').add(t.to(n));`;
expect(parsedJson.connections['Process']?.main[0]?.[0]?.node).toBe('Process 1');
});
});
describe('Edge Case: onError with IF composite', () => {
it('should roundtrip onError connected to IF with true/false branches', () => {
const originalJson: WorkflowJSON = {
id: 'onerror-if-test',
name: 'OnError IF Test',
nodes: [
{
id: 'trigger-1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'http-1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [200, 0],
parameters: { url: 'https://api.example.com' },
onError: 'continueErrorOutput',
},
{
id: 'if-1',
name: 'Error IF',
type: 'n8n-nodes-base.if',
typeVersion: 2,
position: [400, 100],
parameters: {},
},
{
id: 'true-1',
name: 'Retry',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [600, 0],
parameters: {},
},
{
id: 'false-1',
name: 'Log Error',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [600, 200],
parameters: {},
},
],
connections: {
Start: {
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]],
},
'HTTP Request': {
main: [
[], // Output 0 - success (empty)
[{ node: 'Error IF', type: 'main', index: 0 }], // Output 1 - error
],
},
'Error IF': {
main: [
[{ node: 'Retry', type: 'main', index: 0 }],
[{ node: 'Log Error', type: 'main', index: 0 }],
],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
expect(parsedJson.nodes).toHaveLength(5);
expect(parsedJson.connections['HTTP Request']?.main[1]?.[0]?.node).toBe('Error IF');
expect(parsedJson.connections['Error IF']?.main[0]?.[0]?.node).toBe('Retry');
expect(parsedJson.connections['Error IF']?.main[1]?.[0]?.node).toBe('Log Error');
});
});
describe('Edge Case: onError with Switch composite', () => {
it('should roundtrip onError connected to Switch with case branches', () => {
const originalJson: WorkflowJSON = {
id: 'onerror-switch-test',
name: 'OnError Switch Test',
nodes: [
{
id: 'trigger-1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'http-1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [200, 0],
parameters: { url: 'https://api.example.com' },
onError: 'continueErrorOutput',
},
{
id: 'switch-1',
name: 'Error Router',
type: 'n8n-nodes-base.switch',
typeVersion: 3,
position: [400, 100],
parameters: { mode: 'rules' },
},
{
id: 'case-0',
name: 'Handle 404',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [600, 0],
parameters: {},
},
{
id: 'case-1',
name: 'Handle 500',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [600, 200],
parameters: {},
},
],
connections: {
Start: {
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]],
},
'HTTP Request': {
main: [
[], // Output 0 - success (empty)
[{ node: 'Error Router', type: 'main', index: 0 }], // Output 1 - error
],
},
'Error Router': {
main: [
[{ node: 'Handle 404', type: 'main', index: 0 }],
[{ node: 'Handle 500', type: 'main', index: 0 }],
],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
expect(parsedJson.nodes).toHaveLength(5);
expect(parsedJson.connections['HTTP Request']?.main[1]?.[0]?.node).toBe('Error Router');
expect(parsedJson.connections['Error Router']?.main[0]?.[0]?.node).toBe('Handle 404');
expect(parsedJson.connections['Error Router']?.main[1]?.[0]?.node).toBe('Handle 500');
});
});
describe('Edge Case: onError with SplitInBatches composite', () => {
it('should roundtrip onError connected to SplitInBatches with done/each branches', () => {
const originalJson: WorkflowJSON = {
id: 'onerror-sib-test',
name: 'OnError SIB Test',
nodes: [
{
id: 'trigger-1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'http-1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [200, 0],
parameters: { url: 'https://api.example.com' },
onError: 'continueErrorOutput',
},
{
id: 'sib-1',
name: 'Error Batcher',
type: 'n8n-nodes-base.splitInBatches',
typeVersion: 3,
position: [400, 100],
parameters: { batchSize: 10 },
},
{
id: 'done-1',
name: 'All Done',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [600, 0],
parameters: {},
},
{
id: 'each-1',
name: 'Process Each',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [600, 200],
parameters: {},
},
],
connections: {
Start: {
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]],
},
'HTTP Request': {
main: [
[], // Output 0 - success (empty)
[{ node: 'Error Batcher', type: 'main', index: 0 }], // Output 1 - error
],
},
'Error Batcher': {
main: [
[{ node: 'All Done', type: 'main', index: 0 }], // Output 0 - done
[{ node: 'Process Each', type: 'main', index: 0 }], // Output 1 - each
],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
expect(parsedJson.nodes).toHaveLength(5);
expect(parsedJson.connections['HTTP Request']?.main[1]?.[0]?.node).toBe('Error Batcher');
expect(parsedJson.connections['Error Batcher']?.main[0]?.[0]?.node).toBe('All Done');
expect(parsedJson.connections['Error Batcher']?.main[1]?.[0]?.node).toBe('Process Each');
});
});
});
@@ -1,5 +1,6 @@
import type { NodeInstance, WorkflowJSON } from './types/base';
import { workflow } from './workflow-builder';
import { splitInBatches } from './workflow-builder/control-flow-builders/split-in-batches';
import { node, trigger, sticky } from './workflow-builder/node-builders/node-builder';
import {
languageModel,
@@ -415,6 +416,7 @@ describe('Workflow Builder', () => {
expect(json.connections['Send Slack']?.error?.[0]?.[0]?.node).toBe('Error Alert');
});
it('should add nodes from nested .onError() chains', () => {
// Build: trigger → http1.onError(http2.onError(errorFinal.to(downstream)))
// All 5 nodes should appear in toJSON().nodes
@@ -467,6 +469,188 @@ describe('Workflow Builder', () => {
// errorFinal → downstream
expect(json.connections['Error Final']?.main[0]?.[0]?.node).toBe('Downstream');
});
it('should handle IfElse composite as onError target on standalone node', () => {
const httpNode = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP', onError: 'continueErrorOutput' },
});
const ifNode = node({
type: 'n8n-nodes-base.if',
version: 2,
config: { name: 'IF' },
}) as NodeInstance<'n8n-nodes-base.if', string, unknown>;
const trueHandler = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'True Handler' },
});
const falseHandler = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'False Handler' },
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
httpNode.onError(ifNode.onTrue!(trueHandler).onFalse(falseHandler) as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wf = workflow('test-id', 'Test').add(httpNode as any);
const json = wf.toJSON();
expect(json.nodes).toHaveLength(4);
expect(json.connections['HTTP']?.main[1]?.[0]?.node).toBe('IF');
expect(json.connections['IF']?.main[0]?.[0]?.node).toBe('True Handler');
expect(json.connections['IF']?.main[1]?.[0]?.node).toBe('False Handler');
});
it('should handle SwitchCase composite as onError target on standalone node', () => {
const httpNode = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP', onError: 'continueErrorOutput' },
});
const switchNode = node({
type: 'n8n-nodes-base.switch',
version: 3.2,
config: { name: 'Switch', parameters: { mode: 'rules' } },
}) as NodeInstance<'n8n-nodes-base.switch', string, unknown>;
const case0 = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Case 0' },
});
const case1 = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Case 1' },
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
httpNode.onError(switchNode.onCase!(0, case0).onCase(1, case1) as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wf = workflow('test-id', 'Test').add(httpNode as any);
const json = wf.toJSON();
expect(json.nodes).toHaveLength(4);
expect(json.connections['HTTP']?.main[1]?.[0]?.node).toBe('Switch');
expect(json.connections['Switch']?.main[0]?.[0]?.node).toBe('Case 0');
expect(json.connections['Switch']?.main[1]?.[0]?.node).toBe('Case 1');
});
it('should handle SplitInBatches composite as onError target on standalone node', () => {
const httpNode = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP', onError: 'continueErrorOutput' },
});
const sibNode = node({
type: 'n8n-nodes-base.splitInBatches',
version: 3,
config: { name: 'SIB', parameters: { batchSize: 10 } },
});
const doneNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Done' },
});
const eachNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Each' },
});
const sibBuilder = splitInBatches(sibNode).onDone(doneNode).onEachBatch(eachNode);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
httpNode.onError(sibBuilder as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wf = workflow('test-id', 'Test').add(httpNode as any);
const json = wf.toJSON();
expect(json.nodes).toHaveLength(4);
expect(json.connections['HTTP']?.main[1]?.[0]?.node).toBe('SIB');
expect(json.connections['SIB']?.main[0]?.[0]?.node).toBe('Done');
expect(json.connections['SIB']?.main[1]?.[0]?.node).toBe('Each');
});
it('should handle IfElse composite as onError target in chain', () => {
const t = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start' },
});
const httpNode = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP', onError: 'continueErrorOutput' },
});
const ifNode = node({
type: 'n8n-nodes-base.if',
version: 2,
config: { name: 'IF' },
}) as NodeInstance<'n8n-nodes-base.if', string, unknown>;
const trueHandler = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'True Handler' },
});
const falseHandler = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'False Handler' },
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
httpNode.onError(ifNode.onTrue!(trueHandler).onFalse(falseHandler) as any);
const wf = workflow('test-id', 'Test').add(t).to(httpNode);
const json = wf.toJSON();
expect(json.nodes).toHaveLength(5);
expect(json.connections['Start']?.main[0]?.[0]?.node).toBe('HTTP');
expect(json.connections['HTTP']?.main[1]?.[0]?.node).toBe('IF');
expect(json.connections['IF']?.main[0]?.[0]?.node).toBe('True Handler');
expect(json.connections['IF']?.main[1]?.[0]?.node).toBe('False Handler');
});
it('should handle chain leading to composite via onError', () => {
const httpNode = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP', onError: 'continueErrorOutput' },
});
const logNode = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Log' },
});
const ifNode = node({
type: 'n8n-nodes-base.if',
version: 2,
config: { name: 'IF' },
}) as NodeInstance<'n8n-nodes-base.if', string, unknown>;
const trueHandler = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'True Handler' },
});
const falseHandler = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'False Handler' },
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
httpNode.onError(logNode.to(ifNode.onTrue!(trueHandler).onFalse(falseHandler) as any));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wf = workflow('test-id', 'Test').add(httpNode as any);
const json = wf.toJSON();
expect(json.nodes).toHaveLength(5);
expect(json.connections['HTTP']?.main[1]?.[0]?.node).toBe('Log');
expect(json.connections['Log']?.main[0]?.[0]?.node).toBe('IF');
expect(json.connections['IF']?.main[0]?.[0]?.node).toBe('True Handler');
expect(json.connections['IF']?.main[1]?.[0]?.node).toBe('False Handler');
});
});
describe('.settings()', () => {
@@ -1089,6 +1089,13 @@ class WorkflowBuilderImpl implements WorkflowBuilder {
this.addSingleNodeConnectionTargets(nodes, targetChainNode);
}
}
} else if (registry.isCompositeType(target)) {
// Only dispatch if the composite's head node isn't already in the graph
// (avoids re-dispatching composites already handled by the allNodes loop above)
const compositeHeadName = this.resolveTargetNodeName(target, effectiveNameMapping);
if (!compositeHeadName || !nodes.has(compositeHeadName)) {
this.tryPluginDispatch(nodes, target, effectiveNameMapping);
}
} else if (
typeof (target as NodeInstance<string, string, unknown>).name === 'string' &&
!nodes.has((target as NodeInstance<string, string, unknown>).name)
@@ -160,7 +160,9 @@ const handleClick = (event: MouseEvent) => {
background-color: var(--button--color--background);
color: var(--button--color);
box-shadow: var(--button--shadow), var(--button--border--shadow);
box-shadow:
inset var(--button--border--shadow),
var(--button--shadow);
border: none;
> * {
@@ -169,12 +171,16 @@ const handleClick = (event: MouseEvent) => {
&:hover {
background-color: var(--button--color--background-hover);
box-shadow: var(--button--shadow--hover), var(--button--border--shadow--hover);
box-shadow:
inset var(--button--border--shadow--hover),
var(--button--shadow--hover);
}
&:active {
background-color: var(--button--color--background-active);
box-shadow: var(--button--shadow--active), var(--button--border--shadow--active);
box-shadow:
inset var(--button--border--shadow--active),
var(--button--shadow--active);
}
&:focus {