mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(editor): Add ability to extract sub-workflows to canvas context menu (#15538)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import type { IConnection, IConnections } from '../interfaces';
|
||||
|
||||
type MultipleInputNodesError = {
|
||||
errorCode: 'Multiple Input Nodes';
|
||||
nodes: Set<string>;
|
||||
@@ -31,21 +33,21 @@ export type ExtractableErrorResult =
|
||||
| OutputEdgeFromNonLeafNode
|
||||
| NoContinuousPathFromRootToLeaf;
|
||||
|
||||
type AdjacencyList = Map<string, Set<string>>;
|
||||
export type IConnectionAdjacencyList = Map<string, Set<IConnection>>;
|
||||
|
||||
/**
|
||||
* Find all edges leading into the graph described in `graphIds`.
|
||||
*/
|
||||
export function getInputEdges(
|
||||
graphIds: Set<string>,
|
||||
adjacencyList: AdjacencyList,
|
||||
): Array<[string, string]> {
|
||||
const result: Array<[string, string]> = [];
|
||||
adjacencyList: IConnectionAdjacencyList,
|
||||
): Array<[string, IConnection]> {
|
||||
const result: Array<[string, IConnection]> = [];
|
||||
for (const [from, tos] of adjacencyList.entries()) {
|
||||
if (graphIds.has(from)) continue;
|
||||
|
||||
for (const to of tos) {
|
||||
if (graphIds.has(to)) {
|
||||
if (graphIds.has(to.node)) {
|
||||
result.push([from, to]);
|
||||
}
|
||||
}
|
||||
@@ -59,14 +61,14 @@ export function getInputEdges(
|
||||
*/
|
||||
export function getOutputEdges(
|
||||
graphIds: Set<string>,
|
||||
adjacencyList: AdjacencyList,
|
||||
): Array<[string, string]> {
|
||||
const result: Array<[string, string]> = [];
|
||||
adjacencyList: IConnectionAdjacencyList,
|
||||
): Array<[string, IConnection]> {
|
||||
const result: Array<[string, IConnection]> = [];
|
||||
for (const [from, tos] of adjacencyList.entries()) {
|
||||
if (!graphIds.has(from)) continue;
|
||||
|
||||
for (const to of tos) {
|
||||
if (!graphIds.has(to)) {
|
||||
if (!graphIds.has(to.node)) {
|
||||
result.push([from, to]);
|
||||
}
|
||||
}
|
||||
@@ -98,27 +100,49 @@ function difference<T>(minuend: Set<T>, subtrahend: Set<T>): Set<T> {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getRootNodes(graphIds: Set<string>, adjacencyList: AdjacencyList): Set<string> {
|
||||
export function getRootNodes(
|
||||
graphIds: Set<string>,
|
||||
adjacencyList: IConnectionAdjacencyList,
|
||||
): Set<string> {
|
||||
// Inner nodes are all nodes with an incoming edge from another node in the graph
|
||||
let innerNodes = new Set<string>();
|
||||
for (const nodeId of graphIds) {
|
||||
innerNodes = union(innerNodes, adjacencyList.get(nodeId) ?? new Set());
|
||||
innerNodes = union(
|
||||
innerNodes,
|
||||
new Set(
|
||||
[...(adjacencyList.get(nodeId) ?? [])]
|
||||
.filter((x) => x.type === 'main' && x.node !== nodeId)
|
||||
.map((x) => x.node),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return difference(graphIds, innerNodes);
|
||||
}
|
||||
|
||||
export function getLeafNodes(graphIds: Set<string>, adjacencyList: AdjacencyList): Set<string> {
|
||||
export function getLeafNodes(
|
||||
graphIds: Set<string>,
|
||||
adjacencyList: IConnectionAdjacencyList,
|
||||
): Set<string> {
|
||||
const result = new Set<string>();
|
||||
for (const nodeId of graphIds) {
|
||||
if (intersection(adjacencyList.get(nodeId) ?? new Set(), graphIds).size === 0) {
|
||||
if (
|
||||
intersection(
|
||||
new Set(
|
||||
[...(adjacencyList.get(nodeId) ?? [])]
|
||||
.filter((x) => x.type === 'main' && x.node !== nodeId)
|
||||
.map((x) => x.node),
|
||||
),
|
||||
graphIds,
|
||||
).size === 0
|
||||
) {
|
||||
result.add(nodeId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function hasPath(start: string, end: string, adjacencyList: AdjacencyList) {
|
||||
export function hasPath(start: string, end: string, adjacencyList: IConnectionAdjacencyList) {
|
||||
const seen = new Set<string>();
|
||||
const paths: string[] = [start];
|
||||
while (true) {
|
||||
@@ -127,7 +151,14 @@ export function hasPath(start: string, end: string, adjacencyList: AdjacencyList
|
||||
if (next === undefined) return false;
|
||||
seen.add(next);
|
||||
|
||||
paths.push(...difference(adjacencyList.get(next) ?? new Set<string>(), seen));
|
||||
paths.push(
|
||||
...difference(
|
||||
new Set(
|
||||
[...(adjacencyList.get(next) ?? [])].filter((x) => x.type === 'main').map((x) => x.node),
|
||||
),
|
||||
seen,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +167,31 @@ export type ExtractableSubgraphData = {
|
||||
end?: string;
|
||||
};
|
||||
|
||||
export function buildAdjacencyList(
|
||||
connectionsBySourceNode: IConnections,
|
||||
): IConnectionAdjacencyList {
|
||||
const result = new Map<string, Set<IConnection>>();
|
||||
const addOrCreate = (k: string, v: IConnection) =>
|
||||
result.set(k, union(result.get(k) ?? new Set(), new Set([v])));
|
||||
|
||||
for (const sourceNode of Object.keys(connectionsBySourceNode)) {
|
||||
for (const type of Object.keys(connectionsBySourceNode[sourceNode])) {
|
||||
for (const sourceIndex of Object.keys(connectionsBySourceNode[sourceNode][type])) {
|
||||
for (const connectionIndex of Object.keys(
|
||||
connectionsBySourceNode[sourceNode][type][parseInt(sourceIndex, 10)] ?? [],
|
||||
)) {
|
||||
const connection =
|
||||
connectionsBySourceNode[sourceNode][type][parseInt(sourceIndex, 10)]?.[
|
||||
parseInt(connectionIndex, 10)
|
||||
];
|
||||
if (connection) addOrCreate(sourceNode, connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A subgraph is considered extractable if the following properties hold:
|
||||
* - 0-1 input nodes from outside the subgraph, to a root node
|
||||
@@ -152,14 +208,18 @@ export type ExtractableSubgraphData = {
|
||||
*/
|
||||
export function parseExtractableSubgraphSelection(
|
||||
graphIds: Set<string>,
|
||||
adjacencyList: AdjacencyList,
|
||||
adjacencyList: IConnectionAdjacencyList,
|
||||
): ExtractableSubgraphData | ExtractableErrorResult[] {
|
||||
const errors: ExtractableErrorResult[] = [];
|
||||
|
||||
// 0-1 Input nodes
|
||||
const inputEdges = getInputEdges(graphIds, adjacencyList);
|
||||
const inputNodes = new Set(inputEdges.map((x) => x[1]));
|
||||
const rootNodes = getRootNodes(graphIds, adjacencyList);
|
||||
// This filters out e.g. sub-nodes, which are technically parents
|
||||
const inputNodes = new Set(inputEdges.filter((x) => x[1].type === 'main').map((x) => x[1].node));
|
||||
let rootNodes = getRootNodes(graphIds, adjacencyList);
|
||||
|
||||
// this enables supporting cases where we have one input and a loop back to it from within the selection
|
||||
if (rootNodes.size === 0 && inputNodes.size === 1) rootNodes = inputNodes;
|
||||
for (const inputNode of difference(inputNodes, rootNodes).values()) {
|
||||
errors.push({
|
||||
errorCode: 'Input Edge To Non-Root Node',
|
||||
@@ -176,8 +236,13 @@ export function parseExtractableSubgraphSelection(
|
||||
|
||||
// 0-1 Output nodes
|
||||
const outputEdges = getOutputEdges(graphIds, adjacencyList);
|
||||
const outputNodes = new Set(outputEdges.map((x) => x[0]));
|
||||
const leafNodes = getLeafNodes(graphIds, adjacencyList);
|
||||
const outputNodes = new Set(outputEdges.filter((x) => x[1].type === 'main').map((x) => x[0]));
|
||||
let leafNodes = getLeafNodes(graphIds, adjacencyList);
|
||||
// If we have no leaf nodes, and only one output node, we can tolerate this output node
|
||||
// and connect to it.
|
||||
// Note that this is fairly theoretical, as return semantics in this case are not well-defined.
|
||||
if (leafNodes.size === 0 && outputNodes.size === 1) leafNodes = outputNodes;
|
||||
|
||||
for (const outputNode of difference(outputNodes, leafNodes).values()) {
|
||||
errors.push({
|
||||
errorCode: 'Output Edge From Non-Leaf Node',
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from './execution-status';
|
||||
export * from './expression';
|
||||
export * from './from-ai-parse-utils';
|
||||
export * from './node-helpers';
|
||||
export * from './node-reference-parser-utils';
|
||||
export * from './metadata-utils';
|
||||
export * from './workflow';
|
||||
export * from './workflow-data-proxy';
|
||||
@@ -50,6 +51,13 @@ export {
|
||||
isFilterValue,
|
||||
} from './type-guards';
|
||||
|
||||
export {
|
||||
parseExtractableSubgraphSelection,
|
||||
buildAdjacencyList,
|
||||
type ExtractableErrorResult,
|
||||
type ExtractableSubgraphData,
|
||||
type IConnectionAdjacencyList as AdjacencyList,
|
||||
} from './graph/graph-utils';
|
||||
export { ExpressionExtensions } from './extensions';
|
||||
export * as ExpressionParser from './extensions/expression-parser';
|
||||
export { NativeMethods } from './native-methods';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { escapeRegExp, mapValues, isEqual, cloneDeep } from 'lodash';
|
||||
|
||||
import { OperationalError } from './errors';
|
||||
import type { INode, NodeParameterValueType } from './interfaces';
|
||||
import type { INode, INodeParameters, NodeParameterValueType } from './interfaces';
|
||||
|
||||
class LazyRegExp {
|
||||
private regExp?: RegExp;
|
||||
@@ -176,7 +176,6 @@ function parseExpressionMapping(
|
||||
for (; partsIdx < parts.length; ++partsIdx) {
|
||||
if (!DOT_REFERENCEABLE_JS_VARIABLE.test(parts[partsIdx])) break;
|
||||
}
|
||||
|
||||
return {
|
||||
nodeNameInExpression: null,
|
||||
originalExpression: `${exprStart}.${parts.slice(0, partsIdx + 1).join('.')}`, // $json.valid.until, but not ['x'] after
|
||||
@@ -304,8 +303,12 @@ function parseCandidateMatch(
|
||||
|
||||
// Handle matches of form `$json.path.to.value`, which is necessary for the selection input node
|
||||
function parse$jsonMatch(match: RegExpExecArray, expression: string, startNodeName: string) {
|
||||
const candidate = extractExpressionCandidate(expression, match.index, match[0].length);
|
||||
if (candidate === null) return;
|
||||
const candidate = extractExpressionCandidate(
|
||||
expression,
|
||||
match.index,
|
||||
match.index + match[0].length + 1,
|
||||
);
|
||||
if (candidate === null) return null;
|
||||
return parseExpressionMapping(candidate, null, null, startNodeName);
|
||||
}
|
||||
|
||||
@@ -439,6 +442,10 @@ function applyExtractMappingToNode(node: INode, parameterExtractMapping: Paramet
|
||||
return parameters;
|
||||
}
|
||||
|
||||
if (Array.isArray(parameters) && typeof mapping === 'object' && !Array.isArray(mapping)) {
|
||||
return parameters.map((x, i) => applyMapping(x, mapping[i]) as INodeParameters);
|
||||
}
|
||||
|
||||
return mapValues(parameters, (v, k) => applyMapping(v, mapping[k])) as NodeParameterValueType;
|
||||
};
|
||||
|
||||
@@ -477,17 +484,16 @@ export function extractReferencesInNodeExpressions(
|
||||
subGraph: INode[],
|
||||
nodeNames: string[],
|
||||
insertedStartName: string,
|
||||
graphInputNodeName?: string,
|
||||
graphInputNodeNames?: string[],
|
||||
) {
|
||||
////
|
||||
// STEP 1 - Validate input invariants
|
||||
////
|
||||
if (nodeNames.includes(insertedStartName))
|
||||
throw new OperationalError(
|
||||
`StartNodeName ${insertedStartName} already exists in nodeNames: ${JSON.stringify(nodeNames)}`,
|
||||
);
|
||||
|
||||
const subGraphNames = subGraph.map((x) => x.name);
|
||||
if (subGraphNames.includes(insertedStartName))
|
||||
throw new OperationalError(
|
||||
`StartNodeName ${insertedStartName} already exists in nodeNames: ${JSON.stringify(subGraphNames)}`,
|
||||
);
|
||||
|
||||
if (subGraphNames.some((x) => !nodeNames.includes(x))) {
|
||||
throw new OperationalError(
|
||||
@@ -516,7 +522,8 @@ export function extractReferencesInNodeExpressions(
|
||||
////
|
||||
|
||||
// This map is used to change the actual expressions once resolved
|
||||
const recMapByNode = new Map<string, ParameterExtractMapping>();
|
||||
// The value represents fields in the actual parameters object which require change
|
||||
const parameterTreeMappingByNode = new Map<string, ParameterExtractMapping>();
|
||||
// This is used to track all candidates for change, necessary for deduplication
|
||||
const allData = [];
|
||||
|
||||
@@ -527,10 +534,10 @@ export function extractReferencesInNodeExpressions(
|
||||
nodeRegexps,
|
||||
nodeNames,
|
||||
insertedStartName,
|
||||
node.name === graphInputNodeName,
|
||||
graphInputNodeNames?.includes(node.name) ?? false,
|
||||
),
|
||||
);
|
||||
recMapByNode.set(node.name, parameterMapping);
|
||||
parameterTreeMappingByNode.set(node.name, parameterMapping);
|
||||
allData.push(...allMappings);
|
||||
}
|
||||
|
||||
@@ -560,8 +567,8 @@ export function extractReferencesInNodeExpressions(
|
||||
return triggerArgumentMap.get(key);
|
||||
};
|
||||
|
||||
for (const [key, value] of recMapByNode.entries()) {
|
||||
recMapByNode.set(key, applyCanonicalMapping(value, getCanonicalData));
|
||||
for (const [key, value] of parameterTreeMappingByNode.entries()) {
|
||||
parameterTreeMappingByNode.set(key, applyCanonicalMapping(value, getCanonicalData));
|
||||
}
|
||||
|
||||
const allUsedMappings = [];
|
||||
@@ -569,7 +576,7 @@ export function extractReferencesInNodeExpressions(
|
||||
for (const node of subGraph) {
|
||||
const { result, usedMappings } = applyExtractMappingToNode(
|
||||
cloneDeep(node),
|
||||
recMapByNode.get(node.name),
|
||||
parameterTreeMappingByNode.get(node.name),
|
||||
);
|
||||
allUsedMappings.push(...usedMappings);
|
||||
output.push(result);
|
||||
|
||||
@@ -927,4 +927,38 @@ export class Workflow {
|
||||
|
||||
return this.__getStartNode(Object.keys(this.nodes));
|
||||
}
|
||||
|
||||
getConnectionsBetweenNodes(
|
||||
sources: string[],
|
||||
targets: string[],
|
||||
): Array<[IConnection, IConnection]> {
|
||||
const result: Array<[IConnection, IConnection]> = [];
|
||||
|
||||
for (const source of sources) {
|
||||
for (const type of Object.keys(this.connectionsBySourceNode[source] ?? {})) {
|
||||
for (const sourceIndex of Object.keys(this.connectionsBySourceNode[source][type])) {
|
||||
for (const connectionIndex of Object.keys(
|
||||
this.connectionsBySourceNode[source][type][parseInt(sourceIndex, 10)] ?? [],
|
||||
)) {
|
||||
const targetConnectionData =
|
||||
this.connectionsBySourceNode[source][type][parseInt(sourceIndex, 10)]?.[
|
||||
parseInt(connectionIndex, 10)
|
||||
];
|
||||
if (targetConnectionData && targets.includes(targetConnectionData?.node)) {
|
||||
result.push([
|
||||
{
|
||||
node: source,
|
||||
index: parseInt(sourceIndex, 10),
|
||||
type: type as NodeConnectionType,
|
||||
},
|
||||
targetConnectionData,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import {
|
||||
getInputEdges,
|
||||
getOutputEdges,
|
||||
getRootNodes,
|
||||
getLeafNodes,
|
||||
parseExtractableSubgraphSelection,
|
||||
hasPath,
|
||||
} from '@/graph/graph-utils';
|
||||
|
||||
describe('graphUtils', () => {
|
||||
describe('getInputEdges', () => {
|
||||
it('should return edges leading into the graph', () => {
|
||||
const graphIds = new Set(['B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
]);
|
||||
|
||||
const result = getInputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([['A', 'B']]);
|
||||
});
|
||||
|
||||
it('should return an empty array if there are no input edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = getInputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOutputEdges', () => {
|
||||
it('should return edges leading out of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set()],
|
||||
]);
|
||||
|
||||
const result = getOutputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([['B', 'C']]);
|
||||
});
|
||||
|
||||
it('should return an empty array if there are no output edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([['A', new Set(['B'])]]);
|
||||
|
||||
const result = getOutputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRootNodes', () => {
|
||||
it('should return root nodes of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([['A', new Set(['B'])]]);
|
||||
|
||||
const result = getRootNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'C']));
|
||||
});
|
||||
|
||||
it('should return all nodes if there are no incoming edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>();
|
||||
|
||||
const result = getRootNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'B']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeafNodes', () => {
|
||||
it('should return leaf nodes of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set()],
|
||||
]);
|
||||
|
||||
const result = getLeafNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['C']));
|
||||
});
|
||||
|
||||
it('should return all nodes if there are no outgoing edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set()],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = getLeafNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'B']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExtractableSubgraphSelection', () => {
|
||||
it('should return successfully for a valid extractable subgraph', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['C', new Set(['A'])],
|
||||
['A', new Set(['B'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toMatchObject({ start: 'A' });
|
||||
});
|
||||
|
||||
it('should return successfully for multiple edges into single input node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['X', new Set(['A'])],
|
||||
['Y', new Set(['A'])],
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toMatchObject({ start: 'A' });
|
||||
});
|
||||
|
||||
it('should return successfully for multiple edges from single output nodes', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['X', 'Y'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toMatchObject({});
|
||||
});
|
||||
|
||||
it('should return errors for input edge to non-root node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['X', new Set(['B'])],
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Input Edge To Non-Root Node',
|
||||
node: 'B',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return errors for output edge from non-leaf node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([['A', new Set(['B', 'X'])]]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Output Edge From Non-Leaf Node',
|
||||
node: 'A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return successfully for multiple root nodes with 1 input', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['C'])],
|
||||
['B', new Set(['C'])],
|
||||
['X', new Set(['A'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toMatchObject({});
|
||||
});
|
||||
|
||||
it('should return an error for multiple root nodes with inputs', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['C'])],
|
||||
['B', new Set(['C'])],
|
||||
['X', new Set(['A'])],
|
||||
['Y', new Set(['B'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Multiple Input Nodes',
|
||||
nodes: new Set(['A', 'B']),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return successfully for multiple leaf nodes with 1 output', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B', 'C'])],
|
||||
['C', new Set(['X'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toMatchObject({});
|
||||
});
|
||||
|
||||
it('should return an error for multiple leaf nodes with outputs', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B', 'C'])],
|
||||
['B', new Set(['X'])],
|
||||
['C', new Set(['X'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Multiple Output Nodes',
|
||||
nodes: new Set(['B', 'C']),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an error for a non-continuous selection', () => {
|
||||
const graphIds = new Set(['A', 'D']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set(['D'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'No Continuous Path From Root To Leaf In Selection',
|
||||
start: 'D',
|
||||
end: 'A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('hasPath', () => {
|
||||
it('should return true for a direct path between start and end', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'C', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if there is no path between start and end', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['C', new Set(['D'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a path with multiple intermediate nodes', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set(['D'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the start node is not in the adjacency list', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set(['D'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if the end node is not in the adjacency list', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a cyclic graph where a path exists', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set(['A'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'C', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a cyclic graph where no path exists', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['A'])],
|
||||
['C', new Set(['D'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a self-loop', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([['A', new Set(['A'])]]);
|
||||
|
||||
const result = hasPath('A', 'A', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
484
packages/workflow/test/graph/graph-utils.test.ts
Normal file
484
packages/workflow/test/graph/graph-utils.test.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import {
|
||||
getInputEdges,
|
||||
getOutputEdges,
|
||||
getRootNodes,
|
||||
getLeafNodes,
|
||||
parseExtractableSubgraphSelection,
|
||||
hasPath,
|
||||
buildAdjacencyList,
|
||||
} from '@/graph/graph-utils';
|
||||
import type { IConnection, IConnections, NodeConnectionType } from '@/index';
|
||||
|
||||
function makeConnection(
|
||||
node: string,
|
||||
index: number = 0,
|
||||
type: NodeConnectionType = 'main',
|
||||
): IConnection {
|
||||
return {
|
||||
node,
|
||||
index,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
describe('graphUtils', () => {
|
||||
describe('getInputEdges', () => {
|
||||
it('should return edges leading into the graph', () => {
|
||||
const graphIds = new Set(['B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
]);
|
||||
|
||||
const result = getInputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([['A', makeConnection('B')]]);
|
||||
});
|
||||
|
||||
it('should return an empty array if there are no input edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = getInputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOutputEdges', () => {
|
||||
it('should return edges leading out of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set()],
|
||||
]);
|
||||
|
||||
const result = getOutputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([['B', makeConnection('C')]]);
|
||||
});
|
||||
|
||||
it('should return an empty array if there are no output edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = getOutputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRootNodes', () => {
|
||||
it('should return root nodes of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = getRootNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'C']));
|
||||
});
|
||||
|
||||
it('should return all nodes if there are no incoming edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>();
|
||||
|
||||
const result = getRootNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'B']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeafNodes', () => {
|
||||
it('should return leaf nodes of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set()],
|
||||
]);
|
||||
|
||||
const result = getLeafNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['C']));
|
||||
});
|
||||
|
||||
it('should return all nodes if there are no outgoing edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set()],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = getLeafNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'B']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExtractableSubgraphSelection', () => {
|
||||
it('should return successfully for a valid extractable subgraph', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['C', new Set([makeConnection('A')])],
|
||||
['A', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'A', end: undefined });
|
||||
});
|
||||
|
||||
it('should return successfully for multiple edges into single input node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['X', new Set([makeConnection('A')])],
|
||||
['Y', new Set([makeConnection('A')])],
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'A', end: undefined });
|
||||
});
|
||||
|
||||
it('should return successfully for multiple edges from single output nodes', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('X'), makeConnection('Y')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: undefined, end: 'B' });
|
||||
});
|
||||
|
||||
it('should return errors for input edge to non-root node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['X', new Set([makeConnection('B')])],
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Input Edge To Non-Root Node',
|
||||
node: 'B',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return errors for output edge from non-leaf node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B'), makeConnection('X')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Output Edge From Non-Leaf Node',
|
||||
node: 'A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return successfully for multiple root nodes with 1 input', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('C')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['X', new Set([makeConnection('A')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'A', end: undefined });
|
||||
});
|
||||
|
||||
it('should return an error for multiple root nodes with inputs', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('C')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['X', new Set([makeConnection('A')])],
|
||||
['Y', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Multiple Input Nodes',
|
||||
nodes: new Set(['A', 'B']),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return successfully for multiple leaf nodes with 1 output', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B'), makeConnection('C')])],
|
||||
['C', new Set([makeConnection('X')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: undefined, end: 'C' });
|
||||
});
|
||||
|
||||
it('should return an error for multiple leaf nodes with outputs', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B'), makeConnection('C')])],
|
||||
['B', new Set([makeConnection('X')])],
|
||||
['C', new Set([makeConnection('X')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Multiple Output Nodes',
|
||||
nodes: new Set(['B', 'C']),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an error for a non-continuous selection', () => {
|
||||
const graphIds = new Set(['A', 'D']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('D')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'No Continuous Path From Root To Leaf In Selection',
|
||||
start: 'D',
|
||||
end: 'A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should allow loop with node itself', () => {
|
||||
const graphIds = new Set(['A']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('A')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: undefined, end: undefined });
|
||||
});
|
||||
it('should allow loop with node itself with input and output', () => {
|
||||
const graphIds = new Set(['B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('B'), makeConnection('C')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'B', end: 'B' });
|
||||
});
|
||||
it('should allow loop within selection', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('A')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: undefined, end: undefined });
|
||||
});
|
||||
it('should allow loop within selection with input', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('A')])],
|
||||
['D', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'B', end: undefined });
|
||||
});
|
||||
it('should allow loop within selection with two inputs', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('A')])],
|
||||
['D', new Set([makeConnection('B')])],
|
||||
['E', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'B', end: undefined });
|
||||
});
|
||||
it('should not allow loop within selection with inputs to different nodes', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('A')])],
|
||||
['D', new Set([makeConnection('B')])],
|
||||
['E', new Set([makeConnection('C')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{ errorCode: 'Input Edge To Non-Root Node', node: 'B' },
|
||||
{ errorCode: 'Input Edge To Non-Root Node', node: 'C' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('hasPath', () => {
|
||||
it('should return true for a direct path between start and end', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'C', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if there is no path between start and end', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['C', new Set([makeConnection('D')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a path with multiple intermediate nodes', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('D')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the start node is not in the adjacency list', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('D')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if the end node is not in the adjacency list', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a cyclic graph where a path exists', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('A')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'C', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a cyclic graph where no path exists', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('A')])],
|
||||
['C', new Set([makeConnection('D')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a self-loop', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('A')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'A', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('buildAdjacencyList', () => {
|
||||
it('should build an adjacency list from connections by source node', () => {
|
||||
const connectionsBySourceNode: IConnections = {
|
||||
A: {
|
||||
main: [
|
||||
[
|
||||
{ node: 'B', index: 0, type: 'main' },
|
||||
{ node: 'C', index: 1, type: 'main' },
|
||||
],
|
||||
],
|
||||
},
|
||||
B: {
|
||||
main: [[{ node: 'D', index: 0, type: 'main' }]],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildAdjacencyList(connectionsBySourceNode);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B', 0), makeConnection('C', 1)])],
|
||||
['B', new Set([makeConnection('D', 0)])],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle an empty connections object', () => {
|
||||
const connectionsBySourceNode = {};
|
||||
|
||||
const result = buildAdjacencyList(connectionsBySourceNode);
|
||||
|
||||
expect(result).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('should handle connections with multiple types', () => {
|
||||
const connectionsBySourceNode: IConnections = {
|
||||
A: {
|
||||
main: [[{ node: 'B', index: 0, type: 'main' }]],
|
||||
ai_tool: [[{ node: 'C', index: 1, type: 'ai_tool' }]],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildAdjacencyList(connectionsBySourceNode);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B', 0, 'main'), makeConnection('C', 1, 'ai_tool')])],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle connections with multiple indices', () => {
|
||||
const connectionsBySourceNode: IConnections = {
|
||||
A: {
|
||||
main: [[{ node: 'B', index: 0, type: 'main' }], [{ node: 'C', index: 1, type: 'main' }]],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildAdjacencyList(connectionsBySourceNode);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B', 0), makeConnection('C', 1)])],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -196,7 +196,7 @@ describe('NodeReferenceParserUtils', () => {
|
||||
nodes = [makeNode('B', ['$("D")'])];
|
||||
nodeNames = ['B', 'D'];
|
||||
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, 'B');
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']);
|
||||
expect([...result.variables.entries()]).toEqual([]);
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
@@ -210,7 +210,7 @@ describe('NodeReferenceParserUtils', () => {
|
||||
nodes = [makeNode('B', ['$("E").item.json.x'])];
|
||||
nodeNames = ['B'];
|
||||
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, 'B');
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']);
|
||||
expect([...result.variables.entries()]).toEqual([]);
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
@@ -249,7 +249,7 @@ describe('NodeReferenceParserUtils', () => {
|
||||
nodes = [makeNode('B', ['$json.a.b.c_d["e"]["f"]']), makeNode('C', ['$json.x.y.z'])];
|
||||
nodeNames = ['A', 'B', 'C'];
|
||||
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, 'B');
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']);
|
||||
expect([...result.variables.entries()]).toEqual([['a_b_c_d', '$json.a.b.c_d']]);
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
@@ -262,6 +262,31 @@ describe('NodeReferenceParserUtils', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should handle complex $json case for first node', () => {
|
||||
nodes = [
|
||||
{
|
||||
parameters: {
|
||||
p0: '=https://raw.githubusercontent.com/{{ $json.org }}/{{ $json.repo }}/refs/heads/master/package.json',
|
||||
},
|
||||
name: 'A',
|
||||
} as unknown as INode,
|
||||
];
|
||||
nodeNames = ['A', 'B'];
|
||||
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['A']);
|
||||
expect([...result.variables.entries()]).toEqual([
|
||||
['repo', '$json.repo'],
|
||||
['org', '$json.org'],
|
||||
]);
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
name: 'A',
|
||||
parameters: {
|
||||
p0: '=https://raw.githubusercontent.com/{{ $json.org }}/{{ $json.repo }}/refs/heads/master/package.json',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should support different node accessor patterns', () => {
|
||||
nodes = [
|
||||
makeNode('N', ['$("A").item.json.myField']),
|
||||
@@ -621,6 +646,56 @@ describe('NodeReferenceParserUtils', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should handle assignments format of Set node correctly', () => {
|
||||
nodes = [
|
||||
{
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'cf8bd6cb-f28a-4a73-b141-02e5c22cfe74',
|
||||
name: 'ghApiBaseUrl',
|
||||
value: '={{ $("A").item.json.x.y.z }}',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [80, 80],
|
||||
id: '6e2fd284-2aba-4dee-8921-18be9a291484',
|
||||
name: 'Params',
|
||||
},
|
||||
];
|
||||
nodeNames = ['A', 'Params'];
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
|
||||
expect([...result.variables.entries()]).toEqual([['x_y_z', '$("A").item.json.x.y.z']]);
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'cf8bd6cb-f28a-4a73-b141-02e5c22cfe74',
|
||||
name: 'ghApiBaseUrl',
|
||||
value: "={{ $('Start').item.json.x_y_z }}",
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [80, 80],
|
||||
id: '6e2fd284-2aba-4dee-8921-18be9a291484',
|
||||
name: 'Params',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry over unrelated properties', () => {
|
||||
nodes = [
|
||||
{
|
||||
|
||||
@@ -2418,4 +2418,227 @@ describe('Workflow', () => {
|
||||
expect(nodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
describe('getConnectionsBetweenNodes', () => {
|
||||
test('should return empty array if no connections exist between sources and targets', () => {
|
||||
const result = SIMPLE_WORKFLOW.getConnectionsBetweenNodes(['Start'], ['Set1']);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('should return connections between a single source and target', () => {
|
||||
const result = SIMPLE_WORKFLOW.getConnectionsBetweenNodes(['Start'], ['Set']);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ node: 'Start', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'Set', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should return connections between multiple sources and a single target', () => {
|
||||
const connections: IConnections = {
|
||||
Node1: {
|
||||
main: [[{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
Node2: {
|
||||
main: [[{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
const workflow = new Workflow({
|
||||
id: 'test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'Node1',
|
||||
name: 'Node1',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'Node2',
|
||||
name: 'Node2',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'TargetNode',
|
||||
name: 'TargetNode',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections,
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const result = workflow.getConnectionsBetweenNodes(['Node1', 'Node2'], ['TargetNode']);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
[
|
||||
{ node: 'Node2', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should return connections between a single source and multiple targets', () => {
|
||||
const connections: IConnections = {
|
||||
Node1: {
|
||||
main: [
|
||||
[
|
||||
{ node: 'TargetNode1', type: NodeConnectionTypes.Main, index: 0 },
|
||||
{ node: 'TargetNode2', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const workflow = new Workflow({
|
||||
id: 'test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'Node1',
|
||||
name: 'Node1',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'TargetNode1',
|
||||
name: 'TargetNode1',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'TargetNode2',
|
||||
name: 'TargetNode2',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections,
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const result = workflow.getConnectionsBetweenNodes(['Node1'], ['TargetNode1', 'TargetNode2']);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode1', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode2', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle workflows with multiple connection types', () => {
|
||||
const connections: IConnections = {
|
||||
Node1: {
|
||||
main: [
|
||||
[
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 1 },
|
||||
],
|
||||
],
|
||||
[NodeConnectionTypes.AiAgent]: [
|
||||
[{ node: 'TargetNode', type: NodeConnectionTypes.AiAgent, index: 0 }],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const workflow = new Workflow({
|
||||
id: 'test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'Node1',
|
||||
name: 'Node1',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'TargetNode',
|
||||
name: 'TargetNode',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections,
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const result = workflow.getConnectionsBetweenNodes(['Node1'], ['TargetNode']);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 1 },
|
||||
],
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.AiAgent },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.AiAgent, index: 0 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle nodes with no connections', () => {
|
||||
const connections: IConnections = {
|
||||
Node1: {
|
||||
main: [[]],
|
||||
},
|
||||
};
|
||||
|
||||
const workflow = new Workflow({
|
||||
id: 'test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'Node1',
|
||||
name: 'Node1',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'TargetNode',
|
||||
name: 'TargetNode',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections,
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const result = workflow.getConnectionsBetweenNodes(['Node1'], ['TargetNode']);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user