import { validateNodeSelectionForExtraction, validateNodeSelectionForGrouping, } from '../src/node-grouping-validation'; import { NodeConnectionTypes, type IConnections, type INode, type INodeTypeDescription, } from '../src'; function makeNode(overrides: Partial = {}): 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 { 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 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() }, }: { nodes: INode[]; connectionsBySourceNode: IConnections; nodeTypes?: Record; }) { return validateNodeSelectionForGrouping({ nodes, connectionsBySourceNode, getNodeType: (node) => nodeTypes[node.type], }); } 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); }); it('returns trigger-selected when the selection contains a trigger', () => { const graph = makeLinearGraph(); graph.nodes[0].type = 'n8n-nodes-base.manualTrigger'; const result = validateGrouping({ nodes: [graph.nodes[0], graph.nodes[1]], connectionsBySourceNode: graph.connections, nodeTypes: { 'n8n-nodes-base.manualTrigger': makeNodeType({ name: 'n8n-nodes-base.manualTrigger', group: ['trigger'], }), 'n8n-nodes-base.set': makeNodeType(), }, }); expect(result).toEqual({ valid: false, reason: 'trigger-selected', triggers: ['A'] }); }); 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'); } }); });