mirror of
https://github.com/n8n-io/n8n.git
synced 2026-11-05 17:07:44 +08:00
2956 lines
92 KiB
TypeScript
2956 lines
92 KiB
TypeScript
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);
|
||
});
|
||
},
|
||
);
|
||
}
|
||
});
|