diff --git a/packages/core/test/Helpers.ts b/packages/core/test/Helpers.ts new file mode 100644 index 0000000000..2ca59e7915 --- /dev/null +++ b/packages/core/test/Helpers.ts @@ -0,0 +1,253 @@ +import { set } from 'lodash'; + +import { + INodeExecutionData, + INodeParameters, + INodeType, + INodeTypes, + INodeTypesObject, + IRun, + ITaskData, + IWorkflowExecuteAdditionalData, +} from 'n8n-workflow'; + +import { + IDeferredPromise, + IExecuteFunctions, +} from '../src'; + + +class NodeTypesClass implements INodeTypes { + + nodeTypes: INodeTypesObject = { + 'n8n-nodes-base.merge': { + description: { + displayName: 'Merge', + name: 'merge', + icon: 'fa:clone', + group: ['transform'], + version: 1, + description: 'Merges data of multiple streams once data of both is available', + defaults: { + name: 'Merge', + color: '#00cc22', + }, + inputs: ['main', 'main'], + outputs: ['main'], + properties: [ + { + displayName: 'Mode', + name: 'mode', + type: 'options', + options: [ + { + name: 'Append', + value: 'append', + description: 'Combines data of both inputs. The output will contain items of input 1 and input 2.', + }, + { + name: 'Pass-through', + value: 'passThrough', + description: 'Passes through data of one input. The output will conain only items of the defined input.', + }, + { + name: 'Wait', + value: 'wait', + description: 'Waits till data of both inputs is available and will then output a single empty item.', + }, + ], + default: 'append', + description: 'How data should be merged. If it should simply
be appended or merged depending on a property.', + }, + { + displayName: 'Output Data', + name: 'output', + type: 'options', + displayOptions: { + show: { + mode: [ + 'passThrough' + ], + }, + }, + options: [ + { + name: 'Input 1', + value: 'input1', + }, + { + name: 'Input 2', + value: 'input2', + }, + ], + default: 'input1', + description: 'Defines of which input the data should be used as output of node.', + }, + ] + }, + async execute(this: IExecuteFunctions): Promise { + // const itemsInput2 = this.getInputData(1); + + const returnData: INodeExecutionData[] = []; + + const mode = this.getNodeParameter('mode', 0) as string; + + if (mode === 'append') { + // Simply appends the data + for (let i = 0; i < 2; i++) { + returnData.push.apply(returnData, this.getInputData(i)); + } + } else if (mode === 'passThrough') { + const output = this.getNodeParameter('output', 0) as string; + + if (output === 'input1') { + returnData.push.apply(returnData, this.getInputData(0)); + } else { + returnData.push.apply(returnData, this.getInputData(1)); + } + } else if (mode === 'wait') { + returnData.push({ json: {} }); + } + + return [returnData]; + } + }, + 'n8n-nodes-base.set': { + description: { + displayName: 'Set', + name: 'set', + group: ['input'], + version: 1, + description: 'Sets a value', + defaults: { + name: 'Set', + color: '#0000FF', + }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { + displayName: 'Keep Only Set', + name: 'keepOnlySet', + type: 'boolean', + default: false, + description: 'If only the values set on this node should be
kept and all others removed.', + }, + { + displayName: 'Values to Set', + name: 'values', + placeholder: 'Add Value', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + description: 'The value to set.', + default: {}, + options: [ + { + name: 'number', + displayName: 'Number', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: 'propertyName', + description: 'Name of the property to write data to.
Supports dot-notation.
Example: "data.person[0].name"', + }, + { + displayName: 'Value', + name: 'value', + type: 'number', + default: 0, + description: 'The number value to write in the property.', + }, + ] + }, + ], + }, + ] + }, + execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + let item: INodeExecutionData; + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + item = items[itemIndex]; + // Add number values + (this.getNodeParameter('values.number', itemIndex, []) as INodeParameters[]).forEach((setItem) => { + set(item.json, setItem.name as string, setItem.value); + }); + } + + return this.prepareOutputData(items); + } + }, + 'n8n-nodes-base.start': { + description: { + displayName: 'Start', + name: 'start', + group: ['input'], + version: 1, + description: 'Starts the workflow execution from this node', + defaults: { + name: 'Start', + color: '#553399', + }, + inputs: [], + outputs: ['main'], + properties: [] + }, + execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + return this.prepareOutputData(items); + }, + }, + }; + + async init(nodeTypes: INodeTypesObject): Promise { } + + getAll(): INodeType[] { + return Object.values(this.nodeTypes); + } + + getByName(nodeType: string): INodeType { + return this.nodeTypes[nodeType]; + } +} + +let nodeTypesInstance: NodeTypesClass | undefined; + + +export function NodeTypes(): NodeTypesClass { + if (nodeTypesInstance === undefined) { + nodeTypesInstance = new NodeTypesClass(); + nodeTypesInstance.init({}); + } + + return nodeTypesInstance; +} + + +export function WorkflowExecuteAdditionalData(waitPromise: IDeferredPromise, nodeExecutionOrder: string[]): IWorkflowExecuteAdditionalData { + return { + credentials: {}, + hooks: { + nodeExecuteAfter: [ + async (executionId: string, nodeName: string, data: ITaskData): Promise => { + nodeExecutionOrder.push(nodeName); + }, + ], + workflowExecuteAfter: [ + async (fullRunData: IRun, executionId: string): Promise => { + waitPromise.resolve(fullRunData); + }, + ], + }, + encryptionKey: 'test', + timezone: 'America/New_York', + webhookBaseUrl: 'webhook', + webhookTestBaseUrl: 'webhook-test', + }; +} diff --git a/packages/core/test/WorkflowExecute.test.ts b/packages/core/test/WorkflowExecute.test.ts new file mode 100644 index 0000000000..387d1b36f5 --- /dev/null +++ b/packages/core/test/WorkflowExecute.test.ts @@ -0,0 +1,625 @@ + +import { + IConnections, + INode, + IRun, + Workflow, +} from 'n8n-workflow'; + +import { + createDeferredPromise, + WorkflowExecute, +} from '../src'; + +import * as Helpers from './Helpers'; + + +describe('WorkflowExecute', () => { + + describe('run', () => { + + const tests: Array<{ + description: string; + input: { + workflowData: { + nodes: INode[], + connections: IConnections, + } + }, + output: { + nodeExecutionOrder: string[]; + nodeData: { + [key: string]: any[][]; // tslint:disable-line:no-any + }; + }, + }> = [ + { + description: 'should run basic two node workflow', + input: { + // Leave the workflowData in regular JSON to be able to easily + // copy it from/in the UI + workflowData: { + "nodes": [ + { + "parameters": {}, + "name": "Start", + "type": "n8n-nodes-base.start", + "typeVersion": 1, + "position": [ + 100, + 300 + ] + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "value1", + "value": 1 + } + ] + } + }, + "name": "Set", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 280, + 300 + ] + } + ], + "connections": { + "Start": { + "main": [ + [ + { + "node": "Set", + "type": "main", + "index": 0 + } + ] + ] + } + } + }, + }, + output: { + nodeExecutionOrder: [ + 'Start', + 'Set', + ], + nodeData: { + Set: [ + [ + { + value1: 1, + }, + ], + ], + }, + }, + }, + { + description: 'should run node twice when it has two input connections', + input: { + // Leave the workflowData in regular JSON to be able to easily + // copy it from/in the UI + workflowData: { + "nodes": [ + { + "parameters": {}, + "name": "Start", + "type": "n8n-nodes-base.start", + "typeVersion": 1, + "position": [ + 100, + 300 + ] + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "value1", + "value": 1 + } + ] + } + }, + "name": "Set1", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 300, + 250 + ] + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "value2", + "value": 2 + } + ] + } + }, + "name": "Set2", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 500, + 400 + ] + } + ], + "connections": { + "Start": { + "main": [ + [ + { + "node": "Set1", + "type": "main", + "index": 0 + }, + { + "node": "Set2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set1": { + "main": [ + [ + { + "node": "Set2", + "type": "main", + "index": 0 + } + ] + ] + } + } + }, + }, + output: { + nodeExecutionOrder: [ + 'Start', + 'Set1', + 'Set2', + 'Set2', + ], + nodeData: { + Set1: [ + [ + { + value1: 1, + }, + ] + ], + Set2: [ + [ + { + value2: 2, + }, + ], + [ + { + value1: 1, + value2: 2, + }, + ], + ], + }, + }, + }, + { + description: 'should run complicated multi node workflow', + input: { + // Leave the workflowData in regular JSON to be able to easily + // copy it from/in the UI + workflowData: { + "nodes": [ + { + "parameters": { + "mode": "passThrough" + }, + "name": "Merge4", + "type": "n8n-nodes-base.merge", + "typeVersion": 1, + "position": [ + 1150, + 500 + ] + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "value2", + "value": 2 + } + ] + } + }, + "name": "Set2", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 290, + 400 + ] + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "value4", + "value": 4 + } + ] + } + }, + "name": "Set4", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 850, + 200 + ] + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "value3", + "value": 3 + } + ] + } + }, + "name": "Set3", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 650, + 200 + ] + }, + { + "parameters": { + "mode": "passThrough" + }, + "name": "Merge4", + "type": "n8n-nodes-base.merge", + "typeVersion": 1, + "position": [ + 1150, + 500 + ] + }, + { + "parameters": {}, + "name": "Merge3", + "type": "n8n-nodes-base.merge", + "typeVersion": 1, + "position": [ + 1000, + 400 + ] + }, + { + "parameters": { + "mode": "passThrough", + "output": "input2" + }, + "name": "Merge2", + "type": "n8n-nodes-base.merge", + "typeVersion": 1, + "position": [ + 700, + 400 + ] + }, + { + "parameters": {}, + "name": "Merge1", + "type": "n8n-nodes-base.merge", + "typeVersion": 1, + "position": [ + 500, + 300 + ] + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "value1", + "value": 1 + } + ] + } + }, + "name": "Set1", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 300, + 200 + ] + }, + { + "parameters": {}, + "name": "Start", + "type": "n8n-nodes-base.start", + "typeVersion": 1, + "position": [ + 100, + 300 + ] + } + ], + "connections": { + "Set2": { + "main": [ + [ + { + "node": "Merge1", + "type": "main", + "index": 1 + }, + { + "node": "Merge2", + "type": "main", + "index": 1 + } + ] + ] + }, + "Set4": { + "main": [ + [ + { + "node": "Merge3", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set3": { + "main": [ + [ + { + "node": "Set4", + "type": "main", + "index": 0 + } + ] + ] + }, + "Merge3": { + "main": [ + [ + { + "node": "Merge4", + "type": "main", + "index": 0 + } + ] + ] + }, + "Merge2": { + "main": [ + [ + { + "node": "Merge3", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge1": { + "main": [ + [ + { + "node": "Merge2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set1": { + "main": [ + [ + { + "node": "Merge1", + "type": "main", + "index": 0 + }, + { + "node": "Set3", + "type": "main", + "index": 0 + } + ] + ] + }, + "Start": { + "main": [ + [ + { + "node": "Set1", + "type": "main", + "index": 0 + }, + { + "node": "Set2", + "type": "main", + "index": 0 + }, + { + "node": "Merge4", + "type": "main", + "index": 1 + } + ] + ] + } + } + }, + }, + output: { + nodeExecutionOrder: [ + 'Start', + 'Set1', + 'Set2', + 'Set3', + 'Merge1', + 'Set4', + 'Merge2', + 'Merge3', + 'Merge4', + ], + nodeData: { + Set1: [ + [ + { + value1: 1, + }, + ], + ], + Set2: [ + [ + { + value2: 2, + }, + ], + ], + Set3: [ + [ + { + value1: 1, + value3: 3, + }, + ], + ], + Set4: [ + [ + { + value1: 1, + value3: 3, + value4: 4, + }, + ], + ], + Merge1: [ + [ + { + value1: 1, + }, + { + value2: 2, + }, + ] + ], + Merge2: [ + [ + { + value2: 2, + }, + ] + ], + Merge3: [ + [ + { + value1: 1, + value3: 3, + value4: 4, + }, + { + value2: 2, + }, + ] + ], + Merge4: [ + [ + { + value1: 1, + value3: 3, + value4: 4, + }, + { + value2: 2, + }, + ] + ], + }, + }, + }, + ]; + + + const executionMode = 'manual'; + const nodeTypes = Helpers.NodeTypes(); + + for (const testData of tests) { + test(testData.description, async () => { + + const workflowInstance = new Workflow('test', testData.input.workflowData.nodes, testData.input.workflowData.connections, false, nodeTypes); + + const waitPromise = await createDeferredPromise(); + const nodeExecutionOrder: string[] = []; + const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise, nodeExecutionOrder); + + const workflowExecute = new WorkflowExecute(additionalData, executionMode); + + const executionId = await workflowExecute.run(workflowInstance, undefined); + expect(executionId).toBeDefined(); + + const result = await waitPromise.promise(); + + // Check if the output data of the nodes is correct + for (const nodeName of Object.keys(testData.output.nodeData)) { + if (result.data.resultData.runData[nodeName] === undefined) { + throw new Error(`Data for node "${nodeName}" is missing!`); + } + + const resultData = result.data.resultData.runData[nodeName].map((nodeData) => { + if (nodeData.data === undefined) { + return null; + } + return nodeData.data.main[0]!.map((entry) => entry.json ); + }); + + // expect(resultData).toEqual(testData.output.nodeData[nodeName]); + expect(resultData).toEqual(testData.output.nodeData[nodeName]); + } + + // Check if the nodes did execute in the correct order + expect(nodeExecutionOrder).toEqual(testData.output.nodeExecutionOrder); + + // Check if other data has correct value + expect(result.finished).toEqual(true); + expect(result.data.executionData!.contextData).toEqual({}); + expect(result.data.executionData!.nodeExecutionStack).toEqual([]); + expect(result.data.executionData!.waitingExecution).toEqual({}); + }); + } + + }); + +});