n8n/packages/@n8n/workflow-sdk/src/workflow-builder.test.ts

2982 lines
90 KiB
TypeScript

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,
memory,
tool,
outputParser,
} from './workflow-builder/node-builders/subnode-builders';
describe('Workflow Builder', () => {
describe('workflow()', () => {
it('should create a workflow with id and name', () => {
const wf = workflow('test-id', 'Test Workflow');
expect(wf.id).toBe('test-id');
expect(wf.name).toBe('Test Workflow');
});
it('should create a workflow with initial settings', () => {
const wf = workflow('test-id', 'Test Workflow', {
timezone: 'America/New_York',
executionOrder: 'v1',
});
const json = wf.toJSON();
expect(json.settings?.timezone).toBe('America/New_York');
expect(json.settings?.executionOrder).toBe('v1');
});
it('should throw a clear TypeError when id is not a string', () => {
expect(() => {
// @ts-expect-error intentional misuse
workflow({ id: 'test-id' }, 'Test Workflow');
}).toThrow(/workflow\(\) requires a string id as first argument/);
});
it('should throw a clear TypeError when name is not a string', () => {
expect(() => {
// @ts-expect-error intentional misuse
workflow('test-id', { name: 'Test Workflow' });
}).toThrow(/workflow\(\) requires a string name as second argument/);
});
});
describe('.add()', () => {
it('should add a trigger node to the workflow', () => {
const t = trigger({
type: 'n8n-nodes-base.scheduleTrigger',
version: 1.1,
config: { parameters: { rule: { interval: [{ field: 'hours', hour: 8 }] } } },
});
const wf = workflow('test-id', 'Test Workflow').add(t);
const json = wf.toJSON();
expect(json.nodes).toHaveLength(1);
expect(json.nodes[0].type).toBe('n8n-nodes-base.scheduleTrigger');
});
it('should add multiple nodes', () => {
const t = trigger({ type: 'n8n-nodes-base.webhookTrigger', version: 1, config: {} });
const n = node({ type: 'n8n-nodes-base.httpRequest', version: 4.2, config: {} });
const wf = workflow('test-id', 'Test Workflow').add(t).add(n);
const json = wf.toJSON();
expect(json.nodes).toHaveLength(2);
});
it('should add a NodeChain and include all nodes', () => {
const t = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start' },
});
const n1 = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP Request' },
});
const n2 = node({ type: 'n8n-nodes-base.set', version: 3, config: { name: 'Set Data' } });
// Create a chain via .to()
const chain = t.to(n1).to(n2);
// Add the chain to workflow
const wf = workflow('test-id', 'Test Workflow').add(chain);
const json = wf.toJSON();
// All three nodes should be in the workflow
expect(json.nodes).toHaveLength(3);
expect(json.nodes.map((n) => n.name).sort()).toEqual(
['HTTP Request', 'Set Data', 'Start'].sort(),
);
// Connections should be preserved
expect(json.connections['Start'].main[0]![0].node).toBe('HTTP Request');
expect(json.connections['HTTP Request'].main[0]![0].node).toBe('Set Data');
});
it('should add multiple sticky notes with explicit names', () => {
const t = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start' },
});
const agentNode = node({
type: 'n8n-nodes-langchain.agent',
version: 1.6,
config: { name: 'Research Agent', position: [400, 300] },
});
// Create 4 sticky notes with explicit names (recommended approach to avoid collisions)
const sticky1 = sticky('## Research Agent Note', { color: 5, name: 'Research Note' });
const sticky2 = sticky('## Fact-Check Agent Note', { color: 3, name: 'Fact-Check Note' });
const sticky3 = sticky('## Writing Agent Note', { color: 6, name: 'Writing Note' });
const sticky4 = sticky('## Editing Agent Note', { color: 4, name: 'Editing Note' });
const wf = workflow('test-id', 'Test Workflow')
.add(t)
.to(agentNode)
.add(sticky1)
.add(sticky2)
.add(sticky3)
.add(sticky4);
const json = wf.toJSON();
// All 6 nodes should be present (trigger, agent, 4 stickies)
expect(json.nodes).toHaveLength(6);
// All sticky notes should have unique names
const stickyNodes = json.nodes.filter((n) => n.type === 'n8n-nodes-base.stickyNote');
expect(stickyNodes).toHaveLength(4);
// Extract names and verify uniqueness
const stickyNames = stickyNodes.map((n) => n.name);
const uniqueNames = new Set(stickyNames);
expect(uniqueNames.size).toBe(4);
// Verify content is preserved for each sticky
const contents = stickyNodes.map((n) => n.parameters?.content);
expect(contents).toContain('## Research Agent Note');
expect(contents).toContain('## Fact-Check Agent Note');
expect(contents).toContain('## Writing Agent Note');
expect(contents).toContain('## Editing Agent Note');
});
it('should add SwitchCaseBuilder directly', () => {
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' },
});
const switchNode = node({
type: 'n8n-nodes-base.switch',
version: 3.2,
config: {
name: 'Direct Switch',
parameters: { mode: 'rules' },
},
}) as NodeInstance<'n8n-nodes-base.switch', string, unknown>;
// Pass fluent builder directly to add() - runtime supports this but types don't
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wf = workflow('test', 'Test').add(switchNode.onCase!(0, case0).onCase(1, case1) as any);
const json = wf.toJSON();
// All 3 nodes should be present (switch + 2 cases)
expect(json.nodes).toHaveLength(3);
// Switch should connect to cases
expect(json.connections['Direct Switch']?.main[0]?.[0]?.node).toBe('Case 0');
expect(json.connections['Direct Switch']?.main[1]?.[0]?.node).toBe('Case 1');
});
it('should add IfElseBuilder directly', () => {
const trueNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'True Path' },
});
const falseNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'False Path' },
});
const ifNode = node({
type: 'n8n-nodes-base.if',
version: 2.2,
config: {
name: 'Direct IF',
parameters: {
conditions: {
conditions: [
{
leftValue: '={{ $json.value }}',
operator: { type: 'number', operation: 'gt' },
rightValue: 100,
},
],
},
},
},
}) as NodeInstance<'n8n-nodes-base.if', string, unknown>;
// Pass fluent builder directly to add() - runtime supports this but types don't
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wf = workflow('test', 'Test').add(ifNode.onTrue!(trueNode).onFalse(falseNode) as any);
const json = wf.toJSON();
// All 3 nodes should be present (IF + 2 branches)
expect(json.nodes).toHaveLength(3);
// IF should connect to branches
expect(json.connections['Direct IF']?.main[0]?.[0]?.node).toBe('True Path');
expect(json.connections['Direct IF']?.main[1]?.[0]?.node).toBe('False Path');
});
it('should add merge pattern using .input(n) syntax', () => {
const source1 = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Source 1' },
});
const source2 = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Source 2' },
});
const mergeNode = node({
type: 'n8n-nodes-base.merge',
version: 3,
config: {
name: 'Merge',
parameters: { mode: 'append' },
},
}) as NodeInstance<'n8n-nodes-base.merge', string, unknown>;
// Use .input(n) syntax to connect sources to merge inputs
const wf = workflow('test', 'Test')
.add(source1.to(mergeNode.input(0)))
.add(source2.to(mergeNode.input(1)));
const json = wf.toJSON();
// All 3 nodes should be present (merge + 2 sources)
expect(json.nodes).toHaveLength(3);
// Sources should connect to Merge at different input indices
expect(json.connections['Source 1']?.main[0]?.[0]?.node).toBe('Merge');
expect(json.connections['Source 1']?.main[0]?.[0]?.index).toBe(0);
expect(json.connections['Source 2']?.main[0]?.[0]?.node).toBe('Merge');
expect(json.connections['Source 2']?.main[0]?.[0]?.index).toBe(1);
});
it('should mutate the builder in place when add() return value is discarded', () => {
const t = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Trigger' },
});
const n = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP' },
});
// This pattern MUST work (AI-generated code style)
const wf = workflow('test-id', 'Test Workflow');
wf.add(t); // Return value intentionally discarded
wf.add(n); // Return value intentionally discarded
const json = wf.toJSON();
expect(json.nodes).toHaveLength(2); // Should have both nodes
expect(json.nodes.map((node) => node.name).sort()).toEqual(['HTTP', 'Trigger'].sort());
});
});
describe('.output() / .input() error guidance', () => {
it('should throw a clear error when calling .output() on the workflow builder', () => {
const t = trigger({ type: 'n8n-nodes-base.webhookTrigger', version: 1, config: {} });
const wf = workflow('test-id', 'Test Workflow').add(t);
expect(() => (wf as unknown as { output: (n: number) => void }).output(0)).toThrow(
'Cannot call .output() on the workflow builder',
);
});
it('should throw a clear error when calling .input() on the workflow builder', () => {
const t = trigger({ type: 'n8n-nodes-base.webhookTrigger', version: 1, config: {} });
const wf = workflow('test-id', 'Test Workflow').add(t);
expect(() => (wf as unknown as { input: (n: number) => void }).input(1)).toThrow(
'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()', () => {
it('should chain nodes with connections', () => {
const t = trigger({ type: 'n8n-nodes-base.webhookTrigger', version: 1, config: {} });
const n1 = node({ type: 'n8n-nodes-base.httpRequest', version: 4.2, config: {} });
const n2 = node({ type: 'n8n-nodes-base.set', version: 3, config: {} });
const wf = workflow('test-id', 'Test Workflow').add(t).to(n1).to(n2);
const json = wf.toJSON();
expect(json.nodes).toHaveLength(3);
// Check connections: trigger -> n1 -> n2
expect(json.connections[t.name]).toBeDefined();
expect(json.connections[t.name]?.main[0]).toHaveLength(1);
expect(json.connections[t.name]?.main[0]?.[0]?.node).toBe(n1.name);
expect(json.connections[n1.name]).toBeDefined();
expect(json.connections[n1.name]?.main[0]).toHaveLength(1);
expect(json.connections[n1.name]?.main[0]?.[0]?.node).toBe(n2.name);
});
});
describe('NodeInstance.onError()', () => {
it('should connect error output to handler for regular nodes', () => {
const httpNode = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP', onError: 'continueErrorOutput' },
});
const successHandler = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Success' },
});
const errorHandler = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Error Handler' },
});
// Use node.to() and node.onError() to set up connections
httpNode.to(successHandler); // Output 0 -> success
httpNode.onError(errorHandler); // Error output -> error handler
const wf = workflow('test-id', 'Test Workflow')
.add(httpNode)
.add(successHandler)
.add(errorHandler);
const json = wf.toJSON();
expect(json.nodes).toHaveLength(3);
// Check HTTP node has success output at main[0] and error output at main[1]
expect(json.connections['HTTP']?.main[0]).toHaveLength(1);
expect(json.connections['HTTP']?.main[0]?.[0]?.node).toBe('Success');
expect(json.connections['HTTP']?.main[1]).toHaveLength(1);
expect(json.connections['HTTP']?.main[1]?.[0]?.node).toBe('Error Handler');
expect(json.connections['HTTP']?.error).toBeUndefined();
});
it('should calculate correct error output index for IF nodes', () => {
const ifNode = node({
type: 'n8n-nodes-base.if',
version: 2,
config: { name: 'IF', onError: 'continueErrorOutput' },
});
const trueHandler = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'True' },
});
const falseHandler = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'False' },
});
const errorHandler = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Error' },
});
// IF: true=0, false=1, error=2
ifNode.to(trueHandler, 0);
ifNode.to(falseHandler, 1);
ifNode.onError(errorHandler);
const wf = workflow('test-id', 'Test')
.add(ifNode)
.add(trueHandler)
.add(falseHandler)
.add(errorHandler);
const json = wf.toJSON();
expect(json.connections['IF']?.main[0]?.[0]?.node).toBe('True');
expect(json.connections['IF']?.main[1]?.[0]?.node).toBe('False');
// Error pin lands after the two natural IF outputs, at main[2]
expect(json.connections['IF']?.main[2]?.[0]?.node).toBe('Error');
expect(json.connections['IF']?.error).toBeUndefined();
});
it('should return this (not handler) for proper chaining with .to()', () => {
// BUG FIX TEST: When using .to(node.onError(handler)), the .to() should
// connect to the node, not to the handler returned by onError()
const t = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start' },
});
const slackNode = node({
type: 'n8n-nodes-base.slack',
version: 2.4,
config: { name: 'Send Slack', onError: 'continueErrorOutput' },
});
const telegramNode = node({
type: 'n8n-nodes-base.telegram',
version: 1.2,
config: { name: 'Error Alert' },
});
// This chained syntax: .to(node.onError(handler))
// Should result in: trigger -> slack -> (error) -> telegram
// NOT: trigger -> telegram (which happens if onError returns handler)
const wf = workflow('test-id', 'Test').add(t).to(slackNode.onError(telegramNode));
const json = wf.toJSON();
// Trigger should connect to Slack (not Telegram)
expect(json.connections['Start']?.main[0]?.[0]?.node).toBe('Send Slack');
// Slack's error output is emitted at main[1] (modern format)
expect(json.connections['Send Slack']?.main[1]?.[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
const t = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start' },
});
const http1 = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP 1', onError: 'continueErrorOutput' },
});
const http2 = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP 2', onError: 'continueErrorOutput' },
});
const errorFinal = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Error Final' },
});
const downstream = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Downstream' },
});
// Nested .onError chain: http1 errors to http2, http2 errors to errorFinal→downstream
const wf = workflow('test-id', 'Test')
.add(t)
.to(http1.onError(http2.onError(errorFinal.to(downstream))));
const json = wf.toJSON();
const nodeNames = json.nodes.map((n) => n.name);
// All 5 nodes must be present
expect(nodeNames).toContain('Start');
expect(nodeNames).toContain('HTTP 1');
expect(nodeNames).toContain('HTTP 2');
expect(nodeNames).toContain('Error Final');
expect(nodeNames).toContain('Downstream');
expect(json.nodes).toHaveLength(5);
// http1 error output → http2 (modern format: error pin at main[1])
expect(json.connections['HTTP 1']?.main[1]?.[0]?.node).toBe('HTTP 2');
// http2 error output → errorFinal (modern format: error pin at main[1])
expect(json.connections['HTTP 2']?.main[1]?.[0]?.node).toBe('Error Final');
// 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);
// HTTP has only an error pin (no main success) → padded to main[0]=[] , main[1]=IF
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()', () => {
it('should update workflow settings', () => {
const wf = workflow('test-id', 'Test Workflow').settings({
executionTimeout: 3600,
saveManualExecutions: true,
});
const json = wf.toJSON();
expect(json.settings?.executionTimeout).toBe(3600);
expect(json.settings?.saveManualExecutions).toBe(true);
});
it('should merge settings with initial settings', () => {
const wf = workflow('test-id', 'Test Workflow', {
timezone: 'America/New_York',
}).settings({
executionTimeout: 3600,
});
const json = wf.toJSON();
expect(json.settings?.timezone).toBe('America/New_York');
expect(json.settings?.executionTimeout).toBe(3600);
});
});
describe('.getNode()', () => {
it('should retrieve node by name', () => {
const t = trigger({
type: 'n8n-nodes-base.webhookTrigger',
version: 1,
config: { name: 'My Trigger' },
});
const wf = workflow('test-id', 'Test Workflow').add(t);
const found = wf.getNode('My Trigger');
expect(found).toBeDefined();
expect(found?.type).toBe('n8n-nodes-base.webhookTrigger');
});
it('should return undefined for non-existent node', () => {
const wf = workflow('test-id', 'Test Workflow');
const found = wf.getNode('Non Existent');
expect(found).toBeUndefined();
});
});
describe('.toJSON()', () => {
it('should export complete workflow JSON', () => {
const t = trigger({ type: 'n8n-nodes-base.webhookTrigger', version: 1, config: {} });
const n = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { parameters: { url: 'https://example.com' } },
});
const wf = workflow('test-id', 'Test Workflow', {
timezone: 'UTC',
})
.add(t)
.to(n);
const json = wf.toJSON();
expect(json.id).toBe('test-id');
expect(json.name).toBe('Test Workflow');
expect(json.nodes).toHaveLength(2);
expect(json.connections).toBeDefined();
expect(json.settings?.timezone).toBe('UTC');
});
it('should include node positions', () => {
const t = trigger({
type: 'n8n-nodes-base.webhookTrigger',
version: 1,
config: { position: [100, 200] },
});
const wf = workflow('test-id', 'Test').add(t);
const json = wf.toJSON();
expect(json.nodes[0].position).toEqual([100, 200]);
});
it('should auto-position nodes when position not specified', () => {
const t = trigger({ type: 'n8n-nodes-base.webhookTrigger', version: 1, config: {} });
const n = node({ type: 'n8n-nodes-base.httpRequest', version: 4.2, config: {} });
const wf = workflow('test-id', 'Test').add(t).to(n);
const json = wf.toJSON();
// Both nodes should have positions assigned
expect(json.nodes[0].position).toBeDefined();
expect(json.nodes[1].position).toBeDefined();
// Second node should be to the right of the first
expect(json.nodes[1].position[0]).toBeGreaterThan(json.nodes[0].position[0]);
});
});
describe('.toString()', () => {
it('should serialize to JSON string', () => {
const wf = workflow('test-id', 'Test Workflow');
const str = wf.toString();
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse -- Testing toString() output
const parsed = JSON.parse(str) as { name: string };
expect(parsed.name).toBe('Test Workflow');
});
});
describe('workflow.fromJSON()', () => {
it('should import workflow from JSON', () => {
const json = {
id: 'imported-id',
name: 'Imported Workflow',
nodes: [
{
id: 'node-1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
},
],
connections: {},
settings: { timezone: 'UTC' },
};
const wf = workflow.fromJSON(json);
expect(wf.id).toBe('imported-id');
expect(wf.name).toBe('Imported Workflow');
const exported = wf.toJSON();
expect(exported.nodes).toHaveLength(1);
expect(exported.settings?.timezone).toBe('UTC');
});
it('should allow modifications after import', () => {
const json = {
id: 'imported-id',
name: 'Imported Workflow',
nodes: [
{
id: 'node-1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [0, 0] as [number, number],
parameters: { url: 'https://old.com' },
},
],
connections: {},
};
const wf = workflow.fromJSON(json);
const httpNode = wf.getNode('HTTP Request');
expect(httpNode).toBeDefined();
// Add another node
const newNode = node({ type: 'n8n-nodes-base.set', version: 3, config: {} });
const updatedWf = wf.to(newNode);
const exported = updatedWf.toJSON();
expect(exported.nodes).toHaveLength(2);
});
it('should preserve credentials exactly including empty placeholder objects', () => {
// Type assertion needed because empty credential placeholders ({}) are valid at runtime
// but don't satisfy the strict WorkflowJSON type
const json = {
id: 'creds-test',
name: 'Credentials Test',
nodes: [
{
id: 'node-1',
name: 'OpenAI Chat Model',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1.2,
position: [0, 0] as [number, number],
parameters: {},
// Empty placeholder credentials (common in templates)
credentials: {
openAiApi: {},
},
},
{
id: 'node-2',
name: 'Telegram',
type: 'n8n-nodes-base.telegram',
typeVersion: 1.2,
position: [200, 0] as [number, number],
parameters: {},
// Full credentials with name and id
credentials: {
telegramApi: { name: 'My Telegram', id: 'cred-123' },
},
},
{
id: 'node-3',
name: 'Gmail',
type: 'n8n-nodes-base.gmail',
typeVersion: 2.1,
position: [400, 0] as [number, number],
parameters: {},
// Credentials with only name (no id)
credentials: {
gmailOAuth2: { name: 'My Gmail' },
},
},
],
connections: {},
} as WorkflowJSON;
const wf = workflow.fromJSON(json);
const exported = wf.toJSON();
// Find each node in exported JSON
const openAiNode = exported.nodes.find((n) => n.name === 'OpenAI Chat Model');
const telegramNode = exported.nodes.find((n) => n.name === 'Telegram');
const gmailNode = exported.nodes.find((n) => n.name === 'Gmail');
// Empty placeholder credentials should be preserved exactly (no added id: "")
expect(openAiNode?.credentials).toEqual({ openAiApi: {} });
// Full credentials should be preserved exactly
expect(telegramNode?.credentials).toEqual({
telegramApi: { name: 'My Telegram', id: 'cred-123' },
});
// Credentials with only name should be preserved (no added id: "")
expect(gmailNode?.credentials).toEqual({ gmailOAuth2: { name: 'My Gmail' } });
});
it('should normalize legacy top-level "error" connections to main[1] on round-trip', () => {
const json = {
id: 'legacy-error',
name: 'Legacy Error Workflow',
nodes: [
{
id: 'node-1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [0, 0] as [number, number],
parameters: {},
onError: 'continueErrorOutput' as const,
},
{
id: 'node-2',
name: 'Success',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [200, 0] as [number, number],
parameters: {},
},
{
id: 'node-3',
name: 'ErrorHandler',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [200, 200] as [number, number],
parameters: {},
},
],
connections: {
HTTP: {
main: [[{ node: 'Success', type: 'main', index: 0 }]],
error: [[{ node: 'ErrorHandler', type: 'main', index: 0 }]],
},
},
} as WorkflowJSON;
const wf = workflow.fromJSON(json);
const exported = wf.toJSON();
expect(exported.connections.HTTP?.main[0]?.[0]?.node).toBe('Success');
expect(exported.connections.HTTP?.main[1]?.[0]?.node).toBe('ErrorHandler');
expect(exported.connections.HTTP?.error).toBeUndefined();
});
});
describe('AI nodes with subnodes', () => {
it('should include subnodes in exported JSON', () => {
const modelNode = languageModel({
type: 'n8n-nodes-langchain.lmChatOpenAi',
version: 1,
config: { parameters: { model: 'gpt-4' } },
});
const memoryNode = memory({
type: 'n8n-nodes-langchain.memoryBufferWindow',
version: 1,
config: { parameters: { windowSize: 5 } },
});
const toolNode = tool({
type: 'n8n-nodes-langchain.toolCalculator',
version: 1,
config: {},
});
const agentNode = node({
type: 'n8n-nodes-langchain.agent',
version: 1.6,
config: {
parameters: { text: '={{ $json.prompt }}' },
subnodes: {
model: modelNode,
memory: memoryNode,
tools: [toolNode],
},
},
});
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {},
});
const wf = workflow('ai-test', 'AI Agent Test').add(triggerNode).to(agentNode);
const json = wf.toJSON();
// Should include all nodes: trigger + agent + model + memory + tool
expect(json.nodes).toHaveLength(5);
// Verify all node types are present
const nodeTypes = json.nodes.map((n) => n.type);
expect(nodeTypes).toContain('n8n-nodes-base.manualTrigger');
expect(nodeTypes).toContain('n8n-nodes-langchain.agent');
expect(nodeTypes).toContain('n8n-nodes-langchain.lmChatOpenAi');
expect(nodeTypes).toContain('n8n-nodes-langchain.memoryBufferWindow');
expect(nodeTypes).toContain('n8n-nodes-langchain.toolCalculator');
});
it('should create AI connections for subnodes', () => {
const modelNode = languageModel({
type: 'n8n-nodes-langchain.lmChatOpenAi',
version: 1,
config: { parameters: { model: 'gpt-4' } },
});
const agentNode = node({
type: 'n8n-nodes-langchain.agent',
version: 1.6,
config: {
parameters: {},
subnodes: {
model: modelNode,
},
},
});
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {},
});
const wf = workflow('ai-test', 'AI Agent Test').add(triggerNode).to(agentNode);
const json = wf.toJSON();
// Model node should have ai_languageModel connection to agent
const modelConnections = json.connections[modelNode.name];
expect(modelConnections).toBeDefined();
expect(modelConnections?.ai_languageModel).toBeDefined();
expect(modelConnections?.ai_languageModel?.[0]?.[0]?.node).toBe(agentNode.name);
});
});
describe('merge with .input(n) syntax', () => {
it('should connect branches to different merge inputs', () => {
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {},
});
const mergeNode = node({
type: 'n8n-nodes-base.merge',
version: 3,
config: {
name: 'Merge',
parameters: { mode: 'append' },
},
}) as NodeInstance<'n8n-nodes-base.merge', string, unknown>;
const source1 = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'Source 1' },
});
const source2 = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'Source 2' },
});
const source3 = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'Source 3' },
});
// Use .input(n) syntax instead of merge composite
const wf = workflow('test', 'Test')
.add(triggerNode.to([source1, source2, source3]))
.add(source1.to(mergeNode.input(0)))
.add(source2.to(mergeNode.input(1)))
.add(source3.to(mergeNode.input(2)));
const json = wf.toJSON();
// Each source should connect to Merge at a DIFFERENT input index
// source1 -> Merge input 0
// source2 -> Merge input 1
// source3 -> Merge input 2
expect(json.connections['Source 1']?.main[0]?.[0]?.node).toBe('Merge');
expect(json.connections['Source 1']?.main[0]?.[0]?.index).toBe(0);
expect(json.connections['Source 2']?.main[0]?.[0]?.node).toBe('Merge');
expect(json.connections['Source 2']?.main[0]?.[0]?.index).toBe(1);
expect(json.connections['Source 3']?.main[0]?.[0]?.node).toBe('Merge');
expect(json.connections['Source 3']?.main[0]?.[0]?.index).toBe(2);
// Merge node should exist
const foundMergeNode = json.nodes.find((n) => n.type === 'n8n-nodes-base.merge');
expect(foundMergeNode).toBeDefined();
});
it('should support merge with custom name and parameters', () => {
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {},
});
const mergeNode = node({
type: 'n8n-nodes-base.merge',
version: 3,
config: {
name: 'Combine Branches',
parameters: {
mode: 'combine',
combineBy: 'combineByPosition',
},
},
}) as NodeInstance<'n8n-nodes-base.merge', string, unknown>;
const source1 = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Branch A' },
});
const source2 = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Branch B' },
});
// Use .input(n) syntax instead of merge composite
const wf = workflow('test', 'Test')
.add(triggerNode.to([source1, source2]))
.add(source1.to(mergeNode.input(0)))
.add(source2.to(mergeNode.input(1)));
const json = wf.toJSON();
const foundMergeNode = json.nodes.find((n) => n.type === 'n8n-nodes-base.merge');
expect(foundMergeNode?.parameters?.mode).toBe('combine');
expect(foundMergeNode?.parameters?.combineBy).toBe('combineByPosition');
});
});
describe('NodeInstance.to() for fan-out', () => {
it('should support fan-out via multiple .to() calls on same node', () => {
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {},
});
const http1 = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP 1' },
});
const http2 = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP 2' },
});
// Fan-out: trigger connects to both http1 and http2
triggerNode.to(http1);
triggerNode.to(http2);
const wf = workflow('test', 'Test').add(triggerNode).add(http1).add(http2);
const json = wf.toJSON();
// Trigger should have 2 connections from output 0
expect(json.connections[triggerNode.name]?.main[0]).toHaveLength(2);
expect(json.connections[triggerNode.name]?.main[0]?.map((c) => c.node)).toContain('HTTP 1');
expect(json.connections[triggerNode.name]?.main[0]?.map((c) => c.node)).toContain('HTTP 2');
});
it('should support chaining: nodeA.to(nodeB).to(nodeC)', () => {
const nodeA = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'A' } });
const nodeB = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'B' } });
const nodeC = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'C' } });
// Chain: A → B → C
nodeA.to(nodeB).to(nodeC);
const wf = workflow('test', 'Test').add(nodeA).add(nodeB).add(nodeC);
const json = wf.toJSON();
expect(json.connections['A']?.main[0]?.[0]?.node).toBe('B');
expect(json.connections['B']?.main[0]?.[0]?.node).toBe('C');
});
it('should support fan-out from specific output index', () => {
const ifNode = node({
type: 'n8n-nodes-base.if',
version: 2,
config: { name: 'If Check' },
});
const truePath = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'True Path' },
});
const falsePath = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'False Path' },
});
// Connect IF outputs to different paths
ifNode.to(truePath, 0); // output 0 -> truePath
ifNode.to(falsePath, 1); // output 1 -> falsePath
const wf = workflow('test', 'Test').add(ifNode).add(truePath).add(falsePath);
const json = wf.toJSON();
expect(json.connections['If Check']?.main[0]?.[0]?.node).toBe('True Path');
expect(json.connections['If Check']?.main[1]?.[0]?.node).toBe('False Path');
});
it('should return NodeChain for chaining', () => {
const nodeA = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'A' } });
const nodeB = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'B' } });
const result = nodeA.to(nodeB);
// .to() should return a NodeChain containing both nodes
expect(result._isChain).toBe(true);
expect(result.head).toBe(nodeA);
expect(result.tail).toBe(nodeB);
expect(result.allNodes).toEqual([nodeA, nodeB]);
// Chain should proxy tail properties
expect(result.name).toBe(nodeB.name);
expect(result.type).toBe(nodeB.type);
});
it('should allow getting declared connections', () => {
const nodeA = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'A' } });
const nodeB = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'B' } });
const nodeC = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'C' } });
nodeA.to(nodeB);
nodeA.to(nodeC, 1);
const connections = nodeA.getConnections();
expect(connections).toHaveLength(2);
expect(connections[0]).toEqual({ target: nodeB, outputIndex: 0 });
expect(connections[1]).toEqual({ target: nodeC, outputIndex: 1 });
});
});
describe('IF fluent API', () => {
it('should create IF node with true and false branches', () => {
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {},
});
const ifNode = node({
type: 'n8n-nodes-base.if',
version: 2.3,
config: {
name: 'Check Value',
parameters: {
conditions: {
conditions: [
{
leftValue: '={{ $json.value }}',
operator: { type: 'number', operation: 'gt' },
rightValue: 100,
},
],
},
},
},
}) as NodeInstance<'n8n-nodes-base.if', string, unknown>;
const trueNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'True Path' },
});
const falseNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'False Path' },
});
const wf = workflow('test', 'Test')
.add(triggerNode)
.to(ifNode.onTrue!(trueNode).onFalse(falseNode));
const json = wf.toJSON();
// IF node should have correct connections
expect(json.connections['Check Value']?.main[0]?.[0]?.node).toBe('True Path');
expect(json.connections['Check Value']?.main[1]?.[0]?.node).toBe('False Path');
// Trigger should connect to IF
expect(json.connections[triggerNode.name]?.main[0]?.[0]?.node).toBe('Check Value');
// All nodes should be present (trigger, IF, true, false)
expect(json.nodes).toHaveLength(4);
});
it('should use generated IF types for config', () => {
const ifNode = node({
type: 'n8n-nodes-base.if',
version: 2.3,
config: {
name: 'Type Check',
parameters: { looseTypeValidation: true },
},
}) as NodeInstance<'n8n-nodes-base.if', string, unknown>;
const trueNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'True' },
});
const falseNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'False' },
});
const wf = workflow('test', 'Test').to(ifNode.onTrue!(trueNode).onFalse(falseNode));
const json = wf.toJSON();
const foundIfNode = json.nodes.find((n) => n.type === 'n8n-nodes-base.if');
expect(foundIfNode?.typeVersion).toBe(2.3);
expect(foundIfNode?.parameters?.looseTypeValidation).toBe(true);
});
it('should chain after IF node', () => {
const ifNode = node({
type: 'n8n-nodes-base.if',
version: 2.3,
config: { name: 'IF' },
}) as NodeInstance<'n8n-nodes-base.if', string, unknown>;
const trueNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'True' },
});
const falseNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'False' },
});
const afterNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'After' },
});
const wf = workflow('test', 'Test')
.to(ifNode.onTrue!(trueNode).onFalse(falseNode))
.to(afterNode);
const json = wf.toJSON();
// IF should be connected to true and false
expect(json.connections['IF']?.main[0]?.[0]?.node).toBe('True');
expect(json.connections['IF']?.main[1]?.[0]?.node).toBe('False');
// After should be in the workflow (chained from IF's output 0)
expect(json.nodes.find((n) => n.name === 'After')).toBeDefined();
});
it('should handle only false branch connected (no true branch)', () => {
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start' },
});
const ifNode = node({
type: 'n8n-nodes-base.if',
version: 2.3,
config: {
name: 'IF Check',
parameters: {
conditions: {
conditions: [
{
leftValue: '={{ $json.skip }}',
operator: { type: 'boolean', operation: 'true' },
},
],
},
},
},
}) as NodeInstance<'n8n-nodes-base.if', string, unknown>;
const falseNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'False Path' },
});
// Only false branch is connected using onFalse()
const wf = workflow('test', 'Test').add(triggerNode).to(ifNode.onFalse!(falseNode));
const json = wf.toJSON();
// Should have 3 nodes: trigger, IF, false path (no true path)
expect(json.nodes).toHaveLength(3);
expect(json.nodes.map((n) => n.name)).toContain('IF Check');
expect(json.nodes.map((n) => n.name)).toContain('False Path');
// IF should only have output 1 (false) connected, output 0 should be empty or undefined
const output0 = json.connections['IF Check']?.main[0];
expect(!output0 || output0.length === 0).toBe(true);
expect(json.connections['IF Check']?.main[1]?.[0]?.node).toBe('False Path');
});
it('should handle only true branch connected (no false branch)', () => {
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start' },
});
const ifNode = node({
type: 'n8n-nodes-base.if',
version: 2.3,
config: {
name: 'IF Check',
parameters: {
conditions: {
conditions: [
{
leftValue: '={{ $json.proceed }}',
operator: { type: 'boolean', operation: 'true' },
},
],
},
},
},
}) as NodeInstance<'n8n-nodes-base.if', string, unknown>;
const trueNode = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'True Path' },
});
// Only true branch is connected using onTrue()
const wf = workflow('test', 'Test').add(triggerNode).to(ifNode.onTrue!(trueNode));
const json = wf.toJSON();
// Should have 3 nodes: trigger, IF, true path (no false path)
expect(json.nodes).toHaveLength(3);
expect(json.nodes.map((n) => n.name)).toContain('IF Check');
expect(json.nodes.map((n) => n.name)).toContain('True Path');
// IF should only have output 0 (true) connected
expect(json.connections['IF Check']?.main[0]?.[0]?.node).toBe('True Path');
expect(json.connections['IF Check']?.main[1]).toBeUndefined();
});
});
describe('pinData', () => {
it('should collect pinData from node config into workflow JSON', () => {
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start', position: [240, 300] },
});
const boxNode = node({
type: 'n8n-nodes-base.box',
version: 1,
config: {
name: 'Search Box Files',
parameters: {
resource: 'file',
operation: 'search',
query: 'test',
},
position: [540, 300],
pinData: [
{
id: '123456789',
type: 'file',
name: 'Q4_Report.pdf',
size: 2048576,
},
{
id: '987654321',
type: 'file',
name: 'Meeting_Notes.docx',
size: 524288,
},
],
},
});
const wf = workflow('test-id', 'Test Workflow').add(triggerNode).to(boxNode);
const json = wf.toJSON();
// pinData should be in the workflow JSON at the top level, keyed by node name
expect(json.pinData).toBeDefined();
expect(json.pinData!['Search Box Files']).toBeDefined();
expect(json.pinData!['Search Box Files']).toHaveLength(2);
expect(json.pinData!['Search Box Files'][0].id).toBe('123456789');
expect(json.pinData!['Search Box Files'][1].id).toBe('987654321');
});
it('should collect pinData from multiple nodes', () => {
const node1 = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: {
name: 'HTTP Node 1',
pinData: [{ result: 'data1' }],
},
});
const node2 = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: {
name: 'HTTP Node 2',
pinData: [{ result: 'data2' }],
},
});
const wf = workflow('test-id', 'Test').add(node1).to(node2);
const json = wf.toJSON();
expect(json.pinData).toBeDefined();
expect(json.pinData!['HTTP Node 1']).toEqual([{ result: 'data1' }]);
expect(json.pinData!['HTTP Node 2']).toEqual([{ result: 'data2' }]);
});
it('should not include pinData key when no nodes have pinData', () => {
const node1 = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP Node' },
});
const wf = workflow('test-id', 'Test').add(node1);
const json = wf.toJSON();
// pinData should not exist or be undefined when no nodes have pinData
expect(json.pinData).toBeUndefined();
});
});
describe('Switch fluent API', () => {
it('should connect all switch outputs including fallback (output 2)', () => {
// BUG: When using workflow.add(chain).to(switchNode.onCase(...)),
// output 2 (fallback) was not being connected
const t = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start' },
});
const linearNode = node({
type: 'n8n-nodes-base.linear',
version: 1.1,
config: { name: 'Get Issues', onError: 'continueErrorOutput' },
});
const errorHandler = node({
type: 'n8n-nodes-base.slack',
version: 2.4,
config: { name: 'Send Error to Slack' },
});
const switchNode = node({
type: 'n8n-nodes-base.switch',
version: 3.4,
config: {
name: 'Triage Issues',
parameters: {
mode: 'rules',
options: { fallbackOutput: 2 },
},
},
}) as NodeInstance<'n8n-nodes-base.switch', string, unknown>;
const case0 = node({
type: 'n8n-nodes-base.linear',
version: 1.1,
config: { name: 'Update as Bug' },
});
const case1 = node({
type: 'n8n-nodes-base.linear',
version: 1.1,
config: { name: 'Update as Feature' },
});
const case2 = node({
type: 'n8n-nodes-base.linear',
version: 1.1,
config: { name: 'Update as Other' },
});
// Using fluent syntax
const wf = workflow('test', 'Test')
.add(t.to(linearNode.onError(errorHandler)))
.to(switchNode.onCase!(0, case0).onCase(1, case1).onCase(2, case2));
const json = wf.toJSON();
// All 7 nodes should be present (including error handler)
expect(json.nodes).toHaveLength(7);
expect(json.nodes.map((n) => n.name)).toContain('Send Error to Slack');
// Linear node should connect to Switch
expect(json.connections['Get Issues']).toBeDefined();
expect(json.connections['Get Issues'].main[0]![0].node).toBe('Triage Issues');
// Switch should connect to ALL 3 cases
expect(json.connections['Triage Issues']).toBeDefined();
expect(json.connections['Triage Issues'].main[0]![0].node).toBe('Update as Bug');
expect(json.connections['Triage Issues'].main[1]![0].node).toBe('Update as Feature');
// THIS IS THE BUG - output 2 (fallback) should be connected
expect(json.connections['Triage Issues'].main[2]).toBeDefined();
expect(json.connections['Triage Issues'].main[2]![0].node).toBe('Update as Other');
});
it('should connect previous node to switch when using chain with add()', () => {
// BUG FIX TEST: When using .add() with a chain containing switchNode.onCase(),
// the connection from the previous node to the switch was not being created
const t = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start' },
});
const linearNode = node({
type: 'n8n-nodes-base.linear',
version: 1.1,
config: { name: 'Get Issues' },
});
const switchNode = node({
type: 'n8n-nodes-base.switch',
version: 3.4,
config: {
name: 'Triage',
parameters: { mode: 'rules' },
},
}) as NodeInstance<'n8n-nodes-base.switch', string, unknown>;
const case0 = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Bug Handler' },
});
const case1 = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Feature Handler' },
});
// This pattern is what causes the bug: chain with fluent builder inside add()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chain = t.to(linearNode).to(switchNode.onCase!(0, case0).onCase(1, case1) as any);
const wf = workflow('test', 'Test').add(chain);
const json = wf.toJSON();
// All 5 nodes should be present
expect(json.nodes).toHaveLength(5);
// Trigger should connect to Linear node
expect(json.connections['Start'].main[0]![0].node).toBe('Get Issues');
// Linear node should connect to Switch - THIS IS THE BUG!
// Before fix: json.connections['Get Issues'] is undefined
expect(json.connections['Get Issues']).toBeDefined();
expect(json.connections['Get Issues'].main[0]![0].node).toBe('Triage');
// Switch should connect to cases
expect(json.connections['Triage'].main[0]![0].node).toBe('Bug Handler');
expect(json.connections['Triage'].main[1]![0].node).toBe('Feature Handler');
});
it('should create Switch node with case branches', () => {
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {},
});
const switchNode = node({
type: 'n8n-nodes-base.switch',
version: 3.4,
config: {
name: 'Route by Type',
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' },
});
const case2 = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Case 2' },
});
const wf = workflow('test', 'Test')
.add(triggerNode)
.to(switchNode.onCase!(0, case0).onCase(1, case1).onCase(2, case2));
const json = wf.toJSON();
// Switch node should have correct connections
expect(json.connections['Route by Type']?.main[0]?.[0]?.node).toBe('Case 0');
expect(json.connections['Route by Type']?.main[1]?.[0]?.node).toBe('Case 1');
expect(json.connections['Route by Type']?.main[2]?.[0]?.node).toBe('Case 2');
// All nodes should be present (trigger, switch, case0, case1, case2)
expect(json.nodes).toHaveLength(5);
});
it('should include fallback as last case', () => {
const switchNode = node({
type: 'n8n-nodes-base.switch',
version: 3.4,
config: {
name: 'Router',
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 fallback = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Fallback' },
});
const wf = workflow('test', 'Test').to(switchNode.onCase!(0, case0).onCase(1, fallback));
const json = wf.toJSON();
// Fallback is just the last case (output 1)
expect(json.connections['Router']?.main[1]?.[0]?.node).toBe('Fallback');
// Switch node should exist
const foundSwitchNode = json.nodes.find((n) => n.type === 'n8n-nodes-base.switch');
expect(foundSwitchNode).toBeDefined();
});
it('should use latest Switch version', () => {
const switchNode = node({
type: 'n8n-nodes-base.switch',
version: 3.4,
config: {
name: 'Switch',
parameters: { mode: 'expression', numberOutputs: 4 },
},
}) as NodeInstance<'n8n-nodes-base.switch', string, unknown>;
const case0 = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Case 0' },
});
const wf = workflow('test', 'Test').to(switchNode.onCase!(0, case0));
const json = wf.toJSON();
const foundSwitchNode = json.nodes.find((n) => n.type === 'n8n-nodes-base.switch');
expect(foundSwitchNode?.typeVersion).toBe(3.4);
});
it('should connect trigger to switch', () => {
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {},
});
const switchNode = node({
type: 'n8n-nodes-base.switch',
version: 3.4,
config: { name: 'Switch' },
}) as NodeInstance<'n8n-nodes-base.switch', string, unknown>;
const case0 = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'Case 0' },
});
const wf = workflow('test', 'Test').add(triggerNode).to(switchNode.onCase!(0, case0));
const json = wf.toJSON();
// Trigger should connect to Switch
expect(json.connections[triggerNode.name]?.main[0]?.[0]?.node).toBe('Switch');
});
it('should include trigger node when using trigger.to(switch).onCase() directly in add()', () => {
// BUG: When trigger.to(switch).onCase() is passed to add(),
// the trigger node is lost because NodeChain.onCase() delegates to
// tail.onCase() which creates a new SwitchCaseBuilder without the chain context
const triggerNode = trigger({
type: 'n8n-nodes-base.webhook',
version: 2.1,
config: { name: 'Webhook Trigger' },
});
const switchNode = node({
type: 'n8n-nodes-base.switch',
version: 3.4,
config: { name: 'Route by Amount' },
}) as NodeInstance<'n8n-nodes-base.switch', string, unknown>;
const case0 = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Auto Approve' },
});
const case1 = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Manual Approve' },
});
// This pattern loses the trigger: trigger.to(switch).onCase()
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
const switchBuilder = triggerNode.to(switchNode).onCase!(0, case0).onCase(1, case1) as any;
const wf = workflow('test', 'Test').add(switchBuilder);
const json = wf.toJSON();
// All 4 nodes should be present (trigger + switch + 2 cases)
expect(json.nodes).toHaveLength(4);
expect(json.nodes.map((n) => n.name)).toContain('Webhook Trigger');
// Trigger should connect to Switch
expect(json.connections['Webhook Trigger']).toBeDefined();
expect(json.connections['Webhook Trigger'].main[0]![0].node).toBe('Route by Amount');
// Switch should connect to cases
expect(json.connections['Route by Amount'].main[0]![0].node).toBe('Auto Approve');
expect(json.connections['Route by Amount'].main[1]![0].node).toBe('Manual Approve');
});
});
describe('toJSON - resource locator normalization', () => {
it('should add __rl: true to resource locator values missing it', () => {
const wf = workflow('test', 'Test').add(
node({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.2,
config: {
name: 'OpenAI Model',
parameters: {
model: { mode: 'list', value: 'gpt-4o' }, // Missing __rl: true
},
position: [0, 0],
},
}),
);
const json = wf.toJSON();
const modelNode = json.nodes.find((n) => n.name === 'OpenAI Model');
expect(modelNode?.parameters?.model).toEqual({
__rl: true,
mode: 'list',
value: 'gpt-4o',
});
});
it('should preserve existing __rl: true', () => {
const wf = workflow('test', 'Test').add(
node({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.2,
config: {
name: 'OpenAI Model',
parameters: {
model: { __rl: true, mode: 'list', value: 'gpt-4o' },
},
position: [0, 0],
},
}),
);
const json = wf.toJSON();
const modelNode = json.nodes.find((n) => n.name === 'OpenAI Model');
expect(modelNode?.parameters?.model).toEqual({
__rl: true,
mode: 'list',
value: 'gpt-4o',
});
});
it('should normalize nested resource locator values', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.googleSheets',
version: 4.5,
config: {
name: 'Google Sheets',
parameters: {
documentId: { mode: 'list', value: 'doc-123' },
sheetName: { mode: 'list', value: 'Sheet1' },
},
position: [0, 0],
},
}),
);
const json = wf.toJSON();
const sheetsNode = json.nodes.find((n) => n.name === 'Google Sheets');
expect(sheetsNode?.parameters?.documentId).toEqual({
__rl: true,
mode: 'list',
value: 'doc-123',
});
expect(sheetsNode?.parameters?.sheetName).toEqual({
__rl: true,
mode: 'list',
value: 'Sheet1',
});
});
it('should not add __rl to objects without mode property', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
options: { someKey: 'someValue' }, // Not a resource locator (no mode)
},
position: [0, 0],
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
// Should NOT have __rl added since there's no mode property
expect((setNode?.parameters?.options as Record<string, unknown>)?.__rl).toBeUndefined();
});
it('should normalize resource locators in nested objects', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: {
name: 'HTTP Request',
parameters: {
options: {
nestedResource: { mode: 'list', value: 'nested-value' },
},
},
position: [0, 0],
},
}),
);
const json = wf.toJSON();
const httpNode = json.nodes.find((n) => n.name === 'HTTP Request');
const options = httpNode?.parameters?.options as Record<string, unknown> | undefined;
expect(options?.nestedResource).toEqual({
__rl: true,
mode: 'list',
value: 'nested-value',
});
});
it('should clear placeholder values when mode is list', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.slack',
version: 2.4,
config: {
name: 'Slack',
parameters: {
channelId: {
mode: 'list',
value: '<__PLACEHOLDER_VALUE__Select a channel__>',
},
},
position: [0, 0],
},
}),
);
const json = wf.toJSON();
const slackNode = json.nodes.find((n) => n.name === 'Slack');
// Placeholder should be cleared to empty string for list mode
expect(slackNode?.parameters?.channelId).toEqual({
__rl: true,
mode: 'list',
value: '',
});
});
it('should NOT clear placeholder values when mode is id', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.slack',
version: 2.4,
config: {
name: 'Slack',
parameters: {
channelId: {
mode: 'id',
value: '<__PLACEHOLDER_VALUE__Enter channel ID__>',
},
},
position: [0, 0],
},
}),
);
const json = wf.toJSON();
const slackNode = json.nodes.find((n) => n.name === 'Slack');
// Placeholder should be preserved for id mode (user can enter manually)
expect(slackNode?.parameters?.channelId).toEqual({
__rl: true,
mode: 'id',
value: '<__PLACEHOLDER_VALUE__Enter channel ID__>',
});
});
});
describe('addNodeWithSubnodes duplicate detection', () => {
it('should not create duplicate nodes when same instance appears in multiple chain targets', () => {
// Create AI agent with subnode named "Format Response"
const outputParserSubnode = outputParser({
type: '@n8n/n8n-nodes-langchain.outputParserStructured',
version: 1.3,
config: { name: 'Format Response', parameters: {} },
});
const aiAgent = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
subnodes: { outputParser: outputParserSubnode },
},
});
// Create Set node with SAME name as outputParser
const setNode = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Format Response' },
});
const finalNode = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'Send Request' },
});
// Build chain: trigger -> aiAgent -> setNode -> finalNode
const triggerNode = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start' },
});
const wf = workflow('test', 'Test Workflow').add(
triggerNode.to(aiAgent.to(setNode.to(finalNode))),
);
const json = wf.toJSON();
// Count Set nodes - should be exactly 1 (renamed to "Format Response 1")
const setNodes = json.nodes.filter((n) => n.type === 'n8n-nodes-base.set');
expect(setNodes).toHaveLength(1);
expect(setNodes[0].name).toBe('Format Response 1');
// Verify no duplicates like "Format Response 2", "Format Response 3", etc.
const formatResponseNodes = json.nodes.filter(
(n) => n.name?.startsWith('Format Response') && n.type === 'n8n-nodes-base.set',
);
expect(formatResponseNodes).toHaveLength(1);
});
});
describe('toJSON - newline escaping in expressions', () => {
// Category 1: Basic Escaping (double/single quotes inside {{ }})
describe('basic escaping inside {{ }}', () => {
it('should escape raw newline in double-quoted string', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "text\nmore" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "text\\nmore" }}');
});
it('should escape raw newline in single-quoted string', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: "={{ 'text\nmore' }}",
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe("={{ 'text\\nmore' }}");
});
it('should escape multiple string literals in expression', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "a\n" + $json.x + "b\n" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "a\\n" + $json.x + "b\\n" }}');
});
it('should escape newline-only string', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "\n" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "\\n" }}');
});
it('should escape multiple consecutive newlines', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "a\n\n\nb" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "a\\n\\n\\nb" }}');
});
});
// Category 2: Already Escaped (DON'T double-escape)
describe('already escaped newlines', () => {
it('should NOT double-escape already escaped newline', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "text\\nmore" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "text\\nmore" }}');
});
it('should handle mix of escaped and raw newlines', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "pre\\n\n" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "pre\\n\\n" }}');
});
it('should handle double backslash + n (not a newline)', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "a\\\\nb" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
// \\\\ in source = \\ in actual string, so \\n means backslash followed by n
expect(setNode?.parameters?.text).toBe('={{ "a\\\\nb" }}');
});
});
// Category 3: No `=` Prefix (DON'T escape)
describe('no expression prefix', () => {
it('should NOT escape plain text without = prefix', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: 'plain text\nwith newlines',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('plain text\nwith newlines');
});
it('should NOT escape {{ }} without = prefix', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '{{ "text\n" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('{{ "text\n" }}');
});
});
// Category 4: Backticks / Template Literals (DON'T escape)
describe('template literals', () => {
it('should NOT escape newlines inside backtick strings', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ `text\nmore` }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ `text\nmore` }}');
});
it('should NOT escape newlines in template literal with expression', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ `${$json.x}\n` }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ `${$json.x}\n` }}');
});
});
// Category 5: Outside `{{ }}` (DON'T escape)
describe('newlines outside expression blocks', () => {
it('should NOT escape newlines outside {{ }}', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '=\n{{ "a" }}\ntext',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('=\n{{ "a" }}\ntext');
});
it('should escape inside but not outside {{ }}', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '=prefix\n{{ "inner\n" }}\nsuffix',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('=prefix\n{{ "inner\\n" }}\nsuffix');
});
});
// Category 6: No String Literals Inside `{{ }}`
describe('expressions without string literals', () => {
it('should NOT modify expression with only variable reference', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ $json.output }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ $json.output }}');
});
it('should NOT modify expression with numbers', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
value: '={{ 123 }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.value).toBe('={{ 123 }}');
});
});
// Category 7: Empty/Null/Undefined Values
describe('empty and null values', () => {
it('should NOT modify empty string', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('');
});
it('should NOT modify empty expression', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ }}');
});
});
// Category 8: Multiple `{{ }}` Blocks
describe('multiple expression blocks', () => {
it('should escape in multiple {{ }} blocks', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "a\n" }}{{ "b\n" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "a\\n" }}{{ "b\\n" }}');
});
it('should escape in multiple separated {{ }} blocks', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "a\n" }} and {{ "b\n" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "a\\n" }} and {{ "b\\n" }}');
});
});
// Category 9: Escaped Quotes Inside Strings
describe('escaped quotes inside strings', () => {
it('should escape newline after escaped quote in double-quoted string', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "say \\"hi\n\\"" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "say \\"hi\\n\\"" }}');
});
it('should escape newline after escaped quote in single-quoted string', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: "={{ 'it\\'s\n' }}",
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe("={{ 'it\\'s\\n' }}");
});
});
// Category 10: Mixed Quote Types
describe('mixed quote types', () => {
it('should escape in single quotes inside double quotes', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "it\'s\n" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "it\'s\\n" }}');
});
it('should escape in both double and single quoted strings', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "a\n" + \'b\n\' }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "a\\n" + \'b\\n\' }}');
});
});
// Category 11: Complex Expressions
describe('complex expressions', () => {
it('should escape in string argument to function', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ $json.x.split("\n") }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ $json.x.split("\\n") }}');
});
it('should escape in ternary expression', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ cond ? "a\n" : "b\n" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ cond ? "a\\n" : "b\\n" }}');
});
it('should escape in array literal', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ ["a\n", "b\n"] }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ ["a\\n", "b\\n"] }}');
});
it('should escape in object literal', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ { key: "val\n" } }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ { key: "val\\n" } }}');
});
});
// Category 12: Nested Object/Array Parameters
describe('nested parameters', () => {
it('should escape in deeply nested object', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
outer: {
inner: {
text: '={{ "x\n" }}',
},
},
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
const params = setNode?.parameters as Record<string, unknown> | undefined;
const outer = params?.outer as Record<string, unknown> | undefined;
const inner = outer?.inner as Record<string, unknown> | undefined;
expect(inner?.text).toBe('={{ "x\\n" }}');
});
it('should escape in array items', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
items: ['={{ "a\n" }}', '={{ "b\n" }}'],
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
const params = setNode?.parameters as Record<string, unknown> | undefined;
expect(params?.items).toEqual(['={{ "a\\n" }}', '={{ "b\\n" }}']);
});
});
// Category 13: Special Characters (DON'T escape)
describe('special characters', () => {
it('should NOT escape tab characters', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "a\tb" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "a\tb" }}');
});
it('should NOT modify backslashes without newlines', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "path\\\\to\\\\file" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "path\\\\to\\\\file" }}');
});
});
// Category 14: Boundary/Edge Cases
describe('edge cases', () => {
it('should NOT modify expression without newlines', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "x" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "x" }}');
});
it('should NOT modify just equals sign', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '=',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('=');
});
it('should only escape last string with newline when others have none', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "a" + "b" + "c\n" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe('={{ "a" + "b" + "c\\n" }}');
});
});
// Category 15: Real-World Example from Bug
describe('real-world examples', () => {
it('should escape newlines in multi-part prompt expression', () => {
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ "Convert the following report into clean, professional HTML suitable for email: \n" + $json.output + "\nRequirements:" }}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
expect(setNode?.parameters?.text).toBe(
'={{ "Convert the following report into clean, professional HTML suitable for email: \\n" + $json.output + "\\nRequirements:" }}',
);
});
it('should NOT escape newlines in regex literals containing quotes', () => {
// Regression test: regex /\"/g contains a quote that should not start a string
const wf = workflow('test', 'Test').add(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
text: '={{ $json.text.replace(/\\"/g, "escaped")\n}}',
},
},
}),
);
const json = wf.toJSON();
const setNode = json.nodes.find((n) => n.name === 'Set');
// The newline after the .replace() call is outside any string, should NOT be escaped
expect(setNode?.parameters?.text).toBe('={{ $json.text.replace(/\\"/g, "escaped")\n}}');
});
});
});
describe('depth overflow protection', () => {
it('handles convergence patterns without depth overflow', () => {
// Diamond pattern: trigger → IF → (true: A, false: B) → both to Merge → End
// This creates convergence that previously caused exponential recursion
const mergeNode = node({
type: 'n8n-nodes-base.merge',
version: 3,
config: { name: 'Merge' },
});
const nodeA = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'A' },
}).to(mergeNode.input(0));
const nodeB = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: { name: 'B' },
}).to(mergeNode.input(1));
const ifNode = node({
type: 'n8n-nodes-base.if' as const,
version: 2,
config: { name: 'IF' },
});
const ifBuilder = ifNode.onTrue!(nodeA).onFalse(nodeB);
const end = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'End' } });
const t = trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} });
const wf = workflow('test', 'Test').add(t.to(ifBuilder));
// Also connect merge output to end
wf.add(mergeNode.to(end));
// Should not throw - convergence should be handled efficiently
const exported = wf.toJSON();
expect(exported.nodes.length).toBeGreaterThanOrEqual(5);
});
it('throws when branch depth exceeds MAX_BRANCH_DEPTH', () => {
// Build deeply nested IF composites — each true branch is another IF builder
// This triggers recursive addBranchToGraph calls via the IfElse composite handler
// MAX_BRANCH_DEPTH is 500, so we need > 500 nesting levels
const leaf = node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'Leaf' } });
let current: NodeInstance<string, string, unknown> = leaf;
for (let i = 0; i < 510; i++) {
const ifNode = node({
type: 'n8n-nodes-base.if' as const,
version: 2,
config: { name: `IF ${i}` },
});
current = ifNode.onTrue!(current) as unknown as NodeInstance<string, string, unknown>;
}
const t = trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} });
const wf = workflow('test', 'Test').add(t);
expect(() => wf.to(current)).toThrow(/Maximum branch depth/);
});
});
describe('invalid input handling', () => {
it('should throw descriptive TypeError when name is an array', () => {
expect(() => {
// @ts-expect-error intentional misuse
workflow('Test', [trigger({ type: 'n8n-nodes-base.webhook', version: 2, config: {} })]);
}).toThrow(/workflow\(\) requires \(id: string, name: string\)/);
});
it('should throw descriptive TypeError when id is not a string', () => {
expect(() => {
// @ts-expect-error intentional misuse
workflow(123, 'Test');
}).toThrow(/workflow\(\) requires \(id: string, name: string\)/);
});
it('should throw when nodes are passed in the options argument', () => {
const t = trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} });
expect(() => {
workflow('id', 'Test', { nodes: [t] });
}).toThrow(/Do not pass nodes or connections here/);
});
it('should throw when connections are passed in the options argument', () => {
expect(() => {
workflow('id', 'Test', { connections: { 'Node 1': { main: [[]] } } });
}).toThrow(/use \.add\(\) and \.to\(\)/);
});
it('should throw when an array of nodes is passed as the options argument', () => {
const t = trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} });
expect(() => {
// @ts-expect-error intentional misuse
workflow('id', 'Test', [t]);
}).toThrow(/Do not pass nodes or connections here/);
});
});
});