mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-16 23:43:07 +00:00
* feat(auto-fixer): add 5 connection structure fix types Add automatic repair for malformed workflow connections commonly generated by AI models: - connection-numeric-keys: "0","1" keys → main[0], main[1] - connection-invalid-type: type:"0" → type:"main" (or parent key) - connection-id-to-name: node ID refs → node name refs - connection-duplicate-removal: dedup identical connection entries - connection-input-index: out-of-bounds input index → clamped Includes collision-safe ID-to-name renames, medium confidence on merge conflicts and index clamping, shared CONNECTION_FIX_TYPES constant, and 24 unit tests. Concieved by Romuald Członkowski - www.aiadvisors.pl/en * feat(validator): detect IF/Switch/Filter conditional branch fan-out misuse Add CONDITIONAL_BRANCH_FANOUT warning when conditional nodes have all connections on main[0] with higher outputs empty, indicating both branches execute together instead of being split by condition. Extract getShortNodeType() and getConditionalOutputInfo() helpers to deduplicate conditional node detection logic. Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
218 lines
7.9 KiB
TypeScript
218 lines
7.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { WorkflowValidator } from '@/services/workflow-validator';
|
|
import { NodeRepository } from '@/database/node-repository';
|
|
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
|
|
|
// Mock dependencies
|
|
vi.mock('@/database/node-repository');
|
|
vi.mock('@/services/enhanced-config-validator');
|
|
vi.mock('@/services/expression-validator');
|
|
vi.mock('@/utils/logger');
|
|
|
|
describe('WorkflowValidator - AI Sub-Node Main Connection Detection', () => {
|
|
let validator: WorkflowValidator;
|
|
let mockNodeRepository: NodeRepository;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
mockNodeRepository = new NodeRepository({} as any) as any;
|
|
|
|
if (!mockNodeRepository.getAllNodes) {
|
|
mockNodeRepository.getAllNodes = vi.fn();
|
|
}
|
|
if (!mockNodeRepository.getNode) {
|
|
mockNodeRepository.getNode = vi.fn();
|
|
}
|
|
|
|
const nodeTypes: Record<string, any> = {
|
|
'nodes-base.manualTrigger': {
|
|
type: 'nodes-base.manualTrigger',
|
|
displayName: 'Manual Trigger',
|
|
package: 'n8n-nodes-base',
|
|
isTrigger: true,
|
|
outputs: ['main'],
|
|
properties: [],
|
|
},
|
|
'nodes-base.set': {
|
|
type: 'nodes-base.set',
|
|
displayName: 'Set',
|
|
package: 'n8n-nodes-base',
|
|
outputs: ['main'],
|
|
properties: [],
|
|
},
|
|
'nodes-langchain.lmChatGoogleGemini': {
|
|
type: 'nodes-langchain.lmChatGoogleGemini',
|
|
displayName: 'Google Gemini Chat Model',
|
|
package: '@n8n/n8n-nodes-langchain',
|
|
outputs: ['ai_languageModel'],
|
|
properties: [],
|
|
},
|
|
'nodes-langchain.memoryBufferWindow': {
|
|
type: 'nodes-langchain.memoryBufferWindow',
|
|
displayName: 'Window Buffer Memory',
|
|
package: '@n8n/n8n-nodes-langchain',
|
|
outputs: ['ai_memory'],
|
|
properties: [],
|
|
},
|
|
'nodes-langchain.embeddingsOpenAi': {
|
|
type: 'nodes-langchain.embeddingsOpenAi',
|
|
displayName: 'Embeddings OpenAI',
|
|
package: '@n8n/n8n-nodes-langchain',
|
|
outputs: ['ai_embedding'],
|
|
properties: [],
|
|
},
|
|
'nodes-langchain.agent': {
|
|
type: 'nodes-langchain.agent',
|
|
displayName: 'AI Agent',
|
|
package: '@n8n/n8n-nodes-langchain',
|
|
isAITool: true,
|
|
outputs: ['main'],
|
|
properties: [],
|
|
},
|
|
'nodes-langchain.openAi': {
|
|
type: 'nodes-langchain.openAi',
|
|
displayName: 'OpenAI',
|
|
package: '@n8n/n8n-nodes-langchain',
|
|
outputs: ['main'],
|
|
properties: [],
|
|
},
|
|
'nodes-langchain.textClassifier': {
|
|
type: 'nodes-langchain.textClassifier',
|
|
displayName: 'Text Classifier',
|
|
package: '@n8n/n8n-nodes-langchain',
|
|
outputs: ['={{}}'], // Dynamic expression-based outputs
|
|
properties: [],
|
|
},
|
|
'nodes-langchain.vectorStoreInMemory': {
|
|
type: 'nodes-langchain.vectorStoreInMemory',
|
|
displayName: 'In-Memory Vector Store',
|
|
package: '@n8n/n8n-nodes-langchain',
|
|
outputs: ['={{$parameter["mode"] === "retrieve" ? "main" : "ai_vectorStore"}}'],
|
|
properties: [],
|
|
},
|
|
};
|
|
|
|
vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => {
|
|
return nodeTypes[nodeType] || null;
|
|
});
|
|
vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes));
|
|
|
|
validator = new WorkflowValidator(
|
|
mockNodeRepository,
|
|
EnhancedConfigValidator as any
|
|
);
|
|
});
|
|
|
|
function makeWorkflow(sourceType: string, sourceName: string, connectionKey: string = 'main') {
|
|
return {
|
|
nodes: [
|
|
{ id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
|
|
{ id: '2', name: sourceName, type: sourceType, position: [200, 0], parameters: {} },
|
|
{ id: '3', name: 'Set', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
|
],
|
|
connections: {
|
|
'Manual Trigger': {
|
|
main: [[{ node: sourceName, type: 'main', index: 0 }]]
|
|
},
|
|
[sourceName]: {
|
|
[connectionKey]: [[{ node: 'Set', type: connectionKey, index: 0 }]]
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
it('should flag LLM node (lmChatGoogleGemini) connected via main', async () => {
|
|
const workflow = makeWorkflow(
|
|
'n8n-nodes-langchain.lmChatGoogleGemini',
|
|
'Google Gemini'
|
|
);
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
|
|
expect(error).toBeDefined();
|
|
expect(error!.message).toContain('ai_languageModel');
|
|
expect(error!.message).toContain('AI sub-node');
|
|
expect(error!.nodeName).toBe('Google Gemini');
|
|
});
|
|
|
|
it('should flag memory node (memoryBufferWindow) connected via main', async () => {
|
|
const workflow = makeWorkflow(
|
|
'n8n-nodes-langchain.memoryBufferWindow',
|
|
'Window Buffer Memory'
|
|
);
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
|
|
expect(error).toBeDefined();
|
|
expect(error!.message).toContain('ai_memory');
|
|
});
|
|
|
|
it('should flag embeddings node connected via main', async () => {
|
|
const workflow = makeWorkflow(
|
|
'n8n-nodes-langchain.embeddingsOpenAi',
|
|
'Embeddings OpenAI'
|
|
);
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
const error = result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION');
|
|
expect(error).toBeDefined();
|
|
expect(error!.message).toContain('ai_embedding');
|
|
});
|
|
|
|
it('should NOT flag regular langchain nodes (agent, openAi) connected via main', async () => {
|
|
const workflow1 = makeWorkflow('n8n-nodes-langchain.agent', 'AI Agent');
|
|
const workflow2 = makeWorkflow('n8n-nodes-langchain.openAi', 'OpenAI');
|
|
|
|
const result1 = await validator.validateWorkflow(workflow1 as any);
|
|
const result2 = await validator.validateWorkflow(workflow2 as any);
|
|
|
|
expect(result1.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
|
|
expect(result2.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
|
|
});
|
|
|
|
it('should NOT flag dynamic-output nodes (expression-based outputs)', async () => {
|
|
const workflow1 = makeWorkflow('n8n-nodes-langchain.textClassifier', 'Text Classifier');
|
|
const workflow2 = makeWorkflow('n8n-nodes-langchain.vectorStoreInMemory', 'Vector Store');
|
|
|
|
const result1 = await validator.validateWorkflow(workflow1 as any);
|
|
const result2 = await validator.validateWorkflow(workflow2 as any);
|
|
|
|
expect(result1.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
|
|
expect(result2.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
|
|
});
|
|
|
|
it('should NOT flag AI sub-node connected via correct AI type', async () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{ id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
|
|
{ id: '2', name: 'AI Agent', type: 'n8n-nodes-langchain.agent', position: [200, 0], parameters: {} },
|
|
{ id: '3', name: 'Google Gemini', type: 'n8n-nodes-langchain.lmChatGoogleGemini', position: [200, 200], parameters: {} },
|
|
],
|
|
connections: {
|
|
'Manual Trigger': {
|
|
main: [[{ node: 'AI Agent', type: 'main', index: 0 }]]
|
|
},
|
|
'Google Gemini': {
|
|
ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
|
}
|
|
}
|
|
};
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
|
|
});
|
|
|
|
it('should NOT flag unknown/community nodes not in database', async () => {
|
|
const workflow = makeWorkflow('n8n-nodes-community.someNode', 'Community Node');
|
|
|
|
const result = await validator.validateWorkflow(workflow as any);
|
|
|
|
expect(result.errors.find(e => e.code === 'AI_SUBNODE_MAIN_CONNECTION')).toBeUndefined();
|
|
});
|
|
});
|