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

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