mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 05:23:08 +00:00
fix: enhance error output validation to detect incorrect configurations
- Add validateErrorOutputConfiguration method to detect when multiple nodes are incorrectly placed in main[0] - Fix checkWorkflowPatterns to check main[1] for error outputs instead of outputs.error - Cross-validate onError property matches actual connection structure - Provide clear error messages with JSON examples showing correct configuration - Use heuristic detection for error handler nodes (names containing error, fail, catch, etc.) - Add comprehensive test coverage with 16+ test cases - Bump version to 2.12.1 Fixes issues where AI agents would incorrectly configure error outputs by placing multiple nodes in the same array instead of separating them into success (main[0]) and error (main[1]) paths. 🤖 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
793
tests/unit/services/workflow-validator-error-outputs.test.ts
Normal file
793
tests/unit/services/workflow-validator-error-outputs.test.ts
Normal file
@@ -0,0 +1,793 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { WorkflowValidator } from '@/services/workflow-validator';
|
||||
import { NodeRepository } from '@/database/node-repository';
|
||||
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
||||
|
||||
vi.mock('@/utils/logger');
|
||||
|
||||
describe('WorkflowValidator - Error Output Validation', () => {
|
||||
let validator: WorkflowValidator;
|
||||
let mockNodeRepository: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock repository
|
||||
mockNodeRepository = {
|
||||
getNode: vi.fn((type: string) => {
|
||||
// Return mock node info for common node types
|
||||
if (type.includes('httpRequest') || type.includes('webhook') || type.includes('set')) {
|
||||
return {
|
||||
node_type: type,
|
||||
display_name: 'Mock Node',
|
||||
isVersioned: true,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
};
|
||||
|
||||
validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
|
||||
});
|
||||
|
||||
describe('Error Output Configuration', () => {
|
||||
it('should detect incorrect configuration - multiple nodes in same array', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Validate Input',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [-400, 64],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Filter URLs',
|
||||
type: 'n8n-nodes-base.filter',
|
||||
typeVersion: 2.2,
|
||||
position: [-176, 64],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Error Response1',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.5,
|
||||
position: [-160, 240],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Validate Input': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Filter URLs', type: 'main', index: 0 },
|
||||
{ node: 'Error Response1', type: 'main', index: 0 } // WRONG! Both in main[0]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration') &&
|
||||
e.message.includes('Error Response1') &&
|
||||
e.message.includes('appear to be error handlers but are in main[0]')
|
||||
)).toBe(true);
|
||||
|
||||
// Check that the error message includes the fix
|
||||
const errorMsg = result.errors.find(e => e.message.includes('Incorrect error output configuration'));
|
||||
expect(errorMsg?.message).toContain('INCORRECT (current)');
|
||||
expect(errorMsg?.message).toContain('CORRECT (should be)');
|
||||
expect(errorMsg?.message).toContain('main[1] = error output');
|
||||
});
|
||||
|
||||
it('should validate correct configuration - separate arrays', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Validate Input',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [-400, 64],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Filter URLs',
|
||||
type: 'n8n-nodes-base.filter',
|
||||
typeVersion: 2.2,
|
||||
position: [-176, 64],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Error Response1',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.5,
|
||||
position: [-160, 240],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Validate Input': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Filter URLs', type: 'main', index: 0 }
|
||||
],
|
||||
[
|
||||
{ node: 'Error Response1', type: 'main', index: 0 } // Correctly in main[1]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should not have the specific error about incorrect configuration
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect onError without error connections', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4,
|
||||
position: [100, 100],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput' // Has onError
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Process Data',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'HTTP Request': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Process Data', type: 'main', index: 0 }
|
||||
]
|
||||
// No main[1] for error output
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
expect(result.errors.some(e =>
|
||||
e.nodeName === 'HTTP Request' &&
|
||||
e.message.includes("has onError: 'continueErrorOutput' but no error output connections")
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should warn about error connections without onError', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
// Missing onError property
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Process Data',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Error Handler',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 300],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'HTTP Request': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Process Data', type: 'main', index: 0 }
|
||||
],
|
||||
[
|
||||
{ node: 'Error Handler', type: 'main', index: 0 } // Has error connection
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.nodeName === 'HTTP Request' &&
|
||||
w.message.includes('error output connections in main[1] but missing onError')
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handler Detection', () => {
|
||||
it('should detect error handler nodes by name', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'API Call',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Process Success',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Handle Error', // Contains 'error'
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 300],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'API Call': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Process Success', type: 'main', index: 0 },
|
||||
{ node: 'Handle Error', type: 'main', index: 0 } // Wrong placement
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Handle Error') &&
|
||||
e.message.includes('appear to be error handlers')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect error handler nodes by type', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Process',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Respond',
|
||||
type: 'n8n-nodes-base.respondToWebhook', // Common error handler type
|
||||
position: [300, 300],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Process', type: 'main', index: 0 },
|
||||
{ node: 'Respond', type: 'main', index: 0 } // Wrong placement
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Respond') &&
|
||||
e.message.includes('appear to be error handlers')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not flag non-error nodes in main[0]', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'First Process',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Second Process',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 200],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Start': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'First Process', type: 'main', index: 0 },
|
||||
{ node: 'Second Process', type: 'main', index: 0 } // Both are valid success paths
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should not have error about incorrect error configuration
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Error Patterns', () => {
|
||||
it('should handle multiple error handlers correctly', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [100, 100],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Process',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Log Error',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 200],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Send Error Email',
|
||||
type: 'n8n-nodes-base.emailSend',
|
||||
position: [300, 300],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'HTTP Request': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Process', type: 'main', index: 0 }
|
||||
],
|
||||
[
|
||||
{ node: 'Log Error', type: 'main', index: 0 },
|
||||
{ node: 'Send Error Email', type: 'main', index: 0 } // Multiple error handlers OK in main[1]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should not have errors about the configuration
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect mixed success and error handlers in main[0]', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'API Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Transform Data',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Store Data',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [500, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Error Notification',
|
||||
type: 'n8n-nodes-base.emailSend',
|
||||
position: [300, 300],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'API Request': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Transform Data', type: 'main', index: 0 },
|
||||
{ node: 'Store Data', type: 'main', index: 0 },
|
||||
{ node: 'Error Notification', type: 'main', index: 0 } // Error handler mixed with success nodes
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Error Notification') &&
|
||||
e.message.includes('appear to be error handlers but are in main[0]')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle nested error handling (error handlers with their own errors)', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Primary API',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [100, 100],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Success Handler',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Error Logger',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [300, 200],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Fallback Error',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [500, 250],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Primary API': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Success Handler', type: 'main', index: 0 }
|
||||
],
|
||||
[
|
||||
{ node: 'Error Logger', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
},
|
||||
'Error Logger': {
|
||||
main: [
|
||||
[],
|
||||
[
|
||||
{ node: 'Fallback Error', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should not have errors about incorrect configuration
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle workflows with no connections at all', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Isolated Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [100, 100],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should have warning about orphaned node but not error about connections
|
||||
expect(result.warnings.some(w =>
|
||||
w.nodeName === 'Isolated Node' &&
|
||||
w.message.includes('not connected to any other nodes')
|
||||
)).toBe(true);
|
||||
|
||||
// Should not have error about error output configuration
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle nodes with empty main arrays', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Source Node',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [100, 100],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Target Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Source Node': {
|
||||
main: [
|
||||
[], // Empty success array
|
||||
[] // Empty error array
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should detect that onError is set but no error connections exist
|
||||
expect(result.errors.some(e =>
|
||||
e.nodeName === 'Source Node' &&
|
||||
e.message.includes("has onError: 'continueErrorOutput' but no error output connections")
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle workflows with only error outputs (no success path)', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Risky Operation',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [100, 100],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Error Handler Only',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 200],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Risky Operation': {
|
||||
main: [
|
||||
[], // No success connections
|
||||
[
|
||||
{ node: 'Error Handler Only', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should not have errors about incorrect configuration - this is valid
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
)).toBe(false);
|
||||
|
||||
// Should not have errors about missing error connections
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes("has onError: 'continueErrorOutput' but no error output connections")
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle undefined or null connection arrays gracefully', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Source Node',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Source Node': {
|
||||
main: [
|
||||
null, // Null array
|
||||
undefined // Undefined array
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should not crash and should not have configuration errors
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect all variations of error-related node names', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Source',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Handle Failure',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Catch Exception',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 200],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Success Path',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [500, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Source': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Handle Failure', type: 'main', index: 0 },
|
||||
{ node: 'Catch Exception', type: 'main', index: 0 },
|
||||
{ node: 'Success Path', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should detect both 'Handle Failure' and 'Catch Exception' as error handlers
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Handle Failure') &&
|
||||
e.message.includes('Catch Exception') &&
|
||||
e.message.includes('appear to be error handlers but are in main[0]')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not flag legitimate parallel processing nodes', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Data Source',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Process A',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 50],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Process B',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 150],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Transform Data',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 250],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Data Source': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Process A', type: 'main', index: 0 },
|
||||
{ node: 'Process B', type: 'main', index: 0 },
|
||||
{ node: 'Transform Data', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should not flag these as error configuration issues
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
720
tests/unit/services/workflow-validator-mocks.test.ts
Normal file
720
tests/unit/services/workflow-validator-mocks.test.ts
Normal file
@@ -0,0 +1,720 @@
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { WorkflowValidator } from '@/services/workflow-validator';
|
||||
import { NodeRepository } from '@/database/node-repository';
|
||||
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
||||
|
||||
vi.mock('@/utils/logger');
|
||||
|
||||
describe('WorkflowValidator - Mock-based Unit Tests', () => {
|
||||
let validator: WorkflowValidator;
|
||||
let mockNodeRepository: any;
|
||||
let mockGetNode: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create detailed mock repository with spy functions
|
||||
mockGetNode = vi.fn();
|
||||
mockNodeRepository = {
|
||||
getNode: mockGetNode
|
||||
};
|
||||
|
||||
validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
|
||||
|
||||
// Default mock responses
|
||||
mockGetNode.mockImplementation((type: string) => {
|
||||
if (type.includes('httpRequest')) {
|
||||
return {
|
||||
node_type: type,
|
||||
display_name: 'HTTP Request',
|
||||
isVersioned: true,
|
||||
version: 4
|
||||
};
|
||||
} else if (type.includes('set')) {
|
||||
return {
|
||||
node_type: type,
|
||||
display_name: 'Set',
|
||||
isVersioned: true,
|
||||
version: 3
|
||||
};
|
||||
} else if (type.includes('respondToWebhook')) {
|
||||
return {
|
||||
node_type: type,
|
||||
display_name: 'Respond to Webhook',
|
||||
isVersioned: true,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handler Detection Logic', () => {
|
||||
it('should correctly identify error handlers by node name patterns', async () => {
|
||||
const errorNodeNames = [
|
||||
'Error Handler',
|
||||
'Handle Error',
|
||||
'Catch Exception',
|
||||
'Failure Response',
|
||||
'Error Notification',
|
||||
'Fail Safe',
|
||||
'Exception Handler',
|
||||
'Error Callback'
|
||||
];
|
||||
|
||||
const successNodeNames = [
|
||||
'Process Data',
|
||||
'Transform',
|
||||
'Success Handler',
|
||||
'Continue Process',
|
||||
'Normal Flow'
|
||||
];
|
||||
|
||||
for (const errorName of errorNodeNames) {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Source',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Success Path',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: errorName,
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Source': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Success Path', type: 'main', index: 0 },
|
||||
{ node: errorName, type: 'main', index: 0 } // Should be detected as error handler
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should detect this as an incorrect error configuration
|
||||
const hasError = result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration') &&
|
||||
e.message.includes(errorName)
|
||||
);
|
||||
expect(hasError).toBe(true);
|
||||
}
|
||||
|
||||
// Test that success node names are NOT flagged
|
||||
for (const successName of successNodeNames) {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Source',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'First Process',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: successName,
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Source': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'First Process', type: 'main', index: 0 },
|
||||
{ node: successName, type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should NOT detect this as an error configuration
|
||||
const hasError = result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
);
|
||||
expect(hasError).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly identify error handlers by node type patterns', async () => {
|
||||
const errorNodeTypes = [
|
||||
'n8n-nodes-base.respondToWebhook',
|
||||
'n8n-nodes-base.emailSend'
|
||||
// Note: slack and webhook are not in the current detection logic
|
||||
];
|
||||
|
||||
// Update mock to return appropriate node info for these types
|
||||
mockGetNode.mockImplementation((type: string) => {
|
||||
return {
|
||||
node_type: type,
|
||||
display_name: type.split('.').pop() || 'Unknown',
|
||||
isVersioned: true,
|
||||
version: 1
|
||||
};
|
||||
});
|
||||
|
||||
for (const nodeType of errorNodeTypes) {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Source',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Success Path',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Response Node',
|
||||
type: nodeType,
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Source': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Success Path', type: 'main', index: 0 },
|
||||
{ node: 'Response Node', type: 'main', index: 0 } // Should be detected
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should detect this as an incorrect error configuration
|
||||
const hasError = result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration') &&
|
||||
e.message.includes('Response Node')
|
||||
);
|
||||
expect(hasError).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle cases where node repository returns null', async () => {
|
||||
// Mock repository to return null for unknown nodes
|
||||
mockGetNode.mockImplementation((type: string) => {
|
||||
if (type === 'n8n-nodes-base.unknownNode') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
node_type: type,
|
||||
display_name: 'Known Node',
|
||||
isVersioned: true,
|
||||
version: 1
|
||||
};
|
||||
});
|
||||
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Source',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Unknown Node',
|
||||
type: 'n8n-nodes-base.unknownNode',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Error Handler',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Source': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Unknown Node', type: 'main', index: 0 },
|
||||
{ node: 'Error Handler', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should still detect the error configuration based on node name
|
||||
const hasError = result.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration') &&
|
||||
e.message.includes('Error Handler')
|
||||
);
|
||||
expect(hasError).toBe(true);
|
||||
|
||||
// Should not crash due to null node info
|
||||
expect(result).toHaveProperty('valid');
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onError Property Validation Logic', () => {
|
||||
it('should validate onError property combinations correctly', async () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'onError set but no error connections',
|
||||
onError: 'continueErrorOutput',
|
||||
hasErrorConnections: false,
|
||||
expectedErrorType: 'error',
|
||||
expectedMessage: "has onError: 'continueErrorOutput' but no error output connections"
|
||||
},
|
||||
{
|
||||
name: 'error connections but no onError',
|
||||
onError: undefined,
|
||||
hasErrorConnections: true,
|
||||
expectedErrorType: 'warning',
|
||||
expectedMessage: 'error output connections in main[1] but missing onError'
|
||||
},
|
||||
{
|
||||
name: 'onError set with error connections',
|
||||
onError: 'continueErrorOutput',
|
||||
hasErrorConnections: true,
|
||||
expectedErrorType: null,
|
||||
expectedMessage: null
|
||||
},
|
||||
{
|
||||
name: 'no onError and no error connections',
|
||||
onError: undefined,
|
||||
hasErrorConnections: false,
|
||||
expectedErrorType: null,
|
||||
expectedMessage: null
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
...(testCase.onError ? { onError: testCase.onError } : {})
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Success Handler',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Error Handler',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Test Node': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Success Handler', type: 'main', index: 0 }
|
||||
],
|
||||
...(testCase.hasErrorConnections ? [
|
||||
[
|
||||
{ node: 'Error Handler', type: 'main', index: 0 }
|
||||
]
|
||||
] : [])
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
if (testCase.expectedErrorType === 'error') {
|
||||
const hasExpectedError = result.errors.some(e =>
|
||||
e.nodeName === 'Test Node' &&
|
||||
e.message.includes(testCase.expectedMessage!)
|
||||
);
|
||||
expect(hasExpectedError).toBe(true);
|
||||
} else if (testCase.expectedErrorType === 'warning') {
|
||||
const hasExpectedWarning = result.warnings.some(w =>
|
||||
w.nodeName === 'Test Node' &&
|
||||
w.message.includes(testCase.expectedMessage!)
|
||||
);
|
||||
expect(hasExpectedWarning).toBe(true);
|
||||
} else {
|
||||
// Should not have related errors or warnings about onError/error output mismatches
|
||||
const hasRelatedError = result.errors.some(e =>
|
||||
e.nodeName === 'Test Node' &&
|
||||
(e.message.includes("has onError: 'continueErrorOutput' but no error output connections") ||
|
||||
e.message.includes('Incorrect error output configuration'))
|
||||
);
|
||||
const hasRelatedWarning = result.warnings.some(w =>
|
||||
w.nodeName === 'Test Node' &&
|
||||
w.message.includes('error output connections in main[1] but missing onError')
|
||||
);
|
||||
expect(hasRelatedError).toBe(false);
|
||||
expect(hasRelatedWarning).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle different onError values correctly', async () => {
|
||||
const onErrorValues = [
|
||||
'continueErrorOutput',
|
||||
'continueRegularOutput',
|
||||
'stopWorkflow'
|
||||
];
|
||||
|
||||
for (const onErrorValue of onErrorValues) {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
onError: onErrorValue
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Next Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Test Node': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Next Node', type: 'main', index: 0 }
|
||||
]
|
||||
// No error connections
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
if (onErrorValue === 'continueErrorOutput') {
|
||||
// Should have error about missing error connections
|
||||
const hasError = result.errors.some(e =>
|
||||
e.nodeName === 'Test Node' &&
|
||||
e.message.includes("has onError: 'continueErrorOutput' but no error output connections")
|
||||
);
|
||||
expect(hasError).toBe(true);
|
||||
} else {
|
||||
// Should not have error about missing error connections
|
||||
const hasError = result.errors.some(e =>
|
||||
e.nodeName === 'Test Node' &&
|
||||
e.message.includes('but no error output connections')
|
||||
);
|
||||
expect(hasError).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON Format Generation', () => {
|
||||
it('should generate valid JSON in error messages', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'API Call',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Success Process',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Error Handler',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'API Call': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Success Process', type: 'main', index: 0 },
|
||||
{ node: 'Error Handler', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const errorConfigError = result.errors.find(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
);
|
||||
|
||||
expect(errorConfigError).toBeDefined();
|
||||
|
||||
// Extract JSON sections from error message
|
||||
const incorrectMatch = errorConfigError!.message.match(/INCORRECT \(current\):\n([\s\S]*?)\n\nCORRECT/);
|
||||
const correctMatch = errorConfigError!.message.match(/CORRECT \(should be\):\n([\s\S]*?)\n\nAlso add/);
|
||||
|
||||
expect(incorrectMatch).toBeDefined();
|
||||
expect(correctMatch).toBeDefined();
|
||||
|
||||
// Extract just the JSON part (remove comments)
|
||||
const incorrectJsonStr = incorrectMatch![1];
|
||||
const correctJsonStr = correctMatch![1];
|
||||
|
||||
// Remove comments and clean up for JSON parsing
|
||||
const cleanIncorrectJson = incorrectJsonStr.replace(/\/\/.*$/gm, '').replace(/,\s*$/, '');
|
||||
const cleanCorrectJson = correctJsonStr.replace(/\/\/.*$/gm, '').replace(/,\s*$/, '');
|
||||
|
||||
const incorrectJson = `{${cleanIncorrectJson}}`;
|
||||
const correctJson = `{${cleanCorrectJson}}`;
|
||||
|
||||
expect(() => JSON.parse(incorrectJson)).not.toThrow();
|
||||
expect(() => JSON.parse(correctJson)).not.toThrow();
|
||||
|
||||
const parsedIncorrect = JSON.parse(incorrectJson);
|
||||
const parsedCorrect = JSON.parse(correctJson);
|
||||
|
||||
// Validate structure
|
||||
expect(parsedIncorrect).toHaveProperty('API Call');
|
||||
expect(parsedCorrect).toHaveProperty('API Call');
|
||||
expect(parsedIncorrect['API Call']).toHaveProperty('main');
|
||||
expect(parsedCorrect['API Call']).toHaveProperty('main');
|
||||
|
||||
// Incorrect should have both nodes in main[0]
|
||||
expect(Array.isArray(parsedIncorrect['API Call'].main)).toBe(true);
|
||||
expect(parsedIncorrect['API Call'].main).toHaveLength(1);
|
||||
expect(parsedIncorrect['API Call'].main[0]).toHaveLength(2);
|
||||
|
||||
// Correct should have separate arrays
|
||||
expect(Array.isArray(parsedCorrect['API Call'].main)).toBe(true);
|
||||
expect(parsedCorrect['API Call'].main).toHaveLength(2);
|
||||
expect(parsedCorrect['API Call'].main[0]).toHaveLength(1); // Success only
|
||||
expect(parsedCorrect['API Call'].main[1]).toHaveLength(1); // Error only
|
||||
});
|
||||
|
||||
it('should handle special characters in node names in JSON', async () => {
|
||||
// Test simpler special characters that are easier to handle in JSON
|
||||
const specialNodeNames = [
|
||||
'Node with spaces',
|
||||
'Node-with-dashes',
|
||||
'Node_with_underscores'
|
||||
];
|
||||
|
||||
for (const specialName of specialNodeNames) {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Source',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Success',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: specialName,
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Source': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Success', type: 'main', index: 0 },
|
||||
{ node: specialName, type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const errorConfigError = result.errors.find(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
);
|
||||
|
||||
expect(errorConfigError).toBeDefined();
|
||||
|
||||
// Verify the error message contains the special node name
|
||||
expect(errorConfigError!.message).toContain(specialName);
|
||||
|
||||
// Verify JSON structure is present (but don't parse due to comments)
|
||||
expect(errorConfigError!.message).toContain('INCORRECT (current):');
|
||||
expect(errorConfigError!.message).toContain('CORRECT (should be):');
|
||||
expect(errorConfigError!.message).toContain('main[0]');
|
||||
expect(errorConfigError!.message).toContain('main[1]');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository Interaction Patterns', () => {
|
||||
it('should call repository getNode with correct parameters', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'HTTP Node',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Set Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'HTTP Node': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Set Node', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should have called getNode for each node type
|
||||
expect(mockGetNode).toHaveBeenCalledWith('n8n-nodes-base.httpRequest');
|
||||
expect(mockGetNode).toHaveBeenCalledWith('n8n-nodes-base.set');
|
||||
expect(mockGetNode).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
// Mock repository to throw error
|
||||
mockGetNode.mockImplementation(() => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
// Should not throw error
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should still return a valid result
|
||||
expect(result).toHaveProperty('valid');
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
expect(Array.isArray(result.warnings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should optimize repository calls for duplicate node types', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'HTTP 1',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'HTTP 2',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'HTTP 3',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
position: [400, 0],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should call getNode for the same type multiple times (current implementation)
|
||||
// Note: This test documents current behavior. Could be optimized in the future.
|
||||
const httpRequestCalls = mockGetNode.mock.calls.filter(
|
||||
call => call[0] === 'n8n-nodes-base.httpRequest'
|
||||
);
|
||||
expect(httpRequestCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
528
tests/unit/services/workflow-validator-performance.test.ts
Normal file
528
tests/unit/services/workflow-validator-performance.test.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { WorkflowValidator } from '@/services/workflow-validator';
|
||||
import { NodeRepository } from '@/database/node-repository';
|
||||
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
||||
|
||||
vi.mock('@/utils/logger');
|
||||
|
||||
describe('WorkflowValidator - Performance Tests', () => {
|
||||
let validator: WorkflowValidator;
|
||||
let mockNodeRepository: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock repository with performance optimizations
|
||||
mockNodeRepository = {
|
||||
getNode: vi.fn((type: string) => {
|
||||
// Return mock node info for any node type to avoid database calls
|
||||
return {
|
||||
node_type: type,
|
||||
display_name: 'Mock Node',
|
||||
isVersioned: true,
|
||||
version: 1
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
|
||||
});
|
||||
|
||||
describe('Large Workflow Performance', () => {
|
||||
it('should validate large workflows with many error paths efficiently', async () => {
|
||||
// Generate a large workflow with 500 nodes
|
||||
const nodeCount = 500;
|
||||
const nodes = [];
|
||||
const connections: any = {};
|
||||
|
||||
// Create nodes with various error handling patterns
|
||||
for (let i = 1; i <= nodeCount; i++) {
|
||||
nodes.push({
|
||||
id: i.toString(),
|
||||
name: `Node${i}`,
|
||||
type: i % 5 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [i * 10, (i % 10) * 100],
|
||||
parameters: {},
|
||||
...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {})
|
||||
});
|
||||
}
|
||||
|
||||
// Create connections with multiple error handling scenarios
|
||||
for (let i = 1; i < nodeCount; i++) {
|
||||
const hasErrorHandling = i % 3 === 0;
|
||||
const hasMultipleConnections = i % 7 === 0;
|
||||
|
||||
if (hasErrorHandling && hasMultipleConnections) {
|
||||
// Mix correct and incorrect error handling patterns
|
||||
const isIncorrect = i % 14 === 0;
|
||||
|
||||
if (isIncorrect) {
|
||||
// Incorrect: error handlers mixed with success nodes in main[0]
|
||||
connections[`Node${i}`] = {
|
||||
main: [
|
||||
[
|
||||
{ node: `Node${i + 1}`, type: 'main', index: 0 },
|
||||
{ node: `Error Handler ${i}`, type: 'main', index: 0 } // Wrong!
|
||||
]
|
||||
]
|
||||
};
|
||||
} else {
|
||||
// Correct: separate success and error outputs
|
||||
connections[`Node${i}`] = {
|
||||
main: [
|
||||
[
|
||||
{ node: `Node${i + 1}`, type: 'main', index: 0 }
|
||||
],
|
||||
[
|
||||
{ node: `Error Handler ${i}`, type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Add error handler node
|
||||
nodes.push({
|
||||
id: `error-${i}`,
|
||||
name: `Error Handler ${i}`,
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1,
|
||||
position: [(i + nodeCount) * 10, 500],
|
||||
parameters: {}
|
||||
});
|
||||
} else {
|
||||
// Simple connection
|
||||
connections[`Node${i}`] = {
|
||||
main: [
|
||||
[
|
||||
{ node: `Node${i + 1}`, type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const workflow = { nodes, connections };
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
const endTime = performance.now();
|
||||
|
||||
const executionTime = endTime - startTime;
|
||||
|
||||
// Validation should complete within reasonable time
|
||||
expect(executionTime).toBeLessThan(10000); // Less than 10 seconds
|
||||
|
||||
// Should still catch validation errors
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
expect(Array.isArray(result.warnings)).toBe(true);
|
||||
|
||||
// Should detect incorrect error configurations
|
||||
const incorrectConfigErrors = result.errors.filter(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
);
|
||||
expect(incorrectConfigErrors.length).toBeGreaterThan(0);
|
||||
|
||||
console.log(`Validated ${nodes.length} nodes in ${executionTime.toFixed(2)}ms`);
|
||||
console.log(`Found ${result.errors.length} errors and ${result.warnings.length} warnings`);
|
||||
});
|
||||
|
||||
it('should handle deeply nested error handling chains efficiently', async () => {
|
||||
// Create a chain of error handlers, each with their own error handling
|
||||
const chainLength = 100;
|
||||
const nodes = [];
|
||||
const connections: any = {};
|
||||
|
||||
for (let i = 1; i <= chainLength; i++) {
|
||||
// Main processing node
|
||||
nodes.push({
|
||||
id: `main-${i}`,
|
||||
name: `Main ${i}`,
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [i * 150, 100],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
});
|
||||
|
||||
// Error handler node
|
||||
nodes.push({
|
||||
id: `error-${i}`,
|
||||
name: `Error Handler ${i}`,
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [i * 150, 300],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
});
|
||||
|
||||
// Fallback error node
|
||||
nodes.push({
|
||||
id: `fallback-${i}`,
|
||||
name: `Fallback ${i}`,
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [i * 150, 500],
|
||||
parameters: {}
|
||||
});
|
||||
|
||||
// Connections
|
||||
connections[`Main ${i}`] = {
|
||||
main: [
|
||||
// Success path
|
||||
i < chainLength ? [{ node: `Main ${i + 1}`, type: 'main', index: 0 }] : [],
|
||||
// Error path
|
||||
[{ node: `Error Handler ${i}`, type: 'main', index: 0 }]
|
||||
]
|
||||
};
|
||||
|
||||
connections[`Error Handler ${i}`] = {
|
||||
main: [
|
||||
// Success path (continue to next error handler or end)
|
||||
[],
|
||||
// Error path (go to fallback)
|
||||
[{ node: `Fallback ${i}`, type: 'main', index: 0 }]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const workflow = { nodes, connections };
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
const endTime = performance.now();
|
||||
|
||||
const executionTime = endTime - startTime;
|
||||
|
||||
// Should complete quickly even with complex nested error handling
|
||||
expect(executionTime).toBeLessThan(5000); // Less than 5 seconds
|
||||
|
||||
// Should not have errors about incorrect configuration (this is correct)
|
||||
const incorrectConfigErrors = result.errors.filter(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
);
|
||||
expect(incorrectConfigErrors.length).toBe(0);
|
||||
|
||||
console.log(`Validated ${nodes.length} nodes with nested error handling in ${executionTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
it('should efficiently validate workflows with many parallel error paths', async () => {
|
||||
// Create a workflow with one source node that fans out to many parallel paths,
|
||||
// each with their own error handling
|
||||
const parallelPathCount = 200;
|
||||
const nodes = [
|
||||
{
|
||||
id: 'source',
|
||||
name: 'Source',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
}
|
||||
];
|
||||
const connections: any = {
|
||||
'Source': {
|
||||
main: [[]]
|
||||
}
|
||||
};
|
||||
|
||||
// Create parallel paths
|
||||
for (let i = 1; i <= parallelPathCount; i++) {
|
||||
// Processing node
|
||||
nodes.push({
|
||||
id: `process-${i}`,
|
||||
name: `Process ${i}`,
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [200, i * 20],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
} as any);
|
||||
|
||||
// Success handler
|
||||
nodes.push({
|
||||
id: `success-${i}`,
|
||||
name: `Success ${i}`,
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [400, i * 20],
|
||||
parameters: {}
|
||||
});
|
||||
|
||||
// Error handler
|
||||
nodes.push({
|
||||
id: `error-${i}`,
|
||||
name: `Error Handler ${i}`,
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1,
|
||||
position: [400, i * 20 + 10],
|
||||
parameters: {}
|
||||
});
|
||||
|
||||
// Connect source to processing node
|
||||
connections['Source'].main[0].push({
|
||||
node: `Process ${i}`,
|
||||
type: 'main',
|
||||
index: 0
|
||||
});
|
||||
|
||||
// Connect processing node to success and error handlers
|
||||
connections[`Process ${i}`] = {
|
||||
main: [
|
||||
[{ node: `Success ${i}`, type: 'main', index: 0 }],
|
||||
[{ node: `Error Handler ${i}`, type: 'main', index: 0 }]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const workflow = { nodes, connections };
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
const endTime = performance.now();
|
||||
|
||||
const executionTime = endTime - startTime;
|
||||
|
||||
// Should validate efficiently despite many parallel paths
|
||||
expect(executionTime).toBeLessThan(8000); // Less than 8 seconds
|
||||
|
||||
// Should not have errors about incorrect configuration
|
||||
const incorrectConfigErrors = result.errors.filter(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
);
|
||||
expect(incorrectConfigErrors.length).toBe(0);
|
||||
|
||||
console.log(`Validated ${nodes.length} nodes with ${parallelPathCount} parallel error paths in ${executionTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
it('should handle worst-case scenario with many incorrect configurations efficiently', async () => {
|
||||
// Create a workflow where many nodes have the incorrect error configuration
|
||||
// This tests the performance of the error detection algorithm
|
||||
const nodeCount = 300;
|
||||
const nodes = [];
|
||||
const connections: any = {};
|
||||
|
||||
for (let i = 1; i <= nodeCount; i++) {
|
||||
// Main node
|
||||
nodes.push({
|
||||
id: `main-${i}`,
|
||||
name: `Main ${i}`,
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [i * 20, 100],
|
||||
parameters: {}
|
||||
});
|
||||
|
||||
// Success handler
|
||||
nodes.push({
|
||||
id: `success-${i}`,
|
||||
name: `Success ${i}`,
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [i * 20, 200],
|
||||
parameters: {}
|
||||
});
|
||||
|
||||
// Error handler (with error-indicating name)
|
||||
nodes.push({
|
||||
id: `error-${i}`,
|
||||
name: `Error Handler ${i}`,
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1,
|
||||
position: [i * 20, 300],
|
||||
parameters: {}
|
||||
});
|
||||
|
||||
// INCORRECT configuration: both success and error handlers in main[0]
|
||||
connections[`Main ${i}`] = {
|
||||
main: [
|
||||
[
|
||||
{ node: `Success ${i}`, type: 'main', index: 0 },
|
||||
{ node: `Error Handler ${i}`, type: 'main', index: 0 } // Wrong!
|
||||
]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const workflow = { nodes, connections };
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
const endTime = performance.now();
|
||||
|
||||
const executionTime = endTime - startTime;
|
||||
|
||||
// Should complete within reasonable time even when generating many errors
|
||||
expect(executionTime).toBeLessThan(15000); // Less than 15 seconds
|
||||
|
||||
// Should detect ALL incorrect configurations
|
||||
const incorrectConfigErrors = result.errors.filter(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
);
|
||||
expect(incorrectConfigErrors.length).toBe(nodeCount); // One error per node
|
||||
|
||||
console.log(`Detected ${incorrectConfigErrors.length} incorrect configurations in ${nodes.length} nodes in ${executionTime.toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Usage and Optimization', () => {
|
||||
it('should not leak memory during large workflow validation', async () => {
|
||||
// Get initial memory usage
|
||||
const initialMemory = process.memoryUsage().heapUsed;
|
||||
|
||||
// Validate multiple large workflows
|
||||
for (let run = 0; run < 5; run++) {
|
||||
const nodeCount = 200;
|
||||
const nodes = [];
|
||||
const connections: any = {};
|
||||
|
||||
for (let i = 1; i <= nodeCount; i++) {
|
||||
nodes.push({
|
||||
id: i.toString(),
|
||||
name: `Node${i}`,
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [i * 10, 100],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
});
|
||||
|
||||
if (i > 1) {
|
||||
connections[`Node${i - 1}`] = {
|
||||
main: [
|
||||
[{ node: `Node${i}`, type: 'main', index: 0 }],
|
||||
[{ node: `Error${i}`, type: 'main', index: 0 }]
|
||||
]
|
||||
};
|
||||
|
||||
nodes.push({
|
||||
id: `error-${i}`,
|
||||
name: `Error${i}`,
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [i * 10, 200],
|
||||
parameters: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const workflow = { nodes, connections };
|
||||
await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
}
|
||||
|
||||
const finalMemory = process.memoryUsage().heapUsed;
|
||||
const memoryIncrease = finalMemory - initialMemory;
|
||||
const memoryIncreaseMB = memoryIncrease / (1024 * 1024);
|
||||
|
||||
// Memory increase should be reasonable (less than 50MB)
|
||||
expect(memoryIncreaseMB).toBeLessThan(50);
|
||||
|
||||
console.log(`Memory increase after 5 large workflow validations: ${memoryIncreaseMB.toFixed(2)}MB`);
|
||||
});
|
||||
|
||||
it('should handle concurrent validation requests efficiently', async () => {
|
||||
// Create multiple validation requests that run concurrently
|
||||
const concurrentRequests = 10;
|
||||
const workflows = [];
|
||||
|
||||
// Prepare workflows
|
||||
for (let r = 0; r < concurrentRequests; r++) {
|
||||
const nodeCount = 50;
|
||||
const nodes = [];
|
||||
const connections: any = {};
|
||||
|
||||
for (let i = 1; i <= nodeCount; i++) {
|
||||
nodes.push({
|
||||
id: `${r}-${i}`,
|
||||
name: `R${r}Node${i}`,
|
||||
type: i % 2 === 0 ? 'n8n-nodes-base.httpRequest' : 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [i * 20, r * 100],
|
||||
parameters: {},
|
||||
...(i % 3 === 0 ? { onError: 'continueErrorOutput' } : {})
|
||||
});
|
||||
|
||||
if (i > 1) {
|
||||
const hasError = i % 3 === 0;
|
||||
const isIncorrect = i % 6 === 0;
|
||||
|
||||
if (hasError && isIncorrect) {
|
||||
// Incorrect configuration
|
||||
connections[`R${r}Node${i - 1}`] = {
|
||||
main: [
|
||||
[
|
||||
{ node: `R${r}Node${i}`, type: 'main', index: 0 },
|
||||
{ node: `R${r}Error${i}`, type: 'main', index: 0 } // Wrong!
|
||||
]
|
||||
]
|
||||
};
|
||||
|
||||
nodes.push({
|
||||
id: `${r}-error-${i}`,
|
||||
name: `R${r}Error${i}`,
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1,
|
||||
position: [i * 20, r * 100 + 50],
|
||||
parameters: {}
|
||||
});
|
||||
} else if (hasError) {
|
||||
// Correct configuration
|
||||
connections[`R${r}Node${i - 1}`] = {
|
||||
main: [
|
||||
[{ node: `R${r}Node${i}`, type: 'main', index: 0 }],
|
||||
[{ node: `R${r}Error${i}`, type: 'main', index: 0 }]
|
||||
]
|
||||
};
|
||||
|
||||
nodes.push({
|
||||
id: `${r}-error-${i}`,
|
||||
name: `R${r}Error${i}`,
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [i * 20, r * 100 + 50],
|
||||
parameters: {}
|
||||
});
|
||||
} else {
|
||||
// Normal connection
|
||||
connections[`R${r}Node${i - 1}`] = {
|
||||
main: [
|
||||
[{ node: `R${r}Node${i}`, type: 'main', index: 0 }]
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workflows.push({ nodes, connections });
|
||||
}
|
||||
|
||||
// Run concurrent validations
|
||||
const startTime = performance.now();
|
||||
const results = await Promise.all(
|
||||
workflows.map(workflow => validator.validateWorkflow(workflow as any))
|
||||
);
|
||||
const endTime = performance.now();
|
||||
|
||||
const totalTime = endTime - startTime;
|
||||
|
||||
// All validations should complete
|
||||
expect(results).toHaveLength(concurrentRequests);
|
||||
|
||||
// Each result should be valid
|
||||
results.forEach(result => {
|
||||
expect(Array.isArray(result.errors)).toBe(true);
|
||||
expect(Array.isArray(result.warnings)).toBe(true);
|
||||
});
|
||||
|
||||
// Concurrent execution should be efficient
|
||||
expect(totalTime).toBeLessThan(20000); // Less than 20 seconds total
|
||||
|
||||
console.log(`Completed ${concurrentRequests} concurrent validations in ${totalTime.toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user