Files
n8n/packages/@n8n/workflow-sdk/src/codegen/codegen-roundtrip.test.ts
T

2956 lines
92 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'fs';
import { deepCopy } from 'n8n-workflow';
import * as path from 'path';
import { generateWorkflowCode } from './index';
import { parseWorkflowCode, parseWorkflowCodeToBuilder } from './parse-workflow-code';
import {
ensureFixtures,
FixtureDownloadError,
DOWNLOADED_FIXTURES_DIR,
COMMITTED_FIXTURES_DIR,
} from '../__tests__/fixtures-download';
import type { WorkflowJSON } from '../types/base';
import { foldLegacyErrorConnections, normalizeConnections } from '../types/base';
import { validateWorkflow } from '../validation';
import {
escapeNewlinesInExpressionStrings,
isPlaceholderValue,
} from '../workflow-builder/string-utils';
interface ExpectedWarning {
code: string;
nodeName?: string;
}
interface ExpectedError {
code: string;
nodeName?: string;
}
interface TestWorkflow {
id: string;
name: string;
json: WorkflowJSON;
nodeCount: number;
expectedWarnings?: ExpectedWarning[];
expectedErrors?: ExpectedError[];
/** Warnings expected from validateWorkflow when run with a nodeTypesProvider. */
expectedValidationWarnings?: ExpectedWarning[];
/** Errors expected from WorkflowBuilder.validate() (plugin validator pipeline). */
expectedBuilderErrors?: ExpectedError[];
}
function loadWorkflowsFromDir(dir: string, workflows: TestWorkflow[]): void {
const manifestPath = path.join(dir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
return;
}
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse -- Manifest is controlled fixture file
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as {
workflows: Array<{
id: string | number;
name: string;
success: boolean;
skip?: boolean;
skipReason?: string;
expectedWarnings?: ExpectedWarning[];
expectedErrors?: ExpectedError[];
expectedValidationWarnings?: ExpectedWarning[];
expectedBuilderErrors?: ExpectedError[];
}>;
};
for (const entry of manifest.workflows) {
if (!entry.success) continue;
if (entry.skip) continue;
const filePath = path.join(dir, `${entry.id}.json`);
if (fs.existsSync(filePath)) {
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse -- Test fixture file
const json = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as WorkflowJSON;
workflows.push({
id: String(entry.id),
name: entry.name,
json,
nodeCount: json.nodes?.length ?? 0,
expectedWarnings: entry.expectedWarnings,
expectedErrors: entry.expectedErrors,
expectedValidationWarnings: entry.expectedValidationWarnings,
expectedBuilderErrors: entry.expectedBuilderErrors,
});
}
}
}
function loadTestWorkflows(): TestWorkflow[] {
const workflows: TestWorkflow[] = [];
// Load committed workflows first (always available)
loadWorkflowsFromDir(COMMITTED_FIXTURES_DIR, workflows);
// Load downloaded workflows
loadWorkflowsFromDir(DOWNLOADED_FIXTURES_DIR, workflows);
return workflows;
}
// Load workflows at file parse time for individual test generation
const workflows = loadTestWorkflows();
describe('parseWorkflowCode', () => {
it('should parse a simple generated workflow back to JSON', () => {
const originalJson: WorkflowJSON = {
id: 'test-123',
name: 'Simple Test',
nodes: [
{
id: 'node-1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'node-2',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [200, 0],
parameters: {
url: 'https://api.example.com',
method: 'GET',
},
},
],
connections: {
'Manual Trigger': {
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]],
},
},
};
// Generate TypeScript code
const code = generateWorkflowCode(originalJson);
// Parse it back
const parsedJson = parseWorkflowCode(code);
// Verify basic structure
expect(parsedJson.id).toBe('test-123');
expect(parsedJson.name).toBe('Simple Test');
expect(parsedJson.nodes).toHaveLength(2);
// Find nodes by name (since IDs may be regenerated)
const trigger = parsedJson.nodes.find((n) => n.name === 'Manual Trigger');
const http = parsedJson.nodes.find((n) => n.name === 'HTTP Request');
expect(trigger).toBeDefined();
expect(trigger?.type).toBe('n8n-nodes-base.manualTrigger');
expect(trigger?.typeVersion).toBe(1);
expect(http).toBeDefined();
expect(http?.type).toBe('n8n-nodes-base.httpRequest');
expect(http?.typeVersion).toBe(4.2);
expect(http?.parameters).toEqual({
url: 'https://api.example.com',
method: 'GET',
});
// Verify connections
expect(parsedJson.connections['Manual Trigger']).toBeDefined();
expect(parsedJson.connections['Manual Trigger'].main[0]![0].node).toBe('HTTP Request');
});
it('should preserve node-level execution options when generating and parsing code', () => {
const originalJson: WorkflowJSON = {
id: 'debug-workflow',
name: 'Debug Workflow',
nodes: [
{
id: 'trigger-id',
name: 'When clicking Execute workflow',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [-160, 0],
parameters: {},
},
{
id: 'debug-id',
name: 'DebugHelper',
type: 'n8n-nodes-base.debugHelper',
typeVersion: 1,
position: [208, 0],
parameters: {
category: 'randomData',
},
executeOnce: true,
alwaysOutputData: true,
onError: 'continueErrorOutput',
retryOnFail: true,
maxTries: 4,
waitBetweenTries: 2500,
notesInFlow: true,
notes: 'Keep execution settings',
extendsCredential: 'notionApi',
},
{
id: 'success-id',
name: 'Edit Fields Success',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [416, -96],
parameters: {
options: {},
},
},
{
id: 'error-id',
name: 'Edit Fields Error',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [416, 96],
parameters: {
options: {},
},
},
],
connections: {
'When clicking Execute workflow': {
main: [[{ node: 'DebugHelper', type: 'main', index: 0 }]],
},
DebugHelper: {
main: [
[{ node: 'Edit Fields Success', type: 'main', index: 0 }],
[{ node: 'Edit Fields Error', type: 'main', index: 0 }],
],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
const debugNode = parsedJson.nodes.find((node) => node.name === 'DebugHelper');
expect(debugNode).toEqual(
expect.objectContaining({
executeOnce: true,
alwaysOutputData: true,
onError: 'continueErrorOutput',
retryOnFail: true,
maxTries: 4,
waitBetweenTries: 2500,
notesInFlow: true,
notes: 'Keep execution settings',
extendsCredential: 'notionApi',
}),
);
});
it('should round-trip non-ASCII characters (em-dash, en-dash, curly quotes, ellipsis) in workflow name, node names, and string parameters', () => {
const originalJson: WorkflowJSON = {
id: 'unicode-test',
name: 'EM — DASH · EN – DASH … "curly"',
nodes: [
{
id: 'trigger-1',
name: 'Every Hour — Run',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: [0, 0],
parameters: {},
},
{
id: 'set-1',
name: 'Greeting — Hello',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [200, 0],
parameters: {
assignments: {
assignments: [
{
id: 'a',
name: 'msg',
type: 'string',
value: 'hello — world · café "quoted" …',
},
],
},
},
},
],
connections: {
'Every Hour — Run': {
main: [[{ node: 'Greeting — Hello', type: 'main', index: 0 }]],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
expect(parsedJson.name).toBe('EM — DASH · EN – DASH … "curly"');
const names = parsedJson.nodes.map((n) => n.name);
expect(names).toContain('Every Hour — Run');
expect(names).toContain('Greeting — Hello');
const setNode = parsedJson.nodes.find((n) => n.name === 'Greeting — Hello')!;
const value = (setNode.parameters as { assignments: { assignments: Array<{ value: string }> } })
.assignments.assignments[0].value;
expect(value).toBe('hello — world · café "quoted" …');
expect(parsedJson.connections['Every Hour — Run'].main[0]![0].node).toBe('Greeting — Hello');
});
it('should parse workflow with settings', () => {
const originalJson: WorkflowJSON = {
id: 'settings-test',
name: 'Settings Test',
nodes: [
{
id: 'node-1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
settings: {
timezone: 'America/New_York',
executionOrder: 'v1',
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
expect(parsedJson.settings?.timezone).toBe('America/New_York');
expect(parsedJson.settings?.executionOrder).toBe('v1');
});
it('should parse workflow with credentials', () => {
const originalJson: WorkflowJSON = {
id: 'cred-test',
name: 'Credentials Test',
nodes: [
{
id: 'node-1',
name: 'Slack',
type: 'n8n-nodes-base.slack',
typeVersion: 2.2,
position: [0, 0],
parameters: { channel: '#general' },
credentials: {
slackApi: { id: 'cred-123', name: 'My Slack' },
},
},
],
connections: {},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
const slackNode = parsedJson.nodes.find((n) => n.name === 'Slack');
expect(slackNode?.credentials).toEqual({
slackApi: { id: 'cred-123', name: 'My Slack' },
});
});
it('should parse workflow with sticky notes', () => {
const originalJson: WorkflowJSON = {
id: 'sticky-test',
name: 'Sticky Test',
nodes: [
{
id: 'sticky-1',
name: 'Sticky Note',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [0, 0],
parameters: {
content: '## Documentation\n\nThis is a note.',
color: 4,
width: 300,
height: 200,
},
},
],
connections: {},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
const sticky = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.stickyNote');
expect(sticky).toBeDefined();
expect(sticky?.parameters?.content).toBe('## Documentation\n\nThis is a note.');
expect(sticky?.parameters?.color).toBe(4);
});
it('should parse workflow with branching (if node)', () => {
const originalJson: WorkflowJSON = {
id: 'branch-test',
name: 'Branch Test',
nodes: [
{
id: 'trigger-1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'if-1',
name: 'IF',
type: 'n8n-nodes-base.if',
typeVersion: 2,
position: [200, 0],
parameters: {},
},
{
id: 'true-1',
name: 'True Branch',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, -100],
parameters: {},
},
{
id: 'false-1',
name: 'False Branch',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, 100],
parameters: {},
},
],
connections: {
Trigger: {
main: [[{ node: 'IF', type: 'main', index: 0 }]],
},
IF: {
main: [
[{ node: 'True Branch', type: 'main', index: 0 }],
[{ node: 'False Branch', type: 'main', index: 0 }],
],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
// Check branching connections
expect(parsedJson.connections['IF']).toBeDefined();
expect(parsedJson.connections['IF'].main[0]![0].node).toBe('True Branch');
expect(parsedJson.connections['IF'].main[1]![0].node).toBe('False Branch');
});
it('should preserve connections when both IF branches converge to the same node', () => {
// This tests the branch convergence pattern:
// IF node has two branches (True and False), both leading to the same downstream node
// The generated code should create the convergence node as a variable and reference it
// in both branches to preserve both connections
const originalJson: WorkflowJSON = {
id: 'branch-convergence-test',
name: 'Branch Convergence Test',
nodes: [
{
id: 'trigger-1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'if-1',
name: 'IF',
type: 'n8n-nodes-base.if',
typeVersion: 2,
position: [200, 0],
parameters: {},
},
{
id: 'true-1',
name: 'True Path',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, -100],
parameters: {},
},
{
id: 'false-1',
name: 'False Path',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, 100],
parameters: {},
},
{
id: 'converge-1',
name: 'Convergence Node',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [600, 0],
parameters: { mode: 'manual' },
},
],
connections: {
Trigger: {
main: [[{ node: 'IF', type: 'main', index: 0 }]],
},
IF: {
main: [
[{ node: 'True Path', type: 'main', index: 0 }],
[{ node: 'False Path', type: 'main', index: 0 }],
],
},
// Both branches converge to the same node
'True Path': {
main: [[{ node: 'Convergence Node', type: 'main', index: 0 }]],
},
'False Path': {
main: [[{ node: 'Convergence Node', type: 'main', index: 0 }]],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
// Verify all nodes are present
expect(parsedJson.nodes).toHaveLength(5);
// Verify IF connections to branch nodes
expect(parsedJson.connections['IF']).toBeDefined();
expect(parsedJson.connections['IF'].main[0]![0].node).toBe('True Path');
expect(parsedJson.connections['IF'].main[1]![0].node).toBe('False Path');
// CRITICAL: Verify BOTH branches connect to the convergence node
expect(parsedJson.connections['True Path']).toBeDefined();
expect(parsedJson.connections['True Path'].main[0]![0].node).toBe('Convergence Node');
expect(parsedJson.connections['False Path']).toBeDefined();
expect(parsedJson.connections['False Path'].main[0]![0].node).toBe('Convergence Node');
});
it('should preserve connections for switch case with downstream chain', () => {
// This test reproduces the issue where nodes downstream of switch case branches
// get incorrectly chained to the switch composite instead of their specific branch
const originalJson: WorkflowJSON = {
id: 'switch-chain-test',
name: 'Switch Chain Test',
nodes: [
{
id: 'trigger-1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'switch-1',
name: 'Switch',
type: 'n8n-nodes-base.switch',
typeVersion: 3.2,
position: [200, 0],
parameters: {
rules: {
values: [
{ outputKey: 'Case A', conditions: {} },
{ outputKey: 'Case B', conditions: {} },
],
},
},
},
{
id: 'case-a-1',
name: 'Case A Node',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, -100],
parameters: {},
},
{
id: 'case-b-1',
name: 'Case B Node',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, 100],
parameters: {},
},
{
id: 'case-b-2',
name: 'Case B Downstream',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [600, 100],
parameters: { mode: 'manual' },
},
],
connections: {
Trigger: {
main: [[{ node: 'Switch', type: 'main', index: 0 }]],
},
Switch: {
main: [
[{ node: 'Case A Node', type: 'main', index: 0 }],
[{ node: 'Case B Node', type: 'main', index: 0 }],
],
},
// This is the critical connection: Case B Node -> Case B Downstream
'Case B Node': {
main: [[{ node: 'Case B Downstream', type: 'main', index: 0 }]],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
// Verify all nodes are present
expect(parsedJson.nodes).toHaveLength(5);
// Verify Switch connections to case branches
expect(parsedJson.connections['Switch']).toBeDefined();
expect(parsedJson.connections['Switch'].main[0]![0].node).toBe('Case A Node');
expect(parsedJson.connections['Switch'].main[1]![0].node).toBe('Case B Node');
// CRITICAL: Verify the downstream connection is preserved
// Case B Node should connect to Case B Downstream
expect(parsedJson.connections['Case B Node']).toBeDefined();
expect(parsedJson.connections['Case B Node'].main[0]![0].node).toBe('Case B Downstream');
});
it('should preserve cycle connections in polling loop pattern (Wait → IF)', () => {
// This tests the polling loop pattern where a node cycles back to an IF node:
// Trigger → Job Complete? (IF)
// ↓ true ↓ false
// Get Dataset Check Status → Wait → Job Complete? (CYCLE!)
//
// The cycle Wait → Job Complete? must be preserved in the roundtrip.
const originalJson: WorkflowJSON = {
id: 'polling-loop-test',
name: 'Polling Loop Test',
nodes: [
{
id: 'trigger-1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'if-1',
name: 'Job Complete?',
type: 'n8n-nodes-base.if',
typeVersion: 2,
position: [200, 0],
parameters: {},
},
{
id: 'done-1',
name: 'Get Dataset',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [400, -100],
parameters: { url: 'https://api.example.com/dataset' },
},
{
id: 'check-1',
name: 'Check Status',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [400, 100],
parameters: { url: 'https://api.example.com/status' },
},
{
id: 'wait-1',
name: 'Wait',
type: 'n8n-nodes-base.wait',
typeVersion: 1.1,
position: [600, 100],
parameters: { amount: 5 },
},
],
connections: {
Trigger: {
main: [[{ node: 'Job Complete?', type: 'main', index: 0 }]],
},
'Job Complete?': {
main: [
[{ node: 'Get Dataset', type: 'main', index: 0 }], // true branch
[{ node: 'Check Status', type: 'main', index: 0 }], // false branch
],
},
'Check Status': {
main: [[{ node: 'Wait', type: 'main', index: 0 }]],
},
// CRITICAL: This is the cycle - Wait connects back to the IF node
Wait: {
main: [[{ node: 'Job Complete?', type: 'main', index: 0 }]],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
// Verify all nodes are present
expect(parsedJson.nodes).toHaveLength(5);
// Verify IF node connections
expect(parsedJson.connections['Job Complete?']).toBeDefined();
expect(parsedJson.connections['Job Complete?'].main[0]![0].node).toBe('Get Dataset');
expect(parsedJson.connections['Job Complete?'].main[1]![0].node).toBe('Check Status');
// Verify Check Status → Wait connection
expect(parsedJson.connections['Check Status']).toBeDefined();
expect(parsedJson.connections['Check Status'].main[0]![0].node).toBe('Wait');
// CRITICAL: Verify the CYCLE connection Wait → Job Complete? is preserved
expect(parsedJson.connections['Wait']).toBeDefined();
expect(parsedJson.connections['Wait'].main[0]![0].node).toBe('Job Complete?');
});
it('should preserve merge input indices when fan-out includes both regular node and merge', () => {
// Bug: MERGE_SINGLE_INPUT - when a node fans out to both a regular node AND a merge node,
// the merge input index is lost. Pattern from workflow 6897 (HR CVs Filter):
//
// Download Actual File → Store CV (output 0) → Merge (input 0)
// Download Actual File → Merge (input 1) ← This connection loses its input index!
//
// The merge node should receive:
// - input 0: from Store CV
// - input 1: from Download Actual File
const originalJson: WorkflowJSON = {
id: 'merge-fanout-test',
name: 'Merge Fan-out Test',
nodes: [
{
id: 'trigger-1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'fanout-1',
name: 'Fan Out Node',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [200, 0],
parameters: { url: 'https://api.example.com' },
},
{
id: 'branch-1',
name: 'Branch Node',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [400, -100],
parameters: { mode: 'manual' },
},
{
id: 'merge-1',
name: 'Merge',
type: 'n8n-nodes-base.merge',
typeVersion: 3.2,
position: [600, 0],
parameters: {},
},
{
id: 'after-merge',
name: 'After Merge',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [800, 0],
parameters: {},
},
],
connections: {
Trigger: {
main: [[{ node: 'Fan Out Node', type: 'main', index: 0 }]],
},
// Fan Out Node fans out to BOTH Branch Node AND Merge
'Fan Out Node': {
main: [
[
{ node: 'Branch Node', type: 'main', index: 0 },
{ node: 'Merge', type: 'main', index: 1 }, // Goes to merge INPUT 1
],
],
},
// Branch Node goes to Merge input 0
'Branch Node': {
main: [[{ node: 'Merge', type: 'main', index: 0 }]],
},
Merge: {
main: [[{ node: 'After Merge', type: 'main', index: 0 }]],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
// Verify all nodes are present
expect(parsedJson.nodes).toHaveLength(5);
// Verify Fan Out Node has connections to both Branch Node and Merge
const fanOutConns = parsedJson.connections['Fan Out Node']?.main[0];
expect(fanOutConns).toBeDefined();
expect(fanOutConns!.length).toBeGreaterThanOrEqual(2);
// Find connections to Branch Node and Merge
const toBranch = fanOutConns!.find((c) => c.node === 'Branch Node');
const toMerge = fanOutConns!.find((c) => c.node === 'Merge');
expect(toBranch).toBeDefined();
expect(toMerge).toBeDefined();
// CRITICAL: The merge connection from Fan Out Node should go to input 1
expect(toMerge!.index).toBe(1);
// Branch Node should connect to Merge input 0
const branchToMerge = parsedJson.connections['Branch Node']?.main[0]?.[0];
expect(branchToMerge).toBeDefined();
expect(branchToMerge!.node).toBe('Merge');
expect(branchToMerge!.index).toBe(0);
// Verify Merge has output to After Merge
expect(parsedJson.connections['Merge']?.main[0]?.[0]?.node).toBe('After Merge');
});
it('should preserve merge input indices when fan-out includes multiple merge nodes', () => {
// Bug: MERGE_SINGLE_INPUT - when a node fans out to multiple merge nodes,
// the merge input indices are lost. Pattern from workflow 3066:
//
// Source → Regular Node (output 0)
// Source → Merge1 (input 1)
// Source → Merge2 (input 1)
// Regular Node → Merge1 (input 0)
// Other → Merge2 (input 0)
//
// The merge nodes should receive their correct input indices
const originalJson: WorkflowJSON = {
id: 'multi-merge-fanout-test',
name: 'Multiple Merge Fan-out Test',
nodes: [
{
id: 'trigger-1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'source-1',
name: 'Source Node',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [200, 0],
parameters: { url: 'https://api.example.com' },
},
{
id: 'branch-1',
name: 'Branch Node',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [400, -100],
parameters: { mode: 'manual' },
},
{
id: 'other-1',
name: 'Other Node',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [400, 100],
parameters: { mode: 'manual' },
},
{
id: 'merge-1',
name: 'Merge1',
type: 'n8n-nodes-base.merge',
typeVersion: 3.2,
position: [600, -50],
parameters: {},
},
{
id: 'merge-2',
name: 'Merge2',
type: 'n8n-nodes-base.merge',
typeVersion: 3.2,
position: [600, 50],
parameters: {},
},
{
id: 'end-1',
name: 'End',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [800, 0],
parameters: {},
},
],
connections: {
Trigger: {
main: [[{ node: 'Source Node', type: 'main', index: 0 }]],
},
// Source Node fans out to: Branch Node, Merge1 (input 1), Merge2 (input 1)
'Source Node': {
main: [
[
{ node: 'Branch Node', type: 'main', index: 0 },
{ node: 'Merge1', type: 'main', index: 1 }, // Goes to Merge1 INPUT 1
{ node: 'Merge2', type: 'main', index: 1 }, // Goes to Merge2 INPUT 1
],
],
},
// Branch Node goes to Merge1 input 0
'Branch Node': {
main: [[{ node: 'Merge1', type: 'main', index: 0 }]],
},
// Other Node goes to Merge2 input 0
'Other Node': {
main: [[{ node: 'Merge2', type: 'main', index: 0 }]],
},
Merge1: {
main: [[{ node: 'End', type: 'main', index: 0 }]],
},
Merge2: {
main: [[{ node: 'End', type: 'main', index: 0 }]],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
// Verify all nodes are present
expect(parsedJson.nodes).toHaveLength(7);
// Verify Source Node has connections to Branch Node, Merge1, and Merge2
const sourceConns = parsedJson.connections['Source Node']?.main[0];
expect(sourceConns).toBeDefined();
expect(sourceConns!.length).toBeGreaterThanOrEqual(3);
// Find connections to each target
const toBranch = sourceConns!.find((c) => c.node === 'Branch Node');
const toMerge1 = sourceConns!.find((c) => c.node === 'Merge1');
const toMerge2 = sourceConns!.find((c) => c.node === 'Merge2');
expect(toBranch).toBeDefined();
expect(toMerge1).toBeDefined();
expect(toMerge2).toBeDefined();
// CRITICAL: Both merge connections from Source Node should go to input 1
expect(toMerge1!.index).toBe(1);
expect(toMerge2!.index).toBe(1);
// Branch Node should connect to Merge1 input 0
const branchToMerge = parsedJson.connections['Branch Node']?.main[0]?.[0];
expect(branchToMerge).toBeDefined();
expect(branchToMerge!.node).toBe('Merge1');
expect(branchToMerge!.index).toBe(0);
// Other Node should connect to Merge2 input 0
const otherToMerge = parsedJson.connections['Other Node']?.main[0]?.[0];
expect(otherToMerge).toBeDefined();
expect(otherToMerge!.node).toBe('Merge2');
expect(otherToMerge!.index).toBe(0);
});
describe('escapes node references in single-quoted strings', () => {
it('should parse code with unescaped $() node references', () => {
// This code has unescaped single quotes inside single-quoted strings
// The parser should automatically escape them
const codeWithUnescapedQuotes = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({ type: 'n8n-nodes-base.set', version: 3.4, config: {
parameters: {
mode: 'manual',
assignments: {
assignments: [
{
id: 'test',
name: 'value',
value: '={{ $('Manual Trigger').item.json.data }}',
type: 'string'
}
]
}
}
} }))
`;
// This should not throw a syntax error
const parsedJson = parseWorkflowCode(codeWithUnescapedQuotes);
expect(parsedJson.id).toBe('test-id');
expect(parsedJson.nodes).toHaveLength(2);
const setNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.set');
expect(setNode).toBeDefined();
// The expression should be preserved with the node reference
const assignments = (setNode?.parameters as Record<string, unknown>)?.assignments as Record<
string,
unknown
>;
const assignmentList = assignments?.assignments as Array<Record<string, unknown>>;
expect(assignmentList?.[0]?.value).toBe("={{ $('Manual Trigger').item.json.data }}");
});
it('should handle multiple node references in the same string', () => {
const code = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({ type: 'n8n-nodes-base.set', version: 3.4, config: {
parameters: {
mode: 'raw',
jsonOutput: '={{ $('Node A').item.json.a + $('Node B').item.json.b }}'
}
} }))
`;
const parsedJson = parseWorkflowCode(code);
const setNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.set');
expect((setNode?.parameters as Record<string, unknown>)?.jsonOutput).toBe(
"={{ $('Node A').item.json.a + $('Node B').item.json.b }}",
);
});
it('should not double-escape already escaped quotes', () => {
// Code with already-escaped quotes should not be double-escaped
const codeWithEscapedQuotes = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({ type: 'n8n-nodes-base.set', version: 3.4, config: {
parameters: {
mode: 'raw',
jsonOutput: '={{ $(\\'Properly Escaped\\').item.json.data }}'
}
} }))
`;
const parsedJson = parseWorkflowCode(codeWithEscapedQuotes);
const setNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.set');
expect((setNode?.parameters as Record<string, unknown>)?.jsonOutput).toBe(
"={{ $('Properly Escaped').item.json.data }}",
);
});
it('should preserve double-quoted strings unchanged', () => {
// Node references in double-quoted strings don't need escaping
const code = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({ type: 'n8n-nodes-base.set', version: 3.4, config: {
parameters: {
mode: 'raw',
jsonOutput: "={{ $('Node Name').item.json.data }}"
}
} }))
`;
const parsedJson = parseWorkflowCode(code);
const setNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.set');
expect((setNode?.parameters as Record<string, unknown>)?.jsonOutput).toBe(
"={{ $('Node Name').item.json.data }}",
);
});
it('should handle node names with spaces and special characters', () => {
const code = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({ type: 'n8n-nodes-base.set', version: 3.4, config: {
parameters: {
mode: 'raw',
jsonOutput: '={{ $('Lead Generation Form').item.json.fullName }}'
}
} }))
`;
const parsedJson = parseWorkflowCode(code);
const setNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.set');
expect((setNode?.parameters as Record<string, unknown>)?.jsonOutput).toBe(
"={{ $('Lead Generation Form').item.json.fullName }}",
);
});
it('should fix double-escaped $() node references', () => {
// LLMs sometimes generate $(\\'NodeName\\') with double-escaped backslashes
// In a template literal, \\\\ produces \\, so the actual string chars are $( + \ + \ + '
const code = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} })
.to(node({ type: 'n8n-nodes-base.set', version: 3.4, config: {
parameters: {
mode: 'raw',
jsonOutput: '={{ $(\\\\'Manual Trigger\\\\').item.json.data }}'
}
} })))
`;
const parsedJson = parseWorkflowCode(code);
const setNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.set');
expect((setNode?.parameters as Record<string, unknown>)?.jsonOutput).toBe(
"={{ $('Manual Trigger').item.json.data }}",
);
});
it('should fix multiple double-escaped $() references in the same string', () => {
const code = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} })
.to(node({ type: 'n8n-nodes-base.set', version: 3.4, config: {
parameters: {
mode: 'raw',
jsonOutput: '={{ $(\\\\'Node A\\\\').item.json.a + $(\\\\'Node B\\\\').item.json.b }}'
}
} })))
`;
const parsedJson = parseWorkflowCode(code);
const setNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.set');
expect((setNode?.parameters as Record<string, unknown>)?.jsonOutput).toBe(
"={{ $('Node A').item.json.a + $('Node B').item.json.b }}",
);
});
});
describe('parses placeholder() in workflow code', () => {
it('should parse code with placeholder() in parameters', () => {
const code = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({ type: 'n8n-nodes-base.slack', version: 2.2, config: {
name: 'Send Slack Message',
parameters: { channel: placeholder('Enter Slack Channel') }
} }))
`;
const parsedJson = parseWorkflowCode(code);
expect(parsedJson.id).toBe('test-id');
expect(parsedJson.nodes).toHaveLength(2);
const slackNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.slack');
expect(slackNode).toBeDefined();
expect((slackNode?.parameters as Record<string, unknown>)?.channel).toBe(
'<__PLACEHOLDER_VALUE__Enter Slack Channel__>',
);
});
it('should roundtrip JSON with placeholder values through code generation', () => {
// Start with JSON containing placeholder value
const inputJson: WorkflowJSON = {
id: 'test-id',
name: 'Test Workflow',
nodes: [
{
id: 'trigger',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
},
{
id: 'slack',
name: 'Slack',
type: 'n8n-nodes-base.slack',
typeVersion: 2.2,
position: [200, 0] as [number, number],
parameters: {
channel: '<__PLACEHOLDER_VALUE__Enter Slack Channel__>',
text: 'Hello',
},
},
],
connections: {
'Manual Trigger': {
main: [[{ node: 'Slack', type: 'main', index: 0 }]],
},
},
};
// JSON → code
const code = generateWorkflowCode(inputJson);
// Code should contain placeholder() call, not raw string
expect(code).toContain("placeholder('Enter Slack Channel')");
expect(code).not.toContain('<__PLACEHOLDER_VALUE__');
// code → JSON (roundtrip)
const outputJson = parseWorkflowCode(code);
const slackNode = outputJson.nodes.find((n) => n.type === 'n8n-nodes-base.slack');
expect((slackNode?.parameters as Record<string, unknown>)?.channel).toBe(
'<__PLACEHOLDER_VALUE__Enter Slack Channel__>',
);
});
});
describe('parses newCredential() in workflow code', () => {
it('should parse code with newCredential() in credentials', () => {
const code = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({ type: 'n8n-nodes-base.slack', version: 2.2, config: {
name: 'Send Slack Message',
parameters: { channel: '#general', text: 'Hello!' },
credentials: { slackApi: newCredential('My Slack Bot') }
} }))
`;
const parsedJson = parseWorkflowCode(code);
expect(parsedJson.id).toBe('test-id');
expect(parsedJson.nodes).toHaveLength(2);
const slackNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.slack');
expect(slackNode).toBeDefined();
// newCredential serializes to undefined, which is omitted from JSON - not yet implemented
expect(slackNode?.credentials).toEqual({});
});
it('should parse code with multiple newCredential() calls', () => {
const code = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({ type: 'n8n-nodes-base.httpRequest', version: 4.2, config: {
name: 'HTTP Request',
parameters: { url: 'https://api.example.com' },
credentials: {
httpBasicAuth: newCredential('Basic Auth'),
httpHeaderAuth: newCredential('API Key Header')
}
} }))
`;
const parsedJson = parseWorkflowCode(code);
const httpNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.httpRequest');
// newCredential serializes to undefined, which is omitted - not yet implemented
expect(httpNode?.credentials).toEqual({});
});
it('should parse code with newCredential() mixed with regular credentials', () => {
const code = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({ type: 'n8n-nodes-base.httpRequest', version: 4.2, config: {
name: 'HTTP Request',
parameters: { url: 'https://api.example.com' },
credentials: {
httpBasicAuth: { id: 'existing-123', name: 'Existing Auth' },
httpHeaderAuth: newCredential('New API Key')
}
} }))
`;
const parsedJson = parseWorkflowCode(code);
const httpNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.httpRequest');
// Regular credentials preserved, newCredential omitted (not yet implemented)
expect(httpNode?.credentials).toEqual({
httpBasicAuth: { id: 'existing-123', name: 'Existing Auth' },
});
});
it('should parse AI agent with newCredential() on subnode', () => {
const code = `
export default workflow('test-id', 'AI Agent')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
parameters: { promptType: 'define', text: 'You are helpful' },
subnodes: {
model: languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1,
config: {
parameters: {},
credentials: { openAiApi: newCredential('OpenAI API') }
}
})
}
}
}))
`;
const parsedJson = parseWorkflowCode(code);
const openAiNode = parsedJson.nodes.find(
(n) => n.type === '@n8n/n8n-nodes-langchain.lmChatOpenAi',
);
expect(openAiNode).toBeDefined();
// newCredential serializes to undefined, which is omitted - not yet implemented
expect(openAiNode?.credentials).toEqual({});
});
it('should parse newCredential() with id to link existing credential', () => {
const code = `
export default workflow('test-id', 'Test Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({ type: 'n8n-nodes-base.slack', version: 2.2, config: {
name: 'Slack',
parameters: { channel: '#general' },
credentials: { slackApi: newCredential('My Slack', 'cred-123') }
} }))
`;
const parsedJson = parseWorkflowCode(code);
const slackNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.slack');
expect(slackNode).toBeDefined();
// newCredential with id serializes to { id, name }
expect(slackNode?.credentials).toEqual({
slackApi: { id: 'cred-123', name: 'My Slack' },
});
});
it('should roundtrip credentials via newCredential(name, id)', () => {
const originalJson: WorkflowJSON = {
id: 'cred-roundtrip',
name: 'Credential Roundtrip',
nodes: [
{
id: 'node-1',
name: 'Slack',
type: 'n8n-nodes-base.slack',
typeVersion: 2.2,
position: [0, 0],
parameters: { channel: '#general' },
credentials: {
slackApi: { id: 'cred-abc', name: 'Slack Bot' },
},
},
],
connections: {},
};
const code = generateWorkflowCode(originalJson);
// Code should contain newCredential('Slack Bot', 'cred-abc')
expect(code).toContain("newCredential('Slack Bot', 'cred-abc')");
const parsedJson = parseWorkflowCode(code);
const slackNode = parsedJson.nodes.find((n) => n.name === 'Slack');
expect(slackNode?.credentials).toEqual({
slackApi: { id: 'cred-abc', name: 'Slack Bot' },
});
});
});
describe('parses Switch fluent API with pinData', () => {
it('should parse workflow with Switch fluent API and pinData without errors', () => {
// This code reproduces a bug where Switch with pinData fails with
// "Cannot read properties of undefined (reading 'subnodes')"
// Uses fluent syntax: switchNode.onCase(0, case0).onCase(1, case1)
const code = `
// Declare the switch node first
const triageSwitch = node({
type: 'n8n-nodes-base.switch',
version: 3.2,
config: {
name: 'Triage Issues',
parameters: {
mode: 'rules',
rules: {
values: [
{
conditions: {
options: {
caseSensitive: false,
leftValue: '',
typeValidation: 'loose'
},
conditions: [
{
leftValue: '={{ $json.title.toLowerCase() }}',
rightValue: '',
operator: {
type: 'string',
operation: 'contains',
rightType: 'any',
singleValue: 'bug'
}
}
],
combinator: 'or'
},
renameOutput: true,
outputKey: 'Bug'
},
{
conditions: {},
renameOutput: true,
outputKey: 'Feature/Enhancement'
}
]
}
},
position: [840, 300]
}
});
// Declare case nodes
const tagAsBug = node({
type: 'n8n-nodes-base.linear',
version: 1.1,
config: {
name: 'Tag as Bug',
parameters: {
resource: 'issue',
operation: 'update',
issueId: '={{ $json.id }}',
updateFields: {
labelIds: ['bug-label-id']
}
},
credentials: {
linearApi: newCredential('Linear API')
},
position: [1140, 200]
}
});
const tagAsFeature = node({
type: 'n8n-nodes-base.linear',
version: 1.1,
config: {
name: 'Tag as Feature',
parameters: {
resource: 'issue',
operation: 'update',
issueId: '={{ $json.id }}',
updateFields: {
labelIds: ['feature-label-id']
}
},
credentials: {
linearApi: newCredential('Linear API')
},
position: [1140, 400]
}
});
export default workflow('AlNAxHXOpfimqHPOGVuNg', 'My workflow 23')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {
name: 'Start',
position: [240, 300]
}
})
.to(
node({
type: 'n8n-nodes-base.linear',
version: 1.1,
config: {
name: 'Get Linear Issues',
parameters: {
resource: 'issue',
operation: 'getAll',
returnAll: false,
limit: 50
},
credentials: {
linearApi: newCredential('Linear API')
},
position: [540, 300],
onError: 'continueErrorOutput',
pinData: [
{
id: 'ISS-123',
identifier: 'ISS-123',
title: 'Application crashes on startup',
priority: 1,
state: { id: 'state-1', name: 'Todo' },
createdAt: '2024-01-15T10:00:00Z',
creator: { id: 'user-1', displayName: 'John Doe' }
}
]
}
})
.onError(
node({
type: 'n8n-nodes-base.slack',
version: 2.4,
config: {
name: 'Send Error to Slack',
parameters: {
resource: 'message',
operation: 'post',
select: 'channel',
channelId: { mode: 'list', value: '', __rl: true },
messageType: 'text',
text: 'Error occurred'
},
credentials: {
slackApi: newCredential('Slack Bot')
},
position: [840, 500]
}
})
)
)
.to(triageSwitch.onCase(0, tagAsBug).onCase(1, tagAsFeature))
);`;
// This should not throw an error
const parsedJson = parseWorkflowCode(code);
expect(parsedJson.id).toBe('AlNAxHXOpfimqHPOGVuNg');
expect(parsedJson.name).toBe('My workflow 23');
expect(parsedJson.nodes.length).toBeGreaterThanOrEqual(5); // trigger + linear + slack + switch + 2 case nodes
});
});
describe('parses multiple sticky notes', () => {
it('should parse workflow code with multiple sticky notes', () => {
// This code has 4 sticky notes added via .add()
const code = `
// Create all nodes first
const startTrigger = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {
name: 'Start Research',
position: [112, 400]
}
});
const setTopic = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set Research Topic',
parameters: {
mode: 'manual',
duplicateItem: false,
assignments: {},
options: {}
},
position: [336, 400]
}
});
// Research Agent and its subnodes
const researchModel = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'GPT-4.1-mini Model',
parameters: {
model: 'gpt-4.1-mini',
options: {
maxTokens: 4000,
temperature: 0.3
}
},
credentials: {
openAiApi: newCredential('OpenAI API')
},
position: [576, 624]
}
});
const webSearchTool = tool({
type: '@n8n/n8n-nodes-langchain.toolCode',
version: 1.3,
config: {
name: 'Web Search Simulator',
parameters: {
description: 'Simulates web search to find information about a topic.',
language: 'javaScript',
jsCode: 'return JSON.stringify([]);'
},
position: [704, 624]
}
});
const researchAgent = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'Research Agent',
parameters: {
text: '={{ $json.chatInput }}',
options: {}
},
subnodes: {
model: researchModel,
tools: [webSearchTool]
},
position: [560, 400]
}
});
// Fact-Checking Agent
const factCheckModel = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'GPT-4.1-mini Fact Checker',
parameters: {
model: 'gpt-4.1-mini',
options: { maxTokens: 3000, temperature: 0.1 }
},
credentials: {
openAiApi: newCredential('OpenAI API')
},
position: [992, 624]
}
});
const factCheckAgent = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'Fact-Checking Agent',
parameters: {
text: '={{ $json.chatInput }}',
options: {}
},
subnodes: {
model: factCheckModel
},
position: [912, 400]
}
});
// Writing Agent
const writerModel = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'GPT-4.1-mini Writer',
parameters: {
model: 'gpt-4.1-mini',
options: { maxTokens: 2500, temperature: 0.7 }
},
credentials: {
openAiApi: newCredential('OpenAI API')
},
position: [1344, 624]
}
});
const writingAgent = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'Writing Agent',
parameters: {
text: '={{ $json.chatInput }}',
options: {}
},
subnodes: {
model: writerModel
},
position: [1264, 400]
}
});
// Editing Agent
const editorModel = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'GPT-4.1-mini Editor',
parameters: {
model: 'gpt-4.1-mini',
options: { maxTokens: 3000, temperature: 0.4 }
},
credentials: {
openAiApi: newCredential('OpenAI API')
},
position: [1696, 624]
}
});
const editingAgent = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'Editing & Formatting Agent',
parameters: {
text: '={{ $json.chatInput }}',
options: {}
},
subnodes: {
model: editorModel
},
position: [1616, 400]
}
});
// Create sticky notes for each agent
const researchSticky = sticky(
'## Research Agent\\n\\n**Purpose:** Conducts initial research on the given topic',
{
nodes: [researchAgent, researchModel, webSearchTool],
color: 5
}
);
const factCheckSticky = sticky(
'## Fact-Checking Agent\\n\\n**Purpose:** Verifies accuracy and credibility',
{
nodes: [factCheckAgent, factCheckModel],
color: 6
}
);
const writingSticky = sticky(
'## Writing Agent\\n\\n**Purpose:** Transforms verified research into well-written content',
{
nodes: [writingAgent, writerModel],
color: 4
}
);
const editingSticky = sticky(
'## Editing & Formatting Agent\\n\\n**Purpose:** Polishes content and formats as HTML',
{
nodes: [editingAgent, editorModel],
color: 2
}
);
// Build the workflow
export default workflow('test-multi-sticky', 'Multi-Agent Research Workflow')
.add(startTrigger)
.to(setTopic)
.to(researchAgent)
.to(factCheckAgent)
.to(writingAgent)
.to(editingAgent)
.add(researchSticky)
.add(factCheckSticky)
.add(writingSticky)
.add(editingSticky);
`;
const parsedJson = parseWorkflowCode(code);
// Verify we have all 4 sticky notes
const stickyNotes = parsedJson.nodes.filter((n) => n.type === 'n8n-nodes-base.stickyNote');
expect(stickyNotes).toHaveLength(4);
// Verify each sticky content exists
expect(
stickyNotes.some((s) => (s.parameters?.content as string)?.includes('Research Agent')),
).toBe(true);
expect(
stickyNotes.some((s) => (s.parameters?.content as string)?.includes('Fact-Checking Agent')),
).toBe(true);
expect(
stickyNotes.some((s) => (s.parameters?.content as string)?.includes('Writing Agent')),
).toBe(true);
expect(
stickyNotes.some((s) =>
(s.parameters?.content as string)?.includes('Editing & Formatting Agent'),
),
).toBe(true);
// Verify each sticky has a color set
expect(stickyNotes.every((s) => s.parameters?.color !== undefined)).toBe(true);
});
it('should parse simple workflow code with multiple sticky notes without nodes option', () => {
// Simpler test case without the nodes option
const code = `
const startTrigger = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: { name: 'Start', position: [100, 300] }
});
const sticky1 = sticky('## Section 1\\n\\nFirst section', { color: 1, position: [50, 100] });
const sticky2 = sticky('## Section 2\\n\\nSecond section', { color: 2, position: [300, 100] });
const sticky3 = sticky('## Section 3\\n\\nThird section', { color: 3, position: [550, 100] });
export default workflow('test-simple-multi-sticky', 'Simple Multi-Sticky Workflow')
.add(startTrigger)
.add(sticky1)
.add(sticky2)
.add(sticky3);
`;
const parsedJson = parseWorkflowCode(code);
// Verify we have all 3 sticky notes
const stickyNotes = parsedJson.nodes.filter((n) => n.type === 'n8n-nodes-base.stickyNote');
expect(stickyNotes).toHaveLength(3);
// Verify content
expect(
stickyNotes.some((s) => (s.parameters?.content as string)?.includes('Section 1')),
).toBe(true);
expect(
stickyNotes.some((s) => (s.parameters?.content as string)?.includes('Section 2')),
).toBe(true);
expect(
stickyNotes.some((s) => (s.parameters?.content as string)?.includes('Section 3')),
).toBe(true);
// Verify colors are distinct
const colors = stickyNotes.map((s) => s.parameters?.color);
expect(colors).toContain(1);
expect(colors).toContain(2);
expect(colors).toContain(3);
});
});
describe('multi-trigger workflows with shared downstream nodes', () => {
it('should preserve connections from both triggers when they share downstream targets', () => {
// Bug B: Multi-trigger workflows where triggers share downstream nodes
// Pattern: Two triggers both connect to the same downstream nodes
// The first trigger's traversal marks downstream nodes as visited,
// preventing the second trigger from establishing its connections
const originalJson: WorkflowJSON = {
id: 'multi-trigger-test',
name: 'Multi-Trigger Shared Targets',
nodes: [
{
id: 'trigger-1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'trigger-2',
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: [0, 200],
parameters: {},
},
{
id: 'shared-1',
name: 'Shared Node A',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [200, 100],
parameters: { mode: 'manual' },
},
{
id: 'shared-2',
name: 'Shared Node B',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [400, 100],
parameters: { url: 'https://api.example.com' },
},
],
connections: {
// BOTH triggers connect to the same two nodes
'Manual Trigger': {
main: [
[
{ node: 'Shared Node A', type: 'main', index: 0 },
{ node: 'Shared Node B', type: 'main', index: 0 },
],
],
},
'Schedule Trigger': {
main: [
[
{ node: 'Shared Node A', type: 'main', index: 0 },
{ node: 'Shared Node B', type: 'main', index: 0 },
],
],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
// Verify all nodes are present
expect(parsedJson.nodes).toHaveLength(4);
// CRITICAL: Both triggers must have connections to their shared targets
expect(parsedJson.connections['Manual Trigger']).toBeDefined();
const trigger1Targets = parsedJson.connections['Manual Trigger'].main[0]!.map((c) => c.node);
expect(trigger1Targets).toContain('Shared Node A');
expect(trigger1Targets).toContain('Shared Node B');
expect(parsedJson.connections['Schedule Trigger']).toBeDefined();
const trigger2Targets = parsedJson.connections['Schedule Trigger'].main[0]!.map(
(c) => c.node,
);
expect(trigger2Targets).toContain('Shared Node A');
expect(trigger2Targets).toContain('Shared Node B');
});
});
describe('nodes with multiple output slots (classifier pattern)', () => {
it('should preserve all branches when a regular node has multiple output slots', () => {
// Bug A: Nodes with multiple output slots (like textClassifier) lose branches
// Pattern: Node has 3 outputs, each going to a different target
// The traversal only follows the first non-empty output slot
const originalJson: WorkflowJSON = {
id: 'classifier-test',
name: 'Classifier Pattern Test',
nodes: [
{
id: 'trigger-1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'classifier-1',
name: 'Text Classifier',
type: '@n8n/n8n-nodes-langchain.textClassifier',
typeVersion: 1.2,
position: [200, 0],
parameters: {},
},
{
id: 'branch-a',
name: 'Branch A Node',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, -100],
parameters: {},
},
{
id: 'branch-b',
name: 'Branch B Node',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, 0],
parameters: {},
},
{
id: 'branch-c',
name: 'Branch C Node',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, 100],
parameters: {},
},
],
connections: {
Trigger: {
main: [[{ node: 'Text Classifier', type: 'main', index: 0 }]],
},
// Text Classifier has 3 separate output slots
'Text Classifier': {
main: [
[{ node: 'Branch A Node', type: 'main', index: 0 }], // output 0
[{ node: 'Branch B Node', type: 'main', index: 0 }], // output 1
[{ node: 'Branch C Node', type: 'main', index: 0 }], // output 2
],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
// CRITICAL: All 5 nodes must be present
expect(parsedJson.nodes).toHaveLength(5);
// Verify all branch nodes exist
const nodeNames = parsedJson.nodes.map((n) => n.name);
expect(nodeNames).toContain('Branch A Node');
expect(nodeNames).toContain('Branch B Node');
expect(nodeNames).toContain('Branch C Node');
// Verify Text Classifier has connections to all branches
// Note: The codegen may output as fan-out (all on slot 0) rather than separate slots
// The important thing is all targets are connected
expect(parsedJson.connections['Text Classifier']).toBeDefined();
const textClassifierTargets = parsedJson.connections['Text Classifier'].main
.flat()
.filter((c): c is NonNullable<typeof c> => c !== null)
.map((c) => c.node);
expect(textClassifierTargets).toContain('Branch A Node');
expect(textClassifierTargets).toContain('Branch B Node');
expect(textClassifierTargets).toContain('Branch C Node');
});
});
describe('parses Code node jsCode with template literals', () => {
it('should parse Code node with properly escaped template literals', () => {
// When template literals are properly escaped with \$, they should work
const code = `
export default workflow('test-id', 'Code Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Process Data',
parameters: {
mode: 'runOnceForEachItem',
jsCode: \`const data = $input.item.json;
const message = \\\`Processing item: \\\${data.name}\\\`;
return { json: { message } };\`
}
}
}))
`;
const parsedJson = parseWorkflowCode(code);
const codeNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.code');
expect(codeNode).toBeDefined();
// The jsCode should contain the template literal with ${data.name} preserved
expect(codeNode?.parameters?.jsCode).toContain('${data.name}');
});
it('should escape and parse unescaped template expressions with non-n8n variables', () => {
// BUG FIX TEST: When AI generates code with unescaped ${variable} in jsCode,
// the parser should escape them to prevent "variable is not defined" errors.
// This is the failing case from the user's workflow where the AI generated:
// jsCode: `...errors.push(\`Currency '${invoiceData.currency}' is not valid.\`);...`
// The ${invoiceData.currency} should be escaped to \${invoiceData.currency}
const code = `
export default workflow('test-id', 'Validation Workflow')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Validate Data',
parameters: {
mode: 'runOnceForEachItem',
jsCode: \`const errors = [];
const invoiceData = $input.item.json;
if (!invoiceData.currency) {
errors.push(\\\`Currency '\${invoiceData.currency}' is not valid.\\\`);
}
return { json: { errors } };\`
}
}
}))
`;
// This should NOT throw "invoiceData is not defined" - it should escape and parse
const parsedJson = parseWorkflowCode(code);
const codeNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.code');
expect(codeNode).toBeDefined();
// The jsCode should preserve the ${invoiceData.currency} as a literal string
expect(codeNode?.parameters?.jsCode).toContain('${invoiceData.currency}');
});
it('should escape multiple unescaped template expressions in nested template literals', () => {
// More complex case with multiple user-defined variables in template expressions
const code = `
export default workflow('test-id', 'Complex Code')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: {} }))
.to(node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Format Output',
parameters: {
jsCode: \`const item = $input.item.json;
const result = \\\`Name: \${item.name}, Amount: \${item.amount}, Date: \${item.date}\\\`;
return { json: { result } };\`
}
}
}))
`;
const parsedJson = parseWorkflowCode(code);
const codeNode = parsedJson.nodes.find((n) => n.type === 'n8n-nodes-base.code');
expect(codeNode).toBeDefined();
expect(codeNode?.parameters?.jsCode).toContain('${item.name}');
expect(codeNode?.parameters?.jsCode).toContain('${item.amount}');
expect(codeNode?.parameters?.jsCode).toContain('${item.date}');
});
});
describe('SplitInBatches to Merge pattern', () => {
it('should roundtrip SIB outputs to same merge at different inputs (Pattern 9)', () => {
// Pattern 9: SIB.done → Merge:input0, SIB.each → Merge:input1
// This tests the explicit connections pattern
const originalJson: WorkflowJSON = {
id: 'sib-merge-test',
name: 'SIB to Merge Pattern',
nodes: [
{
id: 'sib-1',
name: 'Loop Over Items13',
type: 'n8n-nodes-base.splitInBatches',
typeVersion: 3,
position: [0, 0],
parameters: { options: {} },
},
{
id: 'merge-1',
name: 'Merge',
type: 'n8n-nodes-base.merge',
typeVersion: 3.2,
position: [224, 0],
parameters: {},
},
],
connections: {
'Loop Over Items13': {
main: [
[{ node: 'Merge', type: 'main', index: 0 }], // done → merge input 0
[{ node: 'Merge', type: 'main', index: 1 }], // each → merge input 1
],
},
Merge: {
main: [[{ node: 'Loop Over Items13', type: 'main', index: 0 }]], // merge → SIB (loop back)
},
},
};
// Generate code
const code = generateWorkflowCode(originalJson);
// Parse back to JSON
const parsedJson = parseWorkflowCode(code);
// Verify nodes
expect(parsedJson.nodes).toHaveLength(2);
// Verify connections
const sibConns = parsedJson.connections['Loop Over Items13'];
expect(sibConns).toBeDefined();
expect(sibConns.main[0]).toBeDefined();
expect(sibConns.main[1]).toBeDefined();
// SIB done (output 0) → Merge input 0
const doneConn = sibConns.main[0]![0];
expect(doneConn.node).toBe('Merge');
expect(doneConn.index).toBe(0);
// SIB each (output 1) → Merge input 1
const eachConn = sibConns.main[1]![0];
expect(eachConn.node).toBe('Merge');
expect(eachConn.index).toBe(1);
// Merge → SIB (loop back)
const mergeConns = parsedJson.connections['Merge'];
expect(mergeConns).toBeDefined();
expect(mergeConns.main[0]![0].node).toBe('Loop Over Items13');
});
});
describe('JSON-escaped code strings', () => {
it('should parse code with escaped newlines (from JSON.stringify)', () => {
// When code is passed through JSON.stringify, newlines become \\n
// This is a common scenario when code is stored/transmitted as JSON
const normalCode = `const myTrigger = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {
name: 'Start',
position: [240, 400]
}
});
export default workflow('test-id', 'Test Workflow')
.add(myTrigger);`;
// Simulate what happens when code goes through JSON
const jsonEncoded = JSON.stringify(normalCode);
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse -- Parsing test-controlled JSON.stringify output
const jsonDecoded = JSON.parse(jsonEncoded) as string;
// This should work - the JSON parse/stringify preserves newlines correctly
expect(() => parseWorkflowCode(jsonDecoded)).not.toThrow();
const parsedJson = parseWorkflowCode(jsonDecoded);
expect(parsedJson.id).toBe('test-id');
expect(parsedJson.name).toBe('Test Workflow');
});
it('should parse code with literal backslash-n sequences (double-escaped)', () => {
// When code is double-escaped (e.g., JSON stringified twice, or from certain APIs),
// the \\n becomes literal backslash-n characters in the string
// This commonly happens when code is embedded in JSON that gets stringified again
const codeWithLiteralBackslashN =
"const myTrigger = trigger({\\n type: 'n8n-nodes-base.manualTrigger',\\n version: 1,\\n config: {\\n name: 'Start',\\n position: [240, 400]\\n }\\n});\\n\\nexport default workflow('test-id', 'Test Workflow')\\n .add(myTrigger);";
// The parser should handle this by converting \\n to actual newlines
expect(() => parseWorkflowCode(codeWithLiteralBackslashN)).not.toThrow();
const parsedJson = parseWorkflowCode(codeWithLiteralBackslashN);
expect(parsedJson.id).toBe('test-id');
expect(parsedJson.name).toBe('Test Workflow');
});
it('should parse code with escaped quotes in strings', () => {
// Double-escaped quotes like \\" should be handled
const codeWithEscapedQuotes =
"const setNode = node({\\n type: 'n8n-nodes-base.set',\\n version: 3.4,\\n config: {\\n name: 'Set Data',\\n parameters: {\\n mode: 'manual',\\n assignments: {\\n assignments: [{\\n id: 'msg',\\n name: 'message',\\n value: '={{ $json.error || \\\"Default message\\\" }}',\\n type: 'string'\\n }]\\n }\\n },\\n position: [240, 400]\\n }\\n});\\n\\nexport default workflow('test-id', 'Test')\\n .add(setNode);";
expect(() => parseWorkflowCode(codeWithEscapedQuotes)).not.toThrow();
const parsedJson = parseWorkflowCode(codeWithEscapedQuotes);
expect(parsedJson.id).toBe('test-id');
});
});
describe('shared subnodes (one subnode used by multiple parents)', () => {
it('should produce a single language model node connected to both agents', () => {
const code = `
const sharedModel = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'Shared GPT-4o',
parameters: { model: { mode: 'list', value: 'gpt-4o-mini' } }
}
});
const agent1 = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'Research Agent',
parameters: { text: 'Research this topic' },
subnodes: { model: sharedModel }
}
});
const agent2 = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'Writing Agent',
parameters: { text: 'Write about this topic' },
subnodes: { model: sharedModel }
}
});
export default workflow('shared-model-test', 'Shared Model Test')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: { name: 'Start' } })
.to([agent1, agent2]));
`;
const parsedJson = parseWorkflowCode(code);
// Should have exactly 4 nodes: trigger, 2 agents, 1 shared model
expect(parsedJson.nodes).toHaveLength(4);
// Only ONE language model node should exist
const modelNodes = parsedJson.nodes.filter(
(n) => n.type === '@n8n/n8n-nodes-langchain.lmChatOpenAi',
);
expect(modelNodes).toHaveLength(1);
expect(modelNodes[0].name).toBe('Shared GPT-4o');
// The shared model should have ai_languageModel connections to BOTH agents
const modelConnections = parsedJson.connections['Shared GPT-4o']?.ai_languageModel?.[0];
expect(modelConnections).toBeDefined();
expect(modelConnections).toHaveLength(2);
const targetAgents = modelConnections!.map((c) => c.node).sort();
expect(targetAgents).toEqual(['Research Agent', 'Writing Agent']);
});
it('should produce a single tool node connected to both agents', () => {
const code = `
const sharedCalculator = tool({
type: '@n8n/n8n-nodes-langchain.toolCalculator',
version: 1,
config: { name: 'Shared Calculator' }
});
const agent1 = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'Math Agent',
parameters: { text: 'Calculate this' },
subnodes: {
model: languageModel({ type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', version: 1.3, config: { name: 'Model 1', parameters: { model: { mode: 'list', value: 'gpt-4o-mini' } } } }),
tools: [sharedCalculator]
}
}
});
const agent2 = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'Science Agent',
parameters: { text: 'Calculate that' },
subnodes: {
model: languageModel({ type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', version: 1.3, config: { name: 'Model 2', parameters: { model: { mode: 'list', value: 'gpt-4o-mini' } } } }),
tools: [sharedCalculator]
}
}
});
export default workflow('shared-tool-test', 'Shared Tool Test')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: { name: 'Start' } })
.to([agent1, agent2]));
`;
const parsedJson = parseWorkflowCode(code);
// Should have 6 nodes: trigger, 2 agents, 2 models, 1 shared tool
expect(parsedJson.nodes).toHaveLength(6);
// Only ONE calculator tool node should exist
const toolNodes = parsedJson.nodes.filter(
(n) => n.type === '@n8n/n8n-nodes-langchain.toolCalculator',
);
expect(toolNodes).toHaveLength(1);
expect(toolNodes[0].name).toBe('Shared Calculator');
// The shared tool should have ai_tool connections to BOTH agents
const toolConnections = parsedJson.connections['Shared Calculator']?.ai_tool?.[0];
expect(toolConnections).toBeDefined();
expect(toolConnections).toHaveLength(2);
const targetAgents = toolConnections!.map((c) => c.node).sort();
expect(targetAgents).toEqual(['Math Agent', 'Science Agent']);
});
it('should roundtrip JSON with shared language model through codegen', () => {
// JSON → code → JSON: A single language model connected to 2 agents
const originalJson: WorkflowJSON = {
id: 'shared-model-roundtrip',
name: 'Shared Model Roundtrip',
nodes: [
{
id: 'trigger-1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'agent-1',
name: 'Research Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3.1,
position: [200, -100],
parameters: { text: 'Research this' },
},
{
id: 'agent-2',
name: 'Writing Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3.1,
position: [200, 100],
parameters: { text: 'Write about this' },
},
{
id: 'model-1',
name: 'Shared GPT-4o',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1.3,
position: [200, 0],
parameters: { model: { mode: 'list', value: 'gpt-4o-mini' } },
},
],
connections: {
Start: {
main: [
[
{ node: 'Research Agent', type: 'main', index: 0 },
{ node: 'Writing Agent', type: 'main', index: 0 },
],
],
},
// Shared model connects to BOTH agents
'Shared GPT-4o': {
ai_languageModel: [
[
{ node: 'Research Agent', type: 'ai_languageModel', index: 0 },
{ node: 'Writing Agent', type: 'ai_languageModel', index: 0 },
],
],
},
},
};
const code = generateWorkflowCode(originalJson);
const parsedJson = parseWorkflowCode(code);
// Should have exactly 4 nodes
expect(parsedJson.nodes).toHaveLength(4);
// Only ONE language model node
const modelNodes = parsedJson.nodes.filter(
(n) => n.type === '@n8n/n8n-nodes-langchain.lmChatOpenAi',
);
expect(modelNodes).toHaveLength(1);
// The shared model should connect to BOTH agents
const modelConnections = parsedJson.connections['Shared GPT-4o']?.ai_languageModel?.[0];
expect(modelConnections).toBeDefined();
expect(modelConnections).toHaveLength(2);
const targetAgents = modelConnections!.map((c) => c.node).sort();
expect(targetAgents).toEqual(['Research Agent', 'Writing Agent']);
});
it('should produce a single embedding node connected to both vector stores', () => {
const code = `
const sharedEmbedding = embedding({
type: '@n8n/n8n-nodes-langchain.embeddingsOpenAi',
version: 1.2,
config: {
name: 'Shared Embeddings',
parameters: { model: 'text-embedding-3-small' }
}
});
const vectorStore1 = node({
type: '@n8n/n8n-nodes-langchain.vectorStoreInMemory',
version: 1,
config: {
name: 'Vector Store 1',
parameters: {},
subnodes: { embedding: sharedEmbedding }
}
});
const vectorStore2 = node({
type: '@n8n/n8n-nodes-langchain.vectorStoreInMemory',
version: 1,
config: {
name: 'Vector Store 2',
parameters: {},
subnodes: { embedding: sharedEmbedding }
}
});
export default workflow('shared-embedding-test', 'Shared Embedding Test')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: { name: 'Start' } })
.to([vectorStore1, vectorStore2]));
`;
const parsedJson = parseWorkflowCode(code);
// Should have 4 nodes: trigger, 2 vector stores, 1 shared embedding
expect(parsedJson.nodes).toHaveLength(4);
// Only ONE embedding node should exist
const embeddingNodes = parsedJson.nodes.filter(
(n) => n.type === '@n8n/n8n-nodes-langchain.embeddingsOpenAi',
);
expect(embeddingNodes).toHaveLength(1);
expect(embeddingNodes[0].name).toBe('Shared Embeddings');
// The shared embedding should have ai_embedding connections to BOTH vector stores
const embeddingConnections = parsedJson.connections['Shared Embeddings']?.ai_embedding?.[0];
expect(embeddingConnections).toBeDefined();
expect(embeddingConnections).toHaveLength(2);
const targetStores = embeddingConnections!.map((c) => c.node).sort();
expect(targetStores).toEqual(['Vector Store 1', 'Vector Store 2']);
});
it('should handle same-named agents sharing a language model (auto-rename scenario)', () => {
const code = `
const sharedModel = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'OpenAI Chat Model',
parameters: { model: { mode: 'list', value: 'gpt-4o' } }
}
});
const agent1 = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'Generate Story Script',
parameters: { text: 'Write act 1' },
subnodes: { model: sharedModel }
}
});
const agent2 = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'Generate Story Script',
parameters: { text: 'Write act 2' },
subnodes: { model: sharedModel }
}
});
export default workflow('same-name-agents', 'Same Name Agents')
.add(trigger({ type: 'n8n-nodes-base.manualTrigger', version: 1, config: { name: 'Start' } })
.to([agent1, agent2]));
`;
const parsedJson = parseWorkflowCode(code);
// Should have 4 nodes: trigger, 2 agents (one renamed), 1 shared model
expect(parsedJson.nodes).toHaveLength(4);
// Two agent nodes should exist, one auto-renamed
const agentNodes = parsedJson.nodes.filter(
(n) => n.type === '@n8n/n8n-nodes-langchain.agent',
);
expect(agentNodes).toHaveLength(2);
const agentNames = agentNodes.map((n) => n.name).sort();
expect(agentNames).toEqual(['Generate Story Script', 'Generate Story Script 1']);
// Only ONE language model node
const modelNodes = parsedJson.nodes.filter(
(n) => n.type === '@n8n/n8n-nodes-langchain.lmChatOpenAi',
);
expect(modelNodes).toHaveLength(1);
// The shared model should connect to BOTH agents (using their actual map keys)
const modelConnections = parsedJson.connections['OpenAI Chat Model']?.ai_languageModel?.[0];
expect(modelConnections).toBeDefined();
expect(modelConnections).toHaveLength(2);
const targetAgents = modelConnections!.map((c) => c.node).sort();
expect(targetAgents).toEqual(['Generate Story Script', 'Generate Story Script 1']);
});
});
});
describe('Codegen Roundtrip with Real Workflows', () => {
// Extract fixtures from the committed zip if needed (runs once before all
// tests in this file). Unpacking ~2000 workflow files is IO-bound and can
// take well over the default 10s hook timeout on a contended CI runner, so
// give it a generous budget to avoid flaky "Hook timed out" failures.
beforeAll(() => {
try {
ensureFixtures();
} catch (error) {
if (error instanceof FixtureDownloadError) {
throw new Error(
`Failed to prepare test fixtures: ${error.message}. ` +
'Ensure public_published_templates.zip is committed to test-fixtures/real-workflows/.',
);
}
throw error;
}
}, 60_000);
if (workflows.length === 0) {
it('should have fixtures available (run tests again after download)', () => {
// This test only runs if no workflows were loaded at parse time
// After beforeAll downloads them, re-running tests should work
expect(workflows.length).toBeGreaterThan(0);
});
} else {
describe('generateWorkflowCode -> parseWorkflowCode roundtrip', () => {
// Helper to check if an object looks like a resource locator
const isResourceLocatorLike = (obj: unknown): obj is Record<string, unknown> => {
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return false;
const record = obj as Record<string, unknown>;
return 'mode' in record && 'value' in record;
};
// Helper to recursively add __rl: true to resource locator values
// and clear placeholder values in list mode (matches builder normalization)
const normalizeResourceLocators = (params: unknown): unknown => {
if (typeof params !== 'object' || params === null) return params;
if (Array.isArray(params)) return params.map(normalizeResourceLocators);
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
if (isResourceLocatorLike(value)) {
const rlValue = value;
const normalized = normalizeResourceLocators(value) as Record<string, unknown>;
// Clear placeholder values in list mode (matches builder behavior)
if (rlValue.mode === 'list' && isPlaceholderValue(rlValue.value)) {
result[key] = { __rl: true, ...normalized, value: '' };
} else {
result[key] = { __rl: true, ...normalized };
}
} else if (typeof value === 'object' && value !== null) {
result[key] = normalizeResourceLocators(value);
} else {
result[key] = value;
}
}
return result;
};
// Helper function to normalize expressions (strip leading = from ==)
const normalizeExpressions = (value: unknown): unknown => {
if (typeof value === 'string') {
// Strip leading = from double-equals expressions (e.g., "=={{ $json.x }}" -> "={{ $json.x }}")
return value.startsWith('==') ? value.slice(1) : value;
}
if (Array.isArray(value)) {
return value.map(normalizeExpressions);
}
if (value && typeof value === 'object') {
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
result[k] = normalizeExpressions(v);
}
return result;
}
return value;
};
// Helper function for normalizing parameters
const normalizeParams = (p: unknown, nodeType: string) => {
if (!p || typeof p !== 'object') return p;
const obj = p as Record<string, unknown>;
if (Object.keys(obj).length === 0) return undefined;
if (nodeType === 'n8n-nodes-base.stickyNote') {
// Strip empty content and non-serializable color values (null, empty object)
const cleaned = { ...obj };
if (cleaned.content === '') delete cleaned.content;
if (cleaned.color === null || cleaned.color === undefined) {
delete cleaned.color;
} else if (
typeof cleaned.color === 'object' &&
Object.keys(cleaned.color as Record<string, unknown>).length === 0
) {
delete cleaned.color;
}
return Object.keys(cleaned).length === 0 ? undefined : cleaned;
}
// Normalize resource locators (add __rl: true) for fair comparison
// since SDK-generated code adds __rl: true to all resource locators
// Also normalize expressions (strip leading = from double-equals)
// Also normalize newlines in expression strings (raw \n → \\n inside quotes)
// since the serializer legitimately escapes raw newlines in JS string literals
return escapeNewlinesInExpressionStrings(
normalizeExpressions(normalizeResourceLocators(p)),
);
};
// Helper function for filtering empty connections, orphaned connections (from non-existent nodes),
// and dangling connections (to non-existent target nodes)
const filterEmptyConnections = (
conns: Record<string, unknown>,
validNodeNames?: Set<string>,
) => {
const result: Record<string, unknown> = {};
for (const [nodeName, nodeConns] of Object.entries(conns)) {
// Skip connections from non-existent nodes (orphaned connections in original workflow)
if (validNodeNames && !validNodeNames.has(nodeName)) {
continue;
}
const nonEmptyTypes: Record<string, unknown> = {};
for (const [connType, outputs] of Object.entries(
nodeConns as Record<string, unknown[]>,
)) {
// Filter each output slot to remove connections to non-existent target nodes
const filteredOutputs = (outputs || []).map((slot: unknown) => {
// Normalize null/undefined slots to empty arrays
if (slot === null || slot === undefined) return [] as unknown[];
if (!Array.isArray(slot)) return slot as unknown[];
// Filter out connections to non-existent nodes
if (validNodeNames) {
return slot.filter(
(conn: { node?: string }) => conn.node && validNodeNames.has(conn.node),
) as unknown[];
}
return slot as unknown[];
});
// Strip trailing empty arrays (semantically equivalent to absent)
while (filteredOutputs.length > 0) {
const lastSlot = filteredOutputs[filteredOutputs.length - 1];
if (Array.isArray(lastSlot) && lastSlot.length === 0) {
filteredOutputs.pop();
} else {
break;
}
}
// Only include non-empty output slots
const nonEmptyOutputs = filteredOutputs.filter(
(arr: unknown) => Array.isArray(arr) && arr.length > 0,
);
if (nonEmptyOutputs.length > 0) {
nonEmptyTypes[connType] = filteredOutputs;
}
}
if (Object.keys(nonEmptyTypes).length > 0) {
result[nodeName] = nonEmptyTypes;
}
}
return result;
};
const sortSlotConnections = (conns: Record<string, unknown>) => {
for (const nodeConns of Object.values(conns)) {
for (const outputs of Object.values(nodeConns as Record<string, unknown[][]>)) {
if (!Array.isArray(outputs)) continue;
for (const slot of outputs) {
if (Array.isArray(slot) && slot.length > 1) {
slot.sort((a: unknown, b: unknown) => {
const aNode = (a as { node?: string }).node ?? '';
const bNode = (b as { node?: string }).node ?? '';
const cmp = aNode.localeCompare(bNode);
if (cmp !== 0) return cmp;
const aIndex = (a as { index?: number }).index ?? 0;
const bIndex = (b as { index?: number }).index ?? 0;
return aIndex - bIndex;
});
}
}
}
}
};
// Helper to normalize warnings for comparison
const normalizeWarning = (w: ExpectedWarning): string => `${w.code}:${w.nodeName ?? ''}`;
// Generate individual test for each workflow
workflows.forEach(({ id, name, json, nodeCount, expectedWarnings }) => {
it(`should roundtrip workflow ${id}: "${name}" (${nodeCount} nodes)`, () => {
// Generate TypeScript code
const code = generateWorkflowCode(json);
// Parse back to builder and JSON
const builder = parseWorkflowCodeToBuilder(code);
const parsedJson: WorkflowJSON = builder.toJSON();
// Validate the parsed workflow
const validationResult = builder.validate();
// Get actual warnings
const actualWarnings: ExpectedWarning[] = validationResult.warnings
.map((w: { code: string; nodeName?: string }) => ({
code: w.code,
nodeName: w.nodeName,
}))
.sort((a: ExpectedWarning, b: ExpectedWarning) =>
normalizeWarning(a).localeCompare(normalizeWarning(b)),
);
// Get expected warnings (from manifest or empty array)
const expected = (expectedWarnings ?? []).sort((a: ExpectedWarning, b: ExpectedWarning) =>
normalizeWarning(a).localeCompare(normalizeWarning(b)),
);
// Compare warnings
expect(actualWarnings).toEqual(expected);
// Verify basic structure
expect(parsedJson.id ?? '').toBe(json.id ?? '');
expect(parsedJson.name ?? '').toBe(json.name ?? '');
// Verify node count
expect(parsedJson.nodes).toHaveLength(json.nodes.length);
// Verify all nodes are present by name
for (const originalNode of json.nodes) {
if (originalNode.name === undefined) continue;
const parsedNode = parsedJson.nodes.find((n) => n.name === originalNode.name);
expect(parsedNode).toBeDefined();
if (parsedNode) {
expect(parsedNode.type).toBe(originalNode.type);
// typeVersion: undefined is semantically equivalent to 1
// Compare as numbers since string typeVersions are normalized to numbers
expect(parsedNode.typeVersion).toBe(Number(originalNode.typeVersion ?? 1));
expect(normalizeParams(parsedNode.parameters, parsedNode.type)).toEqual(
normalizeParams(originalNode.parameters, originalNode.type),
);
if (originalNode.credentials && Object.keys(originalNode.credentials).length > 0) {
expect(parsedNode.credentials).toEqual(originalNode.credentials);
}
}
}
// Verify settings
if (json.settings && Object.keys(json.settings).length > 0) {
expect(parsedJson.settings).toEqual(json.settings);
}
// Filter connections from non-existent nodes (orphaned connections in original workflow)
const validNodeNames = new Set(
json.nodes.map((n) => n.name).filter((name): name is string => !!name),
);
// Normalize original connections (clone first to avoid mutating input)
// since the original JSON may have flat tuple connections. Also fold
// any legacy top-level `error` connections into main[last] — the SDK
// now always emits the modern shape, so the original must be aligned
// before comparison. Old templates are kept untouched; the test does
// the old/new translation so symmetry holds against the serializer's
// matching fold.
const normalizedOriginalConns = deepCopy(json.connections);
normalizeConnections(normalizedOriginalConns);
foldLegacyErrorConnections(normalizedOriginalConns, json.nodes);
const filteredOriginal = filterEmptyConnections(normalizedOriginalConns, validNodeNames);
const filteredParsed = filterEmptyConnections(parsedJson.connections);
// Verify all connection source keys match
expect(Object.keys(filteredParsed).sort()).toEqual(Object.keys(filteredOriginal).sort());
// Deep connection comparison
sortSlotConnections(filteredOriginal);
sortSlotConnections(filteredParsed);
for (const nodeName of Object.keys(filteredOriginal)) {
expect(filteredParsed[nodeName]).toEqual(filteredOriginal[nodeName]);
}
});
});
});
}
});
describe('Committed workflows — builder validator errors', () => {
const normalizeError = (e: ExpectedError): string => `${e.code}:${e.nodeName ?? ''}`;
const workflowsWithExpectedBuilderErrors = workflows.filter(
(w) => w.expectedBuilderErrors && w.expectedBuilderErrors.length > 0,
);
if (workflowsWithExpectedBuilderErrors.length === 0) {
it('has at least one fixture with expectedBuilderErrors declared', () => {
expect(workflowsWithExpectedBuilderErrors.length).toBeGreaterThan(0);
});
} else {
workflowsWithExpectedBuilderErrors.forEach(({ id, name, json, expectedBuilderErrors }) => {
it(`emits expected builder validation errors for workflow ${id}: "${name}"`, () => {
const code = generateWorkflowCode(json);
const builder = parseWorkflowCodeToBuilder(code);
const result = builder.validate({ allowDisconnectedNodes: true });
const actualErrors: ExpectedError[] = result.errors
.map((e) => ({ code: e.code, nodeName: e.nodeName }))
.sort((a, b) => normalizeError(a).localeCompare(normalizeError(b)));
const expected = (expectedBuilderErrors ?? [])
.slice()
.sort((a, b) => normalizeError(a).localeCompare(normalizeError(b)));
expect(actualErrors).toEqual(expected);
expect(result.valid).toBe(false);
});
});
}
});
describe('Committed workflows — schema validation errors', () => {
// Mirror the relevant builderHint.inputs / builderHint.outputs declarations from the
// real node types so validateWorkflow can resolve required AI inputs and emit
// output-mode warnings without pulling in the full nodes-langchain dependency tree.
const mockNodeTypesProvider = {
getByNameAndVersion: (type: string, _version?: number) => {
if (type === '@n8n/n8n-nodes-langchain.chatTrigger') {
return {
description: {
inputs: ['main'],
builderHint: {
inputs: {
ai_memory: {
required: true,
displayOptions: {
show: {
mode: ['hostedChat', 'webhook'],
'options.loadPreviousSession': ['memory'],
},
},
},
},
},
},
};
}
if (type === '@n8n/n8n-nodes-langchain.agent') {
return {
description: {
inputs: ['main'],
builderHint: {
inputs: {
ai_languageModel: { required: true },
ai_memory: { required: false },
ai_tool: { required: false },
},
},
},
};
}
if (type === '@n8n/n8n-nodes-langchain.vectorStoreInMemory') {
return {
description: {
inputs: ['main'],
builderHint: {
inputs: { ai_embedding: { required: true } },
outputs: {
main: { displayOptions: { show: { mode: ['insert', 'load', 'update'] } } },
ai_vectorStore: {
required: true,
displayOptions: { show: { mode: ['retrieve'] } },
},
ai_tool: {
required: true,
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
},
},
},
},
};
}
return { description: { inputs: ['main'] } };
},
getByName: (type: string) => mockNodeTypesProvider.getByNameAndVersion(type),
getKnownTypes: () => ({}),
};
const normalizeError = (e: ExpectedError): string => `${e.code}:${e.nodeName ?? ''}`;
const workflowsWithExpectedErrors = workflows.filter(
(w) => w.expectedErrors && w.expectedErrors.length > 0,
);
if (workflowsWithExpectedErrors.length === 0) {
it('has at least one fixture with expectedErrors declared', () => {
expect(workflowsWithExpectedErrors.length).toBeGreaterThan(0);
});
} else {
workflowsWithExpectedErrors.forEach(({ id, name, json, expectedErrors }) => {
it(`emits expected validation errors for workflow ${id}: "${name}"`, () => {
const expectedCodes = new Set((expectedErrors ?? []).map((e) => e.code));
const result = validateWorkflow(json, {
nodeTypesProvider: mockNodeTypesProvider as never,
// Disconnected-node warnings are unrelated to the AI-input checks
// these fixtures are designed to exercise.
allowDisconnectedNodes: true,
});
const actualErrors: ExpectedError[] = result.errors
.filter((e) => expectedCodes.has(e.code))
.map((e) => ({ code: e.code, nodeName: e.nodeName }))
.sort((a, b) => normalizeError(a).localeCompare(normalizeError(b)));
const expected = (expectedErrors ?? [])
.slice()
.sort((a, b) => normalizeError(a).localeCompare(normalizeError(b)));
expect(actualErrors).toEqual(expected);
expect(result.valid).toBe(false);
});
});
}
const normalizeWarning = (w: ExpectedWarning): string => `${w.code}:${w.nodeName ?? ''}`;
const workflowsWithExpectedValidationWarnings = workflows.filter(
(w) => w.expectedValidationWarnings && w.expectedValidationWarnings.length > 0,
);
if (workflowsWithExpectedValidationWarnings.length === 0) {
it('has at least one fixture with expectedValidationWarnings declared', () => {
expect(workflowsWithExpectedValidationWarnings.length).toBeGreaterThan(0);
});
} else {
workflowsWithExpectedValidationWarnings.forEach(
({ id, name, json, expectedValidationWarnings }) => {
it(`emits expected validation warnings for workflow ${id}: "${name}"`, () => {
const expectedCodes = new Set((expectedValidationWarnings ?? []).map((w) => w.code));
const result = validateWorkflow(json, {
nodeTypesProvider: mockNodeTypesProvider as never,
allowDisconnectedNodes: true,
});
const actualWarnings: ExpectedWarning[] = result.warnings
.filter((w) => expectedCodes.has(w.code))
.map((w) => ({ code: w.code, nodeName: w.nodeName }))
.sort((a, b) => normalizeWarning(a).localeCompare(normalizeWarning(b)));
const expected = (expectedValidationWarnings ?? [])
.slice()
.sort((a, b) => normalizeWarning(a).localeCompare(normalizeWarning(b)));
expect(actualWarnings).toEqual(expected);
});
},
);
}
});