mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
2982 lines
90 KiB
TypeScript
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/);
|
|
});
|
|
});
|
|
});
|