mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
1692 lines
49 KiB
TypeScript
1692 lines
49 KiB
TypeScript
import {
|
|
collectSubNodeNames,
|
|
dropInvalidWorkflowGroups,
|
|
formatTopLevelItemsMessage,
|
|
GROUP_DESCRIPTION_MAX_LENGTH,
|
|
makeGetNodeTypeForGrouping,
|
|
normalizeGroupDescription,
|
|
summarizeTopLevelItems,
|
|
TOP_LEVEL_ITEM_CEILING,
|
|
validateNodeSelectionForExtraction,
|
|
validateNodeSelectionForGrouping,
|
|
validateWorkflowGroups,
|
|
type NodeGroupRuleOptions,
|
|
} from '../src/node-grouping-validation';
|
|
import {
|
|
NodeConnectionTypes,
|
|
NO_OP_NODE_TYPE,
|
|
STICKY_NODE_TYPE,
|
|
type IConnections,
|
|
type INode,
|
|
type INodeTypeDescription,
|
|
type INodeTypes,
|
|
} from '../src';
|
|
|
|
function makeNode(overrides: Partial<INode> = {}): INode {
|
|
return {
|
|
id: overrides.id ?? overrides.name ?? 'a',
|
|
name: overrides.name ?? overrides.id ?? 'A',
|
|
type: overrides.type ?? 'n8n-nodes-base.set',
|
|
typeVersion: overrides.typeVersion ?? 1,
|
|
position: overrides.position ?? [0, 0],
|
|
parameters: overrides.parameters ?? {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeNodeType(overrides: Partial<INodeTypeDescription> = {}): INodeTypeDescription {
|
|
return {
|
|
displayName: overrides.displayName ?? 'Set',
|
|
name: overrides.name ?? 'n8n-nodes-base.set',
|
|
group: overrides.group ?? ['transform'],
|
|
version: overrides.version ?? 1,
|
|
description: overrides.description ?? '',
|
|
defaults: overrides.defaults ?? { name: 'Set' },
|
|
inputs: overrides.inputs ?? [NodeConnectionTypes.Main],
|
|
outputs: overrides.outputs ?? [NodeConnectionTypes.Main],
|
|
properties: overrides.properties ?? [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeStickyNode(overrides: Partial<INode> = {}): INode {
|
|
return makeNode({
|
|
id: overrides.id ?? 'sticky',
|
|
name: overrides.name ?? 'Sticky',
|
|
type: STICKY_NODE_TYPE,
|
|
parameters: { content: '', width: 240, height: 160 },
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
const stickyNodeType = makeNodeType({
|
|
name: STICKY_NODE_TYPE,
|
|
group: ['input'],
|
|
inputs: [],
|
|
outputs: [],
|
|
});
|
|
|
|
function makeLinearGraph() {
|
|
const nodes = [
|
|
makeNode({ id: 'a', name: 'A' }),
|
|
makeNode({ id: 'b', name: 'B' }),
|
|
makeNode({ id: 'c', name: 'C' }),
|
|
];
|
|
|
|
const connections: IConnections = {
|
|
A: { main: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]] },
|
|
B: { main: [[{ node: 'C', type: NodeConnectionTypes.Main, index: 0 }]] },
|
|
};
|
|
|
|
return { nodes, connections };
|
|
}
|
|
|
|
function validateGrouping({
|
|
nodes,
|
|
connectionsBySourceNode,
|
|
nodeTypes = { 'n8n-nodes-base.set': makeNodeType() },
|
|
allowTriggerInGroup,
|
|
allowMultipleBoundaryNodes,
|
|
}: {
|
|
nodes: INode[];
|
|
connectionsBySourceNode: IConnections;
|
|
nodeTypes?: Record<string, INodeTypeDescription>;
|
|
} & NodeGroupRuleOptions) {
|
|
return validateNodeSelectionForGrouping({
|
|
nodes,
|
|
connectionsBySourceNode,
|
|
getNodeType: (node) => nodeTypes[node.type],
|
|
allowTriggerInGroup,
|
|
allowMultipleBoundaryNodes,
|
|
});
|
|
}
|
|
|
|
const triggerNodeTypes = {
|
|
'n8n-nodes-base.manualTrigger': makeNodeType({
|
|
name: 'n8n-nodes-base.manualTrigger',
|
|
group: ['trigger'],
|
|
}),
|
|
'n8n-nodes-base.set': makeNodeType(),
|
|
};
|
|
|
|
/** A linear graph whose first node is a trigger. */
|
|
function makeTriggeredGraph() {
|
|
const graph = makeLinearGraph();
|
|
graph.nodes[0].type = 'n8n-nodes-base.manualTrigger';
|
|
return graph;
|
|
}
|
|
|
|
const mainTo = (node: string) => [{ node, type: NodeConnectionTypes.Main, index: 0 }];
|
|
|
|
/**
|
|
* Outside -> A and Outside -> B, both joining at C. Selecting A, B and C gives a
|
|
* group with two entry nodes.
|
|
*/
|
|
function makeTwoEntryGraph() {
|
|
const nodes = [
|
|
makeNode({ id: 'a', name: 'A' }),
|
|
makeNode({ id: 'b', name: 'B' }),
|
|
makeNode({ id: 'c', name: 'C' }),
|
|
];
|
|
|
|
const connections: IConnections = {
|
|
OutsideOne: { main: [mainTo('A')] },
|
|
OutsideTwo: { main: [mainTo('B')] },
|
|
A: { main: [mainTo('C')] },
|
|
B: { main: [mainTo('C')] },
|
|
};
|
|
|
|
return { nodes, connections };
|
|
}
|
|
|
|
/**
|
|
* A -> B and A -> C, with B and C each leaving the selection. Selecting A, B and
|
|
* C gives a group with two exit nodes.
|
|
*/
|
|
function makeTwoExitGraph() {
|
|
const nodes = [
|
|
makeNode({ id: 'a', name: 'A' }),
|
|
makeNode({ id: 'b', name: 'B' }),
|
|
makeNode({ id: 'c', name: 'C' }),
|
|
];
|
|
|
|
const connections: IConnections = {
|
|
A: { main: [[...mainTo('B'), ...mainTo('C')]] },
|
|
B: { main: [mainTo('OutsideOne')] },
|
|
C: { main: [mainTo('OutsideTwo')] },
|
|
};
|
|
|
|
return { nodes, connections };
|
|
}
|
|
|
|
describe('node grouping validation', () => {
|
|
it('returns valid for a connected non-trigger selection', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: graph.connections,
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
if (result.valid) {
|
|
expect(result.subGraph.map((node) => node.name)).toEqual(['A', 'B']);
|
|
}
|
|
});
|
|
|
|
it('returns valid for a single-node extraction selection', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateNodeSelectionForExtraction({
|
|
nodes: [graph.nodes[0]],
|
|
connectionsBySourceNode: graph.connections,
|
|
getNodeType: (node) => makeNodeType({ name: node.type }),
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
if (result.valid) {
|
|
expect(result.subGraph.map((node) => node.name)).toEqual(['A']);
|
|
}
|
|
});
|
|
|
|
it('returns node-already-grouped when a selection id belongs to an existing group', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateNodeSelectionForGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: graph.connections,
|
|
getNodeType: (node) => makeNodeType({ name: node.type }),
|
|
existingNodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a', 'c'] }],
|
|
});
|
|
|
|
expect(result).toEqual({ valid: false, reason: 'node-already-grouped', nodeIds: ['a'] });
|
|
});
|
|
|
|
it('allows grouping when existingNodeGroups is empty or omitted', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
expect(
|
|
validateNodeSelectionForGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: graph.connections,
|
|
getNodeType: (node) => makeNodeType({ name: node.type }),
|
|
existingNodeGroups: [],
|
|
}).valid,
|
|
).toBe(true);
|
|
|
|
expect(
|
|
validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: graph.connections,
|
|
}).valid,
|
|
).toBe(true);
|
|
});
|
|
|
|
// Extraction builds a runnable sub-workflow, so it keeps the strict rules whatever
|
|
// the rollout says. This block outlives the flag.
|
|
describe('sub-workflow extraction', () => {
|
|
const extract = (
|
|
nodes: INode[],
|
|
connectionsBySourceNode: IConnections,
|
|
rules: NodeGroupRuleOptions,
|
|
) =>
|
|
validateNodeSelectionForExtraction({
|
|
nodes,
|
|
connectionsBySourceNode,
|
|
getNodeType: () => makeNodeType(),
|
|
...rules,
|
|
});
|
|
|
|
const everyRuleCombination: NodeGroupRuleOptions[] = [
|
|
{ allowTriggerInGroup: false, allowMultipleBoundaryNodes: false },
|
|
{ allowTriggerInGroup: true, allowMultipleBoundaryNodes: false },
|
|
{ allowTriggerInGroup: false, allowMultipleBoundaryNodes: true },
|
|
{ allowTriggerInGroup: true, allowMultipleBoundaryNodes: true },
|
|
];
|
|
|
|
test.each(everyRuleCombination)(
|
|
'rejects two entry nodes with any rule set (trigger: $allowTriggerInGroup, boundaries: $allowMultipleBoundaryNodes)',
|
|
(rules) => {
|
|
const graph = makeTwoEntryGraph();
|
|
|
|
const result = extract(graph.nodes, graph.connections, rules);
|
|
|
|
expect(result.valid).toBe(false);
|
|
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
},
|
|
);
|
|
|
|
test.each(everyRuleCombination)(
|
|
'rejects two exit nodes with any rule set (trigger: $allowTriggerInGroup, boundaries: $allowMultipleBoundaryNodes)',
|
|
(rules) => {
|
|
const graph = makeTwoExitGraph();
|
|
|
|
const result = extract(graph.nodes, graph.connections, rules);
|
|
|
|
expect(result.valid).toBe(false);
|
|
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
},
|
|
);
|
|
|
|
// Worded without the rules on purpose. The cases above name them, so they go
|
|
// with the flags; this one states the permanent rule and must survive that.
|
|
it('needs one entry and one exit node', () => {
|
|
expect(extract(makeTwoEntryGraph().nodes, makeTwoEntryGraph().connections, {}).valid).toBe(
|
|
false,
|
|
);
|
|
expect(extract(makeTwoExitGraph().nodes, makeTwoExitGraph().connections, {}).valid).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
// The rules the rollout lifts. Each block goes when its own flag becomes
|
|
// permanent; the "with both rules on" block stays.
|
|
describe('with both rules off', () => {
|
|
it('returns trigger-selected when the selection contains a trigger', () => {
|
|
const graph = makeTriggeredGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeTypes: triggerNodeTypes,
|
|
});
|
|
|
|
expect(result).toEqual({ valid: false, reason: 'trigger-selected', triggers: ['A'] });
|
|
});
|
|
|
|
it('returns invalid-subgraph when two different nodes take input from outside', () => {
|
|
const graph = makeTwoEntryGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
|
|
it('returns invalid-subgraph when two different nodes send output outside', () => {
|
|
const graph = makeTwoExitGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
});
|
|
|
|
// One flag must not lift the other rule.
|
|
describe('with the trigger rule on alone', () => {
|
|
it('accepts a trigger together with the nodes that follow it', () => {
|
|
const graph = makeTriggeredGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeTypes: triggerNodeTypes,
|
|
allowTriggerInGroup: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('still rejects a group with two entry nodes', () => {
|
|
const graph = makeTwoEntryGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
allowTriggerInGroup: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
|
|
it('still rejects a group with two exit nodes', () => {
|
|
const graph = makeTwoExitGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
allowTriggerInGroup: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('with the boundary rule on alone', () => {
|
|
it('accepts a group whose members take input from outside at two different nodes', () => {
|
|
const graph = makeTwoEntryGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
allowMultipleBoundaryNodes: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('accepts a group whose members send output outside from two different nodes', () => {
|
|
const graph = makeTwoExitGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
allowMultipleBoundaryNodes: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('still rejects a group that holds a trigger', () => {
|
|
const graph = makeTriggeredGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeTypes: triggerNodeTypes,
|
|
allowMultipleBoundaryNodes: true,
|
|
});
|
|
|
|
expect(result).toEqual({ valid: false, reason: 'trigger-selected', triggers: ['A'] });
|
|
});
|
|
|
|
// The rule lifts the count of entry and exit nodes. A group still has to be
|
|
// a whole slice of the graph, so an edge into or out of the middle stays out.
|
|
it('still rejects an outside edge into a member that is not a root', () => {
|
|
const nodes = [
|
|
makeNode({ id: 'a', name: 'A' }),
|
|
makeNode({ id: 'b', name: 'B' }),
|
|
makeNode({ id: 'c', name: 'C' }),
|
|
];
|
|
const connections: IConnections = {
|
|
Outside: { main: [[...mainTo('A'), ...mainTo('B')]] },
|
|
A: { main: [mainTo('B')] },
|
|
B: { main: [mainTo('C')] },
|
|
};
|
|
|
|
const result = validateGrouping({
|
|
nodes,
|
|
connectionsBySourceNode: connections,
|
|
allowMultipleBoundaryNodes: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
|
|
it('still rejects an outside edge from a member that is not a leaf', () => {
|
|
const nodes = [
|
|
makeNode({ id: 'a', name: 'A' }),
|
|
makeNode({ id: 'b', name: 'B' }),
|
|
makeNode({ id: 'c', name: 'C' }),
|
|
];
|
|
const connections: IConnections = {
|
|
A: { main: [mainTo('B')] },
|
|
B: { main: [[...mainTo('C'), ...mainTo('Outside')]] },
|
|
};
|
|
|
|
const result = validateGrouping({
|
|
nodes,
|
|
connectionsBySourceNode: connections,
|
|
allowMultipleBoundaryNodes: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('with both rules on', () => {
|
|
it('accepts a trigger together with two entry nodes', () => {
|
|
// Trigger, B and C all feed X. B and C each take input from outside, so
|
|
// the group holds a trigger and two entry nodes at once.
|
|
const nodes = [
|
|
makeNode({ id: 'trigger', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger' }),
|
|
makeNode({ id: 'b', name: 'B' }),
|
|
makeNode({ id: 'c', name: 'C' }),
|
|
makeNode({ id: 'x', name: 'X' }),
|
|
];
|
|
const connections: IConnections = {
|
|
Trigger: { main: [mainTo('X')] },
|
|
OutsideOne: { main: [mainTo('B')] },
|
|
OutsideTwo: { main: [mainTo('C')] },
|
|
B: { main: [mainTo('X')] },
|
|
C: { main: [mainTo('X')] },
|
|
};
|
|
|
|
const result = validateGrouping({
|
|
nodes,
|
|
connectionsBySourceNode: connections,
|
|
nodeTypes: triggerNodeTypes,
|
|
allowTriggerInGroup: true,
|
|
allowMultipleBoundaryNodes: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('still rejects a non-main connection that crosses the group boundary', () => {
|
|
const graph = makeTriggeredGraph();
|
|
const model = makeNode({ id: 'model', name: 'Model' });
|
|
const connectionsBySourceNode: IConnections = {
|
|
...graph.connections,
|
|
Model: {
|
|
[NodeConnectionTypes.AiLanguageModel]: [
|
|
[
|
|
{ node: 'B', type: NodeConnectionTypes.AiLanguageModel, index: 0 },
|
|
{ node: 'C', type: NodeConnectionTypes.AiLanguageModel, index: 0 },
|
|
],
|
|
],
|
|
},
|
|
};
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1], model],
|
|
connectionsBySourceNode,
|
|
nodeTypes: triggerNodeTypes,
|
|
allowTriggerInGroup: true,
|
|
allowMultipleBoundaryNodes: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('non-main-boundary');
|
|
}
|
|
});
|
|
|
|
it('still rejects two islands with no path between them', () => {
|
|
const nodes = [makeNode({ id: 'a', name: 'A' }), makeNode({ id: 'b', name: 'B' })];
|
|
|
|
const result = validateGrouping({
|
|
nodes,
|
|
connectionsBySourceNode: {},
|
|
allowTriggerInGroup: true,
|
|
allowMultipleBoundaryNodes: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
|
|
// Each island is a root and a leaf, so the entry and exit counts say nothing
|
|
// about them. `findDisconnectedSelectionError` is what refuses this shape.
|
|
it('still rejects two islands that each take input and send output outside', () => {
|
|
const nodes = [makeNode({ id: 'a', name: 'A' }), makeNode({ id: 'b', name: 'B' })];
|
|
const connections: IConnections = {
|
|
OutsideInOne: { main: [mainTo('A')] },
|
|
OutsideInTwo: { main: [mainTo('B')] },
|
|
A: { main: [mainTo('OutsideOutOne')] },
|
|
B: { main: [mainTo('OutsideOutTwo')] },
|
|
};
|
|
|
|
const result = validateGrouping({
|
|
nodes,
|
|
connectionsBySourceNode: connections,
|
|
allowTriggerInGroup: true,
|
|
allowMultipleBoundaryNodes: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
});
|
|
|
|
it('returns invalid-subgraph when selected nodes skip an intermediate node', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[2]],
|
|
connectionsBySourceNode: graph.connections,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
|
|
it('returns invalid-subgraph when selected nodes are disconnected', () => {
|
|
const nodes = [makeNode({ id: 'a', name: 'A' }), makeNode({ id: 'b', name: 'B' })];
|
|
|
|
const result = validateGrouping({
|
|
nodes,
|
|
connectionsBySourceNode: {},
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
|
|
it('validates against the provided candidate connections', () => {
|
|
const graph = makeLinearGraph();
|
|
const candidateConnections: IConnections = {
|
|
...graph.connections,
|
|
C: { main: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]] },
|
|
};
|
|
|
|
expect(
|
|
validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: graph.connections,
|
|
}).valid,
|
|
).toBe(true);
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: candidateConnections,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
|
|
it('rejects a non-main boundary connection for grouping but not extraction', () => {
|
|
const graph = makeLinearGraph();
|
|
const model = makeNode({ id: 'model', name: 'Model' });
|
|
const connectionsBySourceNode: IConnections = {
|
|
...graph.connections,
|
|
Model: {
|
|
[NodeConnectionTypes.AiLanguageModel]: [
|
|
[
|
|
{ node: 'B', type: NodeConnectionTypes.AiLanguageModel, index: 0 },
|
|
{ node: 'C', type: NodeConnectionTypes.AiLanguageModel, index: 0 },
|
|
],
|
|
],
|
|
},
|
|
};
|
|
|
|
const baseInput = {
|
|
nodes: [graph.nodes[0], graph.nodes[1], model],
|
|
connectionsBySourceNode,
|
|
getNodeType: () => makeNodeType(),
|
|
};
|
|
|
|
expect(validateNodeSelectionForExtraction(baseInput).valid).toBe(true);
|
|
|
|
const result = validateNodeSelectionForGrouping(baseInput);
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('non-main-boundary');
|
|
}
|
|
});
|
|
|
|
it('allows a shared non-main node when every consumer is inside the group', () => {
|
|
const graph = makeLinearGraph();
|
|
const model = makeNode({ id: 'model', name: 'Model' });
|
|
const connectionsBySourceNode: IConnections = {
|
|
...graph.connections,
|
|
Model: {
|
|
[NodeConnectionTypes.AiLanguageModel]: [
|
|
[
|
|
{ node: 'B', type: NodeConnectionTypes.AiLanguageModel, index: 0 },
|
|
{ node: 'C', type: NodeConnectionTypes.AiLanguageModel, index: 0 },
|
|
],
|
|
],
|
|
},
|
|
};
|
|
|
|
const result = validateGrouping({
|
|
nodes: [...graph.nodes, model],
|
|
connectionsBySourceNode,
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('allows grouping a start node with multiple main inputs', () => {
|
|
const graph = makeLinearGraph();
|
|
graph.nodes[1].type = 'n8n-nodes-base.merge';
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[1], graph.nodes[2]],
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeTypes: {
|
|
'n8n-nodes-base.merge': makeNodeType({
|
|
name: 'n8n-nodes-base.merge',
|
|
inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
|
}),
|
|
'n8n-nodes-base.set': makeNodeType(),
|
|
},
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('returns multiple-input-branches for extraction when the start node has multiple main inputs', () => {
|
|
const graph = makeLinearGraph();
|
|
graph.nodes[1].type = 'n8n-nodes-base.merge';
|
|
|
|
const result = validateNodeSelectionForExtraction({
|
|
nodes: [graph.nodes[1], graph.nodes[2]],
|
|
connectionsBySourceNode: graph.connections,
|
|
getNodeType: (node) =>
|
|
node.type === 'n8n-nodes-base.merge'
|
|
? makeNodeType({
|
|
name: 'n8n-nodes-base.merge',
|
|
inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
|
})
|
|
: makeNodeType(),
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('multiple-input-branches');
|
|
}
|
|
});
|
|
|
|
it('uses resolved inputs when checking start node branch count for extraction', () => {
|
|
const graph = makeLinearGraph();
|
|
graph.nodes[1].type = 'n8n-nodes-base.merge';
|
|
|
|
const result = validateNodeSelectionForExtraction({
|
|
nodes: [graph.nodes[1], graph.nodes[2]],
|
|
connectionsBySourceNode: graph.connections,
|
|
getNodeType: (node) =>
|
|
node.type === 'n8n-nodes-base.merge'
|
|
? makeNodeType({
|
|
name: 'n8n-nodes-base.merge',
|
|
inputs: '={{ $parameter.numberInputs }}',
|
|
})
|
|
: makeNodeType(),
|
|
getNodeInputs: () => [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('multiple-input-branches');
|
|
}
|
|
});
|
|
|
|
it('allows grouping an end node with multiple main outputs', () => {
|
|
const graph = makeLinearGraph();
|
|
graph.nodes[1].type = 'n8n-nodes-base.if';
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeTypes: {
|
|
'n8n-nodes-base.if': makeNodeType({
|
|
name: 'n8n-nodes-base.if',
|
|
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
|
}),
|
|
'n8n-nodes-base.set': makeNodeType(),
|
|
},
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('returns multiple-output-branches for extraction when the end node has multiple main outputs', () => {
|
|
const graph = makeLinearGraph();
|
|
graph.nodes[1].type = 'n8n-nodes-base.if';
|
|
|
|
const result = validateNodeSelectionForExtraction({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: graph.connections,
|
|
getNodeType: (node) =>
|
|
node.type === 'n8n-nodes-base.if'
|
|
? makeNodeType({
|
|
name: 'n8n-nodes-base.if',
|
|
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
|
})
|
|
: makeNodeType(),
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('multiple-output-branches');
|
|
}
|
|
});
|
|
|
|
it('uses resolved outputs when checking end node branch count for extraction', () => {
|
|
const graph = makeLinearGraph();
|
|
graph.nodes[1].type = 'n8n-nodes-base.switch';
|
|
|
|
const result = validateNodeSelectionForExtraction({
|
|
nodes: [graph.nodes[0], graph.nodes[1]],
|
|
connectionsBySourceNode: graph.connections,
|
|
getNodeType: (node) =>
|
|
node.type === 'n8n-nodes-base.switch'
|
|
? makeNodeType({
|
|
name: 'n8n-nodes-base.switch',
|
|
outputs: '={{ $parameter.rules }}',
|
|
})
|
|
: makeNodeType(),
|
|
getNodeOutputs: () => [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('multiple-output-branches');
|
|
}
|
|
});
|
|
|
|
describe('sticky notes', () => {
|
|
const stickyNodeTypes: Record<string, INodeTypeDescription> = {
|
|
'n8n-nodes-base.set': makeNodeType(),
|
|
[STICKY_NODE_TYPE]: stickyNodeType,
|
|
};
|
|
|
|
it('allows grouping a sticky together with a connected selection', () => {
|
|
const graph = makeLinearGraph();
|
|
const sticky = makeStickyNode();
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1], sticky],
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeTypes: stickyNodeTypes,
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
if (result.valid) {
|
|
expect(result.subGraph.map((node) => node.name)).toEqual(['A', 'B', 'Sticky']);
|
|
}
|
|
});
|
|
|
|
it('allows grouping a sticky with a single connectable node', () => {
|
|
const graph = makeLinearGraph();
|
|
const stickies = [makeStickyNode(), makeStickyNode({ id: 'sticky2', name: 'Sticky2' })];
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[1], ...stickies],
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeTypes: stickyNodeTypes,
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('still rejects disconnected connectable nodes when a sticky is present', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[2], makeStickyNode()],
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeTypes: stickyNodeTypes,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
|
|
it('treats sticky-only selections as valid group data', () => {
|
|
// Sticky-only groups can come to exist when a group's last connectable
|
|
// node is deleted, so they must validate (creation surfaces enforce at
|
|
// least one connectable node separately).
|
|
for (const nodes of [
|
|
[makeStickyNode()],
|
|
[makeStickyNode(), makeStickyNode({ id: 'sticky2', name: 'Sticky2' })],
|
|
]) {
|
|
const result = validateGrouping({
|
|
nodes,
|
|
connectionsBySourceNode: {},
|
|
nodeTypes: stickyNodeTypes,
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
if (result.valid) {
|
|
expect(result.subGraph).toEqual(nodes);
|
|
expect(result.subGraphData).toEqual({ start: undefined, end: undefined });
|
|
}
|
|
}
|
|
});
|
|
|
|
it('returns node-already-grouped when the sticky belongs to another group', () => {
|
|
const graph = makeLinearGraph();
|
|
const sticky = makeStickyNode();
|
|
|
|
const result = validateNodeSelectionForGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1], sticky],
|
|
connectionsBySourceNode: graph.connections,
|
|
getNodeType: (node) => stickyNodeTypes[node.type],
|
|
existingNodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['sticky'] }],
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
valid: false,
|
|
reason: 'node-already-grouped',
|
|
nodeIds: ['sticky'],
|
|
});
|
|
});
|
|
|
|
it('returns trigger-selected when a trigger accompanies the sticky', () => {
|
|
const graph = makeLinearGraph();
|
|
graph.nodes[0].type = 'n8n-nodes-base.manualTrigger';
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1], makeStickyNode()],
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeTypes: {
|
|
...stickyNodeTypes,
|
|
'n8n-nodes-base.manualTrigger': makeNodeType({
|
|
name: 'n8n-nodes-base.manualTrigger',
|
|
group: ['trigger'],
|
|
}),
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ valid: false, reason: 'trigger-selected', triggers: ['A'] });
|
|
});
|
|
|
|
it('still rejects non-main boundary connections when a sticky is present', () => {
|
|
const graph = makeLinearGraph();
|
|
const model = makeNode({ id: 'model', name: 'Model' });
|
|
const connectionsBySourceNode: IConnections = {
|
|
...graph.connections,
|
|
Model: {
|
|
[NodeConnectionTypes.AiLanguageModel]: [
|
|
[
|
|
{ node: 'B', type: NodeConnectionTypes.AiLanguageModel, index: 0 },
|
|
{ node: 'C', type: NodeConnectionTypes.AiLanguageModel, index: 0 },
|
|
],
|
|
],
|
|
},
|
|
};
|
|
|
|
const result = validateGrouping({
|
|
nodes: [graph.nodes[0], graph.nodes[1], model, makeStickyNode()],
|
|
connectionsBySourceNode,
|
|
nodeTypes: stickyNodeTypes,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('non-main-boundary');
|
|
}
|
|
});
|
|
|
|
it('keeps rejecting stickies in extraction selections', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateNodeSelectionForExtraction({
|
|
nodes: [graph.nodes[0], graph.nodes[1], makeStickyNode()],
|
|
connectionsBySourceNode: graph.connections,
|
|
getNodeType: (node) => stickyNodeTypes[node.type],
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.reason).toBe('invalid-subgraph');
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('normalizeGroupDescription', () => {
|
|
test('keeps a description within the cap unchanged', () => {
|
|
expect(normalizeGroupDescription('short')).toBe('short');
|
|
});
|
|
|
|
test('caps an over-long description to the max length', () => {
|
|
const result = normalizeGroupDescription('x'.repeat(GROUP_DESCRIPTION_MAX_LENGTH + 50));
|
|
expect(result).toHaveLength(GROUP_DESCRIPTION_MAX_LENGTH);
|
|
});
|
|
|
|
test('treats an empty string as no description', () => {
|
|
expect(normalizeGroupDescription('')).toBeUndefined();
|
|
});
|
|
|
|
test.each([
|
|
['undefined', undefined],
|
|
['a number', 42],
|
|
['an object', { a: 1 }],
|
|
['an array', [1, 2, 3]],
|
|
['null', null],
|
|
])('drops a non-string value (%s)', (_label, value) => {
|
|
expect(normalizeGroupDescription(value)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('validateWorkflowGroups', () => {
|
|
const nodeTypesByName: Record<string, INodeTypeDescription> = {
|
|
'n8n-nodes-base.set': makeNodeType(),
|
|
'n8n-nodes-base.manualTrigger': makeNodeType({
|
|
name: 'n8n-nodes-base.manualTrigger',
|
|
group: ['trigger'],
|
|
}),
|
|
};
|
|
const getNodeType = (node: INode) => nodeTypesByName[node.type] ?? null;
|
|
|
|
const expectViolations = (
|
|
result: ReturnType<typeof validateWorkflowGroups>,
|
|
violations: Array<
|
|
Partial<{ groupId: string; groupName: string; code: string; message: string }>
|
|
>,
|
|
) => {
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.violations).toEqual(violations.map((v) => expect.objectContaining(v)));
|
|
}
|
|
};
|
|
|
|
it('returns valid when nodeGroups is missing or empty', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
expect(
|
|
validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
getNodeType,
|
|
}),
|
|
).toEqual({ valid: true });
|
|
|
|
expect(
|
|
validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [],
|
|
getNodeType,
|
|
}),
|
|
).toEqual({ valid: true });
|
|
});
|
|
|
|
it('returns valid for a well-formed group', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a', 'b'] }],
|
|
getNodeType,
|
|
});
|
|
|
|
expect(result).toEqual({ valid: true });
|
|
});
|
|
|
|
it('accepts several incoming and outgoing connections through a one-member NoOp group', () => {
|
|
const nodes = [
|
|
makeNode({ id: 'source-a', name: 'Source A' }),
|
|
makeNode({ id: 'source-c', name: 'Source C' }),
|
|
makeNode({
|
|
id: 'anchor',
|
|
name: 'Planning placeholder',
|
|
type: NO_OP_NODE_TYPE,
|
|
parameters: { emptyGroupAnchor: true },
|
|
}),
|
|
makeNode({ id: 'target-b', name: 'Target B' }),
|
|
makeNode({ id: 'target-d', name: 'Target D' }),
|
|
];
|
|
const connections: IConnections = {
|
|
'Source A': {
|
|
main: [[{ node: 'Planning placeholder', type: NodeConnectionTypes.Main, index: 0 }]],
|
|
},
|
|
'Source C': {
|
|
main: [[{ node: 'Planning placeholder', type: NodeConnectionTypes.Main, index: 0 }]],
|
|
},
|
|
'Planning placeholder': {
|
|
main: [
|
|
[
|
|
{ node: 'Target B', type: NodeConnectionTypes.Main, index: 0 },
|
|
{ node: 'Target D', type: NodeConnectionTypes.Main, index: 0 },
|
|
],
|
|
],
|
|
},
|
|
};
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes,
|
|
connectionsBySourceNode: connections,
|
|
nodeGroups: [{ id: 'planning-group', name: 'Planning group', nodeIds: ['anchor'] }],
|
|
getNodeType: (node) =>
|
|
node.type === NO_OP_NODE_TYPE
|
|
? makeNodeType({ name: NO_OP_NODE_TYPE, defaults: { name: 'No Operation' } })
|
|
: getNodeType(node),
|
|
});
|
|
|
|
expect(result).toEqual({ valid: true });
|
|
});
|
|
|
|
it('reports a duplicate group ID', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [
|
|
{ id: 'g1', name: 'First', nodeIds: ['a'] },
|
|
{ id: 'g1', name: 'Second', nodeIds: ['b'] },
|
|
],
|
|
getNodeType,
|
|
});
|
|
|
|
expectViolations(result, [
|
|
{
|
|
groupId: 'g1',
|
|
groupName: 'Second',
|
|
code: 'duplicate-group-id',
|
|
message: 'Duplicate node group ID "g1".',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('reports a duplicate group name', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [
|
|
{ id: 'g1', name: 'Group', nodeIds: ['a'] },
|
|
{ id: 'g2', name: 'Group', nodeIds: ['b'] },
|
|
],
|
|
getNodeType,
|
|
});
|
|
|
|
expectViolations(result, [
|
|
{
|
|
groupId: 'g2',
|
|
code: 'duplicate-group-name',
|
|
message: 'Duplicate node group name "Group".',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('reports a memberless group', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: [] }],
|
|
getNodeType,
|
|
});
|
|
|
|
expectViolations(result, [{ code: 'empty-group', message: 'Group "Group" has no members.' }]);
|
|
});
|
|
|
|
it('reports a group member that does not exist in the workflow', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a', 'missing'] }],
|
|
getNodeType,
|
|
});
|
|
|
|
expectViolations(result, [
|
|
{
|
|
code: 'unknown-node-id',
|
|
message: 'Group "Group" references node ID "missing" that does not exist in the workflow.',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('reports a node that belongs to multiple groups', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [
|
|
{ id: 'g1', name: 'First', nodeIds: ['a'] },
|
|
{ id: 'g2', name: 'Second', nodeIds: ['a'] },
|
|
],
|
|
getNodeType,
|
|
});
|
|
|
|
expectViolations(result, [
|
|
{
|
|
groupId: 'g2',
|
|
code: 'node-in-multiple-groups',
|
|
message: 'Node "A" belongs to multiple groups: "First" and "Second".',
|
|
},
|
|
// The clean first group still fails its graph rules against the second.
|
|
{
|
|
groupId: 'g1',
|
|
code: 'node-already-grouped',
|
|
message: 'Node group "First" contains nodes that already belong to another group: A.',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('falls back to the node id in messages when the node has no name', () => {
|
|
const unnamed = makeNode({ id: 'node-id-1', name: '' });
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: [unnamed],
|
|
connectionsBySourceNode: {},
|
|
nodeGroups: [
|
|
{ id: 'g1', name: 'First', nodeIds: ['node-id-1'] },
|
|
{ id: 'g2', name: 'Second', nodeIds: ['node-id-1'] },
|
|
],
|
|
getNodeType: null,
|
|
});
|
|
|
|
expectViolations(result, [
|
|
{
|
|
code: 'node-in-multiple-groups',
|
|
message: 'Node "node-id-1" belongs to multiple groups: "First" and "Second".',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('reports a group containing a trigger node', () => {
|
|
const graph = makeLinearGraph();
|
|
const trigger = makeNode({
|
|
id: 'trigger',
|
|
name: 'Trigger',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
});
|
|
const connections: IConnections = {
|
|
Trigger: { main: [[{ node: 'A', type: NodeConnectionTypes.Main, index: 0 }]] },
|
|
...graph.connections,
|
|
};
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: [...graph.nodes, trigger],
|
|
connectionsBySourceNode: connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['trigger', 'a'] }],
|
|
getNodeType,
|
|
});
|
|
|
|
expectViolations(result, [
|
|
{
|
|
code: 'trigger-selected',
|
|
message: 'Node group "Group" cannot contain trigger nodes: Trigger.',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('accepts a group containing a trigger node when the trigger rule is on', () => {
|
|
const graph = makeLinearGraph();
|
|
const trigger = makeNode({
|
|
id: 'trigger',
|
|
name: 'Trigger',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
});
|
|
const connections: IConnections = {
|
|
Trigger: { main: [[{ node: 'A', type: NodeConnectionTypes.Main, index: 0 }]] },
|
|
...graph.connections,
|
|
};
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: [...graph.nodes, trigger],
|
|
connectionsBySourceNode: connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['trigger', 'a'] }],
|
|
getNodeType,
|
|
allowTriggerInGroup: true,
|
|
});
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('reports a group crossing a non-main connection boundary', () => {
|
|
const graph = makeLinearGraph();
|
|
const model = makeNode({ id: 'model', name: 'Model' });
|
|
const connections: IConnections = {
|
|
...graph.connections,
|
|
Model: {
|
|
[NodeConnectionTypes.AiLanguageModel]: [
|
|
[{ node: 'B', type: NodeConnectionTypes.AiLanguageModel, index: 0 }],
|
|
],
|
|
},
|
|
};
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: [...graph.nodes, model],
|
|
connectionsBySourceNode: connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a', 'b'] }],
|
|
getNodeType,
|
|
});
|
|
|
|
expectViolations(result, [
|
|
{
|
|
code: 'non-main-boundary',
|
|
message:
|
|
'Node group "Group" cannot cross the "ai_languageModel" connection between "Model" and "B".',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('reports a disconnected selection as an invalid subgraph', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a', 'c'] }],
|
|
getNodeType,
|
|
});
|
|
|
|
expectViolations(result, [
|
|
{
|
|
code: 'invalid-subgraph',
|
|
message:
|
|
'Node group "Group" must form a single connected subgraph with a single entry and exit (no path from "C" to "A").',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('names the member that faces outward when a group is rejected', () => {
|
|
// A loop body whose back-edge leaves from a node that also continues inside:
|
|
// the engine blames that node, and the message must say so, otherwise the
|
|
// author has to guess which boundary to redraw.
|
|
const nodes = [
|
|
makeNode({ id: 'loop', name: 'Loop', type: 'n8n-nodes-base.splitInBatches' }),
|
|
makeNode({ id: 'fetch', name: 'Fetch' }),
|
|
makeNode({ id: 'convert', name: 'Convert' }),
|
|
makeNode({ id: 'store', name: 'Store' }),
|
|
];
|
|
|
|
const connections: IConnections = {
|
|
Loop: { main: [[], [{ node: 'Fetch', type: NodeConnectionTypes.Main, index: 0 }]] },
|
|
Fetch: { main: [[{ node: 'Convert', type: NodeConnectionTypes.Main, index: 0 }]] },
|
|
Convert: {
|
|
main: [
|
|
[
|
|
{ node: 'Store', type: NodeConnectionTypes.Main, index: 0 },
|
|
{ node: 'Loop', type: NodeConnectionTypes.Main, index: 0 },
|
|
],
|
|
],
|
|
},
|
|
};
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes,
|
|
connectionsBySourceNode: connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Body', nodeIds: ['fetch', 'convert', 'store'] }],
|
|
getNodeType,
|
|
});
|
|
|
|
expectViolations(result, [
|
|
{
|
|
code: 'invalid-subgraph',
|
|
message:
|
|
'Node group "Body" must form a single connected subgraph with a single entry and exit (output edge from non-leaf node: "Convert").',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('identifies the group by name only, keeping the id on the structured violation', () => {
|
|
// The id is deliberately absent from the message: a group that fails these
|
|
// rules may never have been persisted. Consumers use `groupId` instead.
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [{ id: 'group-uuid-1', name: 'Group', nodeIds: ['a', 'c'] }],
|
|
getNodeType,
|
|
});
|
|
|
|
expect(result.valid).toBe(false);
|
|
if (!result.valid) {
|
|
expect(result.violations[0].groupId).toBe('group-uuid-1');
|
|
expect(result.violations[0].message).not.toContain('group-uuid-1');
|
|
}
|
|
});
|
|
|
|
it('skips graph rules for a group that already has a basic violation', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
// Without the skip, {a, c} would additionally report invalid-subgraph.
|
|
const result = validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a', 'c', 'missing'] }],
|
|
getNodeType,
|
|
});
|
|
|
|
expectViolations(result, [{ code: 'unknown-node-id' }]);
|
|
});
|
|
|
|
it('runs basic checks only when getNodeType is null', () => {
|
|
const graph = makeLinearGraph();
|
|
const trigger = makeNode({
|
|
id: 'trigger',
|
|
name: 'Trigger',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
});
|
|
|
|
// A trigger-containing group passes basic-only validation…
|
|
expect(
|
|
validateWorkflowGroups({
|
|
nodes: [...graph.nodes, trigger],
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['trigger', 'a'] }],
|
|
getNodeType: null,
|
|
}),
|
|
).toEqual({ valid: true });
|
|
|
|
// …but basic violations are still reported.
|
|
const result = validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: [] }],
|
|
getNodeType: null,
|
|
});
|
|
expectViolations(result, [{ code: 'empty-group' }]);
|
|
});
|
|
|
|
it('collects all violations, basic checks first', () => {
|
|
const graph = makeLinearGraph();
|
|
|
|
const result = validateWorkflowGroups({
|
|
nodes: graph.nodes,
|
|
connectionsBySourceNode: graph.connections,
|
|
nodeGroups: [
|
|
{ id: 'g1', name: 'Broken', nodeIds: [] },
|
|
{ id: 'g2', name: 'Disconnected', nodeIds: ['a', 'c'] },
|
|
],
|
|
getNodeType,
|
|
});
|
|
|
|
expectViolations(result, [
|
|
{ groupId: 'g1', code: 'empty-group' },
|
|
{ groupId: 'g2', code: 'invalid-subgraph' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('makeGetNodeTypeForGrouping', () => {
|
|
it('returns the description for a known type and null for an unknown one', () => {
|
|
const description = makeNodeType({ name: 'known.node' });
|
|
const nodeTypes = {
|
|
getByNameAndVersion(nodeType: string) {
|
|
if (nodeType === 'known.node') return { description };
|
|
throw new Error('Unknown node type');
|
|
},
|
|
} as INodeTypes;
|
|
|
|
const getNodeType = makeGetNodeTypeForGrouping(nodeTypes);
|
|
|
|
expect(getNodeType(makeNode({ type: 'known.node' }))).toBe(description);
|
|
expect(getNodeType(makeNode({ type: 'unknown.node' }))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('dropInvalidWorkflowGroups', () => {
|
|
it('leaves a valid workflow untouched and reports nothing', () => {
|
|
const graph = makeLinearGraph();
|
|
const workflow = {
|
|
nodes: graph.nodes,
|
|
connections: graph.connections,
|
|
nodeGroups: [{ id: 'g1', name: 'Group', nodeIds: ['a', 'b'] }],
|
|
};
|
|
|
|
expect(dropInvalidWorkflowGroups(workflow, null)).toEqual([]);
|
|
expect(workflow.nodeGroups).toEqual([{ id: 'g1', name: 'Group', nodeIds: ['a', 'b'] }]);
|
|
});
|
|
|
|
it('drops every violating group and keeps the valid ones', () => {
|
|
const graph = makeLinearGraph();
|
|
const workflow = {
|
|
nodes: graph.nodes,
|
|
connections: graph.connections,
|
|
nodeGroups: [
|
|
{ id: 'g1', name: 'Valid', nodeIds: ['a', 'b'] },
|
|
{ id: 'g2', name: 'Unknown member', nodeIds: ['missing'] },
|
|
],
|
|
};
|
|
|
|
const violations = dropInvalidWorkflowGroups(workflow, null);
|
|
|
|
expect(violations).toHaveLength(1);
|
|
expect(violations[0]).toMatchObject({ groupId: 'g2', code: 'unknown-node-id' });
|
|
expect(workflow.nodeGroups).toEqual([{ id: 'g1', name: 'Valid', nodeIds: ['a', 'b'] }]);
|
|
});
|
|
|
|
it('returns every violation for dropped groups while dropping each group once', () => {
|
|
const graph = makeLinearGraph();
|
|
const workflow = {
|
|
nodes: graph.nodes,
|
|
connections: graph.connections,
|
|
nodeGroups: [
|
|
{ id: 'g1', name: 'Duplicate', nodeIds: ['a'] },
|
|
{ id: 'g2', name: 'Duplicate', nodeIds: [] },
|
|
],
|
|
};
|
|
|
|
const violations = dropInvalidWorkflowGroups(workflow, null);
|
|
|
|
expect(violations).toEqual([
|
|
expect.objectContaining({
|
|
groupId: 'g2',
|
|
groupName: 'Duplicate',
|
|
code: 'duplicate-group-name',
|
|
}),
|
|
expect.objectContaining({
|
|
groupId: 'g2',
|
|
groupName: 'Duplicate',
|
|
code: 'empty-group',
|
|
}),
|
|
]);
|
|
expect(workflow.nodeGroups).toEqual([{ id: 'g1', name: 'Duplicate', nodeIds: ['a'] }]);
|
|
});
|
|
|
|
it('drops only the reported group when duplicate IDs make groupId ambiguous', () => {
|
|
const graph = makeLinearGraph();
|
|
const workflow = {
|
|
nodes: graph.nodes,
|
|
connections: graph.connections,
|
|
nodeGroups: [
|
|
{ id: 'dup', name: 'First', nodeIds: ['a'] },
|
|
{ id: 'dup', name: 'Second', nodeIds: ['b'] },
|
|
],
|
|
};
|
|
|
|
const violations = dropInvalidWorkflowGroups(workflow, null);
|
|
|
|
expect(violations).toEqual([
|
|
expect.objectContaining({
|
|
groupId: 'dup',
|
|
groupName: 'Second',
|
|
code: 'duplicate-group-id',
|
|
}),
|
|
]);
|
|
expect(workflow.nodeGroups).toEqual([{ id: 'dup', name: 'First', nodeIds: ['a'] }]);
|
|
});
|
|
|
|
describe('with a shouldDrop predicate', () => {
|
|
// Two groups sharing A: the second is flagged for the overlap, and the
|
|
// first for holding a node that now belongs elsewhere. A caller that can
|
|
// only blame one of them must be able to drop just that one.
|
|
const buildOverlapping = () => {
|
|
const graph = makeLinearGraph();
|
|
return {
|
|
nodes: graph.nodes,
|
|
connections: graph.connections,
|
|
nodeGroups: [
|
|
{ id: 'g1', name: 'First', nodeIds: ['a', 'b'] },
|
|
{ id: 'g2', name: 'Second', nodeIds: ['a'] },
|
|
],
|
|
};
|
|
};
|
|
|
|
it('drops only the matching groups and reports only those', () => {
|
|
const workflow = buildOverlapping();
|
|
|
|
const violations = dropInvalidWorkflowGroups(
|
|
workflow,
|
|
() => makeNodeType(),
|
|
(violation) => violation.groupId === 'g2',
|
|
);
|
|
|
|
expect(violations).toHaveLength(1);
|
|
expect(violations[0].groupId).toBe('g2');
|
|
expect(workflow.nodeGroups).toEqual([{ id: 'g1', name: 'First', nodeIds: ['a', 'b'] }]);
|
|
});
|
|
|
|
it('clears the collateral violation once the culprit is gone', () => {
|
|
const workflow = buildOverlapping();
|
|
dropInvalidWorkflowGroups(
|
|
workflow,
|
|
() => makeNodeType(),
|
|
(violation) => violation.groupId === 'g2',
|
|
);
|
|
|
|
// Second pass: "First" only ever failed because "Second" overlapped it.
|
|
expect(dropInvalidWorkflowGroups(workflow, () => makeNodeType())).toEqual([]);
|
|
expect(workflow.nodeGroups).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps the workflow untouched when nothing matches', () => {
|
|
const workflow = buildOverlapping();
|
|
|
|
expect(
|
|
dropInvalidWorkflowGroups(
|
|
workflow,
|
|
() => makeNodeType(),
|
|
() => false,
|
|
),
|
|
).toEqual([]);
|
|
expect(workflow.nodeGroups).toHaveLength(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('summarizeTopLevelItems', () => {
|
|
const plainNodes = (count: number) =>
|
|
Array.from({ length: count }, (_, i) => makeNode({ id: `n${i}`, name: `N${i}` }));
|
|
|
|
it('stays under the ceiling with exactly TOP_LEVEL_ITEM_CEILING boxes', () => {
|
|
const summary = summarizeTopLevelItems({ nodes: plainNodes(TOP_LEVEL_ITEM_CEILING) });
|
|
|
|
expect(summary.total).toBe(TOP_LEVEL_ITEM_CEILING);
|
|
expect(summary.overCeiling).toBe(false);
|
|
});
|
|
|
|
it('counts the trigger as a box but leaves it out of the groupable list', () => {
|
|
const nodes = [
|
|
makeNode({ id: 't', name: 'When chat message received', type: 'n8n-nodes-base.chatTrigger' }),
|
|
...plainNodes(TOP_LEVEL_ITEM_CEILING),
|
|
];
|
|
|
|
const summary = summarizeTopLevelItems({ nodes });
|
|
|
|
expect(summary.total).toBe(TOP_LEVEL_ITEM_CEILING + 1);
|
|
expect(summary.overCeiling).toBe(true);
|
|
expect(summary.ungroupedNodeNames).toContain('When chat message received');
|
|
expect(summary.groupableNodeNames).not.toContain('When chat message received');
|
|
expect(summary.groupableNodeNames).toHaveLength(TOP_LEVEL_ITEM_CEILING);
|
|
});
|
|
|
|
it('counts a group as one box and its members as none', () => {
|
|
const summary = summarizeTopLevelItems({
|
|
nodes: plainNodes(8),
|
|
nodeGroups: [{ nodeIds: ['n0', 'n1', 'n2'] }],
|
|
});
|
|
|
|
// 1 group + 5 ungrouped nodes.
|
|
expect(summary.total).toBe(6);
|
|
expect(summary.groupCount).toBe(1);
|
|
expect(summary.groupableNodeNames).toEqual(['N3', 'N4', 'N5', 'N6', 'N7']);
|
|
});
|
|
|
|
it('counts an agent and its sub-nodes as one box', () => {
|
|
const connections: IConnections = {
|
|
Model: { ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]] },
|
|
Memory: { ai_memory: [[{ node: 'Agent', type: 'ai_memory', index: 0 }]] },
|
|
Tool: { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] },
|
|
Agent: { main: [[{ node: 'N0', type: NodeConnectionTypes.Main, index: 0 }]] },
|
|
};
|
|
const nodes = [
|
|
makeNode({ id: 'agent', name: 'Agent' }),
|
|
makeNode({ id: 'model', name: 'Model' }),
|
|
makeNode({ id: 'memory', name: 'Memory' }),
|
|
makeNode({ id: 'tool', name: 'Tool' }),
|
|
...plainNodes(6),
|
|
];
|
|
|
|
const summary = summarizeTopLevelItems({ nodes, connectionsBySourceNode: connections });
|
|
|
|
// Agent + 6 plain nodes; the three sub-nodes do not count.
|
|
expect(summary.total).toBe(7);
|
|
expect(summary.overCeiling).toBe(false);
|
|
});
|
|
|
|
it('does not count sticky notes', () => {
|
|
const summary = summarizeTopLevelItems({
|
|
nodes: [...plainNodes(TOP_LEVEL_ITEM_CEILING), makeStickyNode()],
|
|
});
|
|
|
|
expect(summary.total).toBe(TOP_LEVEL_ITEM_CEILING);
|
|
});
|
|
|
|
it('treats a node without an id as ungrouped', () => {
|
|
const nodes = [
|
|
{ type: 'n8n-nodes-base.set', name: 'Anonymous' },
|
|
...plainNodes(TOP_LEVEL_ITEM_CEILING),
|
|
];
|
|
|
|
const summary = summarizeTopLevelItems({ nodes, nodeGroups: [{ nodeIds: ['n0'] }] });
|
|
|
|
expect(summary.total).toBe(TOP_LEVEL_ITEM_CEILING + 1);
|
|
expect(summary.groupableNodeNames).toContain('Anonymous');
|
|
});
|
|
|
|
it('lists the groupable nodes in the message and leaves the trigger out', () => {
|
|
const nodes = [
|
|
makeNode({ id: 't', name: 'Start', type: 'n8n-nodes-base.manualTrigger' }),
|
|
...plainNodes(TOP_LEVEL_ITEM_CEILING),
|
|
];
|
|
|
|
const message = formatTopLevelItemsMessage(summarizeTopLevelItems({ nodes }));
|
|
|
|
expect(message).toContain(`${TOP_LEVEL_ITEM_CEILING + 1} boxes`);
|
|
expect(message).toContain('Still ungrouped: N0, N1');
|
|
expect(message).not.toContain('Start');
|
|
});
|
|
});
|
|
|
|
describe('collectSubNodeNames', () => {
|
|
it('keeps a node with a main output out of the sub-node set', () => {
|
|
const connections: IConnections = {
|
|
Model: { ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]] },
|
|
Agent: { main: [[{ node: 'Next', type: NodeConnectionTypes.Main, index: 0 }]] },
|
|
};
|
|
|
|
expect([...collectSubNodeNames(connections)]).toEqual(['Model']);
|
|
expect(collectSubNodeNames(undefined).size).toBe(0);
|
|
});
|
|
|
|
it('does not treat a node with only empty non-main slots as a sub-node', () => {
|
|
const connections: IConnections = {
|
|
'Loose Tool': { ai_tool: [[]] },
|
|
'Wired Tool': { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] },
|
|
Agent: { main: [[]], ai_tool: [[{ node: 'Other Agent', type: 'ai_tool', index: 0 }]] },
|
|
};
|
|
|
|
expect([...collectSubNodeNames(connections)].sort()).toEqual(['Agent', 'Wired Tool']);
|
|
});
|
|
});
|