mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-02 09:47:00 +02:00
1667 lines
40 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|