diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index 007ba5274f..f5d603da24 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -1523,6 +1523,7 @@
"contextMenu.deselectAll": "Clear selection",
"contextMenu.tidyUpWorkflow": "Tidy up workflow",
"contextMenu.tidyUpSelection": "Tidy up selection",
+ "contextMenu.extract": "Extract node into sub-workflow | Extract {count} nodes into sub-workflow",
"contextMenu.duplicate": "Duplicate | Duplicate {count} {subject}",
"contextMenu.open": "Open...",
"contextMenu.test": "Execute step",
@@ -2370,6 +2371,21 @@
"workflowDetails.active": "Active",
"workflowDetails.addTag": "Add tag",
"workflowDetails.chooseOrCreateATag": "Choose or create a tag",
+ "workflowExtraction.error.failure": "Sub-workflow extraction failed",
+ "workflowExtraction.error.selectionGraph.inputEdgeToNonRoot": "Non-input node '{node}' has a connection from a node outside the current selection.",
+ "workflowExtraction.error.selectionGraph.outputEdgeFromNonLeaf": "Non-output node '{node}' has a connection to a node outside the current selection.",
+ "workflowExtraction.error.selectionGraph.multipleInputNodes": "Multiple nodes [{nodes}] have inputs from outside the selection.",
+ "workflowExtraction.error.selectionGraph.multipleOutputNodes": "Multiple nodes [{nodes}] have outputs to outside the selection.",
+ "workflowExtraction.error.selectionGraph.noContinuousPathFromRootToLeaf": "First node '{start}' has no selected path to last node '{end}'.",
+ "workflowExtraction.error.selectionGraph.listHeader": "Selection is invalid because of these errors:
{body}
See docs for more info.",
+ "workflowExtraction.error.inputNodeHasMultipleInputBranches": "First node '{node}' has multiple input branches, which sub-workflows do not support.",
+ "workflowExtraction.error.outputNodeHasMultipleOutputBranches": "Last node '{node}' has multiple output branches, which sub-workflows do not support.",
+ "workflowExtraction.error.triggerSelected": "Triggers cannot be extracted into a sub-workflow. Please unselect {nodes}.",
+ "workflowExtraction.error.subworkflowCreationFailed": "Sub-workflow creation failed, aborting conversion.",
+ "workflowExtraction.success.title": "Created sub-workflow",
+ "workflowExtraction.success.message": "Open in new Tab",
+ "workflowExtraction.modal.title": "Move to sub-workflow",
+ "workflowExtraction.modal.description": "Extract {nodeCount} node into a new sub-workflow | Extract {nodeCount} nodes into a new sub-workflow",
"workflowHelpers.showMessage.title": "Problem saving workflow",
"workflowOpen.active": "Active",
"workflowOpen.couldNotLoadActiveWorkflows": "Could not load active workflows",
diff --git a/packages/frontend/editor-ui/src/components/Modals.vue b/packages/frontend/editor-ui/src/components/Modals.vue
index 1d1b7fa908..ffc1898520 100644
--- a/packages/frontend/editor-ui/src/components/Modals.vue
+++ b/packages/frontend/editor-ui/src/components/Modals.vue
@@ -38,6 +38,7 @@ import {
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
FROM_AI_PARAMETERS_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
+ WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
} from '@/constants';
import AboutModal from '@/components/AboutModal.vue';
@@ -316,5 +317,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.test.ts b/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.test.ts
new file mode 100644
index 0000000000..33c2a91777
--- /dev/null
+++ b/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect } from 'vitest';
+import WorkflowExtractionNameModal from '@/components/WorkflowExtractionNameModal.vue';
+import { WORKFLOW_EXTRACTION_NAME_MODAL_KEY } from '@/constants';
+import type { INodeUi } from '@/Interface';
+import type { ExtractableSubgraphData } from 'n8n-workflow';
+import { cloneDeep } from 'lodash-es';
+import userEvent from '@testing-library/user-event';
+import { createTestingPinia } from '@pinia/testing';
+import { createComponentRenderer } from '@/__tests__/render';
+
+const extractNodesIntoSubworkflow = vi.fn();
+vi.mock('@/composables/useWorkflowExtraction', () => {
+ return {
+ useWorkflowExtraction: () => ({
+ extractNodesIntoSubworkflow,
+ }),
+ };
+});
+
+const ModalStub = {
+ template: `
+
+
+
+
+
+
+ `,
+};
+
+const global = {
+ stubs: {
+ Modal: ModalStub,
+ },
+};
+
+const renderModal = createComponentRenderer(WorkflowExtractionNameModal);
+let pinia: ReturnType;
+
+const DEFAULT_PROPS = {
+ modalName: WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
+ data: {
+ subGraph: Symbol() as unknown as INodeUi[],
+ selection: Symbol() as ExtractableSubgraphData,
+ },
+};
+
+describe('WorkflowExtractionNameModal.vue', () => {
+ let props = DEFAULT_PROPS;
+ beforeEach(() => {
+ pinia = createTestingPinia();
+ props = cloneDeep(DEFAULT_PROPS);
+ vi.resetAllMocks();
+ });
+
+ it('emits "close" event when the cancel button is clicked', async () => {
+ const { getByTestId } = renderModal({
+ props,
+ global,
+ pinia,
+ });
+ await userEvent.click(getByTestId('cancel-button'));
+ expect(extractNodesIntoSubworkflow).not.toHaveBeenCalled();
+ });
+
+ it('emits "submit" event with the correct name when the form is submitted', async () => {
+ const { getByTestId, getByRole } = renderModal({
+ props,
+ global,
+ pinia,
+ });
+
+ const input = getByRole('textbox');
+ // The auto-select isn't working for the test, so this doesn't clear the input
+ await userEvent.type(input, ' 2');
+ await userEvent.click(getByTestId('submit-button'));
+
+ expect(extractNodesIntoSubworkflow).toHaveBeenCalledWith(
+ DEFAULT_PROPS.data.selection,
+ DEFAULT_PROPS.data.subGraph,
+ 'My Sub-workflow 2',
+ );
+ });
+});
diff --git a/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.vue b/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.vue
new file mode 100644
index 0000000000..962351acd2
--- /dev/null
+++ b/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.vue
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue
index 7e88b82fcf..c84a0c5d16 100644
--- a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue
+++ b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue
@@ -105,6 +105,7 @@ const emit = defineEmits<{
'selection:end': [position: XYPosition];
'open:sub-workflow': [nodeId: string];
'start-chat': [];
+ 'extract-workflow': [ids: string[]];
}>();
const props = withDefaults(
@@ -314,6 +315,7 @@ const keyMap = computed(() => {
ctrl_enter: () => emit('run:workflow'),
ctrl_s: () => emit('save:workflow'),
shift_alt_t: async () => await onTidyUp({ source: 'keyboard-shortcut' }),
+ alt_x: emitWithSelectedNodes((ids) => emit('extract-workflow', ids)),
c: () => emit('start-chat'),
};
return fullKeymap;
@@ -695,6 +697,8 @@ async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[])
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
case 'tidy_up':
return await onTidyUp({ source: 'context-menu' });
+ case 'extract_sub_workflow':
+ return emit('extract-workflow', nodeIds);
case 'open_sub_workflow': {
return emit('open:sub-workflow', nodeIds[0]);
}
diff --git a/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap b/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap
index ab38f536f1..dbfe62cffd 100644
--- a/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap
+++ b/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap
@@ -79,6 +79,18 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe
"shiftKey": true,
},
},
+ {
+ "disabled": true,
+ "divided": true,
+ "id": "extract_sub_workflow",
+ "label": "Extract node into sub-workflow",
+ "shortcut": {
+ "altKey": true,
+ "keys": [
+ "X",
+ ],
+ },
+ },
{
"disabled": false,
"divided": true,
@@ -160,6 +172,18 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe
"shiftKey": true,
},
},
+ {
+ "disabled": true,
+ "divided": true,
+ "id": "extract_sub_workflow",
+ "label": "Extract node into sub-workflow",
+ "shortcut": {
+ "altKey": true,
+ "keys": [
+ "X",
+ ],
+ },
+ },
{
"disabled": false,
"divided": true,
@@ -270,6 +294,18 @@ exports[`useContextMenu > should return the correct actions opening the menu fro
"shiftKey": true,
},
},
+ {
+ "disabled": false,
+ "divided": true,
+ "id": "extract_sub_workflow",
+ "label": "Extract node into sub-workflow",
+ "shortcut": {
+ "altKey": true,
+ "keys": [
+ "X",
+ ],
+ },
+ },
{
"disabled": false,
"divided": true,
@@ -380,6 +416,18 @@ exports[`useContextMenu > should return the correct actions when right clicking
"shiftKey": true,
},
},
+ {
+ "disabled": false,
+ "divided": true,
+ "id": "extract_sub_workflow",
+ "label": "Extract node into sub-workflow",
+ "shortcut": {
+ "altKey": true,
+ "keys": [
+ "X",
+ ],
+ },
+ },
{
"disabled": false,
"divided": true,
@@ -461,6 +509,18 @@ exports[`useContextMenu > should return the correct actions when right clicking
"shiftKey": true,
},
},
+ {
+ "disabled": false,
+ "divided": true,
+ "id": "extract_sub_workflow",
+ "label": "Extract node into sub-workflow",
+ "shortcut": {
+ "altKey": true,
+ "keys": [
+ "X",
+ ],
+ },
+ },
{
"disabled": false,
"divided": true,
@@ -583,6 +643,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (disabled) whe
"shiftKey": true,
},
},
+ {
+ "disabled": false,
+ "divided": true,
+ "id": "extract_sub_workflow",
+ "label": "Extract node into sub-workflow",
+ "shortcut": {
+ "altKey": true,
+ "keys": [
+ "X",
+ ],
+ },
+ },
{
"disabled": false,
"divided": true,
@@ -705,6 +777,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (disabled) whe
"shiftKey": true,
},
},
+ {
+ "disabled": false,
+ "divided": true,
+ "id": "extract_sub_workflow",
+ "label": "Extract node into sub-workflow",
+ "shortcut": {
+ "altKey": true,
+ "keys": [
+ "X",
+ ],
+ },
+ },
{
"disabled": false,
"divided": true,
@@ -827,6 +911,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (enabled) when
"shiftKey": true,
},
},
+ {
+ "disabled": false,
+ "divided": true,
+ "id": "extract_sub_workflow",
+ "label": "Extract node into sub-workflow",
+ "shortcut": {
+ "altKey": true,
+ "keys": [
+ "X",
+ ],
+ },
+ },
{
"disabled": false,
"divided": true,
@@ -949,6 +1045,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (enabled) when
"shiftKey": true,
},
},
+ {
+ "disabled": false,
+ "divided": true,
+ "id": "extract_sub_workflow",
+ "label": "Extract node into sub-workflow",
+ "shortcut": {
+ "altKey": true,
+ "keys": [
+ "X",
+ ],
+ },
+ },
{
"disabled": false,
"divided": true,
@@ -1035,6 +1143,18 @@ exports[`useContextMenu > should support opening and closing (default = right cl
"shiftKey": true,
},
},
+ {
+ "disabled": false,
+ "divided": true,
+ "id": "extract_sub_workflow",
+ "label": "Extract 2 nodes into sub-workflow",
+ "shortcut": {
+ "altKey": true,
+ "keys": [
+ "X",
+ ],
+ },
+ },
{
"disabled": false,
"divided": true,
diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts
index e0be5d0f7d..78f0fca39b 100644
--- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts
+++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts
@@ -40,6 +40,7 @@ import {
RemoveConnectionCommand,
RemoveNodeCommand,
RenameNodeCommand,
+ ReplaceNodeParametersCommand,
} from '@/models/history';
import { useCanvasStore } from '@/stores/canvas.store';
import { useCredentialsStore } from '@/stores/credentials.store';
@@ -97,6 +98,7 @@ import type {
NodeParameterValueType,
Workflow,
NodeConnectionType,
+ INodeParameters,
} from 'n8n-workflow';
import { deepCopy, NodeConnectionTypes, NodeHelpers, TelemetryHelpers } from 'n8n-workflow';
import { computed, nextTick, ref } from 'vue';
@@ -124,6 +126,7 @@ type AddNodesBaseOptions = {
trackHistory?: boolean;
keepPristine?: boolean;
telemetry?: boolean;
+ forcePosition?: boolean;
viewport?: ViewportBoundaries;
};
@@ -245,6 +248,42 @@ export function useCanvasOperations({ router }: { router: ReturnType([0, 0]);
const isOpen = ref(false);
@@ -47,7 +48,6 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const sourceControlStore = useSourceControlStore();
-
const i18n = useI18n();
const workflowPermissions = computed(
@@ -164,6 +164,16 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
},
];
+ const extractionActions: ActionDropdownItem[] = [
+ {
+ id: 'extract_sub_workflow',
+ divided: true,
+ label: i18n.baseText('contextMenu.extract', { adjustToNumber: nodes.length }),
+ shortcut: { altKey: true, keys: ['X'] },
+ disabled: isReadOnly.value,
+ },
+ ];
+
const layoutActions: ActionDropdownItem[] = [
{
id: 'tidy_up',
@@ -222,6 +232,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
disabled: isReadOnly.value || !nodes.every(canDuplicateNode),
},
...layoutActions,
+ ...extractionActions,
...selectionActions,
{
id: 'delete',
diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowExtraction.ts b/packages/frontend/editor-ui/src/composables/useWorkflowExtraction.ts
new file mode 100644
index 0000000000..e6bdab6af4
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/useWorkflowExtraction.ts
@@ -0,0 +1,549 @@
+import { useWorkflowsStore } from '@/stores/workflows.store';
+import {
+ buildAdjacencyList,
+ parseExtractableSubgraphSelection,
+ type ExtractableSubgraphData,
+ type ExtractableErrorResult,
+ extractReferencesInNodeExpressions,
+ type IConnections,
+ type INode,
+ EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
+ NodeHelpers,
+} from 'n8n-workflow';
+import { computed } from 'vue';
+import { useToast } from './useToast';
+import { useRouter } from 'vue-router';
+import { VIEWS, WORKFLOW_EXTRACTION_NAME_MODAL_KEY } from '@/constants';
+import { useHistoryStore } from '@/stores/history.store';
+import { useCanvasOperations } from './useCanvasOperations';
+
+import type { AddedNode, INodeUi, IWorkflowDataCreate, IWorkflowDb } from '@/Interface';
+import { useI18n } from '@n8n/i18n';
+import { PUSH_NODES_OFFSET } from '@/utils/nodeViewUtils';
+import { useUIStore } from '@/stores/ui.store';
+import { useNodeTypesStore } from '@/stores/nodeTypes.store';
+import { useTelemetry } from './useTelemetry';
+import { isEqual } from 'lodash-es';
+import { v4 as uuidv4 } from 'uuid';
+
+const CANVAS_HISTORY_OPTIONS = {
+ trackBulk: false,
+ trackHistory: true,
+};
+
+export function useWorkflowExtraction() {
+ const uiStore = useUIStore();
+ const workflowsStore = useWorkflowsStore();
+ const toast = useToast();
+ const router = useRouter();
+ const historyStore = useHistoryStore();
+ const canvasOperations = useCanvasOperations({ router });
+ const i18n = useI18n();
+ const telemetry = useTelemetry();
+
+ const adjacencyList = computed(() => buildAdjacencyList(workflowsStore.workflow.connections));
+
+ function showError(message: string) {
+ toast.showMessage({
+ type: 'error',
+ message,
+ title: i18n.baseText('workflowExtraction.error.failure'),
+ duration: 15 * 1000,
+ });
+ }
+
+ function extractableErrorResultToMessage(result: ExtractableErrorResult) {
+ switch (result.errorCode) {
+ case 'Input Edge To Non-Root Node':
+ return i18n.baseText('workflowExtraction.error.selectionGraph.inputEdgeToNonRoot', {
+ interpolate: { node: result.node },
+ });
+ case 'Output Edge From Non-Leaf Node':
+ return i18n.baseText('workflowExtraction.error.selectionGraph.outputEdgeFromNonLeaf', {
+ interpolate: { node: result.node },
+ });
+ case 'Multiple Input Nodes':
+ return i18n.baseText('workflowExtraction.error.selectionGraph.multipleInputNodes', {
+ interpolate: { nodes: [...result.nodes].map((x) => `'${x}'`).join(', ') },
+ });
+ case 'Multiple Output Nodes':
+ return i18n.baseText('workflowExtraction.error.selectionGraph.multipleOutputNodes', {
+ interpolate: { nodes: [...result.nodes].map((x) => `'${x}'`).join(', ') },
+ });
+ case 'No Continuous Path From Root To Leaf In Selection':
+ return i18n.baseText(
+ 'workflowExtraction.error.selectionGraph.noContinuousPathFromRootToLeaf',
+ { interpolate: { start: result.start, end: result.end } },
+ );
+ }
+ }
+
+ function makeExecuteWorkflowNode(
+ workflowId: string,
+ name: string,
+ position: [number, number],
+ variables: Map,
+ ): Omit {
+ return {
+ parameters: {
+ workflowId: {
+ __rl: true,
+ value: workflowId,
+ mode: 'list',
+ },
+ workflowInputs: {
+ mappingMode: 'defineBelow',
+ value: Object.fromEntries(variables.entries().map(([k, v]) => [k, `={{ ${v} }}`])),
+ matchingColumns: [...variables.keys()],
+ schema: [
+ ...variables.keys().map((x) => ({
+ id: x,
+ displayName: x,
+ required: false,
+ defaultMatch: false,
+ display: true,
+ canBeUsedToMatch: true,
+ removed: false,
+ // omitted type implicitly uses our `any` type
+ })),
+ ],
+ attemptToConvertTypes: false,
+ convertFieldsToString: true,
+ },
+ options: {},
+ },
+ type: 'n8n-nodes-base.executeWorkflow',
+ typeVersion: 1.2,
+ position,
+ name,
+ };
+ }
+
+ function makeSubworkflow(
+ newWorkflowName: string,
+ { start, end }: ExtractableSubgraphData,
+ nodes: INodeUi[],
+ connections: IConnections,
+ selectionVariables: Map,
+ selectionChildrenVariables: Map,
+ startNodeName: string,
+ returnNodeName: string,
+ ): IWorkflowDataCreate {
+ const newConnections = Object.fromEntries(
+ Object.entries(connections).filter(([k]) => nodes.some((x) => x.name === k)),
+ );
+ if (end) {
+ // this is necessary because the new workflow may crash looking for the
+ // nodes in these connections
+ delete newConnections[end];
+ }
+
+ const startNodeTarget = nodes.find((x) => x.name === start);
+ const firstNode = startNodeTarget ?? nodes.sort((a, b) => a.position[1] - b.position[1])[0];
+ const startNodePosition: [number, number] = [
+ firstNode.position[0] - PUSH_NODES_OFFSET,
+ firstNode.position[1],
+ ];
+
+ const endNodeTarget = nodes.find((x) => x.name === end);
+ const lastNode = endNodeTarget ?? nodes.sort((a, b) => b.position[1] - a.position[1])[0];
+ const endNodePosition: [number, number] = [
+ lastNode.position[0] + PUSH_NODES_OFFSET,
+ lastNode.position[1],
+ ];
+
+ const shouldInsertReturnNode = selectionChildrenVariables.size > 0;
+
+ const startNodeConnection = startNodeTarget
+ ? ({
+ [startNodeName]: {
+ main: [
+ [
+ {
+ node: startNodeTarget.name,
+ type: 'main',
+ index: 0,
+ },
+ ],
+ ],
+ },
+ } satisfies IConnections)
+ : {};
+
+ const endNodeConnection =
+ endNodeTarget && shouldInsertReturnNode
+ ? ({
+ [endNodeTarget.name]: {
+ main: [
+ [
+ {
+ node: returnNodeName,
+ type: 'main',
+ index: 0,
+ },
+ ],
+ ],
+ },
+ } satisfies IConnections)
+ : {};
+
+ const returnNode = shouldInsertReturnNode
+ ? [
+ {
+ parameters: {
+ assignments: {
+ assignments: [
+ ...selectionChildrenVariables.entries().map((x) => ({
+ id: uuidv4(),
+ name: x[0],
+ value: `={{ ${x[1]} }}`,
+ type: 'string',
+ })),
+ ],
+ },
+ options: {},
+ },
+ type: 'n8n-nodes-base.set',
+ typeVersion: 3.4,
+ position: endNodePosition,
+ id: uuidv4(),
+ name: returnNodeName,
+ } satisfies INode,
+ ]
+ : [];
+ const triggerParameters =
+ selectionVariables.size > 0
+ ? {
+ workflowInputs: {
+ values: [...selectionVariables.keys().map((k) => ({ name: k, type: 'any' }))],
+ },
+ }
+ : {
+ inputSource: 'passthrough',
+ };
+
+ const triggerNode: INode = {
+ id: uuidv4(),
+ typeVersion: 1.1,
+ name: startNodeName,
+ type: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
+ position: startNodePosition,
+ parameters: triggerParameters,
+ };
+
+ return {
+ name: newWorkflowName,
+ nodes: [...nodes, ...returnNode, triggerNode],
+ connections: {
+ ...newConnections,
+ ...startNodeConnection,
+ ...endNodeConnection,
+ },
+ settings: { executionOrder: 'v1' },
+ projectId: workflowsStore.workflow.homeProject?.id,
+ parentFolderId: workflowsStore.workflow.parentFolder?.id ?? undefined,
+ };
+ }
+
+ function computeAveragePosition(nodes: INode[]): [number, number] {
+ const summedUp = nodes.reduce(
+ (acc, v) => [acc[0] + v.position[0], acc[1] + v.position[1], acc[2] + 1],
+ [0, 0, 0],
+ );
+ return [summedUp[0] / summedUp[2], summedUp[1] / summedUp[2]];
+ }
+
+ async function tryCreateWorkflow(workflowData: IWorkflowDataCreate): Promise {
+ try {
+ const createdWorkflow = await workflowsStore.createNewWorkflow(workflowData);
+
+ const { href } = router.resolve({
+ name: VIEWS.WORKFLOW,
+ params: {
+ name: createdWorkflow.id,
+ },
+ });
+
+ toast.showMessage({
+ title: i18n.baseText('workflowExtraction.success.title'),
+ message: i18n.baseText('workflowExtraction.success.message', {
+ interpolate: { url: href },
+ }),
+ type: 'success',
+ duration: 10 * 1000,
+ });
+ return createdWorkflow;
+ } catch (e) {
+ toast.showError(e, i18n.baseText('workflowExtraction.error.subworkflowCreationFailed'));
+ return null;
+ }
+ }
+
+ function checkExtractableSelectionValidity(
+ selection: ReturnType,
+ ): selection is ExtractableSubgraphData {
+ if (Array.isArray(selection)) {
+ showError(
+ i18n.baseText('workflowExtraction.error.selectionGraph.listHeader', {
+ interpolate: {
+ body: selection
+ .map(extractableErrorResultToMessage)
+ .map((x) => `- ${x}`)
+ .join('
'),
+ },
+ }),
+ );
+ return false;
+ }
+ const { start, end } = selection;
+
+ const isSingleIO = (
+ nodeName: string,
+ getIOs: (
+ ...x: Parameters
+ ) => ReturnType,
+ ) => {
+ const node = workflowsStore.getNodeByName(nodeName);
+ if (!node) return true; // invariant broken -> abort onto error path
+ const nodeType = useNodeTypesStore().getNodeType(node.type, node.typeVersion);
+ if (!nodeType) return true; // invariant broken -> abort onto error path
+
+ const ios = getIOs(workflowsStore.getCurrentWorkflow(), node, nodeType);
+ return (
+ ios.filter((x) => (typeof x === 'string' ? x === 'main' : x.type === 'main')).length <= 1
+ );
+ };
+
+ if (start && !isSingleIO(start, NodeHelpers.getNodeInputs)) {
+ showError(
+ i18n.baseText('workflowExtraction.error.inputNodeHasMultipleInputBranches', {
+ interpolate: { node: start },
+ }),
+ );
+ return false;
+ }
+ if (end && !isSingleIO(end, NodeHelpers.getNodeOutputs)) {
+ showError(
+ i18n.baseText('workflowExtraction.error.outputNodeHasMultipleOutputBranches', {
+ interpolate: { node: end },
+ }),
+ );
+ return false;
+ }
+
+ // Returns an array of errors
+ return !Array.isArray(selection);
+ }
+
+ async function replaceSelectionWithNode(
+ executeWorkflowNodeData: AddedNode,
+ startId: string | undefined,
+ endId: string | undefined,
+ selection: INode[],
+ selectionChildNodes: INode[],
+ ) {
+ historyStore.startRecordingUndo();
+
+ // In most cases we're about to move the selection anyway
+ // One remarkable edge case is when a single node is right-clicked on
+ // This allows extraction, but does not necessarily select the node
+ uiStore.resetLastInteractedWith();
+
+ const executeWorkflowNode = (
+ await canvasOperations.addNodes([executeWorkflowNodeData], {
+ ...CANVAS_HISTORY_OPTIONS,
+ forcePosition: true,
+ })
+ )[0];
+
+ if (endId)
+ canvasOperations.replaceNodeConnections(endId, executeWorkflowNode.id, {
+ ...CANVAS_HISTORY_OPTIONS,
+ replaceInputs: false,
+ });
+
+ if (startId)
+ canvasOperations.replaceNodeConnections(startId, executeWorkflowNode.id, {
+ ...CANVAS_HISTORY_OPTIONS,
+ replaceOutputs: false,
+ });
+
+ canvasOperations.deleteNodes(
+ selection.map((x) => x.id),
+ CANVAS_HISTORY_OPTIONS,
+ );
+
+ for (const node of selectionChildNodes) {
+ const currentNode = workflowsStore.workflow.nodes.find((x) => x.id === node.id);
+
+ if (isEqual(node, currentNode)) continue;
+
+ canvasOperations.replaceNodeParameters(
+ node.id,
+ { ...currentNode?.parameters },
+ { ...node.parameters },
+ CANVAS_HISTORY_OPTIONS,
+ );
+ }
+
+ uiStore.stateIsDirty = true;
+ historyStore.stopRecordingUndo();
+ }
+
+ function tryExtractNodesIntoSubworkflow(nodeIds: string[]): boolean {
+ const subGraph = nodeIds.map(workflowsStore.getNodeById).filter((x) => x !== undefined);
+
+ const triggers = subGraph.filter((x) =>
+ useNodeTypesStore().getNodeType(x.type, x.typeVersion)?.group.includes('trigger'),
+ );
+ if (triggers.length > 0) {
+ showError(
+ i18n.baseText('workflowExtraction.error.triggerSelected', {
+ interpolate: { nodes: triggers.map((x) => `'${x.name}'`).join(', ') },
+ }),
+ );
+ return false;
+ }
+
+ const selection = parseExtractableSubgraphSelection(
+ new Set(subGraph.map((x) => x.name)),
+ adjacencyList.value,
+ );
+
+ if (!checkExtractableSelectionValidity(selection)) return false;
+
+ uiStore.openModalWithData({
+ name: WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
+ data: { subGraph, selection },
+ });
+ return true;
+ }
+
+ async function doExtractNodesIntoSubworkflow(
+ selection: ExtractableSubgraphData,
+ subGraph: INodeUi[],
+ newWorkflowName: string,
+ ) {
+ const { start, end } = selection;
+
+ const currentWorkflow = workflowsStore.getCurrentWorkflow();
+ const allNodeNames = workflowsStore.workflow.nodes.map((x) => x.name);
+
+ let startNodeName = 'Start';
+ const subGraphNames = subGraph.map((x) => x.name);
+ while (subGraphNames.includes(startNodeName)) startNodeName += '_1';
+
+ let returnNodeName = 'Return';
+ while (subGraphNames.includes(returnNodeName)) returnNodeName += '_1';
+
+ const directAfterEndNodeNames = end
+ ? currentWorkflow
+ .getChildNodes(end, 'main', 1)
+ .map((x) => currentWorkflow.getNode(x)?.name)
+ .filter((x) => x !== undefined)
+ : [];
+
+ const allAfterEndNodes = end
+ ? currentWorkflow
+ .getChildNodes(end, 'ALL')
+ .map((x) => currentWorkflow.getNode(x))
+ .filter((x) => x !== null)
+ : [];
+
+ const { nodes, variables } = extractReferencesInNodeExpressions(
+ subGraph,
+ allNodeNames,
+ startNodeName,
+ start ? [start] : undefined,
+ );
+
+ let executeWorkflowNodeName = `Call ${newWorkflowName}`;
+ while (allNodeNames.includes(executeWorkflowNodeName)) executeWorkflowNodeName += '_1';
+
+ const { nodes: afterNodes, variables: afterVariables } = extractReferencesInNodeExpressions(
+ allAfterEndNodes,
+ allAfterEndNodes
+ .map((x) => x.name)
+ .concat(subGraphNames), // this excludes nodes that will remain in the parent workflow
+ executeWorkflowNodeName,
+ directAfterEndNodeNames,
+ );
+
+ const workflowData = makeSubworkflow(
+ newWorkflowName,
+ selection,
+ nodes,
+ workflowsStore.workflow.connections,
+ variables,
+ afterVariables,
+ startNodeName,
+ returnNodeName,
+ );
+ const createdWorkflow = await tryCreateWorkflow(workflowData);
+ if (createdWorkflow === null) return false;
+
+ const executeWorkflowPosition = computeAveragePosition(subGraph);
+ const executeWorkflowNode = makeExecuteWorkflowNode(
+ createdWorkflow.id,
+ executeWorkflowNodeName,
+ executeWorkflowPosition,
+ variables,
+ );
+ await replaceSelectionWithNode(
+ executeWorkflowNode,
+ subGraph.find((x) => x.name === start)?.id,
+ subGraph.find((x) => x.name === end)?.id,
+ subGraph,
+ afterNodes,
+ );
+
+ return true;
+ }
+
+ /**
+ * This mutates the current workflow and creates a new one.
+ * Intended to be called from @WorkflowExtractionNameModal spawned
+ * by @tryExtractNodesIntoSubworkflow
+ */
+ async function extractNodesIntoSubworkflow(
+ selection: ExtractableSubgraphData,
+ subGraph: INodeUi[],
+ newWorkflowName: string,
+ ) {
+ const success = await doExtractNodesIntoSubworkflow(selection, subGraph, newWorkflowName);
+ trackExtractWorkflow(subGraph.length, success);
+ }
+
+ /**
+ * This starts the extraction process by checking whether the selection is extractable
+ * and spawning a pop up asking for a sub-workflow name.
+ * If confirmed, the modal calls @extractNodesIntoSubworkflow to handle the actual mutation
+ *
+ * @param nodeIds the ids to be extracted from the current workflow into a sub-workflow
+ */
+ async function extractWorkflow(nodeIds: string[]) {
+ const success = tryExtractNodesIntoSubworkflow(nodeIds);
+ trackStartExtractWorkflow(nodeIds.length, success);
+ }
+
+ function trackStartExtractWorkflow(nodeCount: number, success: boolean) {
+ telemetry.track('User started nodes to sub-workflow extraction', {
+ node_count: nodeCount,
+ success,
+ });
+ }
+
+ function trackExtractWorkflow(nodeCount: number, success: boolean) {
+ telemetry.track('User extracted nodes to sub-workflow', {
+ node_count: nodeCount,
+ success,
+ });
+ }
+
+ return {
+ adjacencyList,
+ extractWorkflow,
+ tryExtractNodesIntoSubworkflow,
+ extractNodesIntoSubworkflow,
+ };
+}
diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts
index e267dfbda6..99053fe11c 100644
--- a/packages/frontend/editor-ui/src/constants.ts
+++ b/packages/frontend/editor-ui/src/constants.ts
@@ -82,6 +82,7 @@ export const MOVE_FOLDER_MODAL_KEY = 'moveFolder';
export const WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY =
'workflowActivationConflictingWebhook';
export const FROM_AI_PARAMETERS_MODAL_KEY = 'fromAiParameters';
+export const WORKFLOW_EXTRACTION_NAME_MODAL_KEY = 'workflowExtractionName';
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
UNINSTALL: 'uninstall',
diff --git a/packages/frontend/editor-ui/src/models/history.ts b/packages/frontend/editor-ui/src/models/history.ts
index 8fa521ce38..ff92fc454d 100644
--- a/packages/frontend/editor-ui/src/models/history.ts
+++ b/packages/frontend/editor-ui/src/models/history.ts
@@ -1,5 +1,5 @@
import type { INodeUi, XYPosition } from '@/Interface';
-import type { IConnection } from 'n8n-workflow';
+import type { IConnection, INodeParameters } from 'n8n-workflow';
import { createEventBus } from '@n8n/utils/event-bus';
// Command names don't serve any particular purpose in the app
@@ -13,6 +13,7 @@ export const enum COMMANDS {
REMOVE_CONNECTION = 'removeConnection',
ENABLE_NODE_TOGGLE = 'enableNodeToggle',
RENAME_NODE = 'renameNode',
+ REPLACE_NODE_PARAMETERS = 'replaceNodeParameters',
}
// Triggering multiple canvas actions in sequence leaves
@@ -281,3 +282,43 @@ export class RenameNodeCommand extends Command {
});
}
}
+
+export class ReplaceNodeParametersCommand extends Command {
+ constructor(
+ private nodeId: string,
+ private currentParameters: INodeParameters,
+ private newParameters: INodeParameters,
+ timestamp: number,
+ ) {
+ super(COMMANDS.REPLACE_NODE_PARAMETERS, timestamp);
+ }
+
+ getReverseCommand(timestamp: number): Command {
+ return new ReplaceNodeParametersCommand(
+ this.nodeId,
+ this.newParameters,
+ this.currentParameters,
+ timestamp,
+ );
+ }
+
+ isEqualTo(anotherCommand: Command): boolean {
+ return (
+ anotherCommand instanceof ReplaceNodeParametersCommand &&
+ anotherCommand.nodeId === this.nodeId &&
+ anotherCommand.currentParameters === this.currentParameters &&
+ anotherCommand.newParameters === this.newParameters
+ );
+ }
+
+ async revert(): Promise {
+ return await new Promise((resolve) => {
+ historyBus.emit('revertReplaceNodeParameters', {
+ nodeId: this.nodeId,
+ currentProperties: this.currentParameters,
+ newProperties: this.newParameters,
+ });
+ resolve();
+ });
+ }
+}
diff --git a/packages/frontend/editor-ui/src/stores/ui.store.ts b/packages/frontend/editor-ui/src/stores/ui.store.ts
index 84a601a798..98edc27037 100644
--- a/packages/frontend/editor-ui/src/stores/ui.store.ts
+++ b/packages/frontend/editor-ui/src/stores/ui.store.ts
@@ -41,6 +41,7 @@ import {
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
FROM_AI_PARAMETERS_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
+ WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
} from '@/constants';
import { STORES } from '@n8n/stores';
import type {
@@ -199,6 +200,12 @@ export const useUIStore = defineStore(STORES.UI, () => {
url: '',
},
},
+ [WORKFLOW_EXTRACTION_NAME_MODAL_KEY]: {
+ open: false,
+ data: {
+ workflowName: '',
+ },
+ },
});
const modalStack = ref([]);
diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts
index 584fac988d..da1ffde2e8 100644
--- a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts
+++ b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts
@@ -390,6 +390,35 @@ describe('useWorkflowsStore', () => {
const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode');
expect(result).toBe(false);
});
+
+ it('should return true if connection is indirect within `depth`', () => {
+ workflowsStore.workflow.connections = {
+ RootNode: { main: [[{ node: 'IntermediateNode' } as IConnection]] },
+ IntermediateNode: { main: [[{ node: 'SearchNode' } as IConnection]] },
+ };
+
+ const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode', 2);
+ expect(result).toBe(true);
+ });
+
+ it('should return false if connection is indirect beyond `depth`', () => {
+ workflowsStore.workflow.connections = {
+ RootNode: { main: [[{ node: 'IntermediateNode' } as IConnection]] },
+ IntermediateNode: { main: [[{ node: 'SearchNode' } as IConnection]] },
+ };
+
+ const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode', 1);
+ expect(result).toBe(false);
+ });
+
+ it('should return false if depth is 0', () => {
+ workflowsStore.workflow.connections = {
+ RootNode: { main: [[{ node: 'SearchNode' } as IConnection]] },
+ };
+
+ const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode', 0);
+ expect(result).toBe(false);
+ });
});
describe('getPinDataSize()', () => {
diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.ts b/packages/frontend/editor-ui/src/stores/workflows.store.ts
index 4ab4bddbd7..401be3f65e 100644
--- a/packages/frontend/editor-ui/src/stores/workflows.store.ts
+++ b/packages/frontend/editor-ui/src/stores/workflows.store.ts
@@ -334,14 +334,21 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return workflow.value.connections.hasOwnProperty(nodeName);
}
- function isNodeInOutgoingNodeConnections(rootNodeName: string, searchNodeName: string): boolean {
+ function isNodeInOutgoingNodeConnections(
+ rootNodeName: string,
+ searchNodeName: string,
+ depth = -1,
+ ): boolean {
+ if (depth === 0) return false;
const firstNodeConnections = outgoingConnectionsByNodeName(rootNodeName);
if (!firstNodeConnections?.main?.[0]) return false;
const connections = firstNodeConnections.main[0];
if (connections.some((node) => node.node === searchNodeName)) return true;
- return connections.some((node) => isNodeInOutgoingNodeConnections(node.node, searchNodeName));
+ return connections.some((node) =>
+ isNodeInOutgoingNodeConnections(node.node, searchNodeName, depth - 1),
+ );
}
function getWorkflowById(id: string): IWorkflowDb {
diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue
index 926c46dae5..5d2a17d42d 100644
--- a/packages/frontend/editor-ui/src/views/NodeView.vue
+++ b/packages/frontend/editor-ui/src/views/NodeView.vue
@@ -76,7 +76,13 @@ import {
EVALUATION_TRIGGER_NODE_TYPE,
EVALUATION_NODE_TYPE,
} from 'n8n-workflow';
-import type { NodeConnectionType, IDataObject, ExecutionSummary, IConnection } from 'n8n-workflow';
+import type {
+ NodeConnectionType,
+ IDataObject,
+ ExecutionSummary,
+ IConnection,
+ INodeParameters,
+} from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store';
import { useCredentialsStore } from '@/stores/credentials.store';
@@ -123,6 +129,7 @@ import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import { useBuilderStore } from '@/stores/builder.store';
import { useFoldersStore } from '@/stores/folders.store';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
+import { useWorkflowExtraction } from '@/composables/useWorkflowExtraction';
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
import { useLogsStore } from '@/stores/logs.store';
@@ -195,6 +202,7 @@ const {
revertUpdateNodePosition,
renameNode,
revertRenameNode,
+ revertReplaceNodeParameters,
setNodeActive,
setNodeSelected,
toggleNodesDisabled,
@@ -230,6 +238,7 @@ const {
lastClickPosition,
startChat,
} = useCanvasOperations({ router });
+const { extractWorkflow } = useWorkflowExtraction();
const { applyExecutionData } = useExecutionDebugging();
useClipboard({ onPaste: onClipboardPaste });
@@ -635,6 +644,10 @@ function onTidyUp(event: CanvasLayoutEvent) {
tidyUp(event);
}
+function onExtractWorkflow(nodeIds: string[]) {
+ void extractWorkflow(nodeIds);
+}
+
function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) {
updateNodesPosition(events, { trackHistory: true });
}
@@ -891,6 +904,18 @@ async function onRevertRenameNode({
await revertRenameNode(currentName, newName);
}
+async function onRevertReplaceNodeParameters({
+ nodeId,
+ currentProperties,
+ newProperties,
+}: {
+ nodeId: string;
+ currentProperties: INodeParameters;
+ newProperties: INodeParameters;
+}) {
+ await revertReplaceNodeParameters(nodeId, currentProperties, newProperties);
+}
+
function onUpdateNodeParameters(id: string, parameters: Record) {
setNodeParameters(id, parameters);
}
@@ -1400,6 +1425,7 @@ function addUndoRedoEventBindings() {
historyBus.on('revertAddConnection', onRevertCreateConnection);
historyBus.on('revertRemoveConnection', onRevertDeleteConnection);
historyBus.on('revertRenameNode', onRevertRenameNode);
+ historyBus.on('revertReplaceNodeParameters', onRevertReplaceNodeParameters);
historyBus.on('enableNodeToggle', onRevertToggleNodeDisabled);
}
@@ -1410,6 +1436,7 @@ function removeUndoRedoEventBindings() {
historyBus.off('revertAddConnection', onRevertCreateConnection);
historyBus.off('revertRemoveConnection', onRevertDeleteConnection);
historyBus.off('revertRenameNode', onRevertRenameNode);
+ historyBus.off('revertReplaceNodeParameters', onRevertReplaceNodeParameters);
historyBus.off('enableNodeToggle', onRevertToggleNodeDisabled);
}
@@ -1977,6 +2004,7 @@ onBeforeUnmount(() => {
@selection:end="onSelectionEnd"
@drag-and-drop="onDragAndDrop"
@tidy-up="onTidyUp"
+ @extract-workflow="onExtractWorkflow"
@start-chat="startChat()"
>
diff --git a/packages/workflow/src/graph/graph-utils.ts b/packages/workflow/src/graph/graph-utils.ts
index 1bdd084d75..547c5bac68 100644
--- a/packages/workflow/src/graph/graph-utils.ts
+++ b/packages/workflow/src/graph/graph-utils.ts
@@ -1,3 +1,5 @@
+import type { IConnection, IConnections } from '../interfaces';
+
type MultipleInputNodesError = {
errorCode: 'Multiple Input Nodes';
nodes: Set;
@@ -31,21 +33,21 @@ export type ExtractableErrorResult =
| OutputEdgeFromNonLeafNode
| NoContinuousPathFromRootToLeaf;
-type AdjacencyList = Map>;
+export type IConnectionAdjacencyList = Map>;
/**
* Find all edges leading into the graph described in `graphIds`.
*/
export function getInputEdges(
graphIds: Set,
- 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,
- 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(minuend: Set, subtrahend: Set): Set {
return result;
}
-export function getRootNodes(graphIds: Set, adjacencyList: AdjacencyList): Set {
+export function getRootNodes(
+ graphIds: Set,
+ adjacencyList: IConnectionAdjacencyList,
+): Set {
// Inner nodes are all nodes with an incoming edge from another node in the graph
let innerNodes = new Set();
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, adjacencyList: AdjacencyList): Set {
+export function getLeafNodes(
+ graphIds: Set,
+ adjacencyList: IConnectionAdjacencyList,
+): Set {
const result = new Set();
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();
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(), 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>();
+ 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,
- 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',
diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts
index 2833d89ce7..ff95994efd 100644
--- a/packages/workflow/src/index.ts
+++ b/packages/workflow/src/index.ts
@@ -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';
diff --git a/packages/workflow/src/node-reference-parser-utils.ts b/packages/workflow/src/node-reference-parser-utils.ts
index c5ba85bea7..30c3357d72 100644
--- a/packages/workflow/src/node-reference-parser-utils.ts
+++ b/packages/workflow/src/node-reference-parser-utils.ts
@@ -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();
+ // The value represents fields in the actual parameters object which require change
+ const parameterTreeMappingByNode = new Map();
// 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);
diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts
index 33acbe22a4..6b54a5d9ae 100644
--- a/packages/workflow/src/workflow.ts
+++ b/packages/workflow/src/workflow.ts
@@ -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;
+ }
}
diff --git a/packages/workflow/test/GraphUtils/graph-utils.test.ts b/packages/workflow/test/GraphUtils/graph-utils.test.ts
deleted file mode 100644
index 7c89db4173..0000000000
--- a/packages/workflow/test/GraphUtils/graph-utils.test.ts
+++ /dev/null
@@ -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>([
- ['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>([
- ['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>([
- ['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>([['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>([['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>();
-
- 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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([['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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([
- ['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>([['A', new Set(['A'])]]);
-
- const result = hasPath('A', 'A', adjacencyList);
- expect(result).toBe(true);
- });
- });
-});
diff --git a/packages/workflow/test/graph/graph-utils.test.ts b/packages/workflow/test/graph/graph-utils.test.ts
new file mode 100644
index 0000000000..6f05012925
--- /dev/null
+++ b/packages/workflow/test/graph/graph-utils.test.ts
@@ -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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>();
+
+ 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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['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>([
+ ['A', new Set([makeConnection('B', 0), makeConnection('C', 1)])],
+ ]),
+ );
+ });
+ });
+});
diff --git a/packages/workflow/test/node-reference-parser-utils.test.ts b/packages/workflow/test/node-reference-parser-utils.test.ts
index a7457cbaff..f30b61ca64 100644
--- a/packages/workflow/test/node-reference-parser-utils.test.ts
+++ b/packages/workflow/test/node-reference-parser-utils.test.ts
@@ -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 = [
{
diff --git a/packages/workflow/test/workflow.test.ts b/packages/workflow/test/workflow.test.ts
index 74445cf76f..67782bd68e 100644
--- a/packages/workflow/test/workflow.test.ts
+++ b/packages/workflow/test/workflow.test.ts
@@ -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([]);
+ });
+ });
});