mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 05:23:08 +00:00
Updated test to reflect critical typeVersion validation fix from v2.17.4. ## Issue CI test failing: "should skip node repository lookup for langchain nodes" Expected getNode() NOT to be called for langchain nodes. ## Root Cause Test was written before v2.17.4 when langchain nodes completely bypassed validation. In v2.17.4, we fixed critical bug where langchain nodes with invalid typeVersion (e.g., 99999) passed validation but failed at runtime. ## Fix Updated test to reflect new correct behavior: - Langchain nodes SHOULD call getNode() for typeVersion validation - Prevents invalid typeVersion from bypassing validation - Parameter validation still skipped (handled by AI validators) ## Changes 1. Renamed test to clarify what it tests 2. Changed expectation: getNode() SHOULD be called 3. Check for no typeVersion errors (AI errors may exist) 4. Added new test for invalid typeVersion detection ## Impact - Zero breaking changes (only test update) - Validates v2.17.4 critical bug fix works correctly - Ensures langchain nodes don't bypass typeVersion validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2048 lines
60 KiB
TypeScript
2048 lines
60 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
|
import { WorkflowValidator } from '@/services/workflow-validator';
|
|
import { NodeRepository } from '@/database/node-repository';
|
|
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
|
import { ExpressionValidator } from '@/services/expression-validator';
|
|
import { createWorkflow } from '@tests/utils/builders/workflow.builder';
|
|
import type { WorkflowNode, Workflow } from '@/types/n8n-api';
|
|
|
|
// Mock dependencies
|
|
vi.mock('@/database/node-repository');
|
|
vi.mock('@/services/enhanced-config-validator');
|
|
vi.mock('@/services/expression-validator');
|
|
vi.mock('@/utils/logger');
|
|
|
|
describe('WorkflowValidator - Comprehensive Tests', () => {
|
|
let validator: WorkflowValidator;
|
|
let mockNodeRepository: NodeRepository;
|
|
let mockEnhancedConfigValidator: typeof EnhancedConfigValidator;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
// Create mock instances
|
|
mockNodeRepository = new NodeRepository({} as any) as any;
|
|
mockEnhancedConfigValidator = EnhancedConfigValidator as any;
|
|
|
|
// Ensure the mock repository has all necessary methods
|
|
if (!mockNodeRepository.getAllNodes) {
|
|
mockNodeRepository.getAllNodes = vi.fn();
|
|
}
|
|
if (!mockNodeRepository.getNode) {
|
|
mockNodeRepository.getNode = vi.fn();
|
|
}
|
|
|
|
// Mock common node types data
|
|
const nodeTypes: Record<string, any> = {
|
|
'nodes-base.webhook': {
|
|
type: 'nodes-base.webhook',
|
|
displayName: 'Webhook',
|
|
package: 'n8n-nodes-base',
|
|
version: 2,
|
|
isVersioned: true,
|
|
properties: [],
|
|
category: 'trigger'
|
|
},
|
|
'nodes-base.httpRequest': {
|
|
type: 'nodes-base.httpRequest',
|
|
displayName: 'HTTP Request',
|
|
package: 'n8n-nodes-base',
|
|
version: 4,
|
|
isVersioned: true,
|
|
properties: [],
|
|
category: 'network'
|
|
},
|
|
'nodes-base.set': {
|
|
type: 'nodes-base.set',
|
|
displayName: 'Set',
|
|
package: 'n8n-nodes-base',
|
|
version: 3,
|
|
isVersioned: true,
|
|
properties: [],
|
|
category: 'data'
|
|
},
|
|
'nodes-base.code': {
|
|
type: 'nodes-base.code',
|
|
displayName: 'Code',
|
|
package: 'n8n-nodes-base',
|
|
version: 2,
|
|
isVersioned: true,
|
|
properties: [],
|
|
category: 'code'
|
|
},
|
|
'nodes-base.manualTrigger': {
|
|
type: 'nodes-base.manualTrigger',
|
|
displayName: 'Manual Trigger',
|
|
package: 'n8n-nodes-base',
|
|
version: 1,
|
|
isVersioned: true,
|
|
properties: [],
|
|
category: 'trigger'
|
|
},
|
|
'nodes-base.if': {
|
|
type: 'nodes-base.if',
|
|
displayName: 'IF',
|
|
package: 'n8n-nodes-base',
|
|
version: 2,
|
|
isVersioned: true,
|
|
properties: [],
|
|
category: 'logic'
|
|
},
|
|
'nodes-base.slack': {
|
|
type: 'nodes-base.slack',
|
|
displayName: 'Slack',
|
|
package: 'n8n-nodes-base',
|
|
version: 2,
|
|
isVersioned: true,
|
|
properties: [],
|
|
category: 'communication'
|
|
},
|
|
'nodes-base.googleSheets': {
|
|
type: 'nodes-base.googleSheets',
|
|
displayName: 'Google Sheets',
|
|
package: 'n8n-nodes-base',
|
|
version: 4,
|
|
isVersioned: true,
|
|
properties: [],
|
|
category: 'data'
|
|
},
|
|
'nodes-langchain.agent': {
|
|
type: 'nodes-langchain.agent',
|
|
displayName: 'AI Agent',
|
|
package: '@n8n/n8n-nodes-langchain',
|
|
version: 1,
|
|
isVersioned: true,
|
|
properties: [],
|
|
isAITool: true,
|
|
category: 'ai'
|
|
},
|
|
'nodes-base.postgres': {
|
|
type: 'nodes-base.postgres',
|
|
displayName: 'Postgres',
|
|
package: 'n8n-nodes-base',
|
|
version: 2,
|
|
isVersioned: true,
|
|
properties: [],
|
|
category: 'database'
|
|
},
|
|
'community.customNode': {
|
|
type: 'community.customNode',
|
|
displayName: 'Custom Node',
|
|
package: 'n8n-nodes-custom',
|
|
version: 1,
|
|
isVersioned: false,
|
|
properties: [],
|
|
isAITool: false,
|
|
category: 'custom'
|
|
}
|
|
};
|
|
|
|
// Set up default mock behaviors
|
|
vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => {
|
|
// Handle normalization for custom nodes
|
|
if (nodeType === 'n8n-nodes-custom.customNode') {
|
|
return {
|
|
type: 'n8n-nodes-custom.customNode',
|
|
displayName: 'Custom Node',
|
|
package: 'n8n-nodes-custom',
|
|
version: 1,
|
|
isVersioned: false,
|
|
properties: [],
|
|
isAITool: false
|
|
};
|
|
}
|
|
|
|
return nodeTypes[nodeType] || null;
|
|
});
|
|
|
|
// Mock getAllNodes for NodeSimilarityService
|
|
vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes));
|
|
|
|
vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({
|
|
errors: [],
|
|
warnings: [],
|
|
suggestions: [],
|
|
mode: 'operation' as const,
|
|
valid: true,
|
|
visibleProperties: [],
|
|
hiddenProperties: []
|
|
} as any);
|
|
|
|
vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({
|
|
valid: true,
|
|
errors: [],
|
|
warnings: [],
|
|
usedVariables: new Set(),
|
|
usedNodes: new Set()
|
|
});
|
|
|
|
// Create validator instance
|
|
validator = new WorkflowValidator(mockNodeRepository, mockEnhancedConfigValidator);
|
|
});
|
|
|
|
describe('validateWorkflow', () => {
|
|
it('should validate a minimal valid workflow', async () => {
|
|
const workflow = createWorkflow('Test Workflow')
|
|
.addWebhookNode({ name: 'Webhook' })
|
|
.build();
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors).toHaveLength(0);
|
|
expect(result.statistics.totalNodes).toBe(1);
|
|
expect(result.statistics.enabledNodes).toBe(1);
|
|
expect(result.statistics.triggerNodes).toBe(1);
|
|
});
|
|
|
|
it('should validate a workflow with all options disabled', async () => {
|
|
const workflow = createWorkflow('Test Workflow')
|
|
.addWebhookNode({ name: 'Webhook' })
|
|
.build();
|
|
|
|
const result = await validator.validateWorkflow(workflow as any, {
|
|
validateNodes: false,
|
|
validateConnections: false,
|
|
validateExpressions: false
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(mockNodeRepository.getNode).not.toHaveBeenCalled();
|
|
expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle validation errors gracefully', async () => {
|
|
const workflow = createWorkflow('Test Workflow')
|
|
.addWebhookNode({ name: 'Webhook' })
|
|
.build();
|
|
|
|
// Make the validation throw an error
|
|
vi.mocked(mockNodeRepository.getNode).mockImplementation(() => {
|
|
throw new Error('Database error');
|
|
});
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
expect(result.errors.some(e => e.message.includes('Database error'))).toBe(true);
|
|
});
|
|
|
|
it('should use different validation profiles', async () => {
|
|
const workflow = createWorkflow('Test Workflow')
|
|
.addWebhookNode({ name: 'Webhook' })
|
|
.build();
|
|
|
|
const profiles = ['minimal', 'runtime', 'ai-friendly', 'strict'] as const;
|
|
|
|
for (const profile of profiles) {
|
|
const result = await validator.validateWorkflow(workflow as any, { profile });
|
|
expect(result).toBeDefined();
|
|
expect(mockEnhancedConfigValidator.validateWithMode).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.any(Object),
|
|
expect.any(Array),
|
|
'operation',
|
|
profile
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('validateWorkflowStructure', () => {
|
|
it('should error when nodes array is missing', async () => {
|
|
const workflow = { connections: {} } as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some(e => e.message === 'Workflow must have a nodes array')).toBe(true);
|
|
});
|
|
|
|
it('should error when connections object is missing', async () => {
|
|
const workflow = { nodes: [] } as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some(e => e.message === 'Workflow must have a connections object')).toBe(true);
|
|
});
|
|
|
|
it('should warn when workflow has no nodes', async () => {
|
|
const workflow = { nodes: [], connections: {} } as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.valid).toBe(true); // Empty workflows are valid but get a warning
|
|
expect(result.warnings).toHaveLength(1);
|
|
expect(result.warnings[0].message).toBe('Workflow is empty - no nodes defined');
|
|
});
|
|
|
|
it('should error for single non-webhook node workflow', async () => {
|
|
const workflow = {
|
|
nodes: [{
|
|
id: '1',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some(e => e.message.includes('Single-node workflows are only valid for webhook endpoints'))).toBe(true);
|
|
});
|
|
|
|
it('should warn for webhook without connections', async () => {
|
|
const workflow = {
|
|
nodes: [{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
typeVersion: 2
|
|
}],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.warnings.some(w => w.message.includes('Webhook node has no connections'))).toBe(true);
|
|
});
|
|
|
|
it('should error for multi-node workflow without connections', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true);
|
|
});
|
|
|
|
it('should detect duplicate node names', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Duplicate node name: "Webhook"'))).toBe(true);
|
|
});
|
|
|
|
it('should detect duplicate node IDs', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook1',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '1',
|
|
name: 'Webhook2',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Duplicate node ID: "1"'))).toBe(true);
|
|
});
|
|
|
|
it('should count trigger nodes correctly', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Schedule',
|
|
type: 'n8n-nodes-base.scheduleTrigger',
|
|
position: [100, 300],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '3',
|
|
name: 'Manual',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
position: [100, 500],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.statistics.triggerNodes).toBe(3);
|
|
});
|
|
|
|
it('should warn when no trigger nodes exist', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Code',
|
|
type: 'n8n-nodes-base.code',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'Set': {
|
|
main: [[{ node: 'Code', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('Workflow has no trigger nodes'))).toBe(true);
|
|
});
|
|
|
|
it('should not count disabled nodes in enabledNodes count', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
disabled: true
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.statistics.totalNodes).toBe(2);
|
|
expect(result.statistics.enabledNodes).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('validateAllNodes', () => {
|
|
it('should skip disabled nodes', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
disabled: true
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(mockNodeRepository.getNode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should accept both nodes-base and n8n-nodes-base prefixes as valid', async () => {
|
|
// This test verifies the fix for false positives - both prefixes are valid
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'nodes-base.webhook', // This is now valid (normalized internally)
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
// Mock the normalized node lookup
|
|
(mockNodeRepository.getNode as any) = vi.fn((type: string) => {
|
|
if (type === 'nodes-base.webhook') {
|
|
return {
|
|
nodeType: 'nodes-base.webhook',
|
|
displayName: 'Webhook',
|
|
properties: [],
|
|
isVersioned: false
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
// Should NOT error for nodes-base prefix - it's valid!
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors.some(e => e.message.includes('Invalid node type'))).toBe(false);
|
|
});
|
|
|
|
it.skip('should handle unknown node types with suggestions', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'httpRequest', // Missing package prefix
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some(e => e.message.includes('Unknown node type: "httpRequest"'))).toBe(true);
|
|
expect(result.errors.some(e => e.message.includes('Did you mean "n8n-nodes-base.httpRequest"?'))).toBe(true);
|
|
});
|
|
|
|
it('should try normalized types for n8n-nodes-base', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook');
|
|
});
|
|
|
|
it('should validate typeVersion but skip parameter validation for langchain nodes', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Agent',
|
|
type: '@n8n/n8n-nodes-langchain.agent',
|
|
typeVersion: 1,
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
// After v2.17.4 fix: Langchain nodes SHOULD call getNode for typeVersion validation
|
|
// This prevents invalid typeVersion values from bypassing validation
|
|
// But they skip parameter validation (handled by dedicated AI validators)
|
|
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent');
|
|
|
|
// Should not have typeVersion validation errors (other AI-specific errors may exist)
|
|
const typeVersionErrors = result.errors.filter(e => e.message.includes('typeVersion'));
|
|
expect(typeVersionErrors).toEqual([]);
|
|
});
|
|
|
|
it('should catch invalid typeVersion for langchain nodes (v2.17.4 bug fix)', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Agent',
|
|
type: '@n8n/n8n-nodes-langchain.agent',
|
|
typeVersion: 99999, // Invalid - exceeds maximum
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
// Critical: Before v2.17.4, this would pass validation but fail at runtime
|
|
// After v2.17.4: Invalid typeVersion is caught during validation
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some(e =>
|
|
e.message.includes('typeVersion 99999 exceeds maximum')
|
|
)).toBe(true);
|
|
});
|
|
|
|
it('should validate typeVersion for versioned nodes', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
// Missing typeVersion
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true);
|
|
});
|
|
|
|
it('should error for invalid typeVersion', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
typeVersion: 'invalid' as any
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Invalid typeVersion: invalid'))).toBe(true);
|
|
});
|
|
|
|
it('should warn for outdated typeVersion', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
typeVersion: 1 // Current version is 2
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('Outdated typeVersion: 1. Latest is 2'))).toBe(true);
|
|
});
|
|
|
|
it('should error for typeVersion exceeding maximum', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
typeVersion: 10 // Max is 2
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('typeVersion 10 exceeds maximum supported version 2'))).toBe(true);
|
|
});
|
|
|
|
it('should add node validation errors and warnings', async () => {
|
|
vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({
|
|
errors: [{ type: 'missing_required', property: 'url', message: 'Missing required field: url' }],
|
|
warnings: [{ type: 'security', property: 'url', message: 'Consider using HTTPS' }],
|
|
suggestions: [],
|
|
mode: 'operation' as const,
|
|
valid: false,
|
|
visibleProperties: [],
|
|
hiddenProperties: []
|
|
} as any);
|
|
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
typeVersion: 4
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Missing required field: url'))).toBe(true);
|
|
expect(result.warnings.some(w => w.message.includes('Consider using HTTPS'))).toBe(true);
|
|
});
|
|
|
|
it('should handle node validation failures gracefully', async () => {
|
|
vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockImplementation(() => {
|
|
throw new Error('Validation error');
|
|
});
|
|
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
typeVersion: 4
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Failed to validate node: Validation error'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('validateConnections', () => {
|
|
it('should validate valid connections', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'Webhook': {
|
|
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.statistics.validConnections).toBe(1);
|
|
expect(result.statistics.invalidConnections).toBe(0);
|
|
});
|
|
|
|
it('should error for connection from non-existent node', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'NonExistent': {
|
|
main: [[{ node: 'Webhook', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Connection from non-existent node: "NonExistent"'))).toBe(true);
|
|
expect(result.statistics.invalidConnections).toBe(1);
|
|
});
|
|
|
|
it('should error when using node ID instead of name in source', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: 'webhook-id',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: 'set-id',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'webhook-id': { // Using ID instead of name
|
|
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Connection uses node ID \'webhook-id\' instead of node name \'Webhook\''))).toBe(true);
|
|
});
|
|
|
|
it('should error for connection to non-existent node', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'Webhook': {
|
|
main: [[{ node: 'NonExistent', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Connection to non-existent node: "NonExistent"'))).toBe(true);
|
|
expect(result.statistics.invalidConnections).toBe(1);
|
|
});
|
|
|
|
it('should error when using node ID instead of name in target', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: 'webhook-id',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: 'set-id',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'Webhook': {
|
|
main: [[{ node: 'set-id', type: 'main', index: 0 }]] // Using ID instead of name
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Connection target uses node ID \'set-id\' instead of node name \'Set\''))).toBe(true);
|
|
});
|
|
|
|
it('should warn for connection to disabled node', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 100],
|
|
parameters: {},
|
|
disabled: true
|
|
}
|
|
],
|
|
connections: {
|
|
'Webhook': {
|
|
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('Connection to disabled node: "Set"'))).toBe(true);
|
|
});
|
|
|
|
it('should validate error outputs', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Error Handler',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'HTTP': {
|
|
error: [[{ node: 'Error Handler', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.statistics.validConnections).toBe(1);
|
|
});
|
|
|
|
it('should validate AI tool connections', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Agent',
|
|
type: '@n8n/n8n-nodes-langchain.agent',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Tool',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'Agent': {
|
|
ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.statistics.validConnections).toBe(1);
|
|
});
|
|
|
|
it('should warn for community nodes used as AI tools', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Agent',
|
|
type: '@n8n/n8n-nodes-langchain.agent',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
typeVersion: 1
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'CustomTool',
|
|
type: 'n8n-nodes-custom.customNode',
|
|
position: [300, 100],
|
|
parameters: {},
|
|
typeVersion: 1
|
|
}
|
|
],
|
|
connections: {
|
|
'Agent': {
|
|
ai_tool: [[{ node: 'CustomTool', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('Community node "CustomTool" is being used as an AI tool'))).toBe(true);
|
|
});
|
|
|
|
it('should warn for orphaned nodes', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '3',
|
|
name: 'Orphaned',
|
|
type: 'n8n-nodes-base.code',
|
|
position: [500, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'Webhook': {
|
|
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('Node is not connected to any other nodes') && w.nodeName === 'Orphaned')).toBe(true);
|
|
});
|
|
|
|
it('should detect cycles in workflow', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Node1',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Node2',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '3',
|
|
name: 'Node3',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [500, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'Node1': {
|
|
main: [[{ node: 'Node2', type: 'main', index: 0 }]]
|
|
},
|
|
'Node2': {
|
|
main: [[{ node: 'Node3', type: 'main', index: 0 }]]
|
|
},
|
|
'Node3': {
|
|
main: [[{ node: 'Node1', type: 'main', index: 0 }]] // Creates cycle
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Workflow contains a cycle'))).toBe(true);
|
|
});
|
|
|
|
it('should handle null connections properly', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'IF',
|
|
type: 'n8n-nodes-base.if',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
typeVersion: 2
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'True Branch',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 50],
|
|
parameters: {},
|
|
typeVersion: 3
|
|
}
|
|
],
|
|
connections: {
|
|
'IF': {
|
|
main: [
|
|
[{ node: 'True Branch', type: 'main', index: 0 }],
|
|
null // False branch not connected
|
|
]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.statistics.validConnections).toBe(1);
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('validateExpressions', () => {
|
|
it('should validate expressions in node parameters', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 100],
|
|
parameters: {
|
|
values: {
|
|
string: [
|
|
{
|
|
name: 'field',
|
|
value: '={{ $json.data }}'
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
],
|
|
connections: {
|
|
'Webhook': {
|
|
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(ExpressionValidator.validateNodeExpressions).toHaveBeenCalledWith(
|
|
expect.objectContaining({ values: expect.any(Object) }),
|
|
expect.objectContaining({
|
|
availableNodes: expect.arrayContaining(['Webhook']),
|
|
currentNodeName: 'Set',
|
|
hasInputData: true
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should add expression errors to result', async () => {
|
|
vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({
|
|
valid: false,
|
|
errors: ['Invalid expression syntax'],
|
|
warnings: ['Deprecated variable usage'],
|
|
usedVariables: new Set(['$json']),
|
|
usedNodes: new Set()
|
|
});
|
|
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [100, 100],
|
|
parameters: {
|
|
value: '={{ invalid }}'
|
|
}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Expression error: Invalid expression syntax'))).toBe(true);
|
|
expect(result.warnings.some(w => w.message.includes('Expression warning: Deprecated variable usage'))).toBe(true);
|
|
expect(result.statistics.expressionsValidated).toBe(1);
|
|
});
|
|
|
|
it('should skip expression validation for disabled nodes', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [100, 100],
|
|
parameters: {
|
|
value: '={{ $json.data }}'
|
|
},
|
|
disabled: true
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('checkWorkflowPatterns', () => {
|
|
it('should suggest error handling for large workflows', async () => {
|
|
const builder = createWorkflow('Large Workflow');
|
|
|
|
// Add more than 3 nodes
|
|
for (let i = 0; i < 5; i++) {
|
|
builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Set${i}` });
|
|
}
|
|
|
|
const workflow = builder.build() as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('Consider adding error handling'))).toBe(true);
|
|
});
|
|
|
|
it('should warn about long linear chains', async () => {
|
|
const builder = createWorkflow('Linear Workflow');
|
|
|
|
// Create a chain of 12 nodes
|
|
const nodeNames: string[] = [];
|
|
for (let i = 0; i < 12; i++) {
|
|
const nodeName = `Node${i}`;
|
|
builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: nodeName });
|
|
nodeNames.push(nodeName);
|
|
}
|
|
|
|
// Connect them sequentially
|
|
builder.connectSequentially(nodeNames);
|
|
|
|
const workflow = builder.build() as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('Long linear chain detected'))).toBe(true);
|
|
});
|
|
|
|
it('should warn about missing credentials', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Slack',
|
|
type: 'n8n-nodes-base.slack',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
credentials: {
|
|
slackApi: {} // Missing id
|
|
}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('Missing credentials configuration for slackApi'))).toBe(true);
|
|
});
|
|
|
|
it('should warn about AI agents without tools', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Agent',
|
|
type: '@n8n/n8n-nodes-langchain.agent',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
|
|
});
|
|
|
|
it('should suggest community package setting for AI tools', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Agent',
|
|
type: '@n8n/n8n-nodes-langchain.agent',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Tool',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'Agent': {
|
|
ai_tool: [[{ node: 'Tool', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.suggestions.some(s => s.includes('N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('checkNodeErrorHandling', () => {
|
|
it('should error when node-level properties are inside parameters', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
typeVersion: 4,
|
|
parameters: {
|
|
url: 'https://api.example.com',
|
|
onError: 'continueRegularOutput', // Wrong location!
|
|
retryOnFail: true, // Wrong location!
|
|
credentials: {} // Wrong location!
|
|
}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Node-level properties onError, retryOnFail, credentials are in the wrong location'))).toBe(true);
|
|
expect(result.errors.some(e => e.details?.fix?.includes('Move these properties from node.parameters to the node level'))).toBe(true);
|
|
});
|
|
|
|
it('should validate onError property values', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
onError: 'invalidValue' as any
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Invalid onError value: "invalidValue"'))).toBe(true);
|
|
});
|
|
|
|
it('should warn about deprecated continueOnFail', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
continueOnFail: true
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('Using deprecated "continueOnFail: true"'))).toBe(true);
|
|
});
|
|
|
|
it('should error for conflicting error handling properties', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
continueOnFail: true,
|
|
onError: 'continueRegularOutput'
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('Cannot use both "continueOnFail" and "onError" properties'))).toBe(true);
|
|
});
|
|
|
|
it('should validate retry configuration', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
retryOnFail: true,
|
|
maxTries: 'invalid' as any,
|
|
waitBetweenTries: -1000
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.some(e => e.message.includes('maxTries must be a positive number'))).toBe(true);
|
|
expect(result.errors.some(e => e.message.includes('waitBetweenTries must be a non-negative number'))).toBe(true);
|
|
});
|
|
|
|
it('should warn about excessive retry values', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
retryOnFail: true,
|
|
maxTries: 15,
|
|
waitBetweenTries: 400000
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('maxTries is set to 15'))).toBe(true);
|
|
expect(result.warnings.some(w => w.message.includes('waitBetweenTries is set to 400000ms'))).toBe(true);
|
|
});
|
|
|
|
it('should warn about retryOnFail without maxTries', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
retryOnFail: true
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('retryOnFail is enabled but maxTries is not specified'))).toBe(true);
|
|
});
|
|
|
|
it('should validate other node-level properties', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
typeVersion: 3,
|
|
alwaysOutputData: 'invalid' as any,
|
|
executeOnce: 'invalid' as any,
|
|
disabled: 'invalid' as any,
|
|
notesInFlow: 'invalid' as any,
|
|
notes: 123 as any
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
|
|
expect(result.errors.some(e => e.message.includes('alwaysOutputData must be a boolean'))).toBe(true);
|
|
expect(result.errors.some(e => e.message.includes('executeOnce must be a boolean'))).toBe(true);
|
|
expect(result.errors.some(e => e.message.includes('disabled must be a boolean'))).toBe(true);
|
|
expect(result.errors.some(e => e.message.includes('notesInFlow must be a boolean'))).toBe(true);
|
|
expect(result.errors.some(e => e.message.includes('notes must be a string'))).toBe(true);
|
|
});
|
|
|
|
it('should warn about executeOnce', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
executeOnce: true
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('executeOnce is enabled'))).toBe(true);
|
|
});
|
|
|
|
it('should warn error-prone nodes without error handling', async () => {
|
|
const errorProneNodes = [
|
|
{ type: 'n8n-nodes-base.httpRequest', message: 'HTTP Request', version: 4 },
|
|
{ type: 'n8n-nodes-base.webhook', message: 'Webhook', version: 2 },
|
|
{ type: 'n8n-nodes-base.postgres', message: 'Database operation', version: 2 },
|
|
{ type: 'n8n-nodes-base.slack', message: 'slack node', version: 2 }
|
|
];
|
|
|
|
for (const nodeInfo of errorProneNodes) {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Node',
|
|
type: nodeInfo.type,
|
|
position: [100, 100],
|
|
parameters: {},
|
|
typeVersion: nodeInfo.version
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes(nodeInfo.message) && w.message.includes('without error handling'))).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should warn about conflicting error handling', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
continueOnFail: true,
|
|
retryOnFail: true
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.warnings.some(w => w.message.includes('Both continueOnFail and retryOnFail are enabled'))).toBe(true);
|
|
});
|
|
|
|
it('should suggest alwaysOutputData for debugging', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
retryOnFail: true
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.suggestions.some(s => s.includes('Consider enabling alwaysOutputData'))).toBe(true);
|
|
});
|
|
|
|
it('should provide general error handling suggestions', async () => {
|
|
const builder = createWorkflow('No Error Handling');
|
|
|
|
// Add 6 nodes without error handling
|
|
for (let i = 0; i < 6; i++) {
|
|
builder.addCustomNode('n8n-nodes-base.httpRequest', 4, {}, { name: `HTTP${i}` });
|
|
}
|
|
|
|
const workflow = builder.build() as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.suggestions.some(s => s.includes('Most nodes lack error handling'))).toBe(true);
|
|
});
|
|
|
|
it('should suggest replacing deprecated error handling', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
continueOnFail: true
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.suggestions.some(s => s.includes('Replace "continueOnFail: true" with "onError:'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('generateSuggestions', () => {
|
|
it('should suggest adding trigger for workflows without triggers', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.suggestions.some(s => s.includes('Add a trigger node'))).toBe(true);
|
|
});
|
|
|
|
it('should provide connection examples for connection errors', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {} // Missing connections
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.suggestions.some(s => s.includes('Example connection structure'))).toBe(true);
|
|
expect(result.suggestions.some(s => s.includes('Use node NAMES (not IDs) in connections'))).toBe(true);
|
|
});
|
|
|
|
it('should suggest error handling when missing', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'HTTP',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [300, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {
|
|
'Webhook': {
|
|
main: [[{ node: 'HTTP', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.suggestions.some(s => s.includes('Add error handling'))).toBe(true);
|
|
});
|
|
|
|
it('should suggest breaking up large workflows', async () => {
|
|
const builder = createWorkflow('Large Workflow');
|
|
|
|
// Add 25 nodes
|
|
for (let i = 0; i < 25; i++) {
|
|
builder.addCustomNode('n8n-nodes-base.set', 3, {}, { name: `Node${i}` });
|
|
}
|
|
|
|
const workflow = builder.build() as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.suggestions.some(s => s.includes('Consider breaking this workflow into smaller sub-workflows'))).toBe(true);
|
|
});
|
|
|
|
it('should suggest Code node for complex expressions', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Complex',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [100, 100],
|
|
parameters: {
|
|
field1: '={{ $json.a }}',
|
|
field2: '={{ $json.b }}',
|
|
field3: '={{ $json.c }}',
|
|
field4: '={{ $json.d }}',
|
|
field5: '={{ $json.e }}',
|
|
field6: '={{ $json.f }}'
|
|
}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.suggestions.some(s => s.includes('Consider using a Code node for complex data transformations'))).toBe(true);
|
|
});
|
|
|
|
it('should suggest minimal workflow structure', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Set',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.suggestions.some(s => s.includes('A minimal workflow needs'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('findSimilarNodeTypes', () => {
|
|
it.skip('should find similar node types for common mistakes', async () => {
|
|
// Test that webhook without prefix gets suggestions
|
|
const webhookWorkflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Node',
|
|
type: 'webhook',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const webhookResult = await validator.validateWorkflow(webhookWorkflow);
|
|
|
|
// Check that we get an unknown node error with suggestions
|
|
const unknownNodeError = webhookResult.errors.find(e =>
|
|
e.message && e.message.includes('Unknown node type')
|
|
);
|
|
expect(unknownNodeError).toBeDefined();
|
|
|
|
// For webhook, it should definitely suggest nodes-base.webhook
|
|
expect(unknownNodeError?.message).toContain('nodes-base.webhook');
|
|
|
|
// Test that slack without prefix gets suggestions
|
|
const slackWorkflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Node',
|
|
type: 'slack',
|
|
position: [100, 100],
|
|
parameters: {}
|
|
}
|
|
],
|
|
connections: {}
|
|
} as any;
|
|
|
|
const slackResult = await validator.validateWorkflow(slackWorkflow);
|
|
const slackError = slackResult.errors.find(e =>
|
|
e.message && e.message.includes('Unknown node type')
|
|
);
|
|
expect(slackError).toBeDefined();
|
|
expect(slackError?.message).toContain('nodes-base.slack');
|
|
});
|
|
});
|
|
|
|
describe('Integration Tests', () => {
|
|
it('should validate a complex workflow with multiple issues', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
// Valid trigger
|
|
{
|
|
id: '1',
|
|
name: 'Webhook',
|
|
type: 'n8n-nodes-base.webhook',
|
|
position: [100, 100],
|
|
parameters: {},
|
|
typeVersion: 2
|
|
},
|
|
// Node with valid alternative prefix (no longer an error)
|
|
{
|
|
id: '2',
|
|
name: 'HTTP1',
|
|
type: 'nodes-base.httpRequest', // Valid prefix (normalized internally)
|
|
position: [300, 100],
|
|
parameters: {}
|
|
},
|
|
// Node with missing typeVersion
|
|
{
|
|
id: '3',
|
|
name: 'Slack',
|
|
type: 'n8n-nodes-base.slack',
|
|
position: [500, 100],
|
|
parameters: {}
|
|
},
|
|
// Disabled node
|
|
{
|
|
id: '4',
|
|
name: 'Disabled',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [700, 100],
|
|
parameters: {},
|
|
disabled: true
|
|
},
|
|
// Node with error handling in wrong place
|
|
{
|
|
id: '5',
|
|
name: 'HTTP2',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [900, 100],
|
|
parameters: {
|
|
onError: 'continueRegularOutput'
|
|
},
|
|
typeVersion: 4
|
|
},
|
|
// Orphaned node
|
|
{
|
|
id: '6',
|
|
name: 'Orphaned',
|
|
type: 'n8n-nodes-base.code',
|
|
position: [1100, 100],
|
|
parameters: {},
|
|
typeVersion: 2
|
|
},
|
|
// AI Agent without tools
|
|
{
|
|
id: '7',
|
|
name: 'Agent',
|
|
type: '@n8n/n8n-nodes-langchain.agent',
|
|
position: [100, 300],
|
|
parameters: {},
|
|
typeVersion: 1
|
|
}
|
|
],
|
|
connections: {
|
|
'Webhook': {
|
|
main: [[{ node: 'HTTP1', type: 'main', index: 0 }]]
|
|
},
|
|
'HTTP1': {
|
|
main: [[{ node: 'Slack', type: 'main', index: 0 }]]
|
|
},
|
|
'Slack': {
|
|
main: [[{ node: 'Disabled', type: 'main', index: 0 }]]
|
|
},
|
|
// Using ID instead of name
|
|
'5': {
|
|
main: [[{ node: 'Agent', type: 'main', index: 0 }]]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
// Should have multiple errors (but not for the nodes-base prefix)
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.length).toBeGreaterThan(2); // Reduced by 1 since nodes-base prefix is now valid
|
|
|
|
// Specific errors (removed the invalid node type error as it's no longer invalid)
|
|
expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true);
|
|
expect(result.errors.some(e => e.message.includes('Node-level properties onError are in the wrong location'))).toBe(true);
|
|
expect(result.errors.some(e => e.message.includes('Connection uses node ID \'5\' instead of node name'))).toBe(true);
|
|
|
|
// Warnings
|
|
expect(result.warnings.some(w => w.message.includes('Connection to disabled node'))).toBe(true);
|
|
expect(result.warnings.some(w => w.message.includes('Node is not connected') && w.nodeName === 'Orphaned')).toBe(true);
|
|
expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
|
|
|
|
// Statistics
|
|
expect(result.statistics.totalNodes).toBe(7);
|
|
expect(result.statistics.enabledNodes).toBe(6);
|
|
expect(result.statistics.triggerNodes).toBe(1);
|
|
expect(result.statistics.invalidConnections).toBeGreaterThan(0);
|
|
|
|
// Suggestions
|
|
expect(result.suggestions.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should validate a perfect workflow', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{
|
|
id: '1',
|
|
name: 'Manual Trigger',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
position: [250, 300],
|
|
parameters: {},
|
|
typeVersion: 1
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
position: [450, 300],
|
|
parameters: {
|
|
url: 'https://api.example.com/data',
|
|
method: 'GET'
|
|
},
|
|
typeVersion: 4,
|
|
onError: 'continueErrorOutput',
|
|
retryOnFail: true,
|
|
maxTries: 3,
|
|
waitBetweenTries: 1000
|
|
},
|
|
{
|
|
id: '3',
|
|
name: 'Process Data',
|
|
type: 'n8n-nodes-base.code',
|
|
position: [650, 300],
|
|
parameters: {
|
|
jsCode: 'return items;'
|
|
},
|
|
typeVersion: 2
|
|
},
|
|
{
|
|
id: '4',
|
|
name: 'Error Handler',
|
|
type: 'n8n-nodes-base.set',
|
|
position: [650, 500],
|
|
parameters: {
|
|
values: {
|
|
string: [
|
|
{
|
|
name: 'error',
|
|
value: 'An error occurred'
|
|
}
|
|
]
|
|
}
|
|
},
|
|
typeVersion: 3
|
|
}
|
|
],
|
|
connections: {
|
|
'Manual Trigger': {
|
|
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
|
|
},
|
|
'HTTP Request': {
|
|
main: [
|
|
[{ node: 'Process Data', type: 'main', index: 0 }],
|
|
[{ node: 'Error Handler', type: 'main', index: 0 }]
|
|
]
|
|
}
|
|
}
|
|
} as any;
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors).toHaveLength(0);
|
|
expect(result.warnings).toHaveLength(0);
|
|
expect(result.statistics.validConnections).toBe(3);
|
|
expect(result.statistics.invalidConnections).toBe(0);
|
|
});
|
|
});
|
|
}); |