mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 03:24:59 +02:00
901 lines
25 KiB
TypeScript
901 lines
25 KiB
TypeScript
import {
|
|
GROUP_DESCRIPTION_MAX_LENGTH,
|
|
normalizeGroupDescription,
|
|
validateNodeSelectionForExtraction,
|
|
validateNodeSelectionForGrouping,
|
|
validateWorkflowGroups,
|
|
} from '../src/node-grouping-validation';
|
|
import {
|
|
NodeConnectionTypes,
|
|
STICKY_NODE_TYPE,
|
|
type IConnections,
|
|
type INode,
|
|
type INodeTypeDescription,
|
|
} 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() },
|
|
}: {
|
|
nodes: INode[];
|
|
connectionsBySourceNode: IConnections;
|
|
nodeTypes?: Record<string, INodeTypeDescription>;
|
|
}) {
|
|
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');
|
|
}
|
|
});
|
|
|
|
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('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" (g1) 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" (g1) cannot contain trigger nodes: Trigger.',
|
|
},
|
|
]);
|
|
});
|
|
|
|
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" (g1) 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" (g1) must form a single connected subgraph with a single entry and exit.',
|
|
},
|
|
]);
|
|
});
|
|
|
|
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' },
|
|
]);
|
|
});
|
|
});
|