mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
fix: Add Switch fallback output guidance for workflow builder (#30449)
This commit is contained in:
+47
@@ -125,5 +125,52 @@ Expected Output:
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#### Example 3: Add Catch-All Branch
|
||||
Current Parameters: { "mode": "rules" }
|
||||
Requested Changes: Route urgent and normal priorities, and send everything else to a fallback branch
|
||||
|
||||
Expected Output:
|
||||
{
|
||||
"mode": "rules",
|
||||
"rules": {
|
||||
"values": [
|
||||
{
|
||||
"conditions": {
|
||||
"options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict" },
|
||||
"conditions": [
|
||||
{
|
||||
"leftValue": "={{ $json.priority }}",
|
||||
"rightValue": "urgent",
|
||||
"operator": { "type": "string", "operation": "equals" }
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"renameOutput": true,
|
||||
"outputKey": "Urgent"
|
||||
},
|
||||
{
|
||||
"conditions": {
|
||||
"options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict" },
|
||||
"conditions": [
|
||||
{
|
||||
"leftValue": "={{ $json.priority }}",
|
||||
"rightValue": "normal",
|
||||
"operator": { "type": "string", "operation": "equals" }
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"renameOutput": true,
|
||||
"outputKey": "Normal"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"fallbackOutput": "extra",
|
||||
"renameFallbackOutput": "Fallback"
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -330,13 +330,14 @@ CONNECTION EXAMPLES:
|
||||
|
||||
MULTI-OUTPUT NODES (sourceOutputIndex):
|
||||
- IF node: output 0 = true branch, output 1 = false branch
|
||||
- Switch node: outputs 0 to N-1 based on configured rules, output N = default/fallback
|
||||
- Switch node: outputs 0 to N-1 based on configured rules. Output N exists as the default/fallback branch only when parameters.options.fallbackOutput is set to 'extra'. Without that option, unmatched items are dropped unless fallbackOutput routes them to an existing rule output.
|
||||
|
||||
ERROR OUTPUT CONNECTIONS (onError: 'continueErrorOutput'):
|
||||
When a node has nodeSettings.onError = 'continueErrorOutput', it gains an ADDITIONAL error output appended as the LAST index:
|
||||
- Single-output node (HTTP Request): output 0 = success, output 1 = error
|
||||
- IF node (2 outputs) + error handling: output 0 = true, output 1 = false, output 2 = error
|
||||
- Switch node (N outputs) + error handling: outputs 0 to N-1 = branches, output N = error
|
||||
- Switch node without extra fallback + error handling: outputs 0 to N-1 = branches, output N = error
|
||||
- Switch node with extra fallback + error handling: outputs 0 to N-1 = branches, output N = fallback, output N+1 = error
|
||||
|
||||
Example: HTTP Request with continueErrorOutput → success at index 0, error at index 1
|
||||
Example: IF with continueErrorOutput → true at 0, false at 1, error at 2`,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { noInvalidFromAi } from './no-invalid-from-ai';
|
||||
import { noUnnecessaryCodeNodes } from './no-unnecessary-code-nodes';
|
||||
import { noUnreachableNodes } from './no-unreachable-nodes';
|
||||
import { responseMatchesWorkflowChanges } from './response-matches-workflow-changes';
|
||||
import { switchFallbackOutputEnabled } from './switch-fallback-output-enabled';
|
||||
import { toolsHaveParameters } from './tools-have-parameters';
|
||||
import { validDataFlow } from './valid-data-flow';
|
||||
import { validFieldReferences } from './valid-field-references';
|
||||
@@ -52,6 +53,7 @@ export const DETERMINISTIC_CHECKS: BinaryCheck[] = [
|
||||
noUnreachableNodes,
|
||||
inboundTriggerAuthDefaults,
|
||||
httpGenericAuthTypeMatchesPrompt,
|
||||
switchFallbackOutputEnabled,
|
||||
validNodeConfig,
|
||||
];
|
||||
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { switchFallbackOutputEnabled } from './switch-fallback-output-enabled';
|
||||
import type { WorkflowResponse } from '../../clients/n8n-client';
|
||||
|
||||
const switchRules = {
|
||||
values: [
|
||||
{
|
||||
outputKey: 'Urgent',
|
||||
conditions: {
|
||||
options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' },
|
||||
conditions: [
|
||||
{
|
||||
leftValue: '={{ $json.priority }}',
|
||||
rightValue: 'urgent',
|
||||
operator: { type: 'string', operation: 'equals' },
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
},
|
||||
{
|
||||
outputKey: 'Normal',
|
||||
conditions: {
|
||||
options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' },
|
||||
conditions: [
|
||||
{
|
||||
leftValue: '={{ $json.priority }}',
|
||||
rightValue: 'normal',
|
||||
operator: { type: 'string', operation: 'equals' },
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function workflowWithSwitch(options?: Record<string, unknown>): WorkflowResponse {
|
||||
return {
|
||||
id: 'wf-1',
|
||||
name: 'Switch fallback test',
|
||||
active: false,
|
||||
versionId: 'version-1',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'Route Priority',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
typeVersion: 3.4,
|
||||
parameters: {
|
||||
mode: 'rules',
|
||||
rules: switchRules,
|
||||
...(options ? { options } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Fallback',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [[{ node: 'Route Priority', type: 'main', index: 0 }]],
|
||||
},
|
||||
'Route Priority': {
|
||||
main: [[], [], [{ node: 'Fallback', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('switchFallbackOutputEnabled', () => {
|
||||
it('fails when a Switch fallback branch is wired without fallbackOutput extra', async () => {
|
||||
const result = await switchFallbackOutputEnabled.run(workflowWithSwitch(), { prompt: '' });
|
||||
|
||||
expect(result.pass).toBe(false);
|
||||
expect(result.comment).toContain("options.fallbackOutput is set to 'extra'");
|
||||
});
|
||||
|
||||
it('passes when fallbackOutput extra creates the fallback branch', async () => {
|
||||
const result = await switchFallbackOutputEnabled.run(
|
||||
workflowWithSwitch({ fallbackOutput: 'extra' }),
|
||||
{ prompt: '' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ pass: true });
|
||||
});
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { validateWorkflow } from '@n8n/workflow-sdk';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import type { WorkflowResponse } from '../../clients/n8n-client';
|
||||
import type { BinaryCheck } from '../types';
|
||||
|
||||
function toWorkflowJson(workflow: WorkflowResponse): WorkflowJSON {
|
||||
return {
|
||||
name: workflow.name,
|
||||
nodes: (workflow.nodes ?? []).map((n, i) => ({
|
||||
id: String(i),
|
||||
name: n.name,
|
||||
type: n.type,
|
||||
typeVersion: n.typeVersion ?? 1,
|
||||
parameters: n.parameters ?? {},
|
||||
position: [0, 0],
|
||||
...(n.onError ? { onError: n.onError } : {}),
|
||||
})),
|
||||
connections: workflow.connections,
|
||||
} as unknown as WorkflowJSON;
|
||||
}
|
||||
|
||||
export const switchFallbackOutputEnabled: BinaryCheck = {
|
||||
name: 'switch_fallback_output_enabled',
|
||||
description: 'Switch fallback branches are only wired when the extra fallback output exists',
|
||||
kind: 'deterministic',
|
||||
run(workflow: WorkflowResponse) {
|
||||
const result = validateWorkflow(toWorkflowJson(workflow), {
|
||||
allowNoTrigger: true,
|
||||
allowDisconnectedNodes: true,
|
||||
validateSchema: false,
|
||||
});
|
||||
|
||||
const warnings = result.warnings.filter((w) => w.code === 'SWITCH_FALLBACK_OUTPUT_DISABLED');
|
||||
if (warnings.length === 0) return { pass: true };
|
||||
|
||||
return {
|
||||
pass: false,
|
||||
comment: warnings.map((w) => w.message).join('; '),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -50,6 +50,7 @@ export interface WorkflowNodeResponse {
|
||||
type: string;
|
||||
typeVersion?: number;
|
||||
parameters?: Record<string, unknown>;
|
||||
onError?: 'stopWorkflow' | 'continueRegularOutput' | 'continueErrorOutput';
|
||||
disabled?: boolean;
|
||||
credentials?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id": "switch-fallback-routing",
|
||||
"prompt": "Build a workflow that receives a webhook POST with a JSON body containing 'priority' and 'message'. Route priority 'urgent' items to Slack channel #incidents, priority 'normal' items to Gmail at ops@example.com, and send every unmatched priority to Slack channel #triage as the default fallback branch. Configure all nodes completely and don't ask for credentials."
|
||||
}
|
||||
@@ -128,7 +128,7 @@ ${PLACEHOLDERS_RULE}
|
||||
## Mandatory Process
|
||||
1. **Research**: If the workflow fits a known category (notification, chatbot, scheduling, data_transformation, etc.), call \`nodes(action="suggested")\` first for curated recommendations. Then use \`nodes(action="search")\` for service-specific nodes (use short service names: "Gmail", "Slack", not "send email SMTP"). The results include \`discriminators\` (available resources and operations) for nodes that need them. Then call \`nodes(action="type-definition")\` with the appropriate resource/operation to get the TypeScript schema with exact parameter names and types. **Pay attention to @builderHint annotations** in search results and type definitions — they prevent common configuration mistakes.
|
||||
2. **Build**: Write TypeScript SDK code and call \`build-workflow\`. Follow the SDK patterns below exactly.
|
||||
3. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
|
||||
3. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
|
||||
4. **Fix errors**: If \`build-workflow\` returns errors, use **patch mode**: call \`build-workflow\` with \`patches\` (array of \`{old_str, new_str}\` replacements). Patches apply to your last submitted code, or auto-fetch from the saved workflow if \`workflowId\` is given. Much faster than resending full code.
|
||||
5. **Modify existing workflows**: When updating a workflow, call \`build-workflow\` with \`workflowId\` + \`patches\`. The tool fetches the current code and applies your patches. Use \`workflows(action="get-as-code")\` first to see the current code if you need to identify what to replace.
|
||||
6. **Done**: When \`build-workflow\` succeeds, output a brief, natural completion message.
|
||||
@@ -419,7 +419,7 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`).
|
||||
|
||||
5. **Write workflow code** to \`${workspaceRoot}/src/workflow.ts\`.
|
||||
|
||||
6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
|
||||
6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
|
||||
|
||||
7. **Validate with tsc**: Run the TypeScript compiler for real type checking:
|
||||
\`\`\`
|
||||
@@ -449,7 +449,7 @@ Follow the **Compositional Workflow Pattern** above. The process becomes:
|
||||
c. Submit the chunk: \`submit-workflow\` with \`filePath\` pointing to the chunk file. Test via \`executions(action="run")\`.
|
||||
d. Fix if needed (max 2 submission fix attempts per chunk).
|
||||
6. **Write the main workflow** in \`${workspaceRoot}/src/workflow.ts\` that composes chunks via \`executeWorkflow\` nodes, referencing each chunk's workflow ID.
|
||||
7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
|
||||
7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
|
||||
8. **Submit** the main workflow.
|
||||
9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ flowchart LR
|
||||
|
||||
Use Switch node as primary traffic controller:
|
||||
- Configure cases for each classification value
|
||||
- Always define Default case for unexpected values
|
||||
- Always configure an extra fallback output for unexpected values
|
||||
- Each item follows exactly one branch
|
||||
|
||||
Avoid parallel IF nodes that could match multiple conditions - use Switch node.
|
||||
@@ -102,7 +102,7 @@ Avoid parallel IF nodes that could match multiple conditions - use Switch node.
|
||||
**Switch** (n8n-nodes-base.switch):
|
||||
- Purpose: Multi-way branching based on field values
|
||||
- Use when: Multiple categories (3+ outcomes)
|
||||
- Configure Default output for unmatched items
|
||||
- Configure options.fallbackOutput: 'extra' for unmatched items
|
||||
|
||||
**Merge** (n8n-nodes-base.merge):
|
||||
- Purpose: Consolidate branches for unified logging
|
||||
@@ -125,9 +125,9 @@ For all AI nodes (Text Classifier, AI Agent):
|
||||
## Common Pitfalls to Avoid
|
||||
|
||||
### No Default Path
|
||||
**Problem**: Every Switch must have a Default output. Unmatched items should go to manual review or logging, never drop silently.
|
||||
**Problem**: Switch nodes do not create a default output unless options.fallbackOutput is set to 'extra'. Unmatched items should go to manual review or logging, never drop silently.
|
||||
|
||||
**Solution**: Always configure Default case to route unclassified items to a fallback action (e.g., manual review queue, admin notification)
|
||||
**Solution**: Configure options.fallbackOutput: 'extra' and wire that fallback output to a fallback action (e.g., manual review queue, admin notification).
|
||||
|
||||
### No "Other" Branch in Text Classifier
|
||||
**Problem**: Items that don't match any category get dropped if "When No Clear Match" isn't set.
|
||||
|
||||
@@ -13,4 +13,11 @@ describe('WORKFLOW_SDK_PATTERNS', () => {
|
||||
expect.stringContaining('parameters: {}'),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses current Switch fallback syntax', () => {
|
||||
expect(WORKFLOW_SDK_PATTERNS).not.toContain('.onDefault(');
|
||||
expect(WORKFLOW_SDK_PATTERNS).not.toMatch(/\.onCase\(['"]/);
|
||||
expect(WORKFLOW_SDK_PATTERNS).toContain("fallbackOutput: 'extra'");
|
||||
expect(WORKFLOW_SDK_PATTERNS).toContain('.onCase(2, archive)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,7 +210,8 @@ const routeByPriority = switchCase({
|
||||
{ outputKey: 'urgent', conditions: { options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'urgent' }], combinator: 'and' } },
|
||||
{ outputKey: 'normal', conditions: { options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'normal' }], combinator: 'and' } },
|
||||
]
|
||||
}
|
||||
},
|
||||
options: { fallbackOutput: 'extra', renameFallbackOutput: 'Fallback' }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -218,9 +219,9 @@ const routeByPriority = switchCase({
|
||||
export default workflow('id', 'name')
|
||||
.add(startTrigger)
|
||||
.to(routeByPriority
|
||||
.onCase('urgent', processUrgent.to(notifyTeam.to(escalate)))
|
||||
.onCase('normal', processNormal)
|
||||
.onDefault(archive));
|
||||
.onCase(0, processUrgent.to(notifyTeam.to(escalate)))
|
||||
.onCase(1, processNormal)
|
||||
.onCase(2, archive));
|
||||
\`\`\`
|
||||
|
||||
</multi_way_routing>
|
||||
|
||||
@@ -42,6 +42,7 @@ export type ValidationErrorCode =
|
||||
| 'UNSUPPORTED_SUBNODE_INPUT'
|
||||
| 'MISSING_REQUIRED_INPUT'
|
||||
| 'INVALID_OUTPUT_FOR_MODE'
|
||||
| 'SWITCH_FALLBACK_OUTPUT_DISABLED'
|
||||
| 'MAX_NODES_EXCEEDED'
|
||||
| 'INVALID_EXPRESSION_PATH'
|
||||
| 'PARTIAL_EXPRESSION_PATH'
|
||||
@@ -190,6 +191,7 @@ interface NodeJSON {
|
||||
typeVersion?: number | string;
|
||||
position?: [number, number];
|
||||
parameters?: Record<string, unknown>;
|
||||
onError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -507,6 +509,10 @@ export function validateWorkflow(
|
||||
validatePlaceholderSlots(json, options.nodeTypesProvider, errors);
|
||||
}
|
||||
|
||||
// Switch fallback output validation does not need node metadata. It is derived from
|
||||
// the Switch node's dynamic output contract in rules mode.
|
||||
validateSwitchFallbackOutputConnections(json, warnings);
|
||||
|
||||
// Merge node input-count consistency
|
||||
checkMergeNodeInputCount(json, warnings);
|
||||
|
||||
@@ -1019,6 +1025,81 @@ function validateOutputUsage(
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getSwitchRulesCount(parameters: Record<string, unknown> | undefined): number {
|
||||
const rules = parameters?.rules;
|
||||
if (!isRecord(rules)) return 0;
|
||||
|
||||
const values = rules.values;
|
||||
if (Array.isArray(values)) return values.length;
|
||||
|
||||
const legacyRules = rules.rules;
|
||||
if (Array.isArray(legacyRules)) return legacyRules.length;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getSwitchFallbackOutput(parameters: Record<string, unknown> | undefined): unknown {
|
||||
const options = parameters?.options;
|
||||
if (!isRecord(options)) return undefined;
|
||||
|
||||
return options.fallbackOutput;
|
||||
}
|
||||
|
||||
function hasOutputConnections(
|
||||
outputs: Array<Array<{ node: string; type: string; index: number }> | null>,
|
||||
outputIndex: number,
|
||||
): boolean {
|
||||
const output = outputs[outputIndex];
|
||||
return Array.isArray(output) && output.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that Switch fallback branches are only connected when the node
|
||||
* actually exposes an extra fallback output.
|
||||
*/
|
||||
function validateSwitchFallbackOutputConnections(
|
||||
json: WorkflowJSON,
|
||||
warnings: ValidationWarning[],
|
||||
): void {
|
||||
for (const sourceNode of json.nodes) {
|
||||
if (!sourceNode.name || sourceNode.type !== 'n8n-nodes-base.switch') continue;
|
||||
|
||||
const mode = sourceNode.parameters?.mode;
|
||||
if (mode !== undefined && mode !== 'rules') continue;
|
||||
|
||||
const outgoing = json.connections[sourceNode.name];
|
||||
const mainOutputs = outgoing?.main;
|
||||
if (!Array.isArray(mainOutputs)) continue;
|
||||
|
||||
const rulesCount = getSwitchRulesCount(sourceNode.parameters);
|
||||
const fallbackOutput = getSwitchFallbackOutput(sourceNode.parameters);
|
||||
if (fallbackOutput === 'extra') continue;
|
||||
|
||||
for (let outputIndex = rulesCount; outputIndex < mainOutputs.length; outputIndex++) {
|
||||
if (!hasOutputConnections(mainOutputs, outputIndex)) continue;
|
||||
|
||||
const isErrorOutput =
|
||||
sourceNode.onError === 'continueErrorOutput' && outputIndex === rulesCount;
|
||||
if (isErrorOutput) continue;
|
||||
|
||||
warnings.push(
|
||||
new ValidationWarning(
|
||||
'SWITCH_FALLBACK_OUTPUT_DISABLED',
|
||||
`Switch node '${sourceNode.name}' has a connection from output ${outputIndex}, but rules mode only creates fallback output ${rulesCount} when options.fallbackOutput is set to 'extra'. Set options.fallbackOutput to 'extra' before wiring a catch-all branch, or route unmatched items to an existing rule output with a numeric fallbackOutput value.`,
|
||||
sourceNode.name,
|
||||
'options.fallbackOutput',
|
||||
undefined,
|
||||
'major',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject `placeholder()` markers found in parameter slots whose property
|
||||
* description carries `builderHint.placeholderSupported === false`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { validateWorkflow, ValidationError } from '.';
|
||||
import { setupTestSchemas, teardownTestSchemas } from './test-schema-setup';
|
||||
import type { NodeInstance } from '../types/base';
|
||||
import type { NodeInstance, WorkflowJSON } from '../types/base';
|
||||
import { workflow } from '../workflow-builder';
|
||||
import { node, trigger, sticky } from '../workflow-builder/node-builders/node-builder';
|
||||
import { languageModel, tool } from '../workflow-builder/node-builders/subnode-builders';
|
||||
@@ -2988,6 +2988,154 @@ describe('Validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SWITCH_FALLBACK_OUTPUT_DISABLED validation', () => {
|
||||
const switchRules = {
|
||||
values: [
|
||||
{
|
||||
outputKey: 'Urgent',
|
||||
conditions: {
|
||||
options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' },
|
||||
conditions: [
|
||||
{
|
||||
leftValue: '={{ $json.priority }}',
|
||||
rightValue: 'urgent',
|
||||
operator: { type: 'string', operation: 'equals' },
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
},
|
||||
{
|
||||
outputKey: 'Normal',
|
||||
conditions: {
|
||||
options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' },
|
||||
conditions: [
|
||||
{
|
||||
leftValue: '={{ $json.priority }}',
|
||||
rightValue: 'normal',
|
||||
operator: { type: 'string', operation: 'equals' },
|
||||
},
|
||||
],
|
||||
combinator: 'and',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createSwitchWorkflow(args: {
|
||||
switchOptions?: Record<string, unknown>;
|
||||
switchOnError?: 'continueErrorOutput';
|
||||
fallbackOutputIndex?: number;
|
||||
}): WorkflowJSON {
|
||||
const fallbackOutputIndex = args.fallbackOutputIndex ?? 2;
|
||||
|
||||
return {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'switch-1',
|
||||
name: 'Route Priority',
|
||||
type: 'n8n-nodes-base.switch',
|
||||
typeVersion: 3.4,
|
||||
position: [200, 0],
|
||||
parameters: {
|
||||
mode: 'rules',
|
||||
rules: switchRules,
|
||||
...(args.switchOptions ? { options: args.switchOptions } : {}),
|
||||
},
|
||||
...(args.switchOnError ? { onError: args.switchOnError } : {}),
|
||||
},
|
||||
{
|
||||
id: 'case-1',
|
||||
name: 'Handle Urgent',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [400, -100],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'case-2',
|
||||
name: 'Handle Normal',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [400, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'fallback-1',
|
||||
name: 'Handle Fallback',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [400, 100],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [[{ node: 'Route Priority', type: 'main', index: 0 }]],
|
||||
},
|
||||
'Route Priority': {
|
||||
main: [
|
||||
[{ node: 'Handle Urgent', type: 'main', index: 0 }],
|
||||
[{ node: 'Handle Normal', type: 'main', index: 0 }],
|
||||
...Array.from({ length: Math.max(0, fallbackOutputIndex - 2) }, () => []),
|
||||
[{ node: 'Handle Fallback', type: 'main', index: 0 }],
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getSwitchFallbackWarnings(workflowJson: WorkflowJSON) {
|
||||
return validateWorkflow(workflowJson).warnings.filter(
|
||||
(w) => w.code === 'SWITCH_FALLBACK_OUTPUT_DISABLED',
|
||||
);
|
||||
}
|
||||
|
||||
it('warns when the fallback output is connected without fallbackOutput extra', () => {
|
||||
const warnings = getSwitchFallbackWarnings(createSwitchWorkflow({}));
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0].nodeName).toBe('Route Priority');
|
||||
expect(warnings[0].parameterPath).toBe('options.fallbackOutput');
|
||||
expect(warnings[0].message).toContain("options.fallbackOutput is set to 'extra'");
|
||||
expect(warnings[0].violationLevel).toBe('major');
|
||||
});
|
||||
|
||||
it('does not warn when fallbackOutput extra creates the fallback output', () => {
|
||||
const warnings = getSwitchFallbackWarnings(
|
||||
createSwitchWorkflow({ switchOptions: { fallbackOutput: 'extra' } }),
|
||||
);
|
||||
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns when numeric fallbackOutput is used with an extra fallback connection', () => {
|
||||
const warnings = getSwitchFallbackWarnings(
|
||||
createSwitchWorkflow({ switchOptions: { fallbackOutput: 1 } }),
|
||||
);
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not mistake the error output for a fallback branch', () => {
|
||||
const warnings = getSwitchFallbackWarnings(
|
||||
createSwitchWorkflow({ switchOnError: 'continueErrorOutput' }),
|
||||
);
|
||||
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePlaceholderSlots (builderHint.placeholderSupported=false)', () => {
|
||||
const mockNodeTypesProviderWithPlaceholderOptOut = {
|
||||
getByNameAndVersion: (_type: string, _version?: number) => ({
|
||||
|
||||
@@ -89,6 +89,10 @@ const routeByPriority = switchCase({
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
fallbackOutput: 'extra',
|
||||
renameFallbackOutput: 'Fallback'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,9 +101,9 @@ const routeByPriority = switchCase({
|
||||
export default workflow('id', 'name')
|
||||
.add(startTrigger)
|
||||
.to(routeByPriority
|
||||
.onCase('urgent', processUrgent.to(notifyTeam))
|
||||
.onCase('normal', processNormal)
|
||||
.onDefault(archive));
|
||||
.onCase(0, processUrgent.to(notifyTeam))
|
||||
.onCase(1, processNormal)
|
||||
.onCase(2, archive));
|
||||
</pattern>
|
||||
</patterns>`,
|
||||
},
|
||||
@@ -176,7 +180,7 @@ export default workflow('id', 'name')
|
||||
type: 'fixedCollection',
|
||||
builderHint: {
|
||||
propertyHint:
|
||||
"Use `rules.values` (NOT `rules.rules`). Each rule needs `outputKey` and a complete `conditions` object with these three sibling keys: `combinator` ('and' | 'or'), `conditions` (array of condition objects), `options` (`{ caseSensitive, leftValue, typeValidation }`). Same shape as IF. Each `outputKey` you define must be wired via `.onCase('<outputKey>')` to the intended downstream node — unwired cases silently drop their items.",
|
||||
"Use `rules.values` (NOT `rules.rules`). Each rule needs `outputKey` and a complete `conditions` object with these three sibling keys: `combinator` ('and' | 'or'), `conditions` (array of condition objects), `options` (`{ caseSensitive, leftValue, typeValidation }`). Same shape as IF. Wire rule outputs by zero-based index with `.onCase(index, target)`; `outputKey` is the visible output label, not the `.onCase()` argument. Unwired cases silently drop their items.",
|
||||
},
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
@@ -283,6 +287,10 @@ export default workflow('id', 'name')
|
||||
loadOptionsDependsOn: ['rules.values', '/rules', '/rules.values'],
|
||||
loadOptionsMethod: 'getFallbackOutputOptions',
|
||||
},
|
||||
builderHint: {
|
||||
propertyHint:
|
||||
"Set this to `'extra'` before wiring a catch-all/default branch. In rules mode, `'extra'` creates a fallback output at index `rules.values.length`; default `'none'` creates no fallback output and drops unmatched items. Numeric values route unmatched items to an existing rule output and do not create a new port.",
|
||||
},
|
||||
default: 'none',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
|
||||
description:
|
||||
@@ -309,6 +317,10 @@ export default workflow('id', 'name')
|
||||
type: 'string',
|
||||
placeholder: 'e.g. Fallback',
|
||||
default: '',
|
||||
builderHint: {
|
||||
propertyHint:
|
||||
"Only labels the extra fallback output. Use it together with `fallbackOutput: 'extra'`; it does not create a fallback output by itself.",
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
fallbackOutput: ['extra'],
|
||||
|
||||
@@ -67,6 +67,30 @@ describe('SwitchV3 Node', () => {
|
||||
const switchNode = new SwitchV3(baseDescription);
|
||||
expect(switchNode.description.version).toContain(3.3);
|
||||
});
|
||||
|
||||
it('should document SDK-discoverable fallback output metadata', () => {
|
||||
const switchNode = new SwitchV3(baseDescription);
|
||||
const sdkExample = switchNode.description.builderHint?.extraTypeDefContent?.[0].content ?? '';
|
||||
const optionsProperty = switchNode.description.properties.find(
|
||||
(prop) => prop.name === 'options',
|
||||
);
|
||||
const optionParameters = (optionsProperty?.options ?? []) as Array<{
|
||||
name: string;
|
||||
builderHint?: { propertyHint?: string };
|
||||
}>;
|
||||
const fallbackOutput = optionParameters.find((option) => option.name === 'fallbackOutput');
|
||||
const renameFallbackOutput = optionParameters.find(
|
||||
(option) => option.name === 'renameFallbackOutput',
|
||||
);
|
||||
|
||||
expect(sdkExample).toContain("fallbackOutput: 'extra'");
|
||||
expect(sdkExample).toContain('.onCase(2, archive)');
|
||||
expect(sdkExample).not.toContain('.onDefault(');
|
||||
expect(sdkExample).not.toMatch(/\.onCase\(['"]/);
|
||||
expect(fallbackOutput?.builderHint?.propertyHint).toContain('rules.values.length');
|
||||
expect(fallbackOutput?.builderHint?.propertyHint).toContain('Numeric values');
|
||||
expect(renameFallbackOutput?.builderHint?.propertyHint).toContain("fallbackOutput: 'extra'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expression Mode Execution', () => {
|
||||
|
||||
Reference in New Issue
Block a user