n8n/packages/workflow/test/node-grouping-validation.test.ts

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']);
});
});