fix(core): Fix pairedItem for alwaysOutputData & multi identical resolve (#6405)

This commit is contained in:
Jan Oberhauser 2023-06-21 09:38:28 +02:00 committed by GitHub
parent c3ba0123ad
commit 4b0e0b7970
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 2999 additions and 2250 deletions

View file

@ -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,
},
];
}

View file

@ -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<IHttpRequestOptions> {
return requestParams;
}
async preAuthentication(
helpers: IHttpRequestHelper,
credentials: ICredentialDataDecryptedObject,
typeName: string,
node: INode,
credentialsExpired: boolean,
): Promise<ICredentialDataDecryptedObject | undefined> {
return undefined;
}
getParentTypes(name: string): string[] {
return [];
}
async getDecrypted(
nodeCredentials: INodeCredentialsDetails,
type: string,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async getCredentials(
nodeCredentials: INodeCredentialsDetails,
type: string,
): Promise<Credentials> {
return new Credentials({ id: null, name: '' }, '', [], '');
}
async updateCredentials(
nodeCredentials: INodeCredentialsDetails,
type: string,
data: ICredentialDataDecryptedObject,
): Promise<void> {}
}
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<INodeExecutionData[][]> {
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<br />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<INodeExecutionData[][]> {
// 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<INodeExecutionData[][]> {
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<INodeExecutionData[][]> {
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<br />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.<br />Supports dot-notation.<br />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.<br />Supports dot-notation.<br />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.<br />Supports dot-notation.<br />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:
'<p>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} }.</p><p>If that is not intended this can be deactivated, it will then set { "a.b": value } instead.</p>',
},
],
},
],
},
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
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<INodeExecutionData[][]> {
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<IRun>,
nodeExecutionOrder: string[],
): IWorkflowExecuteAdditionalData {
const hookFunctions = {
nodeExecuteAfter: [
async (nodeName: string, data: ITaskData): Promise<void> => {
nodeExecutionOrder.push(nodeName);
},
],
workflowExecuteAfter: [
async (fullRunData: IRun): Promise<void> => {
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<any> => {},
sendMessageToUI: (message: string) => {},
restApiUrl: '',
encryptionKey: 'test',
timezone: 'America/New_York',
webhookBaseUrl: 'webhook',
webhookWaitingBaseUrl: 'webhook-waiting',
webhookTestBaseUrl: 'webhook-test',
userId: '123',
};
}

View file

@ -17,7 +17,7 @@ import {
getBinaryDataBuffer,
proxyRequestToAxios,
} from '@/NodeExecuteFunctions';
import { initLogger } from './utils';
import { initLogger } from './helpers/utils';
const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n'));

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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<IHttpRequestOptions> {
return requestParams;
}
async preAuthentication(
helpers: IHttpRequestHelper,
credentials: ICredentialDataDecryptedObject,
typeName: string,
node: INode,
credentialsExpired: boolean,
): Promise<ICredentialDataDecryptedObject | undefined> {
return undefined;
}
getParentTypes(name: string): string[] {
return [];
}
async getDecrypted(
nodeCredentials: INodeCredentialsDetails,
type: string,
): Promise<ICredentialDataDecryptedObject> {
return {};
}
async getCredentials(
nodeCredentials: INodeCredentialsDetails,
type: string,
): Promise<Credentials> {
return new Credentials({ id: null, name: '' }, '', [], '');
}
async updateCredentials(
nodeCredentials: INodeCredentialsDetails,
type: string,
data: ICredentialDataDecryptedObject,
): Promise<void> {}
}
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<IRun>,
nodeExecutionOrder: string[],
): IWorkflowExecuteAdditionalData {
const hookFunctions = {
nodeExecuteAfter: [
async (nodeName: string, data: ITaskData): Promise<void> => {
nodeExecutionOrder.push(nodeName);
},
],
workflowExecuteAfter: [
async (fullRunData: IRun): Promise<void> => {
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 = <T>(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<Record<string, NodeLoadingDetails>>(
'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<IWorkflowBase>(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;
};

View file

@ -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": []
}

View file

@ -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) {

View file

@ -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 };

View file

@ -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 {

View file

@ -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',