mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
refactor(core): Move execution engine code out of n8n-workflow (no-changelog) (#12147)
This commit is contained in:
committed by
GitHub
parent
73f0c4cca9
commit
5a055ed526
@@ -1,5 +1,3 @@
|
||||
import type { NodeParameterValue } from './Interfaces';
|
||||
|
||||
export const DIGITS = '0123456789';
|
||||
export const UPPERCASE_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
export const LOWERCASE_LETTERS = UPPERCASE_LETTERS.toLowerCase();
|
||||
@@ -87,35 +85,6 @@ export const LANGCHAIN_CUSTOM_TOOLS = [
|
||||
HTTP_REQUEST_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
];
|
||||
|
||||
//nodes that would execute only once with such parameters
|
||||
//add 'undefined' to parameters values if it is parameter's default value
|
||||
export const SINGLE_EXECUTION_NODES: { [key: string]: { [key: string]: NodeParameterValue[] } } = {
|
||||
'n8n-nodes-base.code': {
|
||||
mode: [undefined, 'runOnceForAllItems'],
|
||||
},
|
||||
'n8n-nodes-base.executeWorkflow': {
|
||||
mode: [undefined, 'once'],
|
||||
},
|
||||
'n8n-nodes-base.crateDb': {
|
||||
operation: [undefined, 'update'], // default insert
|
||||
},
|
||||
'n8n-nodes-base.timescaleDb': {
|
||||
operation: [undefined, 'update'], // default insert
|
||||
},
|
||||
'n8n-nodes-base.microsoftSql': {
|
||||
operation: [undefined, 'update', 'delete'], // default insert
|
||||
},
|
||||
'n8n-nodes-base.questDb': {
|
||||
operation: [undefined], // default insert
|
||||
},
|
||||
'n8n-nodes-base.mongoDb': {
|
||||
operation: ['insert', 'update'],
|
||||
},
|
||||
'n8n-nodes-base.redis': {
|
||||
operation: [undefined], // default info
|
||||
},
|
||||
};
|
||||
|
||||
export const SEND_AND_WAIT_OPERATION = 'sendAndWait';
|
||||
export const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
|
||||
export const AI_TRANSFORM_JS_CODE = 'jsCode';
|
||||
|
||||
@@ -421,60 +421,6 @@ export interface IRunNodeResponse {
|
||||
data: INodeExecutionData[][] | NodeExecutionOutput | null | undefined;
|
||||
closeFunction?: CloseFunction;
|
||||
}
|
||||
export interface IGetExecuteFunctions {
|
||||
(
|
||||
workflow: Workflow,
|
||||
runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
connectionInputData: INodeExecutionData[],
|
||||
inputData: ITaskDataConnections,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
executeData: IExecuteData,
|
||||
mode: WorkflowExecuteMode,
|
||||
closeFunctions: CloseFunction[],
|
||||
abortSignal?: AbortSignal,
|
||||
): IExecuteFunctions;
|
||||
}
|
||||
|
||||
export interface IGetExecuteSingleFunctions {
|
||||
(
|
||||
workflow: Workflow,
|
||||
runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
connectionInputData: INodeExecutionData[],
|
||||
inputData: ITaskDataConnections,
|
||||
node: INode,
|
||||
itemIndex: number,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
executeData: IExecuteData,
|
||||
mode: WorkflowExecuteMode,
|
||||
abortSignal?: AbortSignal,
|
||||
): IExecuteSingleFunctions;
|
||||
}
|
||||
|
||||
export interface IGetExecuteHookFunctions {
|
||||
(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
activation: WorkflowActivateMode,
|
||||
webhookData?: IWebhookData,
|
||||
): IHookFunctions;
|
||||
}
|
||||
|
||||
export interface IGetExecuteWebhookFunctions {
|
||||
(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
webhookData: IWebhookData,
|
||||
closeFunctions: CloseFunction[],
|
||||
runExecutionData: IRunExecutionData | null,
|
||||
): IWebhookFunctions;
|
||||
}
|
||||
|
||||
export interface ISourceDataConnections {
|
||||
// Key for each input type and because there can be multiple inputs of the same type it is an array
|
||||
@@ -1226,10 +1172,6 @@ export interface INodeExecutionData {
|
||||
export interface INodeExecuteFunctions {
|
||||
getExecutePollFunctions: IGetExecutePollFunctions;
|
||||
getExecuteTriggerFunctions: IGetExecuteTriggerFunctions;
|
||||
getExecuteFunctions: IGetExecuteFunctions;
|
||||
getExecuteSingleFunctions: IGetExecuteSingleFunctions;
|
||||
getExecuteHookFunctions: IGetExecuteHookFunctions;
|
||||
getExecuteWebhookFunctions: IGetExecuteWebhookFunctions;
|
||||
}
|
||||
|
||||
export type NodeParameterValue = string | number | boolean | undefined | null;
|
||||
|
||||
@@ -6,13 +6,11 @@
|
||||
import get from 'lodash/get';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
import { SINGLE_EXECUTION_NODES } from './Constants';
|
||||
import { ApplicationError } from './errors/application.error';
|
||||
import { NodeConnectionType } from './Interfaces';
|
||||
import type {
|
||||
FieldType,
|
||||
IContextObject,
|
||||
IHttpRequestMethods,
|
||||
INode,
|
||||
INodeCredentialDescription,
|
||||
INodeIssueObjectProperty,
|
||||
@@ -29,12 +27,9 @@ import type {
|
||||
IParameterDependencies,
|
||||
IRunExecutionData,
|
||||
IVersionedNodeType,
|
||||
IWebhookData,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeParameterValue,
|
||||
ResourceMapperValue,
|
||||
INodeTypeDescription,
|
||||
INodeTypeBaseDescription,
|
||||
INodeOutputConfiguration,
|
||||
INodeInputConfiguration,
|
||||
GenericValue,
|
||||
@@ -239,33 +234,6 @@ export const cronNodeOptions: INodePropertyCollection[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const commonPollingParameters: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Poll Times',
|
||||
name: 'pollTimes',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
multipleValueButtonText: 'Add Poll Time',
|
||||
},
|
||||
default: { item: [{ mode: 'everyMinute' }] },
|
||||
description: 'Time at which polling should occur',
|
||||
placeholder: 'Add Poll Time',
|
||||
options: cronNodeOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export const commonCORSParameters: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Allowed Origins (CORS)',
|
||||
name: 'allowedOrigins',
|
||||
type: 'string',
|
||||
default: '*',
|
||||
description:
|
||||
'Comma-separated list of URLs allowed for cross-origin non-preflight requests. Use * (default) to allow all origins.',
|
||||
},
|
||||
];
|
||||
|
||||
const declarativeNodeOptionParameters: INodeProperties = {
|
||||
displayName: 'Request Options',
|
||||
name: 'requestOptions',
|
||||
@@ -347,101 +315,6 @@ const declarativeNodeOptionParameters: INodeProperties = {
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Modifies the description of the passed in object, such that it can be used
|
||||
* as an AI Agent Tool.
|
||||
* Returns the modified item (not copied)
|
||||
*/
|
||||
export function convertNodeToAiTool<
|
||||
T extends object & { description: INodeTypeDescription | INodeTypeBaseDescription },
|
||||
>(item: T): T {
|
||||
// quick helper function for type-guard down below
|
||||
function isFullDescription(obj: unknown): obj is INodeTypeDescription {
|
||||
return typeof obj === 'object' && obj !== null && 'properties' in obj;
|
||||
}
|
||||
|
||||
if (isFullDescription(item.description)) {
|
||||
item.description.name += 'Tool';
|
||||
item.description.inputs = [];
|
||||
item.description.outputs = [NodeConnectionType.AiTool];
|
||||
item.description.displayName += ' Tool';
|
||||
delete item.description.usableAsTool;
|
||||
|
||||
const hasResource = item.description.properties.some((prop) => prop.name === 'resource');
|
||||
const hasOperation = item.description.properties.some((prop) => prop.name === 'operation');
|
||||
|
||||
if (!item.description.properties.map((prop) => prop.name).includes('toolDescription')) {
|
||||
const descriptionType: INodeProperties = {
|
||||
displayName: 'Tool Description',
|
||||
name: 'descriptionType',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Set Automatically',
|
||||
value: 'auto',
|
||||
description: 'Automatically set based on resource and operation',
|
||||
},
|
||||
{
|
||||
name: 'Set Manually',
|
||||
value: 'manual',
|
||||
description: 'Manually set the description',
|
||||
},
|
||||
],
|
||||
default: 'auto',
|
||||
};
|
||||
|
||||
const descProp: INodeProperties = {
|
||||
displayName: 'Description',
|
||||
name: 'toolDescription',
|
||||
type: 'string',
|
||||
default: item.description.description,
|
||||
required: true,
|
||||
typeOptions: { rows: 2 },
|
||||
description:
|
||||
'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often',
|
||||
placeholder: `e.g. ${item.description.description}`,
|
||||
};
|
||||
|
||||
const noticeProp: INodeProperties = {
|
||||
displayName:
|
||||
"Use the expression {{ $fromAI('placeholder_name') }} for any data to be filled by the model",
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
};
|
||||
|
||||
item.description.properties.unshift(descProp);
|
||||
|
||||
// If node has resource or operation we can determine pre-populate tool description based on it
|
||||
// so we add the descriptionType property as the first property
|
||||
if (hasResource || hasOperation) {
|
||||
item.description.properties.unshift(descriptionType);
|
||||
|
||||
descProp.displayOptions = {
|
||||
show: {
|
||||
descriptionType: ['manual'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
item.description.properties.unshift(noticeProp);
|
||||
}
|
||||
}
|
||||
|
||||
const resources = item.description.codex?.resources ?? {};
|
||||
|
||||
item.description.codex = {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
resources,
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the provided node type has any output types other than the main connection type.
|
||||
* @param typeDescription The node's type description to check.
|
||||
@@ -514,27 +387,6 @@ export function applyDeclarativeNodeOptionParameters(nodeType: INodeType): void
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply special parameters which should be added to nodeTypes depending on their type or configuration
|
||||
*/
|
||||
export function applySpecialNodeParameters(nodeType: INodeType): void {
|
||||
const { properties, polling, supportsCORS } = nodeType.description;
|
||||
if (polling) {
|
||||
properties.unshift(...commonPollingParameters);
|
||||
}
|
||||
if (nodeType.webhook && supportsCORS) {
|
||||
const optionsProperty = properties.find(({ name }) => name === 'options');
|
||||
if (optionsProperty)
|
||||
optionsProperty.options = [
|
||||
...commonCORSParameters,
|
||||
...(optionsProperty.options as INodePropertyOptions[]),
|
||||
];
|
||||
else properties.push(...commonCORSParameters);
|
||||
}
|
||||
|
||||
applyDeclarativeNodeOptionParameters(nodeType);
|
||||
}
|
||||
|
||||
const getPropertyValues = (
|
||||
nodeValues: INodeParameters,
|
||||
propertyName: string,
|
||||
@@ -747,7 +599,6 @@ export function getContext(
|
||||
|
||||
/**
|
||||
* Returns which parameters are dependent on which
|
||||
*
|
||||
*/
|
||||
function getParameterDependencies(nodePropertiesArray: INodeProperties[]): IParameterDependencies {
|
||||
const dependencies: IParameterDependencies = {};
|
||||
@@ -783,7 +634,6 @@ function getParameterDependencies(nodePropertiesArray: INodeProperties[]): IPara
|
||||
/**
|
||||
* Returns in which order the parameters should be resolved
|
||||
* to have the parameters available they depend on
|
||||
*
|
||||
*/
|
||||
export function getParameterResolveOrder(
|
||||
nodePropertiesArray: INodeProperties[],
|
||||
@@ -1177,121 +1027,8 @@ export function getNodeParameters(
|
||||
return nodeParameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the webhooks which should be created for the give node
|
||||
*/
|
||||
export function getNodeWebhooks(
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
ignoreRestartWebhooks = false,
|
||||
): IWebhookData[] {
|
||||
if (node.disabled === true) {
|
||||
// Node is disabled so webhooks will also not be enabled
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
|
||||
if (nodeType.description.webhooks === undefined) {
|
||||
// Node does not have any webhooks so return
|
||||
return [];
|
||||
}
|
||||
|
||||
const workflowId = workflow.id || '__UNSAVED__';
|
||||
const mode = 'internal';
|
||||
|
||||
const returnData: IWebhookData[] = [];
|
||||
for (const webhookDescription of nodeType.description.webhooks) {
|
||||
if (ignoreRestartWebhooks && webhookDescription.restartWebhook === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let nodeWebhookPath = workflow.expression.getSimpleParameterValue(
|
||||
node,
|
||||
webhookDescription.path,
|
||||
mode,
|
||||
{},
|
||||
);
|
||||
if (nodeWebhookPath === undefined) {
|
||||
// TODO: Use a proper logger
|
||||
console.error(
|
||||
`No webhook path could be found for node "${node.name}" in workflow "${workflowId}".`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
nodeWebhookPath = nodeWebhookPath.toString();
|
||||
|
||||
if (nodeWebhookPath.startsWith('/')) {
|
||||
nodeWebhookPath = nodeWebhookPath.slice(1);
|
||||
}
|
||||
if (nodeWebhookPath.endsWith('/')) {
|
||||
nodeWebhookPath = nodeWebhookPath.slice(0, -1);
|
||||
}
|
||||
|
||||
const isFullPath: boolean = workflow.expression.getSimpleParameterValue(
|
||||
node,
|
||||
webhookDescription.isFullPath,
|
||||
'internal',
|
||||
{},
|
||||
undefined,
|
||||
false,
|
||||
) as boolean;
|
||||
const restartWebhook: boolean = workflow.expression.getSimpleParameterValue(
|
||||
node,
|
||||
webhookDescription.restartWebhook,
|
||||
'internal',
|
||||
{},
|
||||
undefined,
|
||||
false,
|
||||
) as boolean;
|
||||
const path = getNodeWebhookPath(workflowId, node, nodeWebhookPath, isFullPath, restartWebhook);
|
||||
|
||||
const webhookMethods = workflow.expression.getSimpleParameterValue(
|
||||
node,
|
||||
webhookDescription.httpMethod,
|
||||
mode,
|
||||
{},
|
||||
undefined,
|
||||
'GET',
|
||||
);
|
||||
|
||||
if (webhookMethods === undefined) {
|
||||
// TODO: Use a proper logger
|
||||
console.error(
|
||||
`The webhook "${path}" for node "${node.name}" in workflow "${workflowId}" could not be added because the httpMethod is not defined.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let webhookId: string | undefined;
|
||||
if ((path.startsWith(':') || path.includes('/:')) && node.webhookId) {
|
||||
webhookId = node.webhookId;
|
||||
}
|
||||
|
||||
String(webhookMethods)
|
||||
.split(',')
|
||||
.forEach((httpMethod) => {
|
||||
if (!httpMethod) return;
|
||||
returnData.push({
|
||||
httpMethod: httpMethod.trim() as IHttpRequestMethods,
|
||||
node: node.name,
|
||||
path,
|
||||
webhookDescription,
|
||||
workflowId,
|
||||
workflowExecuteAdditionalData: additionalData,
|
||||
webhookId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the webhook path
|
||||
*
|
||||
*/
|
||||
export function getNodeWebhookPath(
|
||||
workflowId: string,
|
||||
@@ -1317,7 +1054,6 @@ export function getNodeWebhookPath(
|
||||
|
||||
/**
|
||||
* Returns the webhook URL
|
||||
*
|
||||
*/
|
||||
export function getNodeWebhookUrl(
|
||||
baseUrl: string,
|
||||
@@ -1561,9 +1297,8 @@ export function nodeIssuesToString(issues: INodeIssues, node?: INode): string[]
|
||||
|
||||
/*
|
||||
* Validates resource locator node parameters based on validation ruled defined in each parameter mode
|
||||
*
|
||||
*/
|
||||
export const validateResourceLocatorParameter = (
|
||||
const validateResourceLocatorParameter = (
|
||||
value: INodeParameterResourceLocator,
|
||||
parameterMode: INodePropertyMode,
|
||||
): string[] => {
|
||||
@@ -1592,9 +1327,8 @@ export const validateResourceLocatorParameter = (
|
||||
|
||||
/*
|
||||
* Validates resource mapper values based on service schema
|
||||
*
|
||||
*/
|
||||
export const validateResourceMapperParameter = (
|
||||
const validateResourceMapperParameter = (
|
||||
nodeProperties: INodeProperties,
|
||||
value: ResourceMapperValue,
|
||||
skipRequiredCheck = false,
|
||||
@@ -1633,7 +1367,7 @@ export const validateResourceMapperParameter = (
|
||||
return issues;
|
||||
};
|
||||
|
||||
export const validateParameter = (
|
||||
const validateParameter = (
|
||||
nodeProperties: INodeProperties,
|
||||
value: GenericValue,
|
||||
type: FieldType,
|
||||
@@ -1661,7 +1395,7 @@ export const validateParameter = (
|
||||
* @param {INodeProperties} nodeProperties The properties of the node
|
||||
* @param {NodeParameterValue} value The value of the parameter
|
||||
*/
|
||||
export function addToIssuesIfMissing(
|
||||
function addToIssuesIfMissing(
|
||||
foundIssues: INodeIssues,
|
||||
nodeProperties: INodeProperties,
|
||||
value: NodeParameterValue | INodeParameterResourceLocator,
|
||||
@@ -1936,7 +1670,6 @@ export function mergeIssues(destination: INodeIssues, source: INodeIssues | null
|
||||
|
||||
/**
|
||||
* Merges the given node properties
|
||||
*
|
||||
*/
|
||||
export function mergeNodeProperties(
|
||||
mainProperties: INodeProperties[],
|
||||
@@ -1967,19 +1700,3 @@ export function getVersionedNodeType(
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
export function isSingleExecution(type: string, parameters: INodeParameters): boolean {
|
||||
const singleExecutionCase = SINGLE_EXECUTION_NODES[type];
|
||||
|
||||
if (singleExecutionCase) {
|
||||
for (const parameter of Object.keys(singleExecutionCase)) {
|
||||
if (!singleExecutionCase[parameter].includes(parameters[parameter] as NodeParameterValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +1,36 @@
|
||||
/* eslint-disable @typescript-eslint/no-use-before-define */
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-for-in-array */
|
||||
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
|
||||
import {
|
||||
MANUAL_CHAT_TRIGGER_LANGCHAIN_NODE_TYPE,
|
||||
NODES_WITH_RENAMABLE_CONTENT,
|
||||
STARTING_NODE_TYPES,
|
||||
} from './Constants';
|
||||
import type { IDeferredPromise } from './DeferredPromise';
|
||||
import { ApplicationError } from './errors/application.error';
|
||||
import { Expression } from './Expression';
|
||||
import { getGlobalState } from './GlobalState';
|
||||
import type {
|
||||
IConnections,
|
||||
IExecuteResponsePromiseData,
|
||||
IGetExecuteTriggerFunctions,
|
||||
INode,
|
||||
INodeExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeIssues,
|
||||
INodeParameters,
|
||||
INodes,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
IPinData,
|
||||
IPollFunctions,
|
||||
IRunExecutionData,
|
||||
ITaskDataConnections,
|
||||
ITriggerResponse,
|
||||
IWebhookData,
|
||||
IWebhookResponseData,
|
||||
IWorkflowIssues,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
IWorkflowSettings,
|
||||
WebhookSetupMethodNames,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
IConnection,
|
||||
IConnectedNode,
|
||||
IDataObject,
|
||||
IExecuteData,
|
||||
INodeConnection,
|
||||
IObservableObject,
|
||||
IRun,
|
||||
IRunNodeResponse,
|
||||
NodeParameterValueType,
|
||||
CloseFunction,
|
||||
INodeOutputConfiguration,
|
||||
} from './Interfaces';
|
||||
import { Node, NodeConnectionType } from './Interfaces';
|
||||
import { NodeConnectionType } from './Interfaces';
|
||||
import * as NodeHelpers from './NodeHelpers';
|
||||
import * as ObservableObject from './ObservableObject';
|
||||
import { RoutingNode } from './RoutingNode';
|
||||
|
||||
function dedupe<T>(arr: T[]): T[] {
|
||||
return [...new Set(arr)];
|
||||
@@ -214,112 +188,6 @@ export class Workflow {
|
||||
return returnConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* A workflow can only be activated if it has a node which has either triggers
|
||||
* or webhooks defined.
|
||||
*
|
||||
* @param {string[]} [ignoreNodeTypes] Node-types to ignore in the check
|
||||
*/
|
||||
checkIfWorkflowCanBeActivated(ignoreNodeTypes?: string[]): boolean {
|
||||
let node: INode;
|
||||
let nodeType: INodeType | undefined;
|
||||
|
||||
for (const nodeName of Object.keys(this.nodes)) {
|
||||
node = this.nodes[nodeName];
|
||||
|
||||
if (node.disabled === true) {
|
||||
// Deactivated nodes can not trigger a run so ignore
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoreNodeTypes !== undefined && ignoreNodeTypes.includes(node.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
|
||||
if (nodeType === undefined) {
|
||||
// Type is not known so check is not possible
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
nodeType.poll !== undefined ||
|
||||
nodeType.trigger !== undefined ||
|
||||
nodeType.webhook !== undefined
|
||||
) {
|
||||
// Is a trigger node. So workflow can be activated.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if everything in the workflow is complete
|
||||
* and ready to be executed. If it returns null everything
|
||||
* is fine. If there are issues it returns the issues
|
||||
* which have been found for the different nodes.
|
||||
* TODO: Does currently not check for credential issues!
|
||||
*/
|
||||
checkReadyForExecution(
|
||||
inputData: {
|
||||
startNode?: string;
|
||||
destinationNode?: string;
|
||||
pinDataNodeNames?: string[];
|
||||
} = {},
|
||||
): IWorkflowIssues | null {
|
||||
const workflowIssues: IWorkflowIssues = {};
|
||||
|
||||
let checkNodes: string[] = [];
|
||||
if (inputData.destinationNode) {
|
||||
// If a destination node is given we have to check all the nodes
|
||||
// leading up to it
|
||||
checkNodes = this.getParentNodes(inputData.destinationNode);
|
||||
checkNodes.push(inputData.destinationNode);
|
||||
} else if (inputData.startNode) {
|
||||
// If a start node is given we have to check all nodes which
|
||||
// come after it
|
||||
checkNodes = this.getChildNodes(inputData.startNode);
|
||||
checkNodes.push(inputData.startNode);
|
||||
}
|
||||
|
||||
for (const nodeName of checkNodes) {
|
||||
let nodeIssues: INodeIssues | null = null;
|
||||
const node = this.nodes[nodeName];
|
||||
|
||||
if (node.disabled === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
|
||||
if (nodeType === undefined) {
|
||||
// Node type is not known
|
||||
nodeIssues = {
|
||||
typeUnknown: true,
|
||||
};
|
||||
} else {
|
||||
nodeIssues = NodeHelpers.getNodeParametersIssues(
|
||||
nodeType.description.properties,
|
||||
node,
|
||||
inputData.pinDataNodeNames,
|
||||
);
|
||||
}
|
||||
|
||||
if (nodeIssues !== null) {
|
||||
workflowIssues[node.name] = nodeIssues;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(workflowIssues).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return workflowIssues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the static data of the workflow.
|
||||
* It gets saved with the workflow and will be the same for
|
||||
@@ -1065,437 +933,6 @@ export class Workflow {
|
||||
|
||||
return this.__getStartNode(Object.keys(this.nodes));
|
||||
}
|
||||
|
||||
async createWebhookIfNotExists(
|
||||
webhookData: IWebhookData,
|
||||
nodeExecuteFunctions: INodeExecuteFunctions,
|
||||
mode: WorkflowExecuteMode,
|
||||
activation: WorkflowActivateMode,
|
||||
): Promise<void> {
|
||||
const webhookExists = await this.runWebhookMethod(
|
||||
'checkExists',
|
||||
webhookData,
|
||||
nodeExecuteFunctions,
|
||||
mode,
|
||||
activation,
|
||||
);
|
||||
if (!webhookExists) {
|
||||
// If webhook does not exist yet create it
|
||||
await this.runWebhookMethod('create', webhookData, nodeExecuteFunctions, mode, activation);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteWebhook(
|
||||
webhookData: IWebhookData,
|
||||
nodeExecuteFunctions: INodeExecuteFunctions,
|
||||
mode: WorkflowExecuteMode,
|
||||
activation: WorkflowActivateMode,
|
||||
) {
|
||||
await this.runWebhookMethod('delete', webhookData, nodeExecuteFunctions, mode, activation);
|
||||
}
|
||||
|
||||
private async runWebhookMethod(
|
||||
method: WebhookSetupMethodNames,
|
||||
webhookData: IWebhookData,
|
||||
nodeExecuteFunctions: INodeExecuteFunctions,
|
||||
mode: WorkflowExecuteMode,
|
||||
activation: WorkflowActivateMode,
|
||||
): Promise<boolean | undefined> {
|
||||
const node = this.getNode(webhookData.node);
|
||||
|
||||
if (!node) return;
|
||||
|
||||
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
|
||||
const webhookFn = nodeType.webhookMethods?.[webhookData.webhookDescription.name]?.[method];
|
||||
if (webhookFn === undefined) return;
|
||||
|
||||
const thisArgs = nodeExecuteFunctions.getExecuteHookFunctions(
|
||||
this,
|
||||
node,
|
||||
webhookData.workflowExecuteAdditionalData,
|
||||
mode,
|
||||
activation,
|
||||
webhookData,
|
||||
);
|
||||
|
||||
return await webhookFn.call(thisArgs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the given trigger node so that it can trigger the workflow
|
||||
* when the node has data.
|
||||
*
|
||||
*/
|
||||
async runTrigger(
|
||||
node: INode,
|
||||
getTriggerFunctions: IGetExecuteTriggerFunctions,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
activation: WorkflowActivateMode,
|
||||
): Promise<ITriggerResponse | undefined> {
|
||||
const triggerFunctions = getTriggerFunctions(this, node, additionalData, mode, activation);
|
||||
|
||||
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
|
||||
if (nodeType === undefined) {
|
||||
throw new ApplicationError('Node with unknown node type', {
|
||||
extra: { nodeName: node.name },
|
||||
tags: { nodeType: node.type },
|
||||
});
|
||||
}
|
||||
|
||||
if (!nodeType.trigger) {
|
||||
throw new ApplicationError('Node type does not have a trigger function defined', {
|
||||
extra: { nodeName: node.name },
|
||||
tags: { nodeType: node.type },
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === 'manual') {
|
||||
// In manual mode we do not just start the trigger function we also
|
||||
// want to be able to get informed as soon as the first data got emitted
|
||||
const triggerResponse = await nodeType.trigger.call(triggerFunctions);
|
||||
|
||||
// Add the manual trigger response which resolves when the first time data got emitted
|
||||
triggerResponse!.manualTriggerResponse = new Promise((resolve, reject) => {
|
||||
triggerFunctions.emit = (
|
||||
(resolveEmit) =>
|
||||
(
|
||||
data: INodeExecutionData[][],
|
||||
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
||||
donePromise?: IDeferredPromise<IRun>,
|
||||
) => {
|
||||
additionalData.hooks!.hookFunctions.sendResponse = [
|
||||
async (response: IExecuteResponsePromiseData): Promise<void> => {
|
||||
if (responsePromise) {
|
||||
responsePromise.resolve(response);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (donePromise) {
|
||||
additionalData.hooks!.hookFunctions.workflowExecuteAfter?.unshift(
|
||||
async (runData: IRun): Promise<void> => {
|
||||
return donePromise.resolve(runData);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
resolveEmit(data);
|
||||
}
|
||||
)(resolve);
|
||||
triggerFunctions.emitError = (
|
||||
(rejectEmit) =>
|
||||
(error: Error, responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>) => {
|
||||
additionalData.hooks!.hookFunctions.sendResponse = [
|
||||
async (): Promise<void> => {
|
||||
if (responsePromise) {
|
||||
responsePromise.reject(error);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
rejectEmit(error);
|
||||
}
|
||||
)(reject);
|
||||
});
|
||||
|
||||
return triggerResponse;
|
||||
}
|
||||
// In all other modes simply start the trigger
|
||||
return await nodeType.trigger.call(triggerFunctions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the given trigger node so that it can trigger the workflow
|
||||
* when the node has data.
|
||||
*
|
||||
*/
|
||||
|
||||
async runPoll(
|
||||
node: INode,
|
||||
pollFunctions: IPollFunctions,
|
||||
): Promise<INodeExecutionData[][] | null> {
|
||||
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
|
||||
if (nodeType === undefined) {
|
||||
throw new ApplicationError('Node with unknown node type', {
|
||||
extra: { nodeName: node.name },
|
||||
tags: { nodeType: node.type },
|
||||
});
|
||||
}
|
||||
|
||||
if (!nodeType.poll) {
|
||||
throw new ApplicationError('Node type does not have a poll function defined', {
|
||||
extra: { nodeName: node.name },
|
||||
tags: { nodeType: node.type },
|
||||
});
|
||||
}
|
||||
|
||||
return await nodeType.poll.call(pollFunctions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the webhook data to see what it should return and if the
|
||||
* workflow should be started or not
|
||||
*
|
||||
*/
|
||||
async runWebhook(
|
||||
webhookData: IWebhookData,
|
||||
node: INode,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
nodeExecuteFunctions: INodeExecuteFunctions,
|
||||
mode: WorkflowExecuteMode,
|
||||
runExecutionData: IRunExecutionData | null,
|
||||
): Promise<IWebhookResponseData> {
|
||||
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
if (nodeType === undefined) {
|
||||
throw new ApplicationError('Unknown node type of webhook node', {
|
||||
extra: { nodeName: node.name },
|
||||
});
|
||||
} else if (nodeType.webhook === undefined) {
|
||||
throw new ApplicationError('Node does not have any webhooks defined', {
|
||||
extra: { nodeName: node.name },
|
||||
});
|
||||
}
|
||||
|
||||
const closeFunctions: CloseFunction[] = [];
|
||||
|
||||
const context = nodeExecuteFunctions.getExecuteWebhookFunctions(
|
||||
this,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
webhookData,
|
||||
closeFunctions,
|
||||
runExecutionData,
|
||||
);
|
||||
return nodeType instanceof Node
|
||||
? await nodeType.webhook(context)
|
||||
: await nodeType.webhook.call(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the given node.
|
||||
*
|
||||
*/
|
||||
// eslint-disable-next-line complexity
|
||||
async runNode(
|
||||
executionData: IExecuteData,
|
||||
runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
nodeExecuteFunctions: INodeExecuteFunctions,
|
||||
mode: WorkflowExecuteMode,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<IRunNodeResponse> {
|
||||
const { node } = executionData;
|
||||
let inputData = executionData.data;
|
||||
|
||||
if (node.disabled === true) {
|
||||
// If node is disabled simply pass the data through
|
||||
// return NodeRunHelpers.
|
||||
if (inputData.hasOwnProperty('main') && inputData.main.length > 0) {
|
||||
// If the node is disabled simply return the data from the first main input
|
||||
if (inputData.main[0] === null) {
|
||||
return { data: undefined };
|
||||
}
|
||||
return { data: [inputData.main[0]] };
|
||||
}
|
||||
return { data: undefined };
|
||||
}
|
||||
|
||||
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
if (nodeType === undefined) {
|
||||
throw new ApplicationError('Node type is unknown so cannot run it', {
|
||||
tags: { nodeType: node.type },
|
||||
});
|
||||
}
|
||||
|
||||
let connectionInputData: INodeExecutionData[] = [];
|
||||
if (nodeType.execute || (!nodeType.poll && !nodeType.trigger && !nodeType.webhook)) {
|
||||
// Only stop if first input is empty for execute runs. For all others run anyways
|
||||
// because then it is a trigger node. As they only pass data through and so the input-data
|
||||
// becomes output-data it has to be possible.
|
||||
|
||||
if (inputData.main?.length > 0) {
|
||||
// We always use the data of main input and the first input for execute
|
||||
connectionInputData = inputData.main[0] as INodeExecutionData[];
|
||||
}
|
||||
|
||||
const forceInputNodeExecution = this.settings.executionOrder !== 'v1';
|
||||
if (!forceInputNodeExecution) {
|
||||
// If the nodes do not get force executed data of some inputs may be missing
|
||||
// for that reason do we use the data of the first one that contains any
|
||||
for (const mainData of inputData.main) {
|
||||
if (mainData?.length) {
|
||||
connectionInputData = mainData;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (connectionInputData.length === 0) {
|
||||
// No data for node so return
|
||||
return { data: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
runExecutionData.resultData.lastNodeExecuted === node.name &&
|
||||
runExecutionData.resultData.error !== undefined
|
||||
) {
|
||||
// The node did already fail. So throw an error here that it displays and logs it correctly.
|
||||
// Does get used by webhook and trigger nodes in case they throw an error that it is possible
|
||||
// to log the error and display in Editor-UI.
|
||||
if (
|
||||
runExecutionData.resultData.error.name === 'NodeOperationError' ||
|
||||
runExecutionData.resultData.error.name === 'NodeApiError'
|
||||
) {
|
||||
throw runExecutionData.resultData.error;
|
||||
}
|
||||
|
||||
const error = new Error(runExecutionData.resultData.error.message);
|
||||
error.stack = runExecutionData.resultData.error.stack;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (node.executeOnce === true) {
|
||||
// If node should be executed only once so use only the first input item
|
||||
const newInputData: ITaskDataConnections = {};
|
||||
for (const connectionType of Object.keys(inputData)) {
|
||||
newInputData[connectionType] = inputData[connectionType].map((input) => {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
|
||||
return input && input.slice(0, 1);
|
||||
});
|
||||
}
|
||||
inputData = newInputData;
|
||||
}
|
||||
|
||||
if (nodeType.execute) {
|
||||
const closeFunctions: CloseFunction[] = [];
|
||||
const context = nodeExecuteFunctions.getExecuteFunctions(
|
||||
this,
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
connectionInputData,
|
||||
inputData,
|
||||
node,
|
||||
additionalData,
|
||||
executionData,
|
||||
mode,
|
||||
closeFunctions,
|
||||
abortSignal,
|
||||
);
|
||||
const data =
|
||||
nodeType instanceof Node
|
||||
? await nodeType.execute(context)
|
||||
: await nodeType.execute.call(context);
|
||||
|
||||
const closeFunctionsResults = await Promise.allSettled(
|
||||
closeFunctions.map(async (fn) => await fn()),
|
||||
);
|
||||
|
||||
const closingErrors = closeFunctionsResults
|
||||
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
.map((result) => result.reason);
|
||||
|
||||
if (closingErrors.length > 0) {
|
||||
if (closingErrors[0] instanceof Error) throw closingErrors[0];
|
||||
throw new ApplicationError("Error on execution node's close function(s)", {
|
||||
extra: { nodeName: node.name },
|
||||
tags: { nodeType: node.type },
|
||||
cause: closingErrors,
|
||||
});
|
||||
}
|
||||
|
||||
return { data };
|
||||
} else if (nodeType.poll) {
|
||||
if (mode === 'manual') {
|
||||
// In manual mode run the poll function
|
||||
const thisArgs = nodeExecuteFunctions.getExecutePollFunctions(
|
||||
this,
|
||||
node,
|
||||
additionalData,
|
||||
mode,
|
||||
'manual',
|
||||
);
|
||||
return { data: await nodeType.poll.call(thisArgs) };
|
||||
}
|
||||
// In any other mode pass data through as it already contains the result of the poll
|
||||
return { data: inputData.main as INodeExecutionData[][] };
|
||||
} else if (nodeType.trigger) {
|
||||
if (mode === 'manual') {
|
||||
// In manual mode start the trigger
|
||||
const triggerResponse = await this.runTrigger(
|
||||
node,
|
||||
nodeExecuteFunctions.getExecuteTriggerFunctions,
|
||||
additionalData,
|
||||
mode,
|
||||
'manual',
|
||||
);
|
||||
|
||||
if (triggerResponse === undefined) {
|
||||
return { data: null };
|
||||
}
|
||||
|
||||
let closeFunction;
|
||||
if (triggerResponse.closeFunction) {
|
||||
// In manual mode we return the trigger closeFunction. That allows it to be called directly
|
||||
// but we do not have to wait for it to finish. That is important for things like queue-nodes.
|
||||
// There the full close will may be delayed till a message gets acknowledged after the execution.
|
||||
// If we would not be able to wait for it to close would it cause problems with "own" mode as the
|
||||
// process would be killed directly after it and so the acknowledge would not have been finished yet.
|
||||
closeFunction = triggerResponse.closeFunction;
|
||||
|
||||
// Manual testing of Trigger nodes creates an execution. If the execution is cancelled, `closeFunction` should be called to cleanup any open connections/consumers
|
||||
abortSignal?.addEventListener('abort', closeFunction);
|
||||
}
|
||||
|
||||
if (triggerResponse.manualTriggerFunction !== undefined) {
|
||||
// If a manual trigger function is defined call it and wait till it did run
|
||||
await triggerResponse.manualTriggerFunction();
|
||||
}
|
||||
|
||||
const response = await triggerResponse.manualTriggerResponse!;
|
||||
|
||||
if (response.length === 0) {
|
||||
return { data: null, closeFunction };
|
||||
}
|
||||
|
||||
return { data: response, closeFunction };
|
||||
}
|
||||
// For trigger nodes in any mode except "manual" do we simply pass the data through
|
||||
return { data: inputData.main as INodeExecutionData[][] };
|
||||
} else if (nodeType.webhook) {
|
||||
// For webhook nodes always simply pass the data through
|
||||
return { data: inputData.main as INodeExecutionData[][] };
|
||||
} else {
|
||||
// For nodes which have routing information on properties
|
||||
|
||||
const routingNode = new RoutingNode(
|
||||
this,
|
||||
node,
|
||||
connectionInputData,
|
||||
runExecutionData ?? null,
|
||||
additionalData,
|
||||
mode,
|
||||
);
|
||||
|
||||
return {
|
||||
data: await routingNode.runNode(
|
||||
inputData,
|
||||
runIndex,
|
||||
nodeType,
|
||||
executionData,
|
||||
nodeExecuteFunctions,
|
||||
undefined,
|
||||
abortSignal,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasDotNotationBannedChar(nodeName: string) {
|
||||
|
||||
@@ -14,7 +14,6 @@ export * from './MessageEventBus';
|
||||
export * from './ExecutionStatus';
|
||||
export * from './Expression';
|
||||
export * from './NodeHelpers';
|
||||
export * from './RoutingNode';
|
||||
export * from './Workflow';
|
||||
export * from './WorkflowDataProxy';
|
||||
export * from './WorkflowDataProxyEnvProvider';
|
||||
|
||||
@@ -1,70 +1,16 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import get from 'lodash/get';
|
||||
import path from 'path';
|
||||
|
||||
import type {
|
||||
IExecuteSingleFunctions,
|
||||
IHttpRequestOptions,
|
||||
IN8nHttpFullResponse,
|
||||
IN8nHttpResponse,
|
||||
INode,
|
||||
INodeTypes,
|
||||
IRunExecutionData,
|
||||
} from '@/Interfaces';
|
||||
import type { Workflow } from '@/Workflow';
|
||||
import type { INodeTypes } from '@/Interfaces';
|
||||
|
||||
import { NodeTypes as NodeTypesClass } from './NodeTypes';
|
||||
|
||||
export function getExecuteSingleFunctions(
|
||||
workflow: Workflow,
|
||||
runExecutionData: IRunExecutionData,
|
||||
runIndex: number,
|
||||
node: INode,
|
||||
itemIndex: number,
|
||||
): IExecuteSingleFunctions {
|
||||
return mock<IExecuteSingleFunctions>({
|
||||
getItemIndex: () => itemIndex,
|
||||
getNodeParameter: (parameterName: string) => {
|
||||
return workflow.expression.getParameterValue(
|
||||
get(node.parameters, parameterName),
|
||||
runExecutionData,
|
||||
runIndex,
|
||||
itemIndex,
|
||||
node.name,
|
||||
[],
|
||||
'internal',
|
||||
{},
|
||||
);
|
||||
},
|
||||
getWorkflow: () => ({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
active: workflow.active,
|
||||
}),
|
||||
helpers: mock<IExecuteSingleFunctions['helpers']>({
|
||||
async httpRequest(
|
||||
requestOptions: IHttpRequestOptions,
|
||||
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
|
||||
return {
|
||||
body: {
|
||||
headers: {},
|
||||
statusCode: 200,
|
||||
requestOptions,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let nodeTypesInstance: NodeTypesClass | undefined;
|
||||
|
||||
export function NodeTypes(): INodeTypes {
|
||||
if (nodeTypesInstance === undefined) {
|
||||
nodeTypesInstance = new NodeTypesClass();
|
||||
}
|
||||
|
||||
return nodeTypesInstance;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,8 @@ import {
|
||||
import {
|
||||
getNodeParameters,
|
||||
getNodeHints,
|
||||
isSingleExecution,
|
||||
isSubNodeType,
|
||||
applyDeclarativeNodeOptionParameters,
|
||||
convertNodeToAiTool,
|
||||
} from '@/NodeHelpers';
|
||||
import type { Workflow } from '@/Workflow';
|
||||
|
||||
@@ -3542,34 +3540,6 @@ describe('NodeHelpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSingleExecution', () => {
|
||||
test('should determine based on node parameters if it would be executed once', () => {
|
||||
expect(isSingleExecution('n8n-nodes-base.code', {})).toEqual(true);
|
||||
expect(isSingleExecution('n8n-nodes-base.code', { mode: 'runOnceForEachItem' })).toEqual(
|
||||
false,
|
||||
);
|
||||
expect(isSingleExecution('n8n-nodes-base.executeWorkflow', {})).toEqual(true);
|
||||
expect(isSingleExecution('n8n-nodes-base.executeWorkflow', { mode: 'each' })).toEqual(false);
|
||||
expect(isSingleExecution('n8n-nodes-base.crateDb', {})).toEqual(true);
|
||||
expect(isSingleExecution('n8n-nodes-base.crateDb', { operation: 'update' })).toEqual(true);
|
||||
expect(isSingleExecution('n8n-nodes-base.timescaleDb', {})).toEqual(true);
|
||||
expect(isSingleExecution('n8n-nodes-base.timescaleDb', { operation: 'update' })).toEqual(
|
||||
true,
|
||||
);
|
||||
expect(isSingleExecution('n8n-nodes-base.microsoftSql', {})).toEqual(true);
|
||||
expect(isSingleExecution('n8n-nodes-base.microsoftSql', { operation: 'update' })).toEqual(
|
||||
true,
|
||||
);
|
||||
expect(isSingleExecution('n8n-nodes-base.microsoftSql', { operation: 'delete' })).toEqual(
|
||||
true,
|
||||
);
|
||||
expect(isSingleExecution('n8n-nodes-base.questDb', {})).toEqual(true);
|
||||
expect(isSingleExecution('n8n-nodes-base.mongoDb', { operation: 'insert' })).toEqual(true);
|
||||
expect(isSingleExecution('n8n-nodes-base.mongoDb', { operation: 'update' })).toEqual(true);
|
||||
expect(isSingleExecution('n8n-nodes-base.redis', {})).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSubNodeType', () => {
|
||||
const tests: Array<[boolean, Pick<INodeTypeDescription, 'outputs'> | null]> = [
|
||||
[false, null],
|
||||
@@ -3637,177 +3607,4 @@ describe('NodeHelpers', () => {
|
||||
expect(nodeType.description.properties).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertNodeToAiTool', () => {
|
||||
let fullNodeWrapper: { description: INodeTypeDescription };
|
||||
|
||||
beforeEach(() => {
|
||||
fullNodeWrapper = {
|
||||
description: {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['test'],
|
||||
description: 'A test node',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [NodeConnectionType.Main],
|
||||
outputs: [NodeConnectionType.Main],
|
||||
properties: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should modify the name and displayName correctly', () => {
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.name).toBe('testNodeTool');
|
||||
expect(result.description.displayName).toBe('Test Node Tool');
|
||||
});
|
||||
|
||||
it('should update inputs and outputs', () => {
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.inputs).toEqual([]);
|
||||
expect(result.description.outputs).toEqual([NodeConnectionType.AiTool]);
|
||||
});
|
||||
|
||||
it('should remove the usableAsTool property', () => {
|
||||
fullNodeWrapper.description.usableAsTool = true;
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.usableAsTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should add toolDescription property if it doesn't exist", () => {
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
const toolDescriptionProp = result.description.properties.find(
|
||||
(prop) => prop.name === 'toolDescription',
|
||||
);
|
||||
expect(toolDescriptionProp).toBeDefined();
|
||||
expect(toolDescriptionProp?.type).toBe('string');
|
||||
expect(toolDescriptionProp?.default).toBe(fullNodeWrapper.description.description);
|
||||
});
|
||||
|
||||
it('should set codex categories correctly', () => {
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.codex).toEqual({
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
resources: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing properties', () => {
|
||||
const existingProp: INodeProperties = {
|
||||
displayName: 'Existing Prop',
|
||||
name: 'existingProp',
|
||||
type: 'string',
|
||||
default: 'test',
|
||||
};
|
||||
fullNodeWrapper.description.properties = [existingProp];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties).toHaveLength(3); // Existing prop + toolDescription + notice
|
||||
expect(result.description.properties).toContainEqual(existingProp);
|
||||
});
|
||||
|
||||
it('should handle nodes with resource property', () => {
|
||||
const resourceProp: INodeProperties = {
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [{ name: 'User', value: 'user' }],
|
||||
default: 'user',
|
||||
};
|
||||
fullNodeWrapper.description.properties = [resourceProp];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties[1].name).toBe('descriptionType');
|
||||
expect(result.description.properties[2].name).toBe('toolDescription');
|
||||
expect(result.description.properties[3]).toEqual(resourceProp);
|
||||
});
|
||||
|
||||
it('should handle nodes with operation property', () => {
|
||||
const operationProp: INodeProperties = {
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [{ name: 'Create', value: 'create' }],
|
||||
default: 'create',
|
||||
};
|
||||
fullNodeWrapper.description.properties = [operationProp];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties[1].name).toBe('descriptionType');
|
||||
expect(result.description.properties[2].name).toBe('toolDescription');
|
||||
expect(result.description.properties[3]).toEqual(operationProp);
|
||||
});
|
||||
|
||||
it('should handle nodes with both resource and operation properties', () => {
|
||||
const resourceProp: INodeProperties = {
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [{ name: 'User', value: 'user' }],
|
||||
default: 'user',
|
||||
};
|
||||
const operationProp: INodeProperties = {
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [{ name: 'Create', value: 'create' }],
|
||||
default: 'create',
|
||||
};
|
||||
fullNodeWrapper.description.properties = [resourceProp, operationProp];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties[1].name).toBe('descriptionType');
|
||||
expect(result.description.properties[2].name).toBe('toolDescription');
|
||||
expect(result.description.properties[3]).toEqual(resourceProp);
|
||||
expect(result.description.properties[4]).toEqual(operationProp);
|
||||
});
|
||||
|
||||
it('should handle nodes with empty properties', () => {
|
||||
fullNodeWrapper.description.properties = [];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties).toHaveLength(2);
|
||||
expect(result.description.properties[1].name).toBe('toolDescription');
|
||||
});
|
||||
|
||||
it('should handle nodes with existing codex property', () => {
|
||||
fullNodeWrapper.description.codex = {
|
||||
categories: ['Existing'],
|
||||
subcategories: {
|
||||
Existing: ['Category'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [{ url: 'https://example.com' }],
|
||||
},
|
||||
};
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.codex).toEqual({
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [{ url: 'https://example.com' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nodes with very long names', () => {
|
||||
fullNodeWrapper.description.name = 'veryLongNodeNameThatExceedsNormalLimits'.repeat(10);
|
||||
fullNodeWrapper.description.displayName =
|
||||
'Very Long Node Name That Exceeds Normal Limits'.repeat(10);
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.name.endsWith('Tool')).toBe(true);
|
||||
expect(result.description.displayName.endsWith('Tool')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle nodes with special characters in name and displayName', () => {
|
||||
fullNodeWrapper.description.name = 'special@#$%Node';
|
||||
fullNodeWrapper.description.displayName = 'Special @#$% Node';
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.name).toBe('special@#$%NodeTool');
|
||||
expect(result.description.displayName).toBe('Special @#$% Node Tool');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,22 +6,14 @@ import type {
|
||||
IBinaryKeyData,
|
||||
IConnections,
|
||||
IDataObject,
|
||||
IExecuteData,
|
||||
INode,
|
||||
INodeExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameters,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
INodeTypes,
|
||||
IRunExecutionData,
|
||||
ITriggerFunctions,
|
||||
ITriggerResponse,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeParameterValueType,
|
||||
} from '@/Interfaces';
|
||||
import * as NodeHelpers from '@/NodeHelpers';
|
||||
import { Workflow, type WorkflowParameters } from '@/Workflow';
|
||||
import { Workflow } from '@/Workflow';
|
||||
|
||||
process.env.TEST_VARIABLE_1 = 'valueEnvVariable1';
|
||||
|
||||
@@ -33,126 +25,6 @@ interface StubNode {
|
||||
}
|
||||
|
||||
describe('Workflow', () => {
|
||||
describe('checkIfWorkflowCanBeActivated', () => {
|
||||
const disabledNode = mock<INode>({ type: 'triggerNode', disabled: true });
|
||||
const unknownNode = mock<INode>({ type: 'unknownNode' });
|
||||
const noTriggersNode = mock<INode>({ type: 'noTriggersNode' });
|
||||
const pollNode = mock<INode>({ type: 'pollNode' });
|
||||
const triggerNode = mock<INode>({ type: 'triggerNode' });
|
||||
const webhookNode = mock<INode>({ type: 'webhookNode' });
|
||||
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
nodeTypes.getByNameAndVersion.mockImplementation((type) => {
|
||||
// TODO: getByNameAndVersion signature needs to be updated to allow returning undefined
|
||||
if (type === 'unknownNode') return undefined as unknown as INodeType;
|
||||
const partial: Partial<INodeType> = {
|
||||
poll: undefined,
|
||||
trigger: undefined,
|
||||
webhook: undefined,
|
||||
description: mock<INodeTypeDescription>({
|
||||
properties: [],
|
||||
}),
|
||||
};
|
||||
if (type === 'pollNode') partial.poll = jest.fn();
|
||||
if (type === 'triggerNode') partial.trigger = jest.fn();
|
||||
if (type === 'webhookNode') partial.webhook = jest.fn();
|
||||
return mock(partial);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['should skip disabled nodes', disabledNode, [], false],
|
||||
['should skip nodes marked as ignored', triggerNode, ['triggerNode'], false],
|
||||
['should skip unknown nodes', unknownNode, [], false],
|
||||
['should skip nodes with no trigger method', noTriggersNode, [], false],
|
||||
['should activate if poll method exists', pollNode, [], true],
|
||||
['should activate if trigger method exists', triggerNode, [], true],
|
||||
['should activate if webhook method exists', webhookNode, [], true],
|
||||
])('%s', async (_, node, ignoredNodes, expected) => {
|
||||
const params = mock<WorkflowParameters>({ nodeTypes });
|
||||
params.nodes = [node];
|
||||
const workflow = new Workflow(params);
|
||||
expect(workflow.checkIfWorkflowCanBeActivated(ignoredNodes)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkReadyForExecution', () => {
|
||||
const disabledNode = mock<INode>({ name: 'Disabled Node', disabled: true });
|
||||
const startNode = mock<INode>({ name: 'Start Node' });
|
||||
const unknownNode = mock<INode>({ name: 'Unknown Node', type: 'unknownNode' });
|
||||
|
||||
const nodeParamIssuesSpy = jest.spyOn(NodeHelpers, 'getNodeParametersIssues');
|
||||
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
nodeTypes.getByNameAndVersion.mockImplementation((type) => {
|
||||
// TODO: getByNameAndVersion signature needs to be updated to allow returning undefined
|
||||
if (type === 'unknownNode') return undefined as unknown as INodeType;
|
||||
return mock<INodeType>({
|
||||
description: {
|
||||
properties: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('should return null if there are no nodes', () => {
|
||||
const workflow = new Workflow({
|
||||
nodes: [],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const issues = workflow.checkReadyForExecution();
|
||||
expect(issues).toBe(null);
|
||||
expect(nodeTypes.getByNameAndVersion).not.toHaveBeenCalled();
|
||||
expect(nodeParamIssuesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null if there are no enabled nodes', () => {
|
||||
const workflow = new Workflow({
|
||||
nodes: [disabledNode],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const issues = workflow.checkReadyForExecution({ startNode: disabledNode.name });
|
||||
expect(issues).toBe(null);
|
||||
expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledTimes(1);
|
||||
expect(nodeParamIssuesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return typeUnknown for unknown nodes', () => {
|
||||
const workflow = new Workflow({
|
||||
nodes: [unknownNode],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const issues = workflow.checkReadyForExecution({ startNode: unknownNode.name });
|
||||
expect(issues).toEqual({ [unknownNode.name]: { typeUnknown: true } });
|
||||
expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledTimes(2);
|
||||
expect(nodeParamIssuesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return issues for regular nodes', () => {
|
||||
const workflow = new Workflow({
|
||||
nodes: [startNode],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
nodeParamIssuesSpy.mockReturnValue({ execution: false });
|
||||
|
||||
const issues = workflow.checkReadyForExecution({ startNode: startNode.name });
|
||||
expect(issues).toEqual({ [startNode.name]: { execution: false } });
|
||||
expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledTimes(2);
|
||||
expect(nodeParamIssuesSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameNodeInParameterValue', () => {
|
||||
describe('for expressions', () => {
|
||||
const tests = [
|
||||
@@ -2023,69 +1895,6 @@ describe('Workflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNode', () => {
|
||||
const nodeTypes = mock<INodeTypes>();
|
||||
const triggerNode = mock<INode>();
|
||||
const triggerResponse = mock<ITriggerResponse>({
|
||||
closeFunction: jest.fn(),
|
||||
// This node should never trigger, or return
|
||||
manualTriggerFunction: async () => await new Promise(() => {}),
|
||||
});
|
||||
const triggerNodeType = mock<INodeType>({
|
||||
description: {
|
||||
properties: [],
|
||||
},
|
||||
execute: undefined,
|
||||
poll: undefined,
|
||||
webhook: undefined,
|
||||
async trigger(this: ITriggerFunctions) {
|
||||
return triggerResponse;
|
||||
},
|
||||
});
|
||||
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(triggerNodeType);
|
||||
|
||||
const workflow = new Workflow({
|
||||
nodeTypes,
|
||||
nodes: [triggerNode],
|
||||
connections: {},
|
||||
active: false,
|
||||
});
|
||||
|
||||
const executionData = mock<IExecuteData>();
|
||||
const runExecutionData = mock<IRunExecutionData>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
||||
const nodeExecuteFunctions = mock<INodeExecuteFunctions>();
|
||||
const triggerFunctions = mock<ITriggerFunctions>();
|
||||
nodeExecuteFunctions.getExecuteTriggerFunctions.mockReturnValue(triggerFunctions);
|
||||
const abortController = new AbortController();
|
||||
|
||||
test('should call closeFunction when manual trigger is aborted', async () => {
|
||||
const runPromise = workflow.runNode(
|
||||
executionData,
|
||||
runExecutionData,
|
||||
0,
|
||||
additionalData,
|
||||
nodeExecuteFunctions,
|
||||
'manual',
|
||||
abortController.signal,
|
||||
);
|
||||
// Yield back to the event-loop to let async parts of `runNode` execute
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
let isSettled = false;
|
||||
void runPromise.then(() => {
|
||||
isSettled = true;
|
||||
});
|
||||
expect(isSettled).toBe(false);
|
||||
expect(abortController.signal.aborted).toBe(false);
|
||||
expect(triggerResponse.closeFunction).not.toHaveBeenCalled();
|
||||
|
||||
abortController.abort();
|
||||
expect(triggerResponse.closeFunction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('__getConnectionsByDestination', () => {
|
||||
it('should return empty object when there are no connections', () => {
|
||||
const workflow = new Workflow({
|
||||
|
||||
Reference in New Issue
Block a user