mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-16 23:43:07 +00:00
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 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.36.1] - 2026-03-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Conditional branch fan-out detection** (`CONDITIONAL_BRANCH_FANOUT`): Warns when IF, Filter, or Switch nodes have all connections crammed into `main[0]` with higher-index outputs empty, which usually means all target nodes execute together on one branch while other branches have no effect
|
||||||
|
- Detects IF nodes with both true/false targets on `main[0]`
|
||||||
|
- Detects Filter nodes with both matched/unmatched targets on `main[0]`
|
||||||
|
- Detects Switch nodes with all targets on output 0 and other outputs unused
|
||||||
|
- Skips warning when fan-out is legitimate (higher outputs also have connections)
|
||||||
|
- Skips warning for single connections (intentional true-only/matched-only usage)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Refactored output index validation**: Extracted `getShortNodeType()` and `getConditionalOutputInfo()` helpers to eliminate duplicated conditional node detection logic between `validateOutputIndexBounds` and the new `validateConditionalBranchUsage`
|
||||||
|
|
||||||
|
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||||
|
|
||||||
## [2.36.0] - 2026-03-07
|
## [2.36.0] - 2026-03-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
5
dist/services/workflow-validator.d.ts
vendored
5
dist/services/workflow-validator.d.ts
vendored
@@ -85,7 +85,12 @@ export declare class WorkflowValidator {
|
|||||||
private validateErrorOutputConfiguration;
|
private validateErrorOutputConfiguration;
|
||||||
private validateAIToolConnection;
|
private validateAIToolConnection;
|
||||||
private validateAIToolSource;
|
private validateAIToolSource;
|
||||||
|
private getNodeOutputTypes;
|
||||||
|
private validateNotAISubNode;
|
||||||
|
private getShortNodeType;
|
||||||
|
private getConditionalOutputInfo;
|
||||||
private validateOutputIndexBounds;
|
private validateOutputIndexBounds;
|
||||||
|
private validateConditionalBranchUsage;
|
||||||
private validateInputIndexBounds;
|
private validateInputIndexBounds;
|
||||||
private flagOrphanedNodes;
|
private flagOrphanedNodes;
|
||||||
private validateTriggerReachability;
|
private validateTriggerReachability;
|
||||||
|
|||||||
2
dist/services/workflow-validator.d.ts.map
vendored
2
dist/services/workflow-validator.d.ts.map
vendored
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"workflow-validator.d.ts","sourceRoot":"","sources":["../../src/services/workflow-validator.ts"],"names":[],"mappings":"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"}
|
{"version":3,"file":"workflow-validator.d.ts","sourceRoot":"","sources":["../../src/services/workflow-validator.ts"],"names":[],"mappings":"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"}
|
||||||
107
dist/services/workflow-validator.js
vendored
107
dist/services/workflow-validator.js
vendored
@@ -422,6 +422,9 @@ class WorkflowValidator {
|
|||||||
if (outputKey === 'ai_tool') {
|
if (outputKey === 'ai_tool') {
|
||||||
this.validateAIToolSource(sourceNode, result);
|
this.validateAIToolSource(sourceNode, result);
|
||||||
}
|
}
|
||||||
|
if (outputKey === 'main') {
|
||||||
|
this.validateNotAISubNode(sourceNode, result);
|
||||||
|
}
|
||||||
this.validateConnectionOutputs(sourceName, outputConnections, nodeMap, nodeIdMap, result, outputKey);
|
this.validateConnectionOutputs(sourceName, outputConnections, nodeMap, nodeIdMap, result, outputKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,6 +446,7 @@ class WorkflowValidator {
|
|||||||
if (outputType === 'main' && sourceNode) {
|
if (outputType === 'main' && sourceNode) {
|
||||||
this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result);
|
this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result);
|
||||||
this.validateOutputIndexBounds(sourceNode, outputs, result);
|
this.validateOutputIndexBounds(sourceNode, outputs, result);
|
||||||
|
this.validateConditionalBranchUsage(sourceNode, outputs, result);
|
||||||
}
|
}
|
||||||
outputs.forEach((outputConnections, outputIndex) => {
|
outputs.forEach((outputConnections, outputIndex) => {
|
||||||
if (!outputConnections)
|
if (!outputConnections)
|
||||||
@@ -644,6 +648,57 @@ class WorkflowValidator {
|
|||||||
code: 'INVALID_AI_TOOL_SOURCE'
|
code: 'INVALID_AI_TOOL_SOURCE'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
getNodeOutputTypes(nodeType) {
|
||||||
|
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||||
|
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||||
|
if (!nodeInfo || !nodeInfo.outputs)
|
||||||
|
return null;
|
||||||
|
const outputs = nodeInfo.outputs;
|
||||||
|
if (!Array.isArray(outputs))
|
||||||
|
return null;
|
||||||
|
for (const output of outputs) {
|
||||||
|
if (typeof output === 'string' && output.startsWith('={{')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
validateNotAISubNode(sourceNode, result) {
|
||||||
|
const outputTypes = this.getNodeOutputTypes(sourceNode.type);
|
||||||
|
if (!outputTypes)
|
||||||
|
return;
|
||||||
|
const hasMainOutput = outputTypes.some(t => t === 'main');
|
||||||
|
if (hasMainOutput)
|
||||||
|
return;
|
||||||
|
const aiTypes = outputTypes.filter(t => t !== 'main');
|
||||||
|
const expectedType = aiTypes[0] || 'ai_languageModel';
|
||||||
|
result.errors.push({
|
||||||
|
type: 'error',
|
||||||
|
nodeId: sourceNode.id,
|
||||||
|
nodeName: sourceNode.name,
|
||||||
|
message: `Node "${sourceNode.name}" (${sourceNode.type}) is an AI sub-node that outputs "${expectedType}" connections. ` +
|
||||||
|
`It cannot be used with "main" connections. Connect it to an AI Agent or Chain via "${expectedType}" instead.`,
|
||||||
|
code: 'AI_SUBNODE_MAIN_CONNECTION'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getShortNodeType(sourceNode) {
|
||||||
|
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
|
||||||
|
return normalizedType.replace(/^(n8n-)?nodes-base\./, '');
|
||||||
|
}
|
||||||
|
getConditionalOutputInfo(sourceNode) {
|
||||||
|
const shortType = this.getShortNodeType(sourceNode);
|
||||||
|
if (shortType === 'if' || shortType === 'filter') {
|
||||||
|
return { shortType, expectedOutputs: 2 };
|
||||||
|
}
|
||||||
|
if (shortType === 'switch') {
|
||||||
|
const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules;
|
||||||
|
if (Array.isArray(rules)) {
|
||||||
|
return { shortType, expectedOutputs: rules.length + 1 };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
validateOutputIndexBounds(sourceNode, outputs, result) {
|
validateOutputIndexBounds(sourceNode, outputs, result) {
|
||||||
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
|
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
|
||||||
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||||
@@ -658,19 +713,13 @@ class WorkflowValidator {
|
|||||||
}
|
}
|
||||||
if (mainOutputCount === 0)
|
if (mainOutputCount === 0)
|
||||||
return;
|
return;
|
||||||
const shortType = normalizedType.replace(/^(n8n-)?nodes-base\./, '');
|
const conditionalInfo = this.getConditionalOutputInfo(sourceNode);
|
||||||
if (shortType === 'switch') {
|
if (conditionalInfo) {
|
||||||
const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules;
|
mainOutputCount = conditionalInfo.expectedOutputs;
|
||||||
if (Array.isArray(rules)) {
|
|
||||||
mainOutputCount = rules.length + 1;
|
|
||||||
}
|
}
|
||||||
else {
|
else if (this.getShortNodeType(sourceNode) === 'switch') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (shortType === 'if' || shortType === 'filter') {
|
|
||||||
mainOutputCount = 2;
|
|
||||||
}
|
|
||||||
if (sourceNode.onError === 'continueErrorOutput') {
|
if (sourceNode.onError === 'continueErrorOutput') {
|
||||||
mainOutputCount += 1;
|
mainOutputCount += 1;
|
||||||
}
|
}
|
||||||
@@ -691,6 +740,44 @@ class WorkflowValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
validateConditionalBranchUsage(sourceNode, outputs, result) {
|
||||||
|
const conditionalInfo = this.getConditionalOutputInfo(sourceNode);
|
||||||
|
if (!conditionalInfo || conditionalInfo.expectedOutputs < 2)
|
||||||
|
return;
|
||||||
|
const { shortType, expectedOutputs } = conditionalInfo;
|
||||||
|
const main0Count = outputs[0]?.length || 0;
|
||||||
|
if (main0Count < 2)
|
||||||
|
return;
|
||||||
|
const hasHigherIndexConnections = outputs.slice(1).some(conns => conns && conns.length > 0);
|
||||||
|
if (hasHigherIndexConnections)
|
||||||
|
return;
|
||||||
|
let message;
|
||||||
|
if (shortType === 'if' || shortType === 'filter') {
|
||||||
|
const isFilter = shortType === 'filter';
|
||||||
|
const displayName = isFilter ? 'Filter' : 'IF';
|
||||||
|
const trueLabel = isFilter ? 'matched' : 'true';
|
||||||
|
const falseLabel = isFilter ? 'unmatched' : 'false';
|
||||||
|
message = `${displayName} node "${sourceNode.name}" has ${main0Count} connections on the "${trueLabel}" branch (main[0]) ` +
|
||||||
|
`but no connections on the "${falseLabel}" branch (main[1]). ` +
|
||||||
|
`All ${main0Count} target nodes execute together on the "${trueLabel}" branch, ` +
|
||||||
|
`while the "${falseLabel}" branch has no effect. ` +
|
||||||
|
`Split connections: main[0] for ${trueLabel}, main[1] for ${falseLabel}.`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
message = `Switch node "${sourceNode.name}" has ${main0Count} connections on output 0 ` +
|
||||||
|
`but no connections on any other outputs (1-${expectedOutputs - 1}). ` +
|
||||||
|
`All ${main0Count} target nodes execute together on output 0, ` +
|
||||||
|
`while other switch branches have no effect. ` +
|
||||||
|
`Distribute connections across outputs to match switch rules.`;
|
||||||
|
}
|
||||||
|
result.warnings.push({
|
||||||
|
type: 'warning',
|
||||||
|
nodeId: sourceNode.id,
|
||||||
|
nodeName: sourceNode.name,
|
||||||
|
message,
|
||||||
|
code: 'CONDITIONAL_BRANCH_FANOUT'
|
||||||
|
});
|
||||||
|
}
|
||||||
validateInputIndexBounds(sourceName, targetNode, connection, result) {
|
validateInputIndexBounds(sourceName, targetNode, connection, result) {
|
||||||
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(targetNode.type);
|
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(targetNode.type);
|
||||||
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||||
|
|||||||
2
dist/services/workflow-validator.js.map
vendored
2
dist/services/workflow-validator.js.map
vendored
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.35.6",
|
"version": "2.36.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.35.6",
|
"version": "2.36.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "1.20.1",
|
"@modelcontextprotocol/sdk": "1.20.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.36.0",
|
"version": "2.36.1",
|
||||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -650,6 +650,11 @@ export class WorkflowValidator {
|
|||||||
this.validateAIToolSource(sourceNode, result);
|
this.validateAIToolSource(sourceNode, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate that AI sub-nodes are not connected via main
|
||||||
|
if (outputKey === 'main') {
|
||||||
|
this.validateNotAISubNode(sourceNode, result);
|
||||||
|
}
|
||||||
|
|
||||||
this.validateConnectionOutputs(
|
this.validateConnectionOutputs(
|
||||||
sourceName,
|
sourceName,
|
||||||
outputConnections,
|
outputConnections,
|
||||||
@@ -695,6 +700,7 @@ export class WorkflowValidator {
|
|||||||
if (outputType === 'main' && sourceNode) {
|
if (outputType === 'main' && sourceNode) {
|
||||||
this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result);
|
this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result);
|
||||||
this.validateOutputIndexBounds(sourceNode, outputs, result);
|
this.validateOutputIndexBounds(sourceNode, outputs, result);
|
||||||
|
this.validateConditionalBranchUsage(sourceNode, outputs, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
outputs.forEach((outputConnections, outputIndex) => {
|
outputs.forEach((outputConnections, outputIndex) => {
|
||||||
@@ -987,6 +993,85 @@ export class WorkflowValidator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the static output types for a node from the database.
|
||||||
|
* Returns null if outputs contain expressions (dynamic) or node not found.
|
||||||
|
*/
|
||||||
|
private getNodeOutputTypes(nodeType: string): string[] | null {
|
||||||
|
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||||
|
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||||
|
if (!nodeInfo || !nodeInfo.outputs) return null;
|
||||||
|
|
||||||
|
const outputs = nodeInfo.outputs;
|
||||||
|
if (!Array.isArray(outputs)) return null;
|
||||||
|
|
||||||
|
// Skip if any output is an expression (dynamic — can't determine statically)
|
||||||
|
for (const output of outputs) {
|
||||||
|
if (typeof output === 'string' && output.startsWith('={{')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that AI sub-nodes (nodes that only output AI connection types)
|
||||||
|
* are not connected via "main" connections.
|
||||||
|
*/
|
||||||
|
private validateNotAISubNode(
|
||||||
|
sourceNode: WorkflowNode,
|
||||||
|
result: WorkflowValidationResult
|
||||||
|
): void {
|
||||||
|
const outputTypes = this.getNodeOutputTypes(sourceNode.type);
|
||||||
|
if (!outputTypes) return; // Unknown or dynamic — skip
|
||||||
|
|
||||||
|
// Check if the node outputs ONLY AI types (no 'main')
|
||||||
|
const hasMainOutput = outputTypes.some(t => t === 'main');
|
||||||
|
if (hasMainOutput) return; // Node can legitimately output main
|
||||||
|
|
||||||
|
// All outputs are AI types — this node should not be connected via main
|
||||||
|
const aiTypes = outputTypes.filter(t => t !== 'main');
|
||||||
|
const expectedType = aiTypes[0] || 'ai_languageModel';
|
||||||
|
|
||||||
|
result.errors.push({
|
||||||
|
type: 'error',
|
||||||
|
nodeId: sourceNode.id,
|
||||||
|
nodeName: sourceNode.name,
|
||||||
|
message: `Node "${sourceNode.name}" (${sourceNode.type}) is an AI sub-node that outputs "${expectedType}" connections. ` +
|
||||||
|
`It cannot be used with "main" connections. Connect it to an AI Agent or Chain via "${expectedType}" instead.`,
|
||||||
|
code: 'AI_SUBNODE_MAIN_CONNECTION'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the short node type name (e.g., "if", "switch", "set") from a workflow node.
|
||||||
|
*/
|
||||||
|
private getShortNodeType(sourceNode: WorkflowNode): string {
|
||||||
|
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
|
||||||
|
return normalizedType.replace(/^(n8n-)?nodes-base\./, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the expected main output count for a conditional node (IF, Filter, Switch).
|
||||||
|
* Returns null for non-conditional nodes or when the count cannot be determined.
|
||||||
|
*/
|
||||||
|
private getConditionalOutputInfo(sourceNode: WorkflowNode): { shortType: string; expectedOutputs: number } | null {
|
||||||
|
const shortType = this.getShortNodeType(sourceNode);
|
||||||
|
|
||||||
|
if (shortType === 'if' || shortType === 'filter') {
|
||||||
|
return { shortType, expectedOutputs: 2 };
|
||||||
|
}
|
||||||
|
if (shortType === 'switch') {
|
||||||
|
const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules;
|
||||||
|
if (Array.isArray(rules)) {
|
||||||
|
return { shortType, expectedOutputs: rules.length + 1 }; // rules + fallback
|
||||||
|
}
|
||||||
|
return null; // Cannot determine dynamic output count
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that output indices don't exceed what the node type supports.
|
* Validate that output indices don't exceed what the node type supports.
|
||||||
*/
|
*/
|
||||||
@@ -1012,19 +1097,13 @@ export class WorkflowValidator {
|
|||||||
|
|
||||||
if (mainOutputCount === 0) return;
|
if (mainOutputCount === 0) return;
|
||||||
|
|
||||||
// Account for dynamic output counts based on node type and parameters
|
// Override with dynamic output counts for conditional nodes
|
||||||
const shortType = normalizedType.replace(/^(n8n-)?nodes-base\./, '');
|
const conditionalInfo = this.getConditionalOutputInfo(sourceNode);
|
||||||
if (shortType === 'switch') {
|
if (conditionalInfo) {
|
||||||
// Switch node: output count depends on rules configuration
|
mainOutputCount = conditionalInfo.expectedOutputs;
|
||||||
const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules;
|
} else if (this.getShortNodeType(sourceNode) === 'switch') {
|
||||||
if (Array.isArray(rules)) {
|
// Switch without determinable rules -- skip bounds check
|
||||||
mainOutputCount = rules.length + 1; // rules + fallback
|
return;
|
||||||
} else {
|
|
||||||
return; // Cannot determine dynamic output count, skip bounds check
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (shortType === 'if' || shortType === 'filter') {
|
|
||||||
mainOutputCount = 2; // true/false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Account for continueErrorOutput adding an extra output
|
// Account for continueErrorOutput adding an extra output
|
||||||
@@ -1052,6 +1131,60 @@ export class WorkflowValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect when a conditional node (IF, Filter, Switch) has all connections
|
||||||
|
* crammed into main[0] with higher-index outputs empty. This usually means
|
||||||
|
* both branches execute together on one condition, while the other branches
|
||||||
|
* have no effect.
|
||||||
|
*/
|
||||||
|
private validateConditionalBranchUsage(
|
||||||
|
sourceNode: WorkflowNode,
|
||||||
|
outputs: Array<Array<{ node: string; type: string; index: number }>>,
|
||||||
|
result: WorkflowValidationResult
|
||||||
|
): void {
|
||||||
|
const conditionalInfo = this.getConditionalOutputInfo(sourceNode);
|
||||||
|
if (!conditionalInfo || conditionalInfo.expectedOutputs < 2) return;
|
||||||
|
|
||||||
|
const { shortType, expectedOutputs } = conditionalInfo;
|
||||||
|
|
||||||
|
// Check: main[0] has >= 2 connections AND all main[1+] are empty
|
||||||
|
const main0Count = outputs[0]?.length || 0;
|
||||||
|
if (main0Count < 2) return;
|
||||||
|
|
||||||
|
const hasHigherIndexConnections = outputs.slice(1).some(
|
||||||
|
conns => conns && conns.length > 0
|
||||||
|
);
|
||||||
|
if (hasHigherIndexConnections) return;
|
||||||
|
|
||||||
|
// Build a context-appropriate warning message
|
||||||
|
let message: string;
|
||||||
|
if (shortType === 'if' || shortType === 'filter') {
|
||||||
|
const isFilter = shortType === 'filter';
|
||||||
|
const displayName = isFilter ? 'Filter' : 'IF';
|
||||||
|
const trueLabel = isFilter ? 'matched' : 'true';
|
||||||
|
const falseLabel = isFilter ? 'unmatched' : 'false';
|
||||||
|
message = `${displayName} node "${sourceNode.name}" has ${main0Count} connections on the "${trueLabel}" branch (main[0]) ` +
|
||||||
|
`but no connections on the "${falseLabel}" branch (main[1]). ` +
|
||||||
|
`All ${main0Count} target nodes execute together on the "${trueLabel}" branch, ` +
|
||||||
|
`while the "${falseLabel}" branch has no effect. ` +
|
||||||
|
`Split connections: main[0] for ${trueLabel}, main[1] for ${falseLabel}.`;
|
||||||
|
} else {
|
||||||
|
message = `Switch node "${sourceNode.name}" has ${main0Count} connections on output 0 ` +
|
||||||
|
`but no connections on any other outputs (1-${expectedOutputs - 1}). ` +
|
||||||
|
`All ${main0Count} target nodes execute together on output 0, ` +
|
||||||
|
`while other switch branches have no effect. ` +
|
||||||
|
`Distribute connections across outputs to match switch rules.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.warnings.push({
|
||||||
|
type: 'warning',
|
||||||
|
nodeId: sourceNode.id,
|
||||||
|
nodeName: sourceNode.name,
|
||||||
|
message,
|
||||||
|
code: 'CONDITIONAL_BRANCH_FANOUT'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that input index doesn't exceed what the target node accepts.
|
* Validate that input index doesn't exceed what the target node accepts.
|
||||||
*/
|
*/
|
||||||
|
|||||||
217
tests/unit/services/workflow-validator-ai-subnode.test.ts
Normal file
217
tests/unit/services/workflow-validator-ai-subnode.test.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,6 +63,20 @@ describe('WorkflowValidator - Connection Validation (#620)', () => {
|
|||||||
outputs: ['main', 'main'],
|
outputs: ['main', 'main'],
|
||||||
properties: [],
|
properties: [],
|
||||||
},
|
},
|
||||||
|
'nodes-base.filter': {
|
||||||
|
type: 'nodes-base.filter',
|
||||||
|
displayName: 'Filter',
|
||||||
|
package: 'n8n-nodes-base',
|
||||||
|
outputs: ['main', 'main'],
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
'nodes-base.switch': {
|
||||||
|
type: 'nodes-base.switch',
|
||||||
|
displayName: 'Switch',
|
||||||
|
package: 'n8n-nodes-base',
|
||||||
|
outputs: ['main', 'main', 'main', 'main'],
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
'nodes-base.googleSheets': {
|
'nodes-base.googleSheets': {
|
||||||
type: 'nodes-base.googleSheets',
|
type: 'nodes-base.googleSheets',
|
||||||
displayName: 'Google Sheets',
|
displayName: 'Google Sheets',
|
||||||
@@ -715,4 +729,190 @@ describe('WorkflowValidator - Connection Validation (#620)', () => {
|
|||||||
expect(orphanWarning!.message).toContain('not connected to any other nodes');
|
expect(orphanWarning!.message).toContain('not connected to any other nodes');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Conditional branch fan-out detection (CONDITIONAL_BRANCH_FANOUT)', () => {
|
||||||
|
it('should warn when IF node has both branches in main[0]', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
|
||||||
|
{ id: '3', name: 'TrueTarget', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||||
|
{ id: '4', name: 'FalseTarget', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
|
||||||
|
'Route': {
|
||||||
|
main: [[{ node: 'TrueTarget', type: 'main', index: 0 }, { node: 'FalseTarget', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
|
||||||
|
expect(warning).toBeDefined();
|
||||||
|
expect(warning!.nodeName).toBe('Route');
|
||||||
|
expect(warning!.message).toContain('2 connections on the "true" branch');
|
||||||
|
expect(warning!.message).toContain('"false" branch has no effect');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn when IF node has correct true/false split', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
|
||||||
|
{ id: '3', name: 'TrueTarget', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||||
|
{ id: '4', name: 'FalseTarget', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
|
||||||
|
'Route': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'TrueTarget', type: 'main', index: 0 }],
|
||||||
|
[{ node: 'FalseTarget', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
|
||||||
|
expect(warning).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn when IF has fan-out on main[0] AND connections on main[1]', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
|
||||||
|
{ id: '3', name: 'TrueA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||||
|
{ id: '4', name: 'TrueB', type: 'n8n-nodes-base.set', position: [400, 100], parameters: {} },
|
||||||
|
{ id: '5', name: 'FalseTarget', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
|
||||||
|
'Route': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'TrueA', type: 'main', index: 0 }, { node: 'TrueB', type: 'main', index: 0 }],
|
||||||
|
[{ node: 'FalseTarget', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
|
||||||
|
expect(warning).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn when Switch node has all connections on main[0]', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '2', name: 'MySwitch', type: 'n8n-nodes-base.switch', position: [200, 0], parameters: { rules: { values: [{ value: 'a' }, { value: 'b' }] } } },
|
||||||
|
{ id: '3', name: 'TargetA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||||
|
{ id: '4', name: 'TargetB', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||||
|
{ id: '5', name: 'TargetC', type: 'n8n-nodes-base.set', position: [400, 400], parameters: {} },
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Trigger': { main: [[{ node: 'MySwitch', type: 'main', index: 0 }]] },
|
||||||
|
'MySwitch': {
|
||||||
|
main: [[{ node: 'TargetA', type: 'main', index: 0 }, { node: 'TargetB', type: 'main', index: 0 }, { node: 'TargetC', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
|
||||||
|
expect(warning).toBeDefined();
|
||||||
|
expect(warning!.nodeName).toBe('MySwitch');
|
||||||
|
expect(warning!.message).toContain('3 connections on output 0');
|
||||||
|
expect(warning!.message).toContain('other switch branches have no effect');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn when Switch node has no rules parameter (indeterminate outputs)', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '2', name: 'MySwitch', type: 'n8n-nodes-base.switch', position: [200, 0], parameters: {} },
|
||||||
|
{ id: '3', name: 'TargetA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||||
|
{ id: '4', name: 'TargetB', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Trigger': { main: [[{ node: 'MySwitch', type: 'main', index: 0 }]] },
|
||||||
|
'MySwitch': {
|
||||||
|
main: [[{ node: 'TargetA', type: 'main', index: 0 }, { node: 'TargetB', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
|
||||||
|
expect(warning).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn when regular node has fan-out on main[0]', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '2', name: 'MySet', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} },
|
||||||
|
{ id: '3', name: 'TargetA', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||||
|
{ id: '4', name: 'TargetB', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Trigger': { main: [[{ node: 'MySet', type: 'main', index: 0 }]] },
|
||||||
|
'MySet': {
|
||||||
|
main: [[{ node: 'TargetA', type: 'main', index: 0 }, { node: 'TargetB', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
|
||||||
|
expect(warning).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn when IF has only 1 connection on main[0] with empty main[1]', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '2', name: 'Route', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
|
||||||
|
{ id: '3', name: 'TrueOnly', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Trigger': { main: [[{ node: 'Route', type: 'main', index: 0 }]] },
|
||||||
|
'Route': {
|
||||||
|
main: [[{ node: 'TrueOnly', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
|
||||||
|
expect(warning).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn for Filter node with both branches in main[0]', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '2', name: 'MyFilter', type: 'n8n-nodes-base.filter', position: [200, 0], parameters: {} },
|
||||||
|
{ id: '3', name: 'Matched', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||||
|
{ id: '4', name: 'Unmatched', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Trigger': { main: [[{ node: 'MyFilter', type: 'main', index: 0 }]] },
|
||||||
|
'MyFilter': {
|
||||||
|
main: [[{ node: 'Matched', type: 'main', index: 0 }, { node: 'Unmatched', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
const warning = result.warnings.find(w => w.code === 'CONDITIONAL_BRANCH_FANOUT');
|
||||||
|
expect(warning).toBeDefined();
|
||||||
|
expect(warning!.nodeName).toBe('MyFilter');
|
||||||
|
expect(warning!.message).toContain('"matched" branch');
|
||||||
|
expect(warning!.message).toContain('"unmatched" branch has no effect');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user