n8n/packages/@n8n/workflow-sdk/src/graph-validator.test.ts

1667 lines
40 KiB
TypeScript

import { workflow, trigger, node, sticky, languageModel, tool, embedding } from './index';
import type { WorkflowJSON } from './index';
describe('graph validation', () => {
describe('checkNoNodes', () => {
test('returns error when workflow has no nodes', () => {
const wf = workflow('test-id', 'Empty Workflow');
const result = wf.validate();
expect(result.valid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
code: 'NO_NODES',
message: expect.stringContaining('no nodes') as unknown as string,
}),
);
});
test('returns no error when workflow has nodes', () => {
const wf = workflow('test-id', 'Test Workflow').add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
);
const result = wf.validate();
const noNodesError = result.errors.find((e) => e.code === 'NO_NODES');
expect(noNodesError).toBeUndefined();
});
});
describe('checkTrigger', () => {
test('returns warning when workflow has no trigger', () => {
const wf = workflow('test-id', 'No Trigger Workflow').add(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP Request' },
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({
code: 'MISSING_TRIGGER',
message: expect.stringContaining('trigger') as unknown as string,
}),
);
});
test('returns no warning when workflow has a trigger', () => {
const wf = workflow('test-id', 'Test Workflow').add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
);
const result = wf.validate();
const triggerWarning = result.warnings.find((w) => w.code === 'MISSING_TRIGGER');
expect(triggerWarning).toBeUndefined();
});
test('respects allowNoTrigger option', () => {
const wf = workflow('test-id', 'No Trigger Workflow').add(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP Request' },
}),
);
const result = wf.validate({ allowNoTrigger: true });
const triggerWarning = result.warnings.find((w) => w.code === 'MISSING_TRIGGER');
expect(triggerWarning).toBeUndefined();
});
});
describe('checkAgent', () => {
test('returns warning when agent has static prompt (no expression)', () => {
const wf = workflow('test-id', 'Agent Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
parameters: {
promptType: 'define',
text: 'Hello, how can I help you?', // Static text, no expression
},
},
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({
code: 'AGENT_STATIC_PROMPT',
}),
);
});
test('returns no warning when agent prompt contains expression', () => {
const wf = workflow('test-id', 'Agent Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
parameters: {
promptType: 'define',
text: '={{ $json.chatInput }}', // Has expression
},
},
}),
);
const result = wf.validate();
const staticPromptWarning = result.warnings.find((w) => w.code === 'AGENT_STATIC_PROMPT');
expect(staticPromptWarning).toBeUndefined();
});
test('returns warning when agent has no system message', () => {
const wf = workflow('test-id', 'Agent Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
parameters: {
promptType: 'define',
text: '={{ $json.chatInput }}',
// No options.systemMessage
},
},
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({
code: 'AGENT_NO_SYSTEM_MESSAGE',
}),
);
});
test('returns no warning when agent has system message', () => {
const wf = workflow('test-id', 'Agent Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
parameters: {
promptType: 'define',
text: '={{ $json.chatInput }}',
options: {
systemMessage: 'You are a helpful assistant.',
},
},
},
}),
);
const result = wf.validate();
const noSystemMsgWarning = result.warnings.find((w) => w.code === 'AGENT_NO_SYSTEM_MESSAGE');
expect(noSystemMsgWarning).toBeUndefined();
});
test('skips agent checks when promptType is auto', () => {
const wf = workflow('test-id', 'Agent Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
parameters: {
promptType: 'auto', // Auto mode - should not check text/systemMessage
},
},
}),
);
const result = wf.validate();
const staticPromptWarning = result.warnings.find((w) => w.code === 'AGENT_STATIC_PROMPT');
const noSystemMsgWarning = result.warnings.find((w) => w.code === 'AGENT_NO_SYSTEM_MESSAGE');
expect(staticPromptWarning).toBeUndefined();
expect(noSystemMsgWarning).toBeUndefined();
});
});
describe('checkHttpRequest', () => {
test('returns warning when Authorization header has hardcoded value', () => {
const wf = workflow('test-id', 'HTTP Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: {
name: 'HTTP Request',
parameters: {
url: 'https://api.example.com',
sendHeaders: true,
headerParameters: {
parameters: [
{
name: 'Authorization',
value: 'Bearer sk-1234567890', // Hardcoded!
},
],
},
},
},
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({
code: 'HARDCODED_CREDENTIALS',
}),
);
});
test('returns no warning when Authorization header uses expression', () => {
const wf = workflow('test-id', 'HTTP Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: {
name: 'HTTP Request',
parameters: {
url: 'https://api.example.com',
sendHeaders: true,
headerParameters: {
parameters: [
{
name: 'Authorization',
value: '={{ $env.API_TOKEN }}', // Expression - OK
},
],
},
},
},
}),
);
const result = wf.validate();
const credWarning = result.warnings.find((w) => w.code === 'HARDCODED_CREDENTIALS');
expect(credWarning).toBeUndefined();
});
test('returns warning when api_key query param has hardcoded value', () => {
const wf = workflow('test-id', 'HTTP Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: {
name: 'HTTP Request',
parameters: {
url: 'https://api.example.com',
sendQuery: true,
queryParameters: {
parameters: [
{
name: 'api_key',
value: 'secret123', // Hardcoded!
},
],
},
},
},
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({
code: 'HARDCODED_CREDENTIALS',
}),
);
});
});
describe('checkSetNode', () => {
test('returns warning when Set node has field with credential-like name', () => {
const wf = workflow('test-id', 'Set Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
assignments: {
assignments: [
{
name: 'api_key', // Credential-like name
value: 'secret123',
type: 'string',
},
],
},
},
},
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({
code: 'SET_CREDENTIAL_FIELD',
}),
);
});
test('returns no warning when Set node has normal field names', () => {
const wf = workflow('test-id', 'Set Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
assignments: {
assignments: [
{
name: 'userId', // Normal name
value: '123',
type: 'string',
},
],
},
},
},
}),
);
const result = wf.validate();
const setWarning = result.warnings.find((w) => w.code === 'SET_CREDENTIAL_FIELD');
expect(setWarning).toBeUndefined();
});
});
describe('checkMergeNode', () => {
test('returns warning when Merge node has only one input', () => {
const mergeNode = node({
type: 'n8n-nodes-base.merge',
version: 3,
config: { name: 'Merge' },
});
const wf = workflow('test-id', 'Merge Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(mergeNode); // Only one input connected
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({
code: 'MERGE_SINGLE_INPUT',
}),
);
});
test('returns no warning when Merge node has multiple inputs', () => {
const startTrigger = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start', position: [0, 0] },
});
const branch1 = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Branch 1', position: [200, 0] },
});
const branch2 = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Branch 2', position: [200, 200] },
});
const mergeNode = node({
type: 'n8n-nodes-base.merge',
version: 3,
config: { name: 'Merge', position: [400, 100] },
});
// Build workflow with two branches into merge on different inputs
const wf = workflow('test-id', 'Merge Workflow')
.add(startTrigger.to([branch1, branch2]))
.add(branch1.to(mergeNode.input(0)))
.add(branch2.to(mergeNode.input(1))); // Connect to input 1
const result = wf.validate();
const mergeWarning = result.warnings.find((w) => w.code === 'MERGE_SINGLE_INPUT');
expect(mergeWarning).toBeUndefined();
});
});
describe('checkDisconnected', () => {
test('returns warning when non-trigger node has no incoming connections', () => {
// Create a node that is added but not connected to anything
const wf = workflow('test-id', 'Disconnected Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.add(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'Orphan HTTP Request' },
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({
code: 'DISCONNECTED_NODE',
nodeName: 'Orphan HTTP Request',
}),
);
});
test('returns no warning for trigger nodes without incoming connections', () => {
const wf = workflow('test-id', 'Test Workflow').add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
);
const result = wf.validate();
const disconnectedWarning = result.warnings.find((w) => w.code === 'DISCONNECTED_NODE');
expect(disconnectedWarning).toBeUndefined();
});
test('returns no warning when node has incoming connection via then()', () => {
const wf = workflow('test-id', 'Connected Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'Connected HTTP' },
}),
);
const result = wf.validate();
const disconnectedWarning = result.warnings.find((w) => w.code === 'DISCONNECTED_NODE');
expect(disconnectedWarning).toBeUndefined();
});
test('respects allowDisconnectedNodes option', () => {
const wf = workflow('test-id', 'Disconnected Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.add(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'Orphan HTTP Request' },
}),
);
const result = wf.validate({ allowDisconnectedNodes: true });
const disconnectedWarning = result.warnings.find((w) => w.code === 'DISCONNECTED_NODE');
expect(disconnectedWarning).toBeUndefined();
});
test('does not flag sticky notes as disconnected', () => {
const wf = workflow('test-id', 'Workflow With Sticky Note')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'HTTP Request' },
}),
)
.add(sticky('## Documentation Note', { position: [100, 100] }));
const result = wf.validate();
// Sticky notes should NOT be flagged as disconnected (they never participate in data flow)
const disconnectedWarning = result.warnings.find((w) => w.code === 'DISCONNECTED_NODE');
expect(disconnectedWarning).toBeUndefined();
});
test('does not produce false DISCONNECTED_NODE warnings for connected fromJSON nodes', () => {
const json: WorkflowJSON = {
id: 'test-id',
name: 'Test Workflow',
nodes: [
{
id: 'node-1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'node-2',
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 3,
position: [200, 0],
parameters: {},
},
],
connections: {
Trigger: {
main: [[{ node: 'Set', type: 'main', index: 0 }]],
},
},
};
const wf = workflow.fromJSON(json);
const result = wf.validate();
const disconnectedWarning = result.warnings.find((w) => w.code === 'DISCONNECTED_NODE');
expect(disconnectedWarning).toBeUndefined();
});
test('does not flag subnodes connected to agent as disconnected', () => {
const model = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.2,
config: { parameters: { model: 'gpt-4' } },
});
const codeTool = tool({
type: '@n8n/n8n-nodes-langchain.toolCode',
version: 1.1,
config: { parameters: { code: 'return "hello"' } },
});
const wf = workflow('test-id', 'AI Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 1.7,
config: {
name: 'AI Agent',
parameters: { promptType: 'auto', text: 'Hello' },
subnodes: {
model,
tools: [codeTool],
},
},
}),
);
const result = wf.validate();
// Subnodes should NOT be flagged as disconnected (they connect TO their parent via AI connections)
const disconnectedWarning = result.warnings.find((w) => w.code === 'DISCONNECTED_NODE');
expect(disconnectedWarning).toBeUndefined();
});
});
describe('checkFromAI', () => {
test('returns warning when non-tool node uses $fromAI expression', () => {
const wf = workflow('test-id', 'FromAI Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: {
name: 'HTTP Request',
parameters: {
url: '={{ $fromAI("endpoint") }}', // $fromAI in non-tool node!
},
},
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({
code: 'FROM_AI_IN_NON_TOOL',
nodeName: 'HTTP Request',
}),
);
});
test('returns no warning when tool node uses $fromAI expression', () => {
const wf = workflow('test-id', 'Tool Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
version: 1.1,
config: {
name: 'HTTP Tool',
parameters: {
url: '={{ $fromAI("endpoint") }}', // $fromAI in tool node - OK
},
},
}),
);
const result = wf.validate();
const fromAiWarning = result.warnings.find((w) => w.code === 'FROM_AI_IN_NON_TOOL');
expect(fromAiWarning).toBeUndefined();
});
test('returns no warning when non-tool node uses regular expressions', () => {
const wf = workflow('test-id', 'Regular Expression Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: {
name: 'HTTP Request',
parameters: {
url: '={{ $json.endpoint }}', // Regular expression - OK
},
},
}),
);
const result = wf.validate();
const fromAiWarning = result.warnings.find((w) => w.code === 'FROM_AI_IN_NON_TOOL');
expect(fromAiWarning).toBeUndefined();
});
});
describe('checkChainLlm', () => {
test('returns warning when chainLlm v1.4+ has static text prompt', () => {
const wf = workflow('test-id', 'ChainLlm Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.chainLlm',
version: 1.9,
config: {
name: 'LLM Chain',
parameters: {
promptType: 'define',
text: 'Static text without expression',
},
},
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({ code: 'AGENT_STATIC_PROMPT' }),
);
});
test('returns no warning when chainLlm v1.4+ has expression in text', () => {
const wf = workflow('test-id', 'ChainLlm Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.chainLlm',
version: 1.9,
config: {
name: 'LLM Chain',
parameters: {
promptType: 'define',
text: '={{ $json.input }}',
},
},
}),
);
const result = wf.validate();
const warning = result.warnings.find((w) => w.code === 'AGENT_STATIC_PROMPT');
expect(warning).toBeUndefined();
});
test('returns no warning for chainLlm v1.3 (before promptType existed)', () => {
const wf = workflow('test-id', 'ChainLlm Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.chainLlm',
version: 1.3,
config: {
name: 'LLM Chain',
parameters: {
prompt: 'Old style prompt',
},
},
}),
);
const result = wf.validate();
const warning = result.warnings.find((w) => w.code === 'AGENT_STATIC_PROMPT');
expect(warning).toBeUndefined();
});
});
describe('checkMissingExpressionPrefix', () => {
test('returns warning when parameter has {{ $json }} without = prefix', () => {
const wf = workflow('test-id', 'Test Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
assignments: {
assignments: [
{
name: 'data',
value: 'Input: {{ $json.input }}',
type: 'string',
},
],
},
},
},
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({ code: 'MISSING_EXPRESSION_PREFIX' }),
);
});
test('returns no warning when parameter has ={{ $json }}', () => {
const wf = workflow('test-id', 'Test Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
assignments: {
assignments: [
{
name: 'data',
value: '={{ $json.input }}',
type: 'string',
},
],
},
},
},
}),
);
const result = wf.validate();
const warning = result.warnings.find((w) => w.code === 'MISSING_EXPRESSION_PREFIX');
expect(warning).toBeUndefined();
});
test('returns no warning for {{ someVar }} without n8n variable', () => {
const wf = workflow('test-id', 'Test Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Set',
parameters: {
assignments: {
assignments: [
{
name: 'template',
value: 'Hello {{ name }}',
type: 'string',
},
],
},
},
},
}),
);
const result = wf.validate();
const warning = result.warnings.find((w) => w.code === 'MISSING_EXPRESSION_PREFIX');
expect(warning).toBeUndefined();
});
test('detects issue in nested parameters', () => {
const wf = workflow('test-id', 'Test Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
parameters: {
promptType: 'define',
text: '={{ $json.chatInput }}',
options: {
systemMessage: 'Context: {{ $json.context }}',
},
},
},
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({
code: 'MISSING_EXPRESSION_PREFIX',
message: expect.stringContaining('options.systemMessage') as unknown as string,
}),
);
});
});
describe('warnings for auto-renamed nodes', () => {
test('warning includes both renamed and original name for disconnected node', () => {
// Create two nodes with same name - second gets renamed to "Process 1"
const process1 = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Process' },
});
const process2 = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Process' },
});
const wf = workflow('test-id', 'Test')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(process1)
.add(process2); // Added but not connected - should warn
const result = wf.validate();
// Find the disconnected node warning for the renamed node
const warning = result.warnings.find(
(w) => w.code === 'DISCONNECTED_NODE' && w.nodeName === 'Process 1',
);
expect(warning).toBeDefined();
expect(warning?.message).toContain("'Process 1'");
expect(warning?.message).toContain("originally 'Process'");
expect(warning?.originalName).toBe('Process');
});
test('warning does not include original name when node was not renamed', () => {
const processNode = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Process' },
});
const wf = workflow('test-id', 'Test')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.add(processNode); // Added but not connected
const result = wf.validate();
const warning = result.warnings.find(
(w) => w.code === 'DISCONNECTED_NODE' && w.nodeName === 'Process',
);
expect(warning).toBeDefined();
expect(warning?.message).toContain("'Process'");
expect(warning?.message).not.toContain('originally');
expect(warning?.originalName).toBeUndefined();
});
test('node-specific warnings include original name for renamed nodes', () => {
// Create two Set nodes with credential-like field names
// Using .add() for second node to trigger auto-rename (set2 becomes "Store Data 1")
const set1 = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Store Data',
parameters: {
assignments: {
assignments: [
{ id: 'api-key-assignment', name: 'api_key', value: 'secret123', type: 'string' },
],
},
},
},
});
const set2 = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: {
name: 'Store Data',
parameters: {
assignments: {
assignments: [
{ id: 'password-assignment', name: 'password', value: 'secret456', type: 'string' },
],
},
},
},
});
const wf = workflow('test-id', 'Test')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(set1)
.add(set2); // Use .add() (not .to()) to add as separate node with auto-rename
const result = wf.validate();
// The second node warning should reference both names
const warning = result.warnings.find(
(w) => w.code === 'SET_CREDENTIAL_FIELD' && w.nodeName === 'Store Data 1',
);
expect(warning).toBeDefined();
expect(warning?.message).toContain("'Store Data 1'");
expect(warning?.message).toContain("originally 'Store Data'");
expect(warning?.originalName).toBe('Store Data');
});
});
describe('checkToolNode', () => {
test('returns warning when tool node has no parameters', () => {
const wf = workflow('test-id', 'Tool Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
version: 1.1,
config: {
name: 'HTTP Tool',
// No parameters!
},
}),
);
const result = wf.validate();
expect(result.warnings).toContainEqual(
expect.objectContaining({
code: 'TOOL_NO_PARAMETERS',
}),
);
});
test('returns no warning when tool node has parameters', () => {
const wf = workflow('test-id', 'Tool Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
version: 1.1,
config: {
name: 'HTTP Tool',
parameters: {
url: 'https://api.example.com',
},
},
}),
);
const result = wf.validate();
const toolWarning = result.warnings.find((w) => w.code === 'TOOL_NO_PARAMETERS');
expect(toolWarning).toBeUndefined();
});
test('skips check for known tools that do not need parameters', () => {
const wf = workflow('test-id', 'Tool Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.toolCalculator',
version: 1,
config: {
name: 'Calculator',
// No parameters needed for calculator
},
}),
);
const result = wf.validate();
const toolWarning = result.warnings.find((w) => w.code === 'TOOL_NO_PARAMETERS');
expect(toolWarning).toBeUndefined();
});
});
describe('checkSubnodeConnection', () => {
test('returns error when embedding node is used as regular workflow node', () => {
const wf = workflow('test-id', 'Invalid Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.embeddingsGoogleGemini',
version: 1,
config: {
name: 'Bad Embeddings',
parameters: {
modelName: 'models/text-embedding-004',
},
},
}),
);
const result = wf.validate();
expect(result.valid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
code: 'SUBNODE_NOT_CONNECTED',
message: expect.stringContaining('as embedding') as unknown as string,
}),
);
});
test('returns error when language model node is used as regular workflow node', () => {
const wf = workflow('test-id', 'Invalid Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1,
config: {
name: 'Bad LLM',
parameters: {
model: 'gpt-4',
},
},
}),
);
const result = wf.validate();
expect(result.valid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
code: 'SUBNODE_NOT_CONNECTED',
message: expect.stringContaining('as model') as unknown as string,
}),
);
});
test('returns no error when embedding is properly connected as subnode', () => {
const wf = workflow('test-id', 'Valid Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.vectorStoreSupabase',
version: 1.1,
config: {
name: 'Vector Store',
parameters: {
mode: 'insert',
},
subnodes: {
embedding: embedding({
type: '@n8n/n8n-nodes-langchain.embeddingsGoogleGemini',
version: 1,
config: {
parameters: {
modelName: 'models/text-embedding-004',
},
},
}),
},
},
}),
);
const result = wf.validate();
const subnodeError = result.errors.find((e) => e.code === 'SUBNODE_NOT_CONNECTED');
expect(subnodeError).toBeUndefined();
});
test('returns no error when language model is properly connected as subnode', () => {
const wf = workflow('test-id', 'Valid Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'Agent',
parameters: {
promptType: 'auto',
},
subnodes: {
model: languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.2,
config: {
parameters: {
model: 'gpt-4',
},
},
}),
},
},
}),
);
const result = wf.validate();
const subnodeError = result.errors.find((e) => e.code === 'SUBNODE_NOT_CONNECTED');
expect(subnodeError).toBeUndefined();
});
test('does not error for vectorStore nodes (can be standalone)', () => {
const wf = workflow('test-id', 'Vector Store Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.vectorStoreSupabase',
version: 1.1,
config: {
name: 'Vector Store',
parameters: {
mode: 'retrieve',
},
},
}),
);
const result = wf.validate();
const subnodeError = result.errors.find((e) => e.code === 'SUBNODE_NOT_CONNECTED');
expect(subnodeError).toBeUndefined();
});
test('does not error for tool nodes (can be standalone)', () => {
const wf = workflow('test-id', 'Tool Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: '@n8n/n8n-nodes-langchain.toolCode',
version: 1,
config: {
name: 'Code Tool',
parameters: {
code: 'return "hello"',
},
},
}),
);
const result = wf.validate();
const subnodeError = result.errors.find((e) => e.code === 'SUBNODE_NOT_CONNECTED');
expect(subnodeError).toBeUndefined();
});
});
describe('checkMaxNodes', () => {
// Mock nodeTypesProvider for maxNodes validation
const createMockNodeTypesProvider = (maxNodesMap: Record<string, number>) => ({
getByNameAndVersion: (type: string, _version: number) => {
const maxNodes = maxNodesMap[type];
if (maxNodes === undefined) return undefined;
return {
description: {
maxNodes,
displayName: type.split('.').pop() ?? type,
},
};
},
});
test('returns no error when node count is within maxNodes limit', () => {
const nodeTypesProvider = createMockNodeTypesProvider({
'n8n-nodes-base.manualTrigger': 1,
});
const wf = workflow('test-id', 'Test Workflow').add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
);
const result = wf.validate({ nodeTypesProvider: nodeTypesProvider as never });
const maxNodesError = result.errors.find((e) => e.code === 'MAX_NODES_EXCEEDED');
expect(maxNodesError).toBeUndefined();
});
test('returns error when workflow exceeds maxNodes limit', () => {
const nodeTypesProvider = createMockNodeTypesProvider({
'n8n-nodes-base.manualTrigger': 1,
});
const wf = workflow('test-id', 'Test Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start 1' },
}),
)
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start 2' },
}),
);
const result = wf.validate({ nodeTypesProvider: nodeTypesProvider as never });
expect(result.valid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
code: 'MAX_NODES_EXCEEDED',
message: expect.stringContaining('2') as unknown as string,
}),
);
});
test('returns error for chat trigger when maxNodes: 1 exceeded', () => {
const nodeTypesProvider = createMockNodeTypesProvider({
'@n8n/n8n-nodes-langchain.chatTrigger': 1,
});
const wf = workflow('test-id', 'Test Workflow')
.add(
trigger({
type: '@n8n/n8n-nodes-langchain.chatTrigger',
version: 1.1,
config: { name: 'Chat 1' },
}),
)
.add(
trigger({
type: '@n8n/n8n-nodes-langchain.chatTrigger',
version: 1.1,
config: { name: 'Chat 2' },
}),
);
const result = wf.validate({ nodeTypesProvider: nodeTypesProvider as never });
expect(result.valid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
code: 'MAX_NODES_EXCEEDED',
}),
);
});
test('allows maxNodes: 2 when exactly 2 nodes exist', () => {
const nodeTypesProvider = createMockNodeTypesProvider({
'n8n-nodes-base.set': 2,
});
const wf = workflow('test-id', 'Test Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Set 1' },
}),
)
.to(
node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Set 2' },
}),
);
const result = wf.validate({ nodeTypesProvider: nodeTypesProvider as never });
const maxNodesError = result.errors.find((e) => e.code === 'MAX_NODES_EXCEEDED');
expect(maxNodesError).toBeUndefined();
});
test('returns error when exceeding maxNodes: 2', () => {
const nodeTypesProvider = createMockNodeTypesProvider({
'n8n-nodes-base.set': 2,
});
const set1 = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Set 1' },
});
const set2 = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Set 2' },
});
const set3 = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Set 3' },
});
const wf = workflow('test-id', 'Test Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start' },
}),
)
.to(set1)
.to(set2)
.to(set3);
const result = wf.validate({ nodeTypesProvider: nodeTypesProvider as never });
expect(result.valid).toBe(false);
expect(result.errors).toContainEqual(
expect.objectContaining({
code: 'MAX_NODES_EXCEEDED',
message: expect.stringContaining('3') as unknown as string,
}),
);
});
test('skips validation when nodeTypesProvider not provided', () => {
const wf = workflow('test-id', 'Test Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start 1' },
}),
)
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Start 2' },
}),
);
// No nodeTypesProvider - validation should be skipped
const result = wf.validate();
// No error because maxNodes validation requires nodeTypesProvider
const maxNodesError = result.errors.find((e) => e.code === 'MAX_NODES_EXCEEDED');
expect(maxNodesError).toBeUndefined();
});
test('allows mixing different node types with maxNodes: 1', () => {
const nodeTypesProvider = createMockNodeTypesProvider({
'n8n-nodes-base.manualTrigger': 1,
'n8n-nodes-base.scheduleTrigger': 1,
});
const wf = workflow('test-id', 'Test Workflow')
.add(
trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1.1,
config: { name: 'Manual Start' },
}),
)
.add(
trigger({
type: 'n8n-nodes-base.scheduleTrigger',
version: 1.2,
config: { name: 'Schedule Start' },
}),
);
const result = wf.validate({ nodeTypesProvider: nodeTypesProvider as never });
const maxNodesError = result.errors.find((e) => e.code === 'MAX_NODES_EXCEEDED');
expect(maxNodesError).toBeUndefined();
});
});
});