diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 7e556f2210..f1e2cd6027 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -20,6 +20,7 @@ import type { INode, INodeConnections, INodeExecutionData, + IPairedItemData, IPinData, IRun, IRunData, @@ -971,32 +972,35 @@ export class WorkflowExecute { workflowId: workflow.id, }); - if (nodeSuccessData) { - // Check if the output data contains pairedItem data + if (nodeSuccessData?.length) { + // Check if the output data contains pairedItem data and if not try + // to automatically fix it + + const isSingleInputAndOutput = + executionData.data.main.length === 1 && executionData.data.main[0]?.length === 1; + + const isSameNumberOfItems = + nodeSuccessData.length === 1 && + executionData.data.main.length === 1 && + executionData.data.main[0]?.length === nodeSuccessData[0].length; + checkOutputData: for (const outputData of nodeSuccessData) { if (outputData === null) { continue; } for (const [index, item] of outputData.entries()) { - if (!item.pairedItem) { + if (item.pairedItem === undefined) { // The pairedItem data is missing, so check if it can get automatically fixed - if ( - executionData.data.main.length === 1 && - executionData.data.main[0]?.length === 1 - ) { + if (isSingleInputAndOutput) { // The node has one input and one incoming item, so we know // that all items must originate from that single item.pairedItem = { item: 0, }; - } else if ( - nodeSuccessData.length === 1 && - executionData.data.main.length === 1 && - executionData.data.main[0]?.length === nodeSuccessData[0].length - ) { - // The node has one input and one output. The number of items on both is - // identical so we can make the reasonable assumption that each of the input - // items is the origin of the corresponding output items + } else if (isSameNumberOfItems) { + // The number of oncoming and outcoming items is identical so we can + // make the reasonable assumption that each of the input items + // is the origin of the corresponding output items item.pairedItem = { item: index, }; @@ -1018,10 +1022,26 @@ export class WorkflowExecute { if (nodeSuccessData === null || nodeSuccessData[0][0] === undefined) { if (executionData.node.alwaysOutputData === true) { + const pairedItem: IPairedItemData[] = []; + + // Get pairedItem from all input items + executionData.data.main.forEach((inputData, inputIndex) => { + if (!inputData) { + return; + } + inputData.forEach((item, itemIndex) => { + pairedItem.push({ + item: itemIndex, + input: inputIndex, + }); + }); + }); + nodeSuccessData = nodeSuccessData || []; nodeSuccessData[0] = [ { json: {}, + pairedItem, }, ]; } diff --git a/packages/core/test/Helpers.ts b/packages/core/test/Helpers.ts deleted file mode 100644 index 5b8bb76530..0000000000 --- a/packages/core/test/Helpers.ts +++ /dev/null @@ -1,868 +0,0 @@ -import set from 'lodash/set'; - -import type { - ICredentialDataDecryptedObject, - IDeferredPromise, - IExecuteWorkflowInfo, - IHttpRequestHelper, - IHttpRequestOptions, - INode, - INodeCredentialsDetails, - INodeExecutionData, - INodeParameters, - INodeType, - INodeTypeData, - INodeTypes, - IRun, - ITaskData, - IVersionedNodeType, - IWorkflowBase, - IWorkflowExecuteAdditionalData, - NodeParameterValue, -} from 'n8n-workflow'; -import { deepCopy } from 'n8n-workflow'; -import { ICredentialsHelper, NodeHelpers, WorkflowHooks } from 'n8n-workflow'; -import { Credentials } from '@/Credentials'; -import type { IExecuteFunctions } from '@/Interfaces'; - -export class CredentialsHelper extends ICredentialsHelper { - async authenticate( - credentials: ICredentialDataDecryptedObject, - typeName: string, - requestParams: IHttpRequestOptions, - ): Promise { - return requestParams; - } - - async preAuthentication( - helpers: IHttpRequestHelper, - credentials: ICredentialDataDecryptedObject, - typeName: string, - node: INode, - credentialsExpired: boolean, - ): Promise { - return undefined; - } - - getParentTypes(name: string): string[] { - return []; - } - - async getDecrypted( - nodeCredentials: INodeCredentialsDetails, - type: string, - ): Promise { - return {}; - } - - async getCredentials( - nodeCredentials: INodeCredentialsDetails, - type: string, - ): Promise { - return new Credentials({ id: null, name: '' }, '', [], ''); - } - - async updateCredentials( - nodeCredentials: INodeCredentialsDetails, - type: string, - data: ICredentialDataDecryptedObject, - ): Promise {} -} - -class NodeTypesClass implements INodeTypes { - nodeTypes: INodeTypeData = { - 'n8n-nodes-base.if': { - sourcePath: '', - type: { - description: { - displayName: 'If', - name: 'if', - group: ['transform'], - version: 1, - description: 'Splits a stream depending on defined compare operations.', - defaults: { - name: 'IF', - color: '#408000', - }, - inputs: ['main'], - outputs: ['main', 'main'], - properties: [ - { - displayName: 'Conditions', - name: 'conditions', - placeholder: 'Add Condition', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - description: 'The type of values to compare.', - default: {}, - options: [ - { - name: 'boolean', - displayName: 'Boolean', - values: [ - { - displayName: 'Value 1', - name: 'value1', - type: 'boolean', - default: false, - description: 'The value to compare with the second one.', - }, - { - displayName: 'Operation', - name: 'operation', - type: 'options', - options: [ - { - name: 'Equal', - value: 'equal', - }, - { - name: 'Not Equal', - value: 'notEqual', - }, - ], - default: 'equal', - description: 'Operation to decide where the the data should be mapped to.', - }, - { - displayName: 'Value 2', - name: 'value2', - type: 'boolean', - default: false, - description: 'The value to compare with the first one.', - }, - ], - }, - { - name: 'number', - displayName: 'Number', - values: [ - { - displayName: 'Value 1', - name: 'value1', - type: 'number', - default: 0, - description: 'The value to compare with the second one.', - }, - { - displayName: 'Operation', - name: 'operation', - type: 'options', - options: [ - { - name: 'Smaller', - value: 'smaller', - }, - { - name: 'Smaller Equal', - value: 'smallerEqual', - }, - { - name: 'Equal', - value: 'equal', - }, - { - name: 'Not Equal', - value: 'notEqual', - }, - { - name: 'Larger', - value: 'larger', - }, - { - name: 'Larger Equal', - value: 'largerEqual', - }, - { - name: 'Is Empty', - value: 'isEmpty', - }, - ], - default: 'smaller', - description: 'Operation to decide where the the data should be mapped to.', - }, - { - displayName: 'Value 2', - name: 'value2', - type: 'number', - displayOptions: { - hide: { - operation: ['isEmpty'], - }, - }, - default: 0, - description: 'The value to compare with the first one.', - }, - ], - }, - { - name: 'string', - displayName: 'String', - values: [ - { - displayName: 'Value 1', - name: 'value1', - type: 'string', - default: '', - description: 'The value to compare with the second one.', - }, - { - displayName: 'Operation', - name: 'operation', - type: 'options', - options: [ - { - name: 'Contains', - value: 'contains', - }, - { - name: 'Ends With', - value: 'endsWith', - }, - { - name: 'Equal', - value: 'equal', - }, - { - name: 'Not Contains', - value: 'notContains', - }, - { - name: 'Not Equal', - value: 'notEqual', - }, - { - name: 'Regex', - value: 'regex', - }, - { - name: 'Starts With', - value: 'startsWith', - }, - { - name: 'Is Empty', - value: 'isEmpty', - }, - ], - default: 'equal', - description: 'Operation to decide where the the data should be mapped to.', - }, - { - displayName: 'Value 2', - name: 'value2', - type: 'string', - displayOptions: { - hide: { - operation: ['isEmpty', 'regex'], - }, - }, - default: '', - description: 'The value to compare with the first one.', - }, - { - displayName: 'Regex', - name: 'value2', - type: 'string', - displayOptions: { - show: { - operation: ['regex'], - }, - }, - default: '', - placeholder: '/text/i', - description: 'The regex which has to match.', - }, - ], - }, - ], - }, - { - displayName: 'Combine', - name: 'combineOperation', - type: 'options', - options: [ - { - name: 'ALL', - description: 'Only if all conditions are met it goes into "true" branch.', - value: 'all', - }, - { - name: 'ANY', - description: 'If any of the conditions is met it goes into "true" branch.', - value: 'any', - }, - ], - default: 'all', - description: - 'If multiple rules got set this settings decides if it is true as soon as ANY condition matches or only if ALL get meet.', - }, - ], - }, - async execute(this: IExecuteFunctions): Promise { - const returnDataTrue: INodeExecutionData[] = []; - const returnDataFalse: INodeExecutionData[] = []; - - const items = this.getInputData(); - - let item: INodeExecutionData; - let combineOperation: string; - - // The compare operations - const compareOperationFunctions: { - [key: string]: (value1: NodeParameterValue, value2: NodeParameterValue) => boolean; - } = { - contains: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || '').toString().includes((value2 || '').toString()), - notContains: (value1: NodeParameterValue, value2: NodeParameterValue) => - !(value1 || '').toString().includes((value2 || '').toString()), - endsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 as string).endsWith(value2 as string), - equal: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 === value2, - notEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 !== value2, - larger: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || 0) > (value2 || 0), - largerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || 0) >= (value2 || 0), - smaller: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || 0) < (value2 || 0), - smallerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || 0) <= (value2 || 0), - startsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 as string).startsWith(value2 as string), - isEmpty: (value1: NodeParameterValue) => - [undefined, null, ''].includes(value1 as string), - regex: (value1: NodeParameterValue, value2: NodeParameterValue) => { - const regexMatch = (value2 || '') - .toString() - .match(new RegExp('^/(.*?)/([gimusy]*)$')); - - let regex: RegExp; - if (!regexMatch) { - regex = new RegExp((value2 || '').toString()); - } else if (regexMatch.length === 1) { - regex = new RegExp(regexMatch[1]); - } else { - regex = new RegExp(regexMatch[1], regexMatch[2]); - } - - return !!(value1 || '').toString().match(regex); - }, - }; - - // The different dataTypes to check the values in - const dataTypes = ['boolean', 'number', 'string']; - - // Iterate over all items to check which ones should be output as via output "true" and - // which ones via output "false" - let dataType: string; - let compareOperationResult: boolean; - itemLoop: for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { - item = items[itemIndex]; - - let compareData: INodeParameters; - - combineOperation = this.getNodeParameter('combineOperation', itemIndex) as string; - - // Check all the values of the different dataTypes - for (dataType of dataTypes) { - // Check all the values of the current dataType - for (compareData of this.getNodeParameter( - `conditions.${dataType}`, - itemIndex, - [], - ) as INodeParameters[]) { - // Check if the values passes - compareOperationResult = compareOperationFunctions[compareData.operation as string]( - compareData.value1 as NodeParameterValue, - compareData.value2 as NodeParameterValue, - ); - - if (compareOperationResult && combineOperation === 'any') { - // If it passes and the operation is "any" we do not have to check any - // other ones as it should pass anyway. So go on with the next item. - returnDataTrue.push(item); - continue itemLoop; - } else if (!compareOperationResult && combineOperation === 'all') { - // If it fails and the operation is "all" we do not have to check any - // other ones as it should be not pass anyway. So go on with the next item. - returnDataFalse.push(item); - continue itemLoop; - } - } - } - - if (combineOperation === 'all') { - // If the operation is "all" it means the item did match all conditions - // so it passes. - returnDataTrue.push(item); - } else { - // If the operation is "any" it means the the item did not match any condition. - returnDataFalse.push(item); - } - } - - return [returnDataTrue, returnDataFalse]; - }, - }, - }, - 'n8n-nodes-base.merge': { - sourcePath: '', - type: { - 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 contain 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.noOp': { - sourcePath: '', - type: { - description: { - displayName: 'No Operation, do nothing', - name: 'noOp', - icon: 'fa:arrow-right', - group: ['organization'], - version: 1, - description: 'No Operation', - defaults: { - name: 'NoOp', - color: '#b0b0b0', - }, - inputs: ['main'], - outputs: ['main'], - properties: [], - }, - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - return this.prepareOutputData(items); - }, - }, - }, - 'n8n-nodes-base.versionTest': { - sourcePath: '', - type: { - description: { - displayName: 'Version Test', - name: 'versionTest', - group: ['input'], - version: 1, - description: 'Tests if versioning works', - defaults: { - name: 'Version Test', - color: '#0000FF', - }, - inputs: ['main'], - outputs: ['main'], - properties: [ - { - displayName: 'Display V1', - name: 'versionTest', - type: 'number', - displayOptions: { - show: { - '@version': [1], - }, - }, - default: 1, - }, - { - displayName: 'Display V2', - name: 'versionTest', - type: 'number', - displayOptions: { - show: { - '@version': [2], - }, - }, - default: 2, - }, - ], - }, - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - const returnData: INodeExecutionData[] = []; - - for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { - const newItem: INodeExecutionData = { - json: { - versionFromParameter: this.getNodeParameter('versionTest', itemIndex), - versionFromNode: this.getNode().typeVersion, - }, - }; - - returnData.push(newItem); - } - - return this.prepareOutputData(returnData); - }, - }, - }, - 'n8n-nodes-base.set': { - sourcePath: '', - type: { - 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: 'boolean', - displayName: 'Boolean', - 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: 'boolean', - default: false, - description: 'The boolean value to write in the property.', - }, - ], - }, - { - 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.', - }, - ], - }, - { - name: 'string', - displayName: 'String', - 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: 'string', - default: '', - description: 'The string value to write in the property.', - }, - ], - }, - ], - }, - - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - options: [ - { - displayName: 'Dot Notation', - name: 'dotNotation', - type: 'boolean', - default: true, - description: - '

By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.

If that is not intended this can be deactivated, it will then set { "a.b": value } instead.

', - }, - ], - }, - ], - }, - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - - if (items.length === 0) { - items.push({ json: {} }); - } - - const returnData: INodeExecutionData[] = []; - - let item: INodeExecutionData; - let keepOnlySet: boolean; - for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { - keepOnlySet = this.getNodeParameter('keepOnlySet', itemIndex, false) as boolean; - item = items[itemIndex]; - const options = this.getNodeParameter('options', itemIndex, {}); - - const newItem: INodeExecutionData = { - json: {}, - }; - - if (!keepOnlySet) { - if (item.binary !== undefined) { - // Create a shallow copy of the binary data so that the old - // data references which do not get changed still stay behind - // but the incoming data does not get changed. - newItem.binary = {}; - Object.assign(newItem.binary, item.binary); - } - - newItem.json = deepCopy(item.json); - } - - // Add boolean values - (this.getNodeParameter('values.boolean', itemIndex, []) as INodeParameters[]).forEach( - (setItem) => { - if (options.dotNotation === false) { - newItem.json[setItem.name as string] = !!setItem.value; - } else { - set(newItem.json, setItem.name as string, !!setItem.value); - } - }, - ); - - // Add number values - (this.getNodeParameter('values.number', itemIndex, []) as INodeParameters[]).forEach( - (setItem) => { - if (options.dotNotation === false) { - newItem.json[setItem.name as string] = setItem.value; - } else { - set(newItem.json, setItem.name as string, setItem.value); - } - }, - ); - - // Add string values - (this.getNodeParameter('values.string', itemIndex, []) as INodeParameters[]).forEach( - (setItem) => { - if (options.dotNotation === false) { - newItem.json[setItem.name as string] = setItem.value; - } else { - set(newItem.json, setItem.name as string, setItem.value); - } - }, - ); - - returnData.push(newItem); - } - - return this.prepareOutputData(returnData); - }, - }, - }, - 'n8n-nodes-base.start': { - sourcePath: '', - type: { - 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: [], - }, - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - - return this.prepareOutputData(items); - }, - }, - }, - }; - - getByName(nodeType: string): INodeType | IVersionedNodeType { - return this.nodeTypes[nodeType].type; - } - - getByNameAndVersion(nodeType: string, version?: number): INodeType { - return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version); - } -} - -let nodeTypesInstance: NodeTypesClass | undefined; - -export function NodeTypes(): NodeTypesClass { - if (nodeTypesInstance === undefined) { - nodeTypesInstance = new NodeTypesClass(); - } - - return nodeTypesInstance; -} - -export function WorkflowExecuteAdditionalData( - waitPromise: IDeferredPromise, - nodeExecutionOrder: string[], -): IWorkflowExecuteAdditionalData { - const hookFunctions = { - nodeExecuteAfter: [ - async (nodeName: string, data: ITaskData): Promise => { - nodeExecutionOrder.push(nodeName); - }, - ], - workflowExecuteAfter: [ - async (fullRunData: IRun): Promise => { - waitPromise.resolve(fullRunData); - }, - ], - }; - - const workflowData: IWorkflowBase = { - name: '', - createdAt: new Date(), - updatedAt: new Date(), - active: true, - nodes: [], - connections: {}, - }; - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return { - credentialsHelper: new CredentialsHelper(''), - hooks: new WorkflowHooks(hookFunctions, 'trigger', '1', workflowData), - executeWorkflow: async (workflowInfo: IExecuteWorkflowInfo): Promise => {}, - sendMessageToUI: (message: string) => {}, - restApiUrl: '', - encryptionKey: 'test', - timezone: 'America/New_York', - webhookBaseUrl: 'webhook', - webhookWaitingBaseUrl: 'webhook-waiting', - webhookTestBaseUrl: 'webhook-test', - userId: '123', - }; -} diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index f91c605b2c..d16bcf1a65 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -17,7 +17,7 @@ import { getBinaryDataBuffer, proxyRequestToAxios, } from '@/NodeExecuteFunctions'; -import { initLogger } from './utils'; +import { initLogger } from './helpers/utils'; const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n')); diff --git a/packages/core/test/WorkflowExecute.test.ts b/packages/core/test/WorkflowExecute.test.ts index 5c5eb2ba58..7fe484ea35 100644 --- a/packages/core/test/WorkflowExecute.test.ts +++ b/packages/core/test/WorkflowExecute.test.ts @@ -1,9 +1,10 @@ -import type { IConnections, INode, IRun } from 'n8n-workflow'; +import type { IRun, WorkflowTestData } from 'n8n-workflow'; import { createDeferredPromise, Workflow } from 'n8n-workflow'; import { WorkflowExecute } from '@/WorkflowExecute'; -import * as Helpers from './Helpers'; -import { initLogger } from './utils'; +import * as Helpers from './helpers'; +import { initLogger } from './helpers/utils'; +import { predefinedWorkflowExecuteTests } from './helpers/constants'; describe('WorkflowExecute', () => { beforeAll(() => { @@ -11,1344 +12,7 @@ describe('WorkflowExecute', () => { }); describe('run', () => { - const tests: Array<{ - description: string; - input: { - workflowData: { - nodes: INode[]; - connections: IConnections; - }; - }; - output: { - nodeExecutionOrder: string[]; - nodeData: { - [key: string]: 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: [ - { - id: 'uuid-1', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [100, 300], - }, - { - id: 'uuid-2', - 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: [ - { - id: 'uuid-1', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [100, 300], - }, - { - id: 'uuid-2', - parameters: { - values: { - number: [ - { - name: 'value1', - value: 1, - }, - ], - }, - }, - name: 'Set1', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [300, 250], - }, - { - id: 'uuid-3', - 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: [ - { - id: 'uuid-1', - parameters: { - mode: 'passThrough', - }, - name: 'Merge4', - type: 'n8n-nodes-base.merge', - typeVersion: 1, - position: [1150, 500], - }, - { - id: 'uuid-2', - parameters: { - values: { - number: [ - { - name: 'value2', - value: 2, - }, - ], - }, - }, - name: 'Set2', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [290, 400], - }, - { - id: 'uuid-3', - parameters: { - values: { - number: [ - { - name: 'value4', - value: 4, - }, - ], - }, - }, - name: 'Set4', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [850, 200], - }, - { - id: 'uuid-4', - parameters: { - values: { - number: [ - { - name: 'value3', - value: 3, - }, - ], - }, - }, - name: 'Set3', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [650, 200], - }, - { - id: 'uuid-5', - parameters: { - mode: 'passThrough', - }, - name: 'Merge4', - type: 'n8n-nodes-base.merge', - typeVersion: 1, - position: [1150, 500], - }, - { - id: 'uuid-6', - parameters: {}, - name: 'Merge3', - type: 'n8n-nodes-base.merge', - typeVersion: 1, - position: [1000, 400], - }, - { - id: 'uuid-7', - parameters: { - mode: 'passThrough', - output: 'input2', - }, - name: 'Merge2', - type: 'n8n-nodes-base.merge', - typeVersion: 1, - position: [700, 400], - }, - { - id: 'uuid-8', - parameters: {}, - name: 'Merge1', - type: 'n8n-nodes-base.merge', - typeVersion: 1, - position: [500, 300], - }, - { - id: 'uuid-9', - parameters: { - values: { - number: [ - { - name: 'value1', - value: 1, - }, - ], - }, - }, - name: 'Set1', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [300, 200], - }, - { - id: 'uuid-10', - 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, - }, - ], - ], - }, - }, - }, - { - description: - 'should run workflow also if node has multiple input connections and one is empty', - 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, - id: 'uuid-1', - position: [250, 450], - }, - { - parameters: { - conditions: { - boolean: [], - number: [ - { - value1: '={{Object.keys($json).length}}', - operation: 'notEqual', - }, - ], - }, - }, - name: 'IF', - type: 'n8n-nodes-base.if', - typeVersion: 1, - id: 'uuid-2', - position: [650, 350], - }, - { - parameters: {}, - name: 'Merge1', - type: 'n8n-nodes-base.merge', - typeVersion: 1, - id: 'uuid-3', - position: [1150, 450], - }, - { - parameters: { - values: { - string: [ - { - name: 'test1', - value: 'a', - }, - ], - }, - options: {}, - }, - name: 'Set1', - type: 'n8n-nodes-base.set', - typeVersion: 1, - id: 'uuid-4', - position: [450, 450], - }, - { - parameters: { - values: { - string: [ - { - name: 'test2', - value: 'b', - }, - ], - }, - options: {}, - }, - name: 'Set2', - type: 'n8n-nodes-base.set', - typeVersion: 1, - id: 'uuid-1', - position: [800, 250], - }, - ], - connections: { - Start: { - main: [ - [ - { - node: 'Set1', - type: 'main', - index: 0, - }, - ], - ], - }, - IF: { - main: [ - [ - { - node: 'Set2', - type: 'main', - index: 0, - }, - ], - [ - { - node: 'Merge1', - type: 'main', - index: 0, - }, - ], - ], - }, - Set1: { - main: [ - [ - { - node: 'IF', - type: 'main', - index: 0, - }, - { - node: 'Merge1', - type: 'main', - index: 1, - }, - ], - ], - }, - Set2: { - main: [ - [ - { - node: 'Merge1', - type: 'main', - index: 0, - }, - ], - ], - }, - }, - }, - }, - output: { - nodeExecutionOrder: ['Start', 'Set1', 'IF', 'Set2', 'Merge1'], - nodeData: { - Merge1: [ - [ - { - test1: 'a', - test2: 'b', - }, - { - test1: 'a', - }, - ], - ], - }, - }, - }, - { - description: 'should use empty data if second input does not have any data', - input: { - // Leave the workflowData in regular JSON to be able to easily - // copy it from/in the UI - workflowData: { - nodes: [ - { - id: 'uuid-1', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [250, 300], - }, - { - id: 'uuid-2', - parameters: {}, - name: 'Merge', - type: 'n8n-nodes-base.merge', - typeVersion: 1, - position: [800, 450], - }, - { - id: 'uuid-3', - parameters: {}, - name: 'Merge1', - type: 'n8n-nodes-base.merge', - typeVersion: 1, - position: [1000, 300], - }, - { - id: 'uuid-4', - parameters: { - conditions: { - boolean: [ - { - value2: true, - }, - ], - string: [ - { - value1: '={{$json["key"]}}', - value2: 'a', - }, - ], - }, - combineOperation: 'any', - }, - name: 'IF', - type: 'n8n-nodes-base.if', - typeVersion: 1, - position: [600, 600], - alwaysOutputData: false, - }, - { - id: 'uuid-5', - parameters: { - values: { - number: [ - { - name: 'number0', - }, - ], - string: [ - { - name: 'key', - value: 'a', - }, - ], - }, - options: {}, - }, - name: 'Set0', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [450, 300], - }, - { - id: 'uuid-6', - parameters: { - values: { - number: [ - { - name: 'number1', - value: 1, - }, - ], - string: [ - { - name: 'key', - value: 'b', - }, - ], - }, - options: {}, - }, - name: 'Set1', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [450, 450], - }, - { - id: 'uuid-7', - parameters: { - values: { - number: [ - { - name: 'number2', - value: 2, - }, - ], - string: [ - { - name: 'key', - value: 'c', - }, - ], - }, - options: {}, - }, - name: 'Set2', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [450, 600], - }, - ], - connections: { - Start: { - main: [ - [ - { - node: 'Set0', - type: 'main', - index: 0, - }, - ], - ], - }, - Merge: { - main: [ - [ - { - node: 'Merge1', - type: 'main', - index: 1, - }, - ], - ], - }, - IF: { - main: [ - [ - { - node: 'Merge', - type: 'main', - index: 1, - }, - ], - ], - }, - Set0: { - main: [ - [ - { - node: 'Merge1', - type: 'main', - index: 0, - }, - ], - ], - }, - Set1: { - main: [ - [ - { - node: 'Merge', - type: 'main', - index: 0, - }, - ], - ], - }, - Set2: { - main: [ - [ - { - node: 'IF', - type: 'main', - index: 0, - }, - ], - ], - }, - }, - }, - }, - output: { - nodeExecutionOrder: ['Start', 'Set0', 'Set2', 'IF', 'Set1', 'Merge', 'Merge1'], - nodeData: { - Merge: [ - [ - { - number1: 1, - key: 'b', - }, - ], - ], - Merge1: [ - [ - { - number0: 0, - key: 'a', - }, - { - number1: 1, - key: 'b', - }, - ], - ], - }, - }, - }, - { - description: - 'should use empty data if input of sibling does not receive any data from parent', - input: { - // Leave the workflowData in regular JSON to be able to easily - // copy it from/in the UI - workflowData: { - nodes: [ - { - id: 'uuid-1', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [250, 300], - }, - { - id: 'uuid-2', - parameters: { - conditions: { - number: [ - { - value1: '={{$json["value1"]}}', - operation: 'equal', - value2: 1, - }, - ], - }, - }, - name: 'IF', - type: 'n8n-nodes-base.if', - typeVersion: 1, - position: [650, 300], - }, - { - id: 'uuid-3', - parameters: { - values: { - string: [], - number: [ - { - name: 'value2', - value: 2, - }, - ], - }, - options: {}, - }, - name: 'Set2', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [850, 450], - }, - { - id: 'uuid-4', - parameters: { - values: { - number: [ - { - name: 'value1', - value: 1, - }, - ], - }, - options: {}, - }, - name: 'Set1', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [450, 300], - }, - { - id: 'uuid-5', - parameters: {}, - name: 'Merge', - type: 'n8n-nodes-base.merge', - typeVersion: 1, - position: [1050, 300], - }, - ], - connections: { - Start: { - main: [ - [ - { - node: 'Set1', - type: 'main', - index: 0, - }, - ], - ], - }, - IF: { - main: [ - [ - { - node: 'Merge', - type: 'main', - index: 0, - }, - ], - [ - { - node: 'Set2', - type: 'main', - index: 0, - }, - ], - ], - }, - Set2: { - main: [ - [ - { - node: 'Merge', - type: 'main', - index: 1, - }, - ], - ], - }, - Set1: { - main: [ - [ - { - node: 'IF', - type: 'main', - index: 0, - }, - ], - ], - }, - }, - }, - }, - output: { - nodeExecutionOrder: ['Start', 'Set1', 'IF', 'Set2', 'Merge'], - nodeData: { - Merge: [ - [ - { - value1: 1, - }, - { - value2: 2, - }, - ], - ], - }, - }, - }, - { - description: 'should not use empty data in sibling if parent did not send any data', - input: { - // Leave the workflowData in regular JSON to be able to easily - // copy it from/in the UI - workflowData: { - nodes: [ - { - id: 'uuid-1', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [250, 300], - }, - { - id: 'uuid-2', - parameters: { - values: { - number: [ - { - name: 'value1', - }, - ], - }, - options: {}, - }, - name: 'Set', - type: 'n8n-nodes-base.set', - typeVersion: 1, - position: [450, 300], - }, - { - id: 'uuid-3', - parameters: {}, - name: 'Merge', - type: 'n8n-nodes-base.merge', - typeVersion: 1, - position: [1050, 250], - }, - { - id: 'uuid-4', - parameters: { - conditions: { - number: [ - { - value1: '={{$json["value1"]}}', - operation: 'equal', - value2: 1, - }, - ], - }, - }, - name: 'IF', - type: 'n8n-nodes-base.if', - typeVersion: 1, - position: [650, 300], - }, - { - id: 'uuid-5', - parameters: {}, - name: 'NoOpTrue', - type: 'n8n-nodes-base.noOp', - typeVersion: 1, - position: [850, 150], - }, - { - id: 'uuid-6', - parameters: {}, - name: 'NoOpFalse', - type: 'n8n-nodes-base.noOp', - typeVersion: 1, - position: [850, 400], - }, - ], - connections: { - Start: { - main: [ - [ - { - node: 'Set', - type: 'main', - index: 0, - }, - ], - ], - }, - Set: { - main: [ - [ - { - node: 'IF', - type: 'main', - index: 0, - }, - ], - ], - }, - IF: { - main: [ - [ - { - node: 'NoOpTrue', - type: 'main', - index: 0, - }, - { - node: 'Merge', - type: 'main', - index: 1, - }, - ], - [ - { - node: 'NoOpFalse', - type: 'main', - index: 0, - }, - ], - ], - }, - NoOpTrue: { - main: [ - [ - { - node: 'Merge', - type: 'main', - index: 0, - }, - ], - ], - }, - }, - }, - }, - output: { - nodeExecutionOrder: ['Start', 'Set', 'IF', 'NoOpFalse'], - nodeData: { - IF: [[]], - NoOpFalse: [ - [ - { - value1: 0, - }, - ], - ], - }, - }, - }, - - { - description: - 'should display the correct parameters and so correct data when simplified node-versioning is used', - input: { - workflowData: { - nodes: [ - { - id: 'uuid-1', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], - }, - { - id: 'uuid-2', - parameters: {}, - name: 'VersionTest1a', - type: 'n8n-nodes-base.versionTest', - typeVersion: 1, - position: [460, 300], - }, - { - id: 'uuid-3', - parameters: { - versionTest: 11, - }, - name: 'VersionTest1b', - type: 'n8n-nodes-base.versionTest', - typeVersion: 1, - position: [680, 300], - }, - { - id: 'uuid-4', - parameters: {}, - name: 'VersionTest2a', - type: 'n8n-nodes-base.versionTest', - typeVersion: 2, - position: [880, 300], - }, - { - id: 'uuid-5', - parameters: { - versionTest: 22, - }, - name: 'VersionTest2b', - type: 'n8n-nodes-base.versionTest', - typeVersion: 2, - position: [1080, 300], - }, - ], - connections: { - Start: { - main: [ - [ - { - node: 'VersionTest1a', - type: 'main', - index: 0, - }, - ], - ], - }, - VersionTest1a: { - main: [ - [ - { - node: 'VersionTest1b', - type: 'main', - index: 0, - }, - ], - ], - }, - VersionTest1b: { - main: [ - [ - { - node: 'VersionTest2a', - type: 'main', - index: 0, - }, - ], - ], - }, - VersionTest2a: { - main: [ - [ - { - node: 'VersionTest2b', - type: 'main', - index: 0, - }, - ], - ], - }, - }, - }, - }, - output: { - nodeExecutionOrder: [ - 'Start', - 'VersionTest1a', - 'VersionTest1b', - 'VersionTest2a', - 'VersionTest2b', - ], - nodeData: { - VersionTest1a: [ - [ - { - versionFromNode: 1, - versionFromParameter: 1, - }, - ], - ], - VersionTest1b: [ - [ - { - versionFromNode: 1, - versionFromParameter: 11, - }, - ], - ], - VersionTest2a: [ - [ - { - versionFromNode: 2, - versionFromParameter: 2, - }, - ], - ], - VersionTest2b: [ - [ - { - versionFromNode: 2, - versionFromParameter: 22, - }, - ], - ], - }, - }, - }, - ]; + const tests: WorkflowTestData[] = predefinedWorkflowExecuteTests; const executionMode = 'manual'; const nodeTypes = Helpers.NodeTypes(); @@ -1407,4 +71,63 @@ describe('WorkflowExecute', () => { }); } }); + + //run tests on json files from specified directory, default 'workflows' + //workflows must have pinned data that would be used to test output after execution + describe('run test workflows', () => { + const tests: WorkflowTestData[] = Helpers.workflowToTests(__dirname); + + const executionMode = 'manual'; + const nodeTypes = Helpers.NodeTypes(Helpers.getNodeTypes(tests)); + + for (const testData of tests) { + test(testData.description, async () => { + const workflowInstance = new Workflow({ + id: 'test', + nodes: testData.input.workflowData.nodes, + connections: testData.input.workflowData.connections, + active: false, + nodeTypes, + }); + + const waitPromise = await createDeferredPromise(); + const nodeExecutionOrder: string[] = []; + const additionalData = Helpers.WorkflowExecuteAdditionalData( + waitPromise, + nodeExecutionOrder, + ); + + const workflowExecute = new WorkflowExecute(additionalData, executionMode); + + const executionData = await workflowExecute.run(workflowInstance); + + const result = await waitPromise.promise(); + + // Check if the data from WorkflowExecute is identical to data received + // by the webhooks + expect(executionData).toEqual(result); + + // 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]; + }); + + expect(resultData).toEqual(testData.output.nodeData[nodeName]); + } + + // Check if other data has correct value + expect(result.finished).toEqual(true); + // expect(result.data.executionData!.contextData).toEqual({}); //Fails when test workflow Includes splitInbatches + expect(result.data.executionData!.nodeExecutionStack).toEqual([]); + }); + } + }); }); diff --git a/packages/core/test/helpers/constants.ts b/packages/core/test/helpers/constants.ts new file mode 100644 index 0000000000..9a38c8cddf --- /dev/null +++ b/packages/core/test/helpers/constants.ts @@ -0,0 +1,2067 @@ +import set from 'lodash/set'; + +import type { + INodeExecutionData, + INodeParameters, + INodeTypeData, + NodeParameterValue, + WorkflowTestData, +} from 'n8n-workflow'; +import { deepCopy } from 'n8n-workflow'; + +import type { IExecuteFunctions } from '@/Interfaces'; + +export const predefinedNodesTypes: INodeTypeData = { + 'n8n-nodes-base.if': { + sourcePath: '', + type: { + description: { + displayName: 'If', + name: 'if', + group: ['transform'], + version: 1, + description: 'Splits a stream depending on defined compare operations.', + defaults: { + name: 'IF', + color: '#408000', + }, + inputs: ['main'], + outputs: ['main', 'main'], + properties: [ + { + displayName: 'Conditions', + name: 'conditions', + placeholder: 'Add Condition', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + description: 'The type of values to compare.', + default: {}, + options: [ + { + name: 'boolean', + displayName: 'Boolean', + values: [ + { + displayName: 'Value 1', + name: 'value1', + type: 'boolean', + default: false, + description: 'The value to compare with the second one.', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Equal', + value: 'equal', + }, + { + name: 'Not Equal', + value: 'notEqual', + }, + ], + default: 'equal', + description: 'Operation to decide where the the data should be mapped to.', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'boolean', + default: false, + description: 'The value to compare with the first one.', + }, + ], + }, + { + name: 'number', + displayName: 'Number', + values: [ + { + displayName: 'Value 1', + name: 'value1', + type: 'number', + default: 0, + description: 'The value to compare with the second one.', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Smaller', + value: 'smaller', + }, + { + name: 'Smaller Equal', + value: 'smallerEqual', + }, + { + name: 'Equal', + value: 'equal', + }, + { + name: 'Not Equal', + value: 'notEqual', + }, + { + name: 'Larger', + value: 'larger', + }, + { + name: 'Larger Equal', + value: 'largerEqual', + }, + { + name: 'Is Empty', + value: 'isEmpty', + }, + ], + default: 'smaller', + description: 'Operation to decide where the the data should be mapped to.', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'number', + displayOptions: { + hide: { + operation: ['isEmpty'], + }, + }, + default: 0, + description: 'The value to compare with the first one.', + }, + ], + }, + { + name: 'string', + displayName: 'String', + values: [ + { + displayName: 'Value 1', + name: 'value1', + type: 'string', + default: '', + description: 'The value to compare with the second one.', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Contains', + value: 'contains', + }, + { + name: 'Ends With', + value: 'endsWith', + }, + { + name: 'Equal', + value: 'equal', + }, + { + name: 'Not Contains', + value: 'notContains', + }, + { + name: 'Not Equal', + value: 'notEqual', + }, + { + name: 'Regex', + value: 'regex', + }, + { + name: 'Starts With', + value: 'startsWith', + }, + { + name: 'Is Empty', + value: 'isEmpty', + }, + ], + default: 'equal', + description: 'Operation to decide where the the data should be mapped to.', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'string', + displayOptions: { + hide: { + operation: ['isEmpty', 'regex'], + }, + }, + default: '', + description: 'The value to compare with the first one.', + }, + { + displayName: 'Regex', + name: 'value2', + type: 'string', + displayOptions: { + show: { + operation: ['regex'], + }, + }, + default: '', + placeholder: '/text/i', + description: 'The regex which has to match.', + }, + ], + }, + ], + }, + { + displayName: 'Combine', + name: 'combineOperation', + type: 'options', + options: [ + { + name: 'ALL', + description: 'Only if all conditions are met it goes into "true" branch.', + value: 'all', + }, + { + name: 'ANY', + description: 'If any of the conditions is met it goes into "true" branch.', + value: 'any', + }, + ], + default: 'all', + description: + 'If multiple rules got set this settings decides if it is true as soon as ANY condition matches or only if ALL get meet.', + }, + ], + }, + async execute(this: IExecuteFunctions): Promise { + const returnDataTrue: INodeExecutionData[] = []; + const returnDataFalse: INodeExecutionData[] = []; + + const items = this.getInputData(); + + let item: INodeExecutionData; + let combineOperation: string; + + // The compare operations + const compareOperationFunctions: { + [key: string]: (value1: NodeParameterValue, value2: NodeParameterValue) => boolean; + } = { + contains: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || '').toString().includes((value2 || '').toString()), + notContains: (value1: NodeParameterValue, value2: NodeParameterValue) => + !(value1 || '').toString().includes((value2 || '').toString()), + endsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 as string).endsWith(value2 as string), + equal: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 === value2, + notEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 !== value2, + larger: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) > (value2 || 0), + largerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) >= (value2 || 0), + smaller: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) < (value2 || 0), + smallerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) <= (value2 || 0), + startsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 as string).startsWith(value2 as string), + isEmpty: (value1: NodeParameterValue) => [undefined, null, ''].includes(value1 as string), + regex: (value1: NodeParameterValue, value2: NodeParameterValue) => { + const regexMatch = (value2 || '').toString().match(new RegExp('^/(.*?)/([gimusy]*)$')); + + let regex: RegExp; + if (!regexMatch) { + regex = new RegExp((value2 || '').toString()); + } else if (regexMatch.length === 1) { + regex = new RegExp(regexMatch[1]); + } else { + regex = new RegExp(regexMatch[1], regexMatch[2]); + } + + return !!(value1 || '').toString().match(regex); + }, + }; + + // The different dataTypes to check the values in + const dataTypes = ['boolean', 'number', 'string']; + + // Iterate over all items to check which ones should be output as via output "true" and + // which ones via output "false" + let dataType: string; + let compareOperationResult: boolean; + itemLoop: for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + item = items[itemIndex]; + + let compareData: INodeParameters; + + combineOperation = this.getNodeParameter('combineOperation', itemIndex) as string; + + // Check all the values of the different dataTypes + for (dataType of dataTypes) { + // Check all the values of the current dataType + for (compareData of this.getNodeParameter( + `conditions.${dataType}`, + itemIndex, + [], + ) as INodeParameters[]) { + // Check if the values passes + compareOperationResult = compareOperationFunctions[compareData.operation as string]( + compareData.value1 as NodeParameterValue, + compareData.value2 as NodeParameterValue, + ); + + if (compareOperationResult && combineOperation === 'any') { + // If it passes and the operation is "any" we do not have to check any + // other ones as it should pass anyway. So go on with the next item. + returnDataTrue.push(item); + continue itemLoop; + } else if (!compareOperationResult && combineOperation === 'all') { + // If it fails and the operation is "all" we do not have to check any + // other ones as it should be not pass anyway. So go on with the next item. + returnDataFalse.push(item); + continue itemLoop; + } + } + } + + if (combineOperation === 'all') { + // If the operation is "all" it means the item did match all conditions + // so it passes. + returnDataTrue.push(item); + } else { + // If the operation is "any" it means the the item did not match any condition. + returnDataFalse.push(item); + } + } + + return [returnDataTrue, returnDataFalse]; + }, + }, + }, + 'n8n-nodes-base.merge': { + sourcePath: '', + type: { + 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 contain 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.noOp': { + sourcePath: '', + type: { + description: { + displayName: 'No Operation, do nothing', + name: 'noOp', + icon: 'fa:arrow-right', + group: ['organization'], + version: 1, + description: 'No Operation', + defaults: { + name: 'NoOp', + color: '#b0b0b0', + }, + inputs: ['main'], + outputs: ['main'], + properties: [], + }, + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + return this.prepareOutputData(items); + }, + }, + }, + 'n8n-nodes-base.versionTest': { + sourcePath: '', + type: { + description: { + displayName: 'Version Test', + name: 'versionTest', + group: ['input'], + version: 1, + description: 'Tests if versioning works', + defaults: { + name: 'Version Test', + color: '#0000FF', + }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { + displayName: 'Display V1', + name: 'versionTest', + type: 'number', + displayOptions: { + show: { + '@version': [1], + }, + }, + default: 1, + }, + { + displayName: 'Display V2', + name: 'versionTest', + type: 'number', + displayOptions: { + show: { + '@version': [2], + }, + }, + default: 2, + }, + ], + }, + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + const newItem: INodeExecutionData = { + json: { + versionFromParameter: this.getNodeParameter('versionTest', itemIndex), + versionFromNode: this.getNode().typeVersion, + }, + }; + + returnData.push(newItem); + } + + return this.prepareOutputData(returnData); + }, + }, + }, + 'n8n-nodes-base.set': { + sourcePath: '', + type: { + 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: 'boolean', + displayName: 'Boolean', + 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: 'boolean', + default: false, + description: 'The boolean value to write in the property.', + }, + ], + }, + { + 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.', + }, + ], + }, + { + name: 'string', + displayName: 'String', + 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: 'string', + default: '', + description: 'The string value to write in the property.', + }, + ], + }, + ], + }, + + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Dot Notation', + name: 'dotNotation', + type: 'boolean', + default: true, + description: + '

By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.

If that is not intended this can be deactivated, it will then set { "a.b": value } instead.

', + }, + ], + }, + ], + }, + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + if (items.length === 0) { + items.push({ json: {} }); + } + + const returnData: INodeExecutionData[] = []; + + let item: INodeExecutionData; + let keepOnlySet: boolean; + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + keepOnlySet = this.getNodeParameter('keepOnlySet', itemIndex, false) as boolean; + item = items[itemIndex]; + const options = this.getNodeParameter('options', itemIndex, {}); + + const newItem: INodeExecutionData = { + json: {}, + }; + + if (!keepOnlySet) { + if (item.binary !== undefined) { + // Create a shallow copy of the binary data so that the old + // data references which do not get changed still stay behind + // but the incoming data does not get changed. + newItem.binary = {}; + Object.assign(newItem.binary, item.binary); + } + + newItem.json = deepCopy(item.json); + } + + // Add boolean values + (this.getNodeParameter('values.boolean', itemIndex, []) as INodeParameters[]).forEach( + (setItem) => { + if (options.dotNotation === false) { + newItem.json[setItem.name as string] = !!setItem.value; + } else { + set(newItem.json, setItem.name as string, !!setItem.value); + } + }, + ); + + // Add number values + (this.getNodeParameter('values.number', itemIndex, []) as INodeParameters[]).forEach( + (setItem) => { + if (options.dotNotation === false) { + newItem.json[setItem.name as string] = setItem.value; + } else { + set(newItem.json, setItem.name as string, setItem.value); + } + }, + ); + + // Add string values + (this.getNodeParameter('values.string', itemIndex, []) as INodeParameters[]).forEach( + (setItem) => { + if (options.dotNotation === false) { + newItem.json[setItem.name as string] = setItem.value; + } else { + set(newItem.json, setItem.name as string, setItem.value); + } + }, + ); + + returnData.push(newItem); + } + + return this.prepareOutputData(returnData); + }, + }, + }, + 'n8n-nodes-base.start': { + sourcePath: '', + type: { + 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: [], + }, + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + return this.prepareOutputData(items); + }, + }, + }, +}; + +export const predefinedWorkflowExecuteTests: WorkflowTestData[] = [ + { + 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: [ + { + id: 'uuid-1', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 300], + }, + { + id: 'uuid-2', + 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: [ + { + id: 'uuid-1', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 300], + }, + { + id: 'uuid-2', + parameters: { + values: { + number: [ + { + name: 'value1', + value: 1, + }, + ], + }, + }, + name: 'Set1', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [300, 250], + }, + { + id: 'uuid-3', + 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: [ + { + id: 'uuid-1', + parameters: { + mode: 'passThrough', + }, + name: 'Merge4', + type: 'n8n-nodes-base.merge', + typeVersion: 1, + position: [1150, 500], + }, + { + id: 'uuid-2', + parameters: { + values: { + number: [ + { + name: 'value2', + value: 2, + }, + ], + }, + }, + name: 'Set2', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [290, 400], + }, + { + id: 'uuid-3', + parameters: { + values: { + number: [ + { + name: 'value4', + value: 4, + }, + ], + }, + }, + name: 'Set4', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [850, 200], + }, + { + id: 'uuid-4', + parameters: { + values: { + number: [ + { + name: 'value3', + value: 3, + }, + ], + }, + }, + name: 'Set3', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [650, 200], + }, + { + id: 'uuid-5', + parameters: { + mode: 'passThrough', + }, + name: 'Merge4', + type: 'n8n-nodes-base.merge', + typeVersion: 1, + position: [1150, 500], + }, + { + id: 'uuid-6', + parameters: {}, + name: 'Merge3', + type: 'n8n-nodes-base.merge', + typeVersion: 1, + position: [1000, 400], + }, + { + id: 'uuid-7', + parameters: { + mode: 'passThrough', + output: 'input2', + }, + name: 'Merge2', + type: 'n8n-nodes-base.merge', + typeVersion: 1, + position: [700, 400], + }, + { + id: 'uuid-8', + parameters: {}, + name: 'Merge1', + type: 'n8n-nodes-base.merge', + typeVersion: 1, + position: [500, 300], + }, + { + id: 'uuid-9', + parameters: { + values: { + number: [ + { + name: 'value1', + value: 1, + }, + ], + }, + }, + name: 'Set1', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [300, 200], + }, + { + id: 'uuid-10', + 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, + }, + ], + ], + }, + }, + }, + { + description: 'should run workflow also if node has multiple input connections and one is empty', + 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, + id: 'uuid-1', + position: [250, 450], + }, + { + parameters: { + conditions: { + boolean: [], + number: [ + { + value1: '={{Object.keys($json).length}}', + operation: 'notEqual', + }, + ], + }, + }, + name: 'IF', + type: 'n8n-nodes-base.if', + typeVersion: 1, + id: 'uuid-2', + position: [650, 350], + }, + { + parameters: {}, + name: 'Merge1', + type: 'n8n-nodes-base.merge', + typeVersion: 1, + id: 'uuid-3', + position: [1150, 450], + }, + { + parameters: { + values: { + string: [ + { + name: 'test1', + value: 'a', + }, + ], + }, + options: {}, + }, + name: 'Set1', + type: 'n8n-nodes-base.set', + typeVersion: 1, + id: 'uuid-4', + position: [450, 450], + }, + { + parameters: { + values: { + string: [ + { + name: 'test2', + value: 'b', + }, + ], + }, + options: {}, + }, + name: 'Set2', + type: 'n8n-nodes-base.set', + typeVersion: 1, + id: 'uuid-1', + position: [800, 250], + }, + ], + connections: { + Start: { + main: [ + [ + { + node: 'Set1', + type: 'main', + index: 0, + }, + ], + ], + }, + IF: { + main: [ + [ + { + node: 'Set2', + type: 'main', + index: 0, + }, + ], + [ + { + node: 'Merge1', + type: 'main', + index: 0, + }, + ], + ], + }, + Set1: { + main: [ + [ + { + node: 'IF', + type: 'main', + index: 0, + }, + { + node: 'Merge1', + type: 'main', + index: 1, + }, + ], + ], + }, + Set2: { + main: [ + [ + { + node: 'Merge1', + type: 'main', + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start', 'Set1', 'IF', 'Set2', 'Merge1'], + nodeData: { + Merge1: [ + [ + { + test1: 'a', + test2: 'b', + }, + { + test1: 'a', + }, + ], + ], + }, + }, + }, + { + description: 'should use empty data if second input does not have any data', + input: { + // Leave the workflowData in regular JSON to be able to easily + // copy it from/in the UI + workflowData: { + nodes: [ + { + id: 'uuid-1', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [250, 300], + }, + { + id: 'uuid-2', + parameters: {}, + name: 'Merge', + type: 'n8n-nodes-base.merge', + typeVersion: 1, + position: [800, 450], + }, + { + id: 'uuid-3', + parameters: {}, + name: 'Merge1', + type: 'n8n-nodes-base.merge', + typeVersion: 1, + position: [1000, 300], + }, + { + id: 'uuid-4', + parameters: { + conditions: { + boolean: [ + { + value2: true, + }, + ], + string: [ + { + value1: '={{$json["key"]}}', + value2: 'a', + }, + ], + }, + combineOperation: 'any', + }, + name: 'IF', + type: 'n8n-nodes-base.if', + typeVersion: 1, + position: [600, 600], + alwaysOutputData: false, + }, + { + id: 'uuid-5', + parameters: { + values: { + number: [ + { + name: 'number0', + }, + ], + string: [ + { + name: 'key', + value: 'a', + }, + ], + }, + options: {}, + }, + name: 'Set0', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [450, 300], + }, + { + id: 'uuid-6', + parameters: { + values: { + number: [ + { + name: 'number1', + value: 1, + }, + ], + string: [ + { + name: 'key', + value: 'b', + }, + ], + }, + options: {}, + }, + name: 'Set1', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [450, 450], + }, + { + id: 'uuid-7', + parameters: { + values: { + number: [ + { + name: 'number2', + value: 2, + }, + ], + string: [ + { + name: 'key', + value: 'c', + }, + ], + }, + options: {}, + }, + name: 'Set2', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [450, 600], + }, + ], + connections: { + Start: { + main: [ + [ + { + node: 'Set0', + type: 'main', + index: 0, + }, + ], + ], + }, + Merge: { + main: [ + [ + { + node: 'Merge1', + type: 'main', + index: 1, + }, + ], + ], + }, + IF: { + main: [ + [ + { + node: 'Merge', + type: 'main', + index: 1, + }, + ], + ], + }, + Set0: { + main: [ + [ + { + node: 'Merge1', + type: 'main', + index: 0, + }, + ], + ], + }, + Set1: { + main: [ + [ + { + node: 'Merge', + type: 'main', + index: 0, + }, + ], + ], + }, + Set2: { + main: [ + [ + { + node: 'IF', + type: 'main', + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start', 'Set0', 'Set2', 'IF', 'Set1', 'Merge', 'Merge1'], + nodeData: { + Merge: [ + [ + { + number1: 1, + key: 'b', + }, + ], + ], + Merge1: [ + [ + { + number0: 0, + key: 'a', + }, + { + number1: 1, + key: 'b', + }, + ], + ], + }, + }, + }, + { + description: 'should use empty data if input of sibling does not receive any data from parent', + input: { + // Leave the workflowData in regular JSON to be able to easily + // copy it from/in the UI + workflowData: { + nodes: [ + { + id: 'uuid-1', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [250, 300], + }, + { + id: 'uuid-2', + parameters: { + conditions: { + number: [ + { + value1: '={{$json["value1"]}}', + operation: 'equal', + value2: 1, + }, + ], + }, + }, + name: 'IF', + type: 'n8n-nodes-base.if', + typeVersion: 1, + position: [650, 300], + }, + { + id: 'uuid-3', + parameters: { + values: { + string: [], + number: [ + { + name: 'value2', + value: 2, + }, + ], + }, + options: {}, + }, + name: 'Set2', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [850, 450], + }, + { + id: 'uuid-4', + parameters: { + values: { + number: [ + { + name: 'value1', + value: 1, + }, + ], + }, + options: {}, + }, + name: 'Set1', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [450, 300], + }, + { + id: 'uuid-5', + parameters: {}, + name: 'Merge', + type: 'n8n-nodes-base.merge', + typeVersion: 1, + position: [1050, 300], + }, + ], + connections: { + Start: { + main: [ + [ + { + node: 'Set1', + type: 'main', + index: 0, + }, + ], + ], + }, + IF: { + main: [ + [ + { + node: 'Merge', + type: 'main', + index: 0, + }, + ], + [ + { + node: 'Set2', + type: 'main', + index: 0, + }, + ], + ], + }, + Set2: { + main: [ + [ + { + node: 'Merge', + type: 'main', + index: 1, + }, + ], + ], + }, + Set1: { + main: [ + [ + { + node: 'IF', + type: 'main', + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start', 'Set1', 'IF', 'Set2', 'Merge'], + nodeData: { + Merge: [ + [ + { + value1: 1, + }, + { + value2: 2, + }, + ], + ], + }, + }, + }, + { + description: 'should not use empty data in sibling if parent did not send any data', + input: { + // Leave the workflowData in regular JSON to be able to easily + // copy it from/in the UI + workflowData: { + nodes: [ + { + id: 'uuid-1', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [250, 300], + }, + { + id: 'uuid-2', + parameters: { + values: { + number: [ + { + name: 'value1', + }, + ], + }, + options: {}, + }, + name: 'Set', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [450, 300], + }, + { + id: 'uuid-3', + parameters: {}, + name: 'Merge', + type: 'n8n-nodes-base.merge', + typeVersion: 1, + position: [1050, 250], + }, + { + id: 'uuid-4', + parameters: { + conditions: { + number: [ + { + value1: '={{$json["value1"]}}', + operation: 'equal', + value2: 1, + }, + ], + }, + }, + name: 'IF', + type: 'n8n-nodes-base.if', + typeVersion: 1, + position: [650, 300], + }, + { + id: 'uuid-5', + parameters: {}, + name: 'NoOpTrue', + type: 'n8n-nodes-base.noOp', + typeVersion: 1, + position: [850, 150], + }, + { + id: 'uuid-6', + parameters: {}, + name: 'NoOpFalse', + type: 'n8n-nodes-base.noOp', + typeVersion: 1, + position: [850, 400], + }, + ], + connections: { + Start: { + main: [ + [ + { + node: 'Set', + type: 'main', + index: 0, + }, + ], + ], + }, + Set: { + main: [ + [ + { + node: 'IF', + type: 'main', + index: 0, + }, + ], + ], + }, + IF: { + main: [ + [ + { + node: 'NoOpTrue', + type: 'main', + index: 0, + }, + { + node: 'Merge', + type: 'main', + index: 1, + }, + ], + [ + { + node: 'NoOpFalse', + type: 'main', + index: 0, + }, + ], + ], + }, + NoOpTrue: { + main: [ + [ + { + node: 'Merge', + type: 'main', + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: ['Start', 'Set', 'IF', 'NoOpFalse'], + nodeData: { + IF: [[]], + NoOpFalse: [ + [ + { + value1: 0, + }, + ], + ], + }, + }, + }, + + { + description: + 'should display the correct parameters and so correct data when simplified node-versioning is used', + input: { + workflowData: { + nodes: [ + { + id: 'uuid-1', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-2', + parameters: {}, + name: 'VersionTest1a', + type: 'n8n-nodes-base.versionTest', + typeVersion: 1, + position: [460, 300], + }, + { + id: 'uuid-3', + parameters: { + versionTest: 11, + }, + name: 'VersionTest1b', + type: 'n8n-nodes-base.versionTest', + typeVersion: 1, + position: [680, 300], + }, + { + id: 'uuid-4', + parameters: {}, + name: 'VersionTest2a', + type: 'n8n-nodes-base.versionTest', + typeVersion: 2, + position: [880, 300], + }, + { + id: 'uuid-5', + parameters: { + versionTest: 22, + }, + name: 'VersionTest2b', + type: 'n8n-nodes-base.versionTest', + typeVersion: 2, + position: [1080, 300], + }, + ], + connections: { + Start: { + main: [ + [ + { + node: 'VersionTest1a', + type: 'main', + index: 0, + }, + ], + ], + }, + VersionTest1a: { + main: [ + [ + { + node: 'VersionTest1b', + type: 'main', + index: 0, + }, + ], + ], + }, + VersionTest1b: { + main: [ + [ + { + node: 'VersionTest2a', + type: 'main', + index: 0, + }, + ], + ], + }, + VersionTest2a: { + main: [ + [ + { + node: 'VersionTest2b', + type: 'main', + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeExecutionOrder: [ + 'Start', + 'VersionTest1a', + 'VersionTest1b', + 'VersionTest2a', + 'VersionTest2b', + ], + nodeData: { + VersionTest1a: [ + [ + { + versionFromNode: 1, + versionFromParameter: 1, + }, + ], + ], + VersionTest1b: [ + [ + { + versionFromNode: 1, + versionFromParameter: 11, + }, + ], + ], + VersionTest2a: [ + [ + { + versionFromNode: 2, + versionFromParameter: 2, + }, + ], + ], + VersionTest2b: [ + [ + { + versionFromNode: 2, + versionFromParameter: 22, + }, + ], + ], + }, + }, + }, +]; diff --git a/packages/core/test/helpers/index.ts b/packages/core/test/helpers/index.ts new file mode 100644 index 0000000000..e6550f1b4e --- /dev/null +++ b/packages/core/test/helpers/index.ts @@ -0,0 +1,237 @@ +import path from 'path'; +import { readdirSync, readFileSync } from 'fs'; + +const BASE_DIR = path.resolve(__dirname, '../../..'); + +import type { + ICredentialDataDecryptedObject, + IDataObject, + IDeferredPromise, + IExecuteWorkflowInfo, + IHttpRequestHelper, + IHttpRequestOptions, + INode, + INodeCredentialsDetails, + INodeType, + INodeTypeData, + INodeTypes, + IRun, + ITaskData, + IVersionedNodeType, + IWorkflowBase, + IWorkflowExecuteAdditionalData, + NodeLoadingDetails, + WorkflowTestData, +} from 'n8n-workflow'; + +import { ICredentialsHelper, NodeHelpers, WorkflowHooks } from 'n8n-workflow'; +import { Credentials } from '@/Credentials'; + +import { predefinedNodesTypes } from './constants'; + +export class CredentialsHelper extends ICredentialsHelper { + async authenticate( + credentials: ICredentialDataDecryptedObject, + typeName: string, + requestParams: IHttpRequestOptions, + ): Promise { + return requestParams; + } + + async preAuthentication( + helpers: IHttpRequestHelper, + credentials: ICredentialDataDecryptedObject, + typeName: string, + node: INode, + credentialsExpired: boolean, + ): Promise { + return undefined; + } + + getParentTypes(name: string): string[] { + return []; + } + + async getDecrypted( + nodeCredentials: INodeCredentialsDetails, + type: string, + ): Promise { + return {}; + } + + async getCredentials( + nodeCredentials: INodeCredentialsDetails, + type: string, + ): Promise { + return new Credentials({ id: null, name: '' }, '', [], ''); + } + + async updateCredentials( + nodeCredentials: INodeCredentialsDetails, + type: string, + data: ICredentialDataDecryptedObject, + ): Promise {} +} + +class NodeTypesClass implements INodeTypes { + nodeTypes: INodeTypeData; + + constructor(nodeTypes?: INodeTypeData) { + if (nodeTypes) { + this.nodeTypes = nodeTypes; + } else { + this.nodeTypes = predefinedNodesTypes; + } + } + + getByName(nodeType: string): INodeType | IVersionedNodeType { + return this.nodeTypes[nodeType].type; + } + + getByNameAndVersion(nodeType: string, version?: number): INodeType { + return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version); + } +} + +let nodeTypesInstance: NodeTypesClass | undefined; + +export function NodeTypes(nodeTypes?: INodeTypeData): NodeTypesClass { + if (nodeTypesInstance === undefined || nodeTypes !== undefined) { + nodeTypesInstance = new NodeTypesClass(nodeTypes); + } + + return nodeTypesInstance; +} + +export function WorkflowExecuteAdditionalData( + waitPromise: IDeferredPromise, + nodeExecutionOrder: string[], +): IWorkflowExecuteAdditionalData { + const hookFunctions = { + nodeExecuteAfter: [ + async (nodeName: string, data: ITaskData): Promise => { + nodeExecutionOrder.push(nodeName); + }, + ], + workflowExecuteAfter: [ + async (fullRunData: IRun): Promise => { + waitPromise.resolve(fullRunData); + }, + ], + }; + + const workflowData: IWorkflowBase = { + name: '', + createdAt: new Date(), + updatedAt: new Date(), + active: true, + nodes: [], + connections: {}, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return { + credentialsHelper: new CredentialsHelper(''), + hooks: new WorkflowHooks(hookFunctions, 'trigger', '1', workflowData), + executeWorkflow: async (workflowInfo: IExecuteWorkflowInfo) => {}, + sendMessageToUI: (message: string) => {}, + restApiUrl: '', + encryptionKey: 'test', + timezone: 'America/New_York', + webhookBaseUrl: 'webhook', + webhookWaitingBaseUrl: 'webhook-waiting', + webhookTestBaseUrl: 'webhook-test', + userId: '123', + }; +} + +const preparePinData = (pinData: IDataObject) => { + const returnData = Object.keys(pinData).reduce( + (acc, key) => { + const data = pinData[key] as IDataObject[]; + acc[key] = [data]; + return acc; + }, + {} as { + [key: string]: IDataObject[][]; + }, + ); + return returnData; +}; + +const readJsonFileSync = (filePath: string) => + JSON.parse(readFileSync(path.join(BASE_DIR, filePath), 'utf-8')) as T; + +export function getNodeTypes(testData: WorkflowTestData[] | WorkflowTestData) { + if (!Array.isArray(testData)) { + testData = [testData]; + } + + const nodeTypes: INodeTypeData = {}; + + const nodes = [...new Set(testData.flatMap((data) => data.input.workflowData.nodes))]; + + const nodeNames = nodes.map((n) => n.type); + + const knownNodes = readJsonFileSync>( + 'nodes-base/dist/known/nodes.json', + ); + + for (const nodeName of nodeNames) { + if (!nodeName.startsWith('n8n-nodes-base.')) { + throw new Error(`Unknown node type: ${nodeName}`); + } + const loadInfo = knownNodes[nodeName.replace('n8n-nodes-base.', '')]; + if (!loadInfo) { + throw new Error(`Unknown node type: ${nodeName}`); + } + const sourcePath = loadInfo.sourcePath.replace(/^dist\//, './').replace(/\.js$/, '.ts'); + const nodeSourcePath = path.join(BASE_DIR, 'nodes-base', sourcePath); + const node = new (require(nodeSourcePath)[loadInfo.className])() as INodeType; + nodeTypes[nodeName] = { + sourcePath: '', + type: node, + }; + } + + return nodeTypes; +} + +const getWorkflowFilenames = (dirname: string, testFolder = 'workflows') => { + const workflows: string[] = []; + + const filenames: string[] = readdirSync(`${dirname}${path.sep}${testFolder}`); + + filenames.forEach((file) => { + if (file.endsWith('.json')) { + workflows.push(path.join('core', 'test', testFolder, file)); + } + }); + + return workflows; +}; + +export const workflowToTests = (dirname: string, testFolder = 'workflows') => { + const workflowFiles: string[] = getWorkflowFilenames(dirname, testFolder); + + const testCases: WorkflowTestData[] = []; + + for (const filePath of workflowFiles) { + const description = filePath.replace('.json', ''); + const workflowData = readJsonFileSync(filePath); + if (workflowData.pinData === undefined) { + throw new Error('Workflow data does not contain pinData'); + } + + const nodeData = preparePinData(workflowData.pinData); + + delete workflowData.pinData; + + const input = { workflowData }; + const output = { nodeData }; + + testCases.push({ description, input, output }); + } + return testCases; +}; diff --git a/packages/core/test/utils.ts b/packages/core/test/helpers/utils.ts similarity index 100% rename from packages/core/test/utils.ts rename to packages/core/test/helpers/utils.ts diff --git a/packages/core/test/workflows/paired_items_fix.json b/packages/core/test/workflows/paired_items_fix.json new file mode 100644 index 0000000000..dbd036f70c --- /dev/null +++ b/packages/core/test/workflows/paired_items_fix.json @@ -0,0 +1,565 @@ +{ + "name": "paired items fix", + "nodes": [ + { + "parameters": { + "values": { + "string": [ + { + "name": "setting", + "value": "hello" + } + ] + }, + "options": {} + }, + "name": "Set", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [500, 680], + "id": "18333790-db22-4235-92e6-b7dec8c20b77" + }, + { + "parameters": { + "conditions": { + "boolean": [ + { + "value1": true + } + ] + } + }, + "id": "4d4af5e5-860d-416f-b2d7-f0f87f380355", + "name": "IF", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [1080, 500], + "alwaysOutputData": true + }, + { + "parameters": { + "values": { + "string": [ + { + "value": "={{ $('Set').item.json }}" + } + ] + }, + "options": {} + }, + "id": "26569caf-084d-4d5b-a575-c8e439358d10", + "name": "Set1", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [1340, 480] + }, + { + "parameters": {}, + "id": "f4f91c8c-e695-422b-97ad-802b10c7d868", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [200, 980] + }, + { + "parameters": { + "jsCode": "return [\n {\n 'thing': 1,\n 'letter': 'a'\n },\n {\n 'thing': 2,\n 'letter': 'b'\n },\n {\n 'thing': 3,\n 'letter': 'c'\n }\n]" + }, + "id": "5eb81a1f-b845-408a-9fcc-e75e607212fa", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [840, 500] + }, + { + "parameters": { + "functionCode": "return [\n {\n 'number': 1,\n 'letter': 'a'\n },\n {\n 'number': 2,\n 'letter': 'b'\n },\n {\n 'number': 3,\n 'letter': 'c'\n }\n]" + }, + "name": "Generate new items", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [840, 860], + "id": "dd5d92f2-5893-4591-9f22-051f50e1b348" + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "numberOriginal", + "value": "={{ $('Generate new items').item.json.number }}" + } + ], + "string": [ + { + "name": "letterOriginal", + "value": "={{ $('Generate new items').item.json.letter }}" + } + ] + }, + "options": {} + }, + "id": "ebb23410-831b-4f8f-834a-0ca22eb7c050", + "name": "Set3", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [1320, 860] + }, + { + "parameters": { + "functionCode": "return [\n {\n 'json': {\n 'originalItem': 'third'\n },\n 'pairedItem': 2\n },\n {\n 'json': {\n 'originalItem': 'first'\n },\n 'pairedItem': 0\n },\n {\n 'json': {\n 'originalItem': 'second'\n },\n 'pairedItem': 1\n }\n]" + }, + "name": "Mix up pairing", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [1080, 860], + "id": "33ee2a0e-edc9-4197-94a2-4f77735240ff" + }, + { + "parameters": { + "content": "### Always output data & multiple possible output resolve which are identical", + "height": 258, + "width": 855 + }, + "id": "3dc9ccfa-ef78-4022-8bbc-45ef8eb3a207", + "name": "Sticky Note1", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [780, 400] + }, + { + "parameters": { + "operation": "getAllPeople" + }, + "id": "5ba8d43b-9fa3-4ba0-9c08-3199a9d2d602", + "name": "cuctomers", + "type": "n8n-nodes-base.n8nTrainingCustomerDatastore", + "typeVersion": 1, + "position": [640, 1340] + }, + { + "parameters": { + "options": {} + }, + "id": "00114764-691d-40b4-ae11-c5206a9448e3", + "name": "result", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [1380, 1180], + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nconst data = [];\nfor (const [index, entry] of $input.all().entries()) {\n entry.json.myNewField = index;\n entry.pairedItem = 0;\n data.push(entry);\n}\n\nreturn data;" + }, + "id": "d3aa3bc3-3e5a-42d2-a26d-ea0c273ea3e8", + "name": "changePairedindex", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [920, 1180] + }, + { + "parameters": { + "keepOnlySet": true, + "values": { + "string": [ + { + "name": "=nameOriginalItem", + "value": "={{ $('cuctomers').item.json.name }}" + }, + { + "name": "name", + "value": "={{ $json.name }}" + } + ], + "boolean": [ + { + "name": "test", + "value": "={{ $('cuctomers').item.json.id === $json.id }}" + } + ] + }, + "options": {} + }, + "id": "af18482d-4a88-4ffb-b6e3-67be45cdfad1", + "name": "checkWithOriginal", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [1140, 1180] + }, + { + "parameters": { + "options": {} + }, + "id": "3bad8f81-2fac-4e6e-bb7c-3a4921674005", + "name": "loop", + "type": "n8n-nodes-base.splitInBatches", + "typeVersion": 2, + "position": [920, 1420] + }, + { + "parameters": { + "keepOnlySet": true, + "values": { + "string": [ + { + "name": "=nameOriginalItem", + "value": "={{ $('cuctomers').item.json.name }}" + }, + { + "name": "name", + "value": "={{ $json.name }}" + } + ], + "boolean": [ + { + "name": "test", + "value": "={{ $('cuctomers').item.json.id === $json.id }}" + } + ] + }, + "options": {} + }, + "id": "865691b7-e4b8-487e-a5ec-80387118ea61", + "name": "testAfterLoop", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [1180, 1620] + }, + { + "parameters": { + "options": {} + }, + "id": "8c5d3a1c-e34b-4937-bc22-88b418391002", + "name": "result1", + "type": "n8n-nodes-base.set", + "typeVersion": 2, + "position": [1380, 1620], + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nconst data = [];\nfor (const [index, entry] of $input.all().entries()) {\n entry.json.myNewField = index;\n entry.pairedItem = 0;\n data.push(entry);\n}\n\nreturn data;" + }, + "id": "ad476a3a-d491-406f-903d-022cb0f0ef3c", + "name": "changePairedindex1", + "type": "n8n-nodes-base.code", + "typeVersion": 1, + "position": [1380, 1400] + } + ], + "pinData": { + "Set3": [ + { + "json": { + "originalItem": "third", + "numberOriginal": 3, + "letterOriginal": "c" + }, + "pairedItem": { + "item": 0 + } + }, + { + "json": { + "originalItem": "first", + "numberOriginal": 1, + "letterOriginal": "a" + }, + "pairedItem": { + "item": 1 + } + }, + { + "json": { + "originalItem": "second", + "numberOriginal": 2, + "letterOriginal": "b" + }, + "pairedItem": { + "item": 2 + } + } + ], + "Set1": [ + { + "json": { + "propertyName": { + "setting": "hello" + } + }, + "pairedItem": { + "item": 0 + } + } + ], + "result": [ + { + "json": { + "test": true, + "nameOriginalItem": "Jay Gatsby", + "name": "Jay Gatsby" + }, + "pairedItem": { + "item": 0 + } + }, + { + "json": { + "test": false, + "nameOriginalItem": "Jay Gatsby", + "name": "José Arcadio Buendía" + }, + "pairedItem": { + "item": 1 + } + }, + { + "json": { + "test": false, + "nameOriginalItem": "Jay Gatsby", + "name": "Max Sendak" + }, + "pairedItem": { + "item": 2 + } + }, + { + "json": { + "test": false, + "nameOriginalItem": "Jay Gatsby", + "name": "Zaphod Beeblebrox" + }, + "pairedItem": { + "item": 3 + } + }, + { + "json": { + "test": false, + "nameOriginalItem": "Jay Gatsby", + "name": "Edmund Pevensie" + }, + "pairedItem": { + "item": 4 + } + } + ], + "result1": [ + { + "json": { + "test": true, + "nameOriginalItem": "Jay Gatsby", + "name": "Jay Gatsby" + }, + "pairedItem": { + "item": 0 + } + }, + { + "json": { + "test": false, + "nameOriginalItem": "Jay Gatsby", + "name": "José Arcadio Buendía" + }, + "pairedItem": { + "item": 1 + } + }, + { + "json": { + "test": false, + "nameOriginalItem": "Jay Gatsby", + "name": "Max Sendak" + }, + "pairedItem": { + "item": 2 + } + }, + { + "json": { + "test": false, + "nameOriginalItem": "Jay Gatsby", + "name": "Zaphod Beeblebrox" + }, + "pairedItem": { + "item": 3 + } + }, + { + "json": { + "test": false, + "nameOriginalItem": "Jay Gatsby", + "name": "Edmund Pevensie" + }, + "pairedItem": { + "item": 4 + } + } + ] + }, + "connections": { + "Set": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + }, + { + "node": "Generate new items", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF": { + "main": [ + [ + { + "node": "Set1", + "type": "main", + "index": 0 + } + ] + ] + }, + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Set", + "type": "main", + "index": 0 + }, + { + "node": "cuctomers", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code": { + "main": [ + [ + { + "node": "IF", + "type": "main", + "index": 0 + } + ] + ] + }, + "Generate new items": { + "main": [ + [ + { + "node": "Mix up pairing", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mix up pairing": { + "main": [ + [ + { + "node": "Set3", + "type": "main", + "index": 0 + } + ] + ] + }, + "cuctomers": { + "main": [ + [ + { + "node": "changePairedindex", + "type": "main", + "index": 0 + }, + { + "node": "loop", + "type": "main", + "index": 0 + } + ] + ] + }, + "changePairedindex": { + "main": [ + [ + { + "node": "checkWithOriginal", + "type": "main", + "index": 0 + } + ] + ] + }, + "checkWithOriginal": { + "main": [ + [ + { + "node": "result", + "type": "main", + "index": 0 + } + ] + ] + }, + "loop": { + "main": [ + [ + { + "node": "changePairedindex1", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "testAfterLoop", + "type": "main", + "index": 0 + } + ] + ] + }, + "testAfterLoop": { + "main": [ + [ + { + "node": "result1", + "type": "main", + "index": 0 + } + ] + ] + }, + "changePairedindex1": { + "main": [ + [ + { + "node": "loop", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "6f6ee01c-8c99-493f-a30c-6a5ed2b71750", + "id": "169", + "meta": { + "instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0" + }, + "tags": [] +} diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index f1ba61aa12..b30e6acd34 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -19,6 +19,7 @@ import type { IHttpRequestOptions, ILogger, INode, + INodeCredentials, INodeCredentialsDetails, INodeType, INodeTypeData, @@ -29,10 +30,10 @@ import type { IWorkflowBase, IWorkflowExecuteAdditionalData, NodeLoadingDetails, + WorkflowTestData, } from 'n8n-workflow'; import { ICredentialsHelper, LoggerProxy, NodeHelpers, WorkflowHooks } from 'n8n-workflow'; import { executeWorkflow } from './ExecuteWorkflow'; -import type { WorkflowTestData } from './types'; import { FAKE_CREDENTIALS_DATA } from './FakeCredentialsMap'; @@ -237,7 +238,7 @@ export function setup(testData: WorkflowTestData[] | WorkflowTestData) { const nodes = [...new Set(testData.flatMap((data) => data.input.workflowData.nodes))]; const credentialNames = nodes .filter((n) => n.credentials) - .flatMap(({ credentials }) => Object.keys(credentials!)); + .flatMap(({ credentials }) => Object.keys(credentials as INodeCredentials)); for (const credentialName of credentialNames) { const loadInfo = knownCredentials[credentialName]; if (!loadInfo) { diff --git a/packages/nodes-base/test/nodes/types.ts b/packages/nodes-base/test/nodes/types.ts index 000c34c9b1..73e09fcb24 100644 --- a/packages/nodes-base/test/nodes/types.ts +++ b/packages/nodes-base/test/nodes/types.ts @@ -1,23 +1,3 @@ -import type { INode, IConnections } from 'n8n-workflow'; - -export interface WorkflowTestData { - description: string; - input: { - workflowData: { - nodes: INode[]; - connections: IConnections; - settings?: { - saveManualExecutions: boolean; - callerPolicy: string; - timezone: string; - saveExecutionProgress: string; - }; - }; - }; - output: { - nodeExecutionOrder?: string[]; - nodeData: { - [key: string]: any[][]; - }; - }; -} +import type { WorkflowTestData } from 'n8n-workflow'; +//TODO: remove, update import in tests +export { WorkflowTestData }; diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 0854551a69..c6e2ed3486 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1763,6 +1763,23 @@ export interface IWorkflowSettings { executionTimeout?: number; } +export interface WorkflowTestData { + description: string; + input: { + workflowData: { + nodes: INode[]; + connections: IConnections; + settings?: IWorkflowSettings; + }; + }; + output: { + nodeExecutionOrder?: string[]; + nodeData: { + [key: string]: any[][]; + }; + }; +} + export type LogTypes = 'debug' | 'verbose' | 'info' | 'warn' | 'error'; export interface ILogger { diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index c6466ae41e..24527df845 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -806,6 +806,13 @@ export class WorkflowDataProxy { .filter((result) => result !== null); if (results.length !== 1) { + // Check if the results are all the same + const firstResult = results[0]; + if (results.every((result) => result === firstResult)) { + // All results are the same so return the first one + return firstResult; + } + throw createExpressionError('Invalid expression', { messageTemplate: 'Invalid expression under ‘%%PARAMETER%%’', functionality: 'pairedItem',