diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index eb42cc9473..9f777b68ae 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -2001,11 +2001,7 @@ export class WorkflowExecute { ensureInputData(workflow: Workflow, executionNode: INode, executionData: IExecuteData): boolean { const inputConnections = workflow.connectionsByDestinationNode[executionNode.name]?.main ?? []; for (let connectionIndex = 0; connectionIndex < inputConnections.length; connectionIndex++) { - const highestNodes = workflow.getHighestNode( - executionNode.name, - NodeConnectionType.Main, - connectionIndex, - ); + const highestNodes = workflow.getHighestNode(executionNode.name, connectionIndex); if (highestNodes.length === 0) { // If there is no valid incoming node (if all are disabled) // then ignore that it has inputs and simply execute it as it is without diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index b63a83105a..10e0de094d 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -458,7 +458,6 @@ export class Workflow { */ getHighestNode( nodeName: string, - type: NodeConnectionType = NodeConnectionType.Main, nodeConnectionIndex?: number, checkedNodes?: string[], ): string[] { @@ -473,7 +472,7 @@ export class Workflow { return currentHighest; } - if (!this.connectionsByDestinationNode[nodeName].hasOwnProperty(type)) { + if (!this.connectionsByDestinationNode[nodeName].hasOwnProperty(NodeConnectionType.Main)) { // Node does not have incoming connections of given type return currentHighest; } @@ -493,14 +492,15 @@ export class Workflow { let connectionsByIndex: IConnection[] | null; for ( let connectionIndex = 0; - connectionIndex < this.connectionsByDestinationNode[nodeName][type].length; + connectionIndex < this.connectionsByDestinationNode[nodeName][NodeConnectionType.Main].length; connectionIndex++ ) { if (nodeConnectionIndex !== undefined && nodeConnectionIndex !== connectionIndex) { // If a connection-index is given ignore all other ones continue; } - connectionsByIndex = this.connectionsByDestinationNode[nodeName][type][connectionIndex]; + connectionsByIndex = + this.connectionsByDestinationNode[nodeName][NodeConnectionType.Main][connectionIndex]; // eslint-disable-next-line @typescript-eslint/no-loop-func connectionsByIndex?.forEach((connection) => { if (checkedNodes.includes(connection.node)) { @@ -508,7 +508,7 @@ export class Workflow { return; } - addNodes = this.getHighestNode(connection.node, type, undefined, checkedNodes); + addNodes = this.getHighestNode(connection.node, undefined, checkedNodes); if (addNodes.length === 0) { // The checked node does not have any further parents so add it diff --git a/packages/workflow/test/Workflow.test.ts b/packages/workflow/test/Workflow.test.ts index 88c41e8d9f..f7d72cfa66 100644 --- a/packages/workflow/test/Workflow.test.ts +++ b/packages/workflow/test/Workflow.test.ts @@ -25,6 +25,326 @@ interface StubNode { } describe('Workflow', () => { + const nodeTypes = Helpers.NodeTypes(); + + const SIMPLE_WORKFLOW = new Workflow({ + nodeTypes, + nodes: [ + { + parameters: {}, + name: 'Start', + type: 'test.set', + typeVersion: 1, + id: 'uuid-1', + position: [240, 300], + }, + { + parameters: { + options: {}, + }, + name: 'Set', + type: 'test.set', + typeVersion: 1, + id: 'uuid-2', + position: [460, 300], + }, + { + parameters: { + options: {}, + }, + name: 'Set1', + type: 'test.set', + typeVersion: 1, + id: 'uuid-3', + position: [680, 300], + }, + ], + connections: { + Start: { + main: [ + [ + { + node: 'Set', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + Set: { + main: [ + [ + { + node: 'Set1', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + active: false, + }); + + const WORKFLOW_WITH_SWITCH = new Workflow({ + active: false, + nodeTypes, + nodes: [ + { + parameters: {}, + name: 'Switch', + type: 'test.switch', + typeVersion: 1, + id: 'uuid-1', + position: [460, 300], + }, + { + parameters: { + options: {}, + }, + name: 'Set', + type: 'test.set', + typeVersion: 1, + id: 'uuid-2', + position: [740, 300], + }, + { + parameters: { + options: {}, + }, + name: 'Set1', + type: 'test.set', + typeVersion: 1, + id: 'uuid-3', + position: [780, 100], + }, + { + parameters: { + options: {}, + }, + name: 'Set2', + type: 'test.set', + typeVersion: 1, + id: 'uuid-4', + position: [1040, 260], + }, + ], + connections: { + Switch: { + main: [ + [ + { + node: 'Set1', + type: NodeConnectionType.Main, + index: 0, + }, + ], + [ + { + node: 'Set', + type: NodeConnectionType.Main, + index: 0, + }, + ], + [ + { + node: 'Set', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + Set: { + main: [ + [ + { + node: 'Set2', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + Set1: { + main: [ + [ + { + node: 'Set2', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }); + + const WORKFLOW_WITH_LOOPS = new Workflow({ + nodeTypes, + active: false, + nodes: [ + { + parameters: {}, + name: 'Switch', + type: 'test.switch', + typeVersion: 1, + id: 'uuid-1', + position: [920, 340], + }, + { + parameters: {}, + name: 'Start', + type: 'test.set', + typeVersion: 1, + id: 'uuid-2', + position: [240, 300], + }, + { + parameters: { + options: {}, + }, + name: 'Set1', + type: 'test.set', + typeVersion: 1, + id: 'uuid-3', + position: [700, 340], + }, + { + parameters: { + options: {}, + }, + name: 'Set', + type: 'test.set', + typeVersion: 1, + id: 'uuid-4', + position: [1220, 300], + }, + { + parameters: {}, + name: 'Switch', + type: 'test.switch', + typeVersion: 1, + id: 'uuid-5', + position: [920, 340], + }, + ], + connections: { + Switch: { + main: [ + [ + { + node: 'Set', + type: NodeConnectionType.Main, + index: 0, + }, + ], + [], // todo why is null not accepted + [ + { + node: 'Switch', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + Start: { + main: [ + [ + { + node: 'Set1', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + Set1: { + main: [ + [ + { + node: 'Set1', + type: NodeConnectionType.Main, + index: 0, + }, + { + node: 'Switch', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + Set: { + main: [ + [ + { + node: 'Set1', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + }); + + const WORKFLOW_WITH_MIXED_CONNECTIONS = new Workflow({ + nodeTypes, + nodes: [ + { + parameters: {}, + name: 'Start', + type: 'test.set', + typeVersion: 1, + id: 'uuid-1', + position: [240, 300], + }, + { + parameters: {}, + name: 'AINode', + type: 'test.ai', + typeVersion: 1, + id: 'uuid-2', + position: [460, 300], + }, + { + parameters: {}, + name: 'Set1', + type: 'test.set', + typeVersion: 1, + id: 'uuid-3', + position: [680, 300], + }, + ], + connections: { + Start: { + main: [ + [ + { + node: 'AINode', + type: NodeConnectionType.AiAgent, + index: 0, + }, + ], + ], + }, + AINode: { + ai: [ + [ + { + node: 'Set1', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + active: false, + }); + describe('renameNodeInParameterValue', () => { describe('for expressions', () => { const tests = [ @@ -259,7 +579,6 @@ describe('Workflow', () => { }, ]; - const nodeTypes = Helpers.NodeTypes(); const workflow = new Workflow({ nodes: [], connections: {}, active: false, nodeTypes }); for (const testData of tests) { @@ -309,7 +628,7 @@ describe('Workflow', () => { nodes: [], connections: {}, active: false, - nodeTypes: Helpers.NodeTypes(), + nodeTypes, }); for (const t of tests) { @@ -713,7 +1032,6 @@ describe('Workflow', () => { }, ]; - const nodeTypes = Helpers.NodeTypes(); let workflow: Workflow; function createNodeData(stubData: StubNode): INode { @@ -1324,85 +1642,7 @@ describe('Workflow', () => { }); } - // test('should be able to set and read key data without initial data set', () => { - - // const nodes: Node[] = [ - // { - // "name": "Node1", - // "parameters": { - // "value": "outputSet1" - // }, - // "type": "test.set", - // "typeVersion": 1, - // "position": [ - // 100, - // 200 - // ] - // }, - // { - // "name": "Node2", - // "parameters": { - // "name": "=[data.propertyName]" - // }, - // "type": "test.set", - // "typeVersion": 1, - // "position": [ - // 100, - // 300 - // ] - // } - // ]; - // const connections: Connections = { - // "Node1": { - // "main": [ - // [ - // { - // "node": "Node2", - // "type": "main", - // "index": 0 - // } - // ] - // ] - // } - // }; - - // const nodeTypes = Helpers.NodeTypes(); - // const workflow = new Workflow({ nodes, connections, active: false, nodeTypes }); - // const activeNodeName = 'Node2'; - - // const parameterValue = nodes.find((node) => node.name === activeNodeName).parameters.name; - // // const parameterValue = '=[data.propertyName]'; // TODO: Make this dynamic from node-data via "activeNodeName"! - // const runData: RunData = { - // Node1: [ - // { - // startTime: 1, - // executionTime: 1, - // data: { - // main: [ - // [ - // { - // json: { - // propertyName: 'outputSet1' - // } - // } - // ] - // ] - // } - // } - // ] - // }; - - // const itemIndex = 0; - // const connectionInputData: NodeExecutionData[] = runData!['Node1']![0]!.data!.main[0]!; - - // const result = workflow.getParameterValue(parameterValue, runData, itemIndex, activeNodeName, connectionInputData); - - // expect(result).toEqual('outputSet1'); - // }); - test('should also resolve all child parameters when the parent get requested', () => { - const nodeTypes = Helpers.NodeTypes(); - const nodes: INode[] = [ { name: 'Node1', @@ -1490,270 +1730,6 @@ describe('Workflow', () => { }); describe('getParentNodesByDepth', () => { - const nodeTypes = Helpers.NodeTypes(); - const SIMPLE_WORKFLOW = new Workflow({ - nodeTypes, - nodes: [ - { - parameters: {}, - name: 'Start', - type: 'test.set', - typeVersion: 1, - id: 'uuid-1', - position: [240, 300], - }, - { - parameters: { - options: {}, - }, - name: 'Set', - type: 'test.set', - typeVersion: 1, - id: 'uuid-2', - position: [460, 300], - }, - { - parameters: { - options: {}, - }, - name: 'Set1', - type: 'test.set', - typeVersion: 1, - id: 'uuid-3', - position: [680, 300], - }, - ], - connections: { - Start: { - main: [ - [ - { - node: 'Set', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - Set: { - main: [ - [ - { - node: 'Set1', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - }, - active: false, - }); - - const WORKFLOW_WITH_SWITCH = new Workflow({ - active: false, - nodeTypes, - nodes: [ - { - parameters: {}, - name: 'Switch', - type: 'test.switch', - typeVersion: 1, - id: 'uuid-1', - position: [460, 300], - }, - { - parameters: { - options: {}, - }, - name: 'Set', - type: 'test.set', - typeVersion: 1, - id: 'uuid-2', - position: [740, 300], - }, - { - parameters: { - options: {}, - }, - name: 'Set1', - type: 'test.set', - typeVersion: 1, - id: 'uuid-3', - position: [780, 100], - }, - { - parameters: { - options: {}, - }, - name: 'Set2', - type: 'test.set', - typeVersion: 1, - id: 'uuid-4', - position: [1040, 260], - }, - ], - connections: { - Switch: { - main: [ - [ - { - node: 'Set1', - type: NodeConnectionType.Main, - index: 0, - }, - ], - [ - { - node: 'Set', - type: NodeConnectionType.Main, - index: 0, - }, - ], - [ - { - node: 'Set', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - Set: { - main: [ - [ - { - node: 'Set2', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - Set1: { - main: [ - [ - { - node: 'Set2', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - }, - }); - - const WORKFLOW_WITH_LOOPS = new Workflow({ - nodeTypes, - active: false, - nodes: [ - { - parameters: {}, - name: 'Switch', - type: 'test.switch', - typeVersion: 1, - id: 'uuid-1', - position: [920, 340], - }, - { - parameters: {}, - name: 'Start', - type: 'test.set', - typeVersion: 1, - id: 'uuid-2', - position: [240, 300], - }, - { - parameters: { - options: {}, - }, - name: 'Set1', - type: 'test.set', - typeVersion: 1, - id: 'uuid-3', - position: [700, 340], - }, - { - parameters: { - options: {}, - }, - name: 'Set', - type: 'test.set', - typeVersion: 1, - id: 'uuid-4', - position: [1220, 300], - }, - { - parameters: {}, - name: 'Switch', - type: 'test.switch', - typeVersion: 1, - id: 'uuid-5', - position: [920, 340], - }, - ], - connections: { - Switch: { - main: [ - [ - { - node: 'Set', - type: NodeConnectionType.Main, - index: 0, - }, - ], - [], // todo why is null not accepted - [ - { - node: 'Switch', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - Start: { - main: [ - [ - { - node: 'Set1', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - Set1: { - main: [ - [ - { - node: 'Set1', - type: NodeConnectionType.Main, - index: 0, - }, - { - node: 'Switch', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - Set: { - main: [ - [ - { - node: 'Set1', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - }, - }); - test('Should return parent nodes of nodes', () => { expect(SIMPLE_WORKFLOW.getParentNodesByDepth('Start')).toEqual([]); expect(SIMPLE_WORKFLOW.getParentNodesByDepth('Set')).toEqual([ @@ -2044,4 +2020,340 @@ describe('Workflow', () => { }); }); }); + + describe('getHighestNode', () => { + const createNode = (name: string, disabled = false) => + ({ + name, + type: 'test.set', + typeVersion: 1, + disabled, + position: [0, 0], + parameters: {}, + }) as INode; + + test('should return node name if node is not disabled', () => { + const node = createNode('Node1'); + const workflow = new Workflow({ + id: 'test', + nodes: [node], + connections: {}, + active: false, + nodeTypes, + }); + + const result = workflow.getHighestNode(node.name); + expect(result).toEqual([node.name]); + }); + + test('should return empty array if node is disabled', () => { + const node = createNode('Node1', true); + const workflow = new Workflow({ + id: 'test', + nodes: [node], + connections: {}, + active: false, + nodeTypes, + }); + + const result = workflow.getHighestNode(node.name); + expect(result).toEqual([]); + }); + + test('should return highest nodes when multiple parent nodes exist', () => { + const node1 = createNode('Node1'); + const node2 = createNode('Node2'); + const node3 = createNode('Node3'); + const targetNode = createNode('TargetNode'); + + const connections = { + Node1: { + main: [[{ node: 'TargetNode', type: NodeConnectionType.Main, index: 0 }]], + }, + Node2: { + main: [[{ node: 'TargetNode', type: NodeConnectionType.Main, index: 0 }]], + }, + }; + + const workflow = new Workflow({ + id: 'test', + nodes: [node1, node2, node3, targetNode], + connections, + active: false, + nodeTypes, + }); + + const result = workflow.getHighestNode(targetNode.name); + expect(result).toEqual([node1.name, node2.name]); + }); + + test('should ignore disabled parent nodes', () => { + const node1 = createNode('Node1', true); + const node2 = createNode('Node2'); + const targetNode = createNode('TargetNode'); + + const connections = { + Node1: { + main: [[{ node: 'TargetNode', type: NodeConnectionType.Main, index: 0 }]], + }, + Node2: { + main: [[{ node: 'TargetNode', type: NodeConnectionType.Main, index: 0 }]], + }, + }; + + const workflow = new Workflow({ + id: 'test', + nodes: [node1, node2, targetNode], + connections, + active: false, + nodeTypes, + }); + + const result = workflow.getHighestNode(targetNode.name); + expect(result).toEqual([node2.name]); + }); + + test('should handle nested connections', () => { + const node1 = createNode('Node1'); + const node2 = createNode('Node2'); + const node3 = createNode('Node3'); + const targetNode = createNode('TargetNode'); + + const connections = { + Node3: { + main: [ + [{ node: 'Node1', type: NodeConnectionType.Main, index: 0 }], + [{ node: 'Node2', type: NodeConnectionType.Main, index: 0 }], + ], + }, + TargetNode: { + main: [[{ node: 'Node3', type: NodeConnectionType.Main, index: 0 }]], + }, + }; + + const workflow = new Workflow({ + id: 'test', + nodes: [node1, node2, node3, targetNode], + connections, + active: false, + nodeTypes, + }); + + const result = workflow.getHighestNode(targetNode.name); + expect(result).toEqual([targetNode.name]); + }); + + test('should handle specified connection index', () => { + const node1 = createNode('Node1'); + const node2 = createNode('Node2'); + const targetNode = createNode('TargetNode'); + + const connections = { + Node1: { + main: [[{ node: 'TargetNode', type: NodeConnectionType.Main, index: 0 }]], + }, + Node2: { + main: [[], [{ node: 'TargetNode', type: NodeConnectionType.Main, index: 1 }]], + }, + }; + + const workflow = new Workflow({ + id: 'test', + nodes: [node1, node2, targetNode], + connections, + active: false, + nodeTypes, + }); + + const resultFirstIndex = workflow.getHighestNode(targetNode.name, 0); + const resultSecondIndex = workflow.getHighestNode(targetNode.name, 1); + + expect(resultFirstIndex).toEqual([node1.name]); + expect(resultSecondIndex).toEqual([node2.name]); + }); + + test('should prevent infinite loops with cyclic connections', () => { + const node1 = createNode('Node1'); + const node2 = createNode('Node2'); + const targetNode = createNode('TargetNode'); + + const connections = { + Node1: { + main: [[{ node: 'Node2', type: NodeConnectionType.Main, index: 0 }]], + }, + Node2: { + main: [[{ node: 'Node1', type: NodeConnectionType.Main, index: 0 }]], + }, + TargetNode: { + main: [[{ node: 'Node1', type: NodeConnectionType.Main, index: 0 }]], + }, + }; + + const workflow = new Workflow({ + id: 'test', + nodes: [node1, node2, targetNode], + connections, + active: false, + nodeTypes, + }); + + const result = workflow.getHighestNode(targetNode.name); + expect(result).toEqual([targetNode.name]); + }); + + test('should handle connections to nodes not defined in workflow', () => { + const node1 = createNode('Node1'); + const targetNode = createNode('TargetNode'); + + const connections = { + Node1: { + main: [[{ node: 'NonExistentNode', type: NodeConnectionType.Main, index: 0 }]], + }, + TargetNode: { + main: [[{ node: 'NonExistentNode', type: NodeConnectionType.Main, index: 0 }]], + }, + }; + + const workflow = new Workflow({ + id: 'test', + nodes: [node1, targetNode], + connections, + active: false, + nodeTypes, + }); + + const result = workflow.getHighestNode(targetNode.name); + + expect(result).toEqual([targetNode.name]); + }); + }); + + describe('getParentMainInputNode', () => { + test('should return the node itself if no parent connections exist', () => { + const startNode = SIMPLE_WORKFLOW.getNode('Start')!; + const result = SIMPLE_WORKFLOW.getParentMainInputNode(startNode); + expect(result).toBe(startNode); + }); + + test('should return direct main input parent node', () => { + const set1Node = SIMPLE_WORKFLOW.getNode('Set1')!; + const result = SIMPLE_WORKFLOW.getParentMainInputNode(set1Node); + expect(result).toBe(set1Node); + }); + + test('should traverse through non-main connections to find main input', () => { + const set1Node = WORKFLOW_WITH_MIXED_CONNECTIONS.getNode('Set1')!; + const result = WORKFLOW_WITH_MIXED_CONNECTIONS.getParentMainInputNode(set1Node); + expect(result).toBe(set1Node); + }); + + test('should handle nested non-main connections', () => { + const set1Node = WORKFLOW_WITH_LOOPS.getNode('Set1')!; + const result = WORKFLOW_WITH_LOOPS.getParentMainInputNode(set1Node); + expect(result).toBe(set1Node); + }); + }); + + describe('getNodeConnectionIndexes', () => { + test('should return undefined for non-existent parent node', () => { + const result = SIMPLE_WORKFLOW.getNodeConnectionIndexes('Set', 'NonExistentNode'); + expect(result).toBeUndefined(); + }); + + test('should return undefined for nodes without connections', () => { + const result = SIMPLE_WORKFLOW.getNodeConnectionIndexes('Start', 'Set1'); + expect(result).toBeUndefined(); + }); + + test('should return correct connection indexes for direct connections', () => { + const result = SIMPLE_WORKFLOW.getNodeConnectionIndexes('Set', 'Start'); + expect(result).toEqual({ + sourceIndex: 0, + destinationIndex: 0, + }); + }); + + test('should return correct connection indexes for multi-step connections', () => { + const result = SIMPLE_WORKFLOW.getNodeConnectionIndexes('Set1', 'Start'); + expect(result).toEqual({ + sourceIndex: 0, + destinationIndex: 0, + }); + }); + + test('should return undefined when depth is 0', () => { + const result = SIMPLE_WORKFLOW.getNodeConnectionIndexes( + 'Set', + 'Start', + NodeConnectionType.Main, + 0, + ); + expect(result).toBeUndefined(); + }); + + test('should handle workflows with multiple connection indexes', () => { + const result = WORKFLOW_WITH_SWITCH.getNodeConnectionIndexes('Set', 'Switch'); + expect(result).toEqual({ + sourceIndex: 1, + destinationIndex: 0, + }); + }); + }); + + describe('getStartNode', () => { + const manualTriggerNode = mock({ + name: 'ManualTrigger', + type: 'n8n-nodes-base.manualTrigger', + }); + const scheduleTriggerNode = mock({ + name: 'ScheduleTrigger', + type: 'n8n-nodes-base.scheduleTrigger', + }); + const httpRequestNode = mock({ + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + }); + const set1Node = mock({ + name: 'Set1', + type: 'n8n-nodes-base.set', + }); + const disabledSetNode = mock({ + name: 'Set Disabled', + type: 'n8n-nodes-base.set', + disabled: true, + }); + + test('returns first trigger node when multiple start nodes exist', () => { + const workflow = new Workflow({ + nodes: [manualTriggerNode, scheduleTriggerNode], + connections: {}, + active: false, + nodeTypes, + }); + + expect(workflow.getStartNode()).toBe(scheduleTriggerNode); + }); + + test('returns first starting node type when no trigger nodes are present', () => { + const workflow = new Workflow({ + nodes: [httpRequestNode, set1Node], + connections: {}, + active: false, + nodeTypes, + }); + + expect(workflow.getStartNode()).toBe(httpRequestNode); + }); + + test('returns undefined when all nodes are disabled', () => { + const workflow = new Workflow({ + nodes: [disabledSetNode], + connections: {}, + active: false, + nodeTypes, + }); + + expect(workflow.getStartNode()).toBeUndefined(); + }); + }); }); diff --git a/packages/workflow/test/WorkflowDataProxy.test.ts b/packages/workflow/test/WorkflowDataProxy.test.ts index 943d765280..b749cb80c9 100644 --- a/packages/workflow/test/WorkflowDataProxy.test.ts +++ b/packages/workflow/test/WorkflowDataProxy.test.ts @@ -1,3 +1,5 @@ +import { DateTime, Duration, Interval } from 'luxon'; + import { ensureError } from '@/errors/ensure-error'; import { ExpressionError } from '@/errors/expression.error'; import { @@ -590,4 +592,198 @@ describe('WorkflowDataProxy', () => { }); }); }); + + describe('DateTime and Time-related functions', () => { + const fixture = loadFixture('base'); + const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); + + test('$now should return current datetime', () => { + expect(proxy.$now).toBeInstanceOf(DateTime); + }); + + test('$today should return datetime at start of day', () => { + const today = proxy.$today; + expect(today).toBeInstanceOf(DateTime); + expect(today.hour).toBe(0); + expect(today.minute).toBe(0); + expect(today.second).toBe(0); + expect(today.millisecond).toBe(0); + }); + + test('should expose DateTime, Interval, and Duration', () => { + expect(proxy.DateTime).toBe(DateTime); + expect(proxy.Interval).toBe(Interval); + expect(proxy.Duration).toBe(Duration); + }); + + test('$now should be configurable with timezone', () => { + const timezoneProxy = getProxyFromFixture( + { ...fixture.workflow, settings: { timezone: 'America/New_York' } }, + fixture.run, + 'End', + ); + + expect(timezoneProxy.$now.zoneName).toBe('America/New_York'); + }); + }); + + describe('Node version and ID', () => { + const fixture = loadFixture('base'); + const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); + + test('$nodeVersion should return node type version', () => { + expect(proxy.$nodeVersion).toBe(1); + }); + + test('$nodeId should return node ID', () => { + expect(proxy.$nodeId).toBe('uuid-5'); + }); + + test('$webhookId should be optional', () => { + expect(proxy.$webhookId).toBeUndefined(); + }); + }); + + describe('$jmesPath', () => { + const fixture = loadFixture('base'); + const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); + + test('should query simple object', () => { + const data = { name: 'John', age: 30 }; + expect(proxy.$jmesPath(data, 'name')).toBe('John'); + }); + + test('should query nested object', () => { + const data = { + user: { + name: 'John', + details: { age: 30 }, + }, + }; + expect(proxy.$jmesPath(data, 'user.details.age')).toBe(30); + }); + + test('should query array', () => { + const data = [ + { name: 'John', age: 30 }, + { name: 'Jane', age: 25 }, + ]; + expect(proxy.$jmesPath(data, '[*].name')).toEqual(['John', 'Jane']); + }); + + test('should throw error for invalid arguments', () => { + expect(() => proxy.$jmesPath('not an object', 'test')).toThrow(ExpressionError); + expect(() => proxy.$jmesPath({}, 123 as unknown as string)).toThrow(ExpressionError); + }); + + test('$jmespath should alias $jmesPath', () => { + const data = { name: 'John' }; + expect(proxy.$jmespath(data, 'name')).toBe(proxy.$jmesPath(data, 'name')); + }); + }); + + describe('$mode', () => { + const fixture = loadFixture('base'); + const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End', 'manual'); + + test('should return execution mode', () => { + expect(proxy.$mode).toBe('manual'); + }); + }); + + describe('$item', () => { + const fixture = loadFixture('base'); + const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); + + test('should return data proxy for specific item', () => { + const itemProxy = proxy.$item(1); + expect(itemProxy.$json.data).toBe(160); + }); + + test('should allow specifying run index', () => { + const itemProxy = proxy.$item(1, 0); + expect(itemProxy.$json.data).toBe(160); + }); + }); + + describe('$items', () => { + const fixture = loadFixture('base'); + const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'End'); + + describe('Default behavior (no arguments)', () => { + test('should return input items from previous node', () => { + const items = proxy.$items(); + expect(items.length).toBe(5); + expect(items[0].json.data).toBe(105); + expect(items[1].json.data).toBe(160); + }); + + test('should limit items for nodes with executeOnce=true', () => { + // Mock a node with executeOnce=true + const mockWorkflow = { + ...fixture.workflow, + nodes: fixture.workflow.nodes.map((node) => + node.name === 'Rename' ? { ...node, executeOnce: true } : node, + ), + }; + + const mockProxy = getProxyFromFixture(mockWorkflow, fixture.run, 'End'); + const items = mockProxy.$items(); + + expect(items.length).toBe(1); + expect(items[0].json.data).toBe(105); + }); + }); + + describe('With node name argument', () => { + test('should return items for specified node', () => { + const items = proxy.$items('Rename'); + expect(items.length).toBe(5); + expect(items[0].json.data).toBe(105); + expect(items[1].json.data).toBe(160); + }); + + test('should throw error for non-existent node', () => { + expect(() => proxy.$items('NonExistentNode')).toThrowError(ExpressionError); + }); + }); + + describe('With node name and output index', () => { + const switchWorkflow = loadFixture('multiple_outputs'); + const switchProxy = getProxyFromFixture( + switchWorkflow.workflow, + switchWorkflow.run, + 'Edit Fields', + ); + + test('should return items from specific output', () => { + const items = switchProxy.$items('If', 1); + expect(items[0].json.code).toBe(1); + }); + }); + + describe('With node name, output index, and run index', () => { + test('should handle negative run index', () => { + const items = proxy.$items('Rename', 0, -1); + expect(items.length).toBe(5); + expect(items[0].json.data).toBe(105); + }); + }); + + describe('Error handling', () => { + test('should throw error for invalid run index', () => { + expect(() => proxy.$items('Rename', 0, 999)).toThrowError(ExpressionError); + }); + + test('should handle nodes with no execution data', () => { + const noDataWorkflow = { + ...fixture.workflow, + nodes: fixture.workflow.nodes.filter((node) => node.name !== 'Rename'), + }; + const noDataProxy = getProxyFromFixture(noDataWorkflow, null, 'End'); + + expect(() => noDataProxy.$items('Rename')).toThrowError(ExpressionError); + }); + }); + }); });